Admin Panel (Next.js): - Dashboard with stats - Users list with relationships (watches/watched_by) - User detail pages - Deployments list and detail pages - Devices, Orders, Subscriptions pages - OTP-based admin authentication Backend Optimizations: - Fixed N+1 query problem in admin APIs - Added pagination support - Added .range() and count support to Supabase wrapper - Optimized batch queries with lookup maps Database: - Added migrations for schema evolution - New tables: push_tokens, notification_settings - Updated access model iOS Build Scripts: - build-ios.sh, clear-apple-cache.sh - EAS configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
433 lines
11 KiB
JavaScript
433 lines
11 KiB
JavaScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import AdminLayout from '../../components/AdminLayout';
|
|
import { getOrders, updateOrder } from '../../lib/api';
|
|
|
|
const STATUS_OPTIONS = ['paid', 'preparing', 'shipped', 'delivered', 'installed'];
|
|
const CARRIERS = ['UPS', 'FedEx', 'USPS', 'DHL'];
|
|
|
|
export default function OrdersPage() {
|
|
const [orders, setOrders] = useState([]);
|
|
const [filter, setFilter] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [showTrackingModal, setShowTrackingModal] = useState(null);
|
|
|
|
useEffect(() => {
|
|
loadOrders();
|
|
}, [filter]);
|
|
|
|
const loadOrders = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await getOrders(filter || undefined);
|
|
setOrders(data.orders || []);
|
|
} catch (err) {
|
|
console.error('Failed to load orders:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleStatusChange = async (orderId, newStatus) => {
|
|
try {
|
|
await updateOrder(orderId, { status: newStatus });
|
|
loadOrders();
|
|
} catch (err) {
|
|
alert('Failed to update status: ' + err.message);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AdminLayout>
|
|
<div style={styles.header}>
|
|
<h1 style={styles.title}>Orders</h1>
|
|
<select
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
style={styles.select}
|
|
>
|
|
<option value="">All Orders</option>
|
|
{STATUS_OPTIONS.map((s) => (
|
|
<option key={s} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p style={styles.loading}>Loading...</p>
|
|
) : orders.length === 0 ? (
|
|
<div style={styles.empty}>
|
|
<p>No orders yet</p>
|
|
<p style={styles.emptyHint}>Orders are created when customers purchase through the app</p>
|
|
</div>
|
|
) : (
|
|
<div style={styles.list}>
|
|
{orders.map((order) => (
|
|
<div key={order.id} style={styles.orderCard}>
|
|
<div style={styles.orderHeader}>
|
|
<div>
|
|
<span style={styles.orderId}>#{order.order_number || order.id.slice(0,8)}</span>
|
|
<span style={styles.amount}>
|
|
${((order.total_amount || 0) / 100).toFixed(2)}
|
|
</span>
|
|
</div>
|
|
<StatusBadge status={order.status} />
|
|
</div>
|
|
|
|
<div style={styles.orderInfo}>
|
|
<div style={styles.infoRow}>
|
|
<span style={styles.label}>Customer:</span>
|
|
<span>{order.user?.email || order.customer_email || 'N/A'}</span>
|
|
</div>
|
|
<div style={styles.infoRow}>
|
|
<span style={styles.label}>Date:</span>
|
|
<span>{new Date(order.created_at).toLocaleString()}</span>
|
|
</div>
|
|
{order.shipping_address && (
|
|
<div style={styles.infoRow}>
|
|
<span style={styles.label}>Address:</span>
|
|
<span style={styles.address}>{order.shipping_address}</span>
|
|
</div>
|
|
)}
|
|
{order.tracking_number && (
|
|
<div style={styles.infoRow}>
|
|
<span style={styles.label}>Tracking:</span>
|
|
<a
|
|
href={getTrackingUrl(order.carrier, order.tracking_number)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={styles.trackingLink}
|
|
>
|
|
{order.carrier} {order.tracking_number}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={styles.actions}>
|
|
<select
|
|
value={order.status}
|
|
onChange={(e) => handleStatusChange(order.id, e.target.value)}
|
|
style={styles.statusSelect}
|
|
>
|
|
{STATUS_OPTIONS.map((s) => (
|
|
<option key={s} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
|
|
{(order.status === 'preparing' || order.status === 'paid') && !order.tracking_number && (
|
|
<button
|
|
onClick={() => setShowTrackingModal(order)}
|
|
style={styles.trackingBtn}
|
|
>
|
|
Add Tracking
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showTrackingModal && (
|
|
<TrackingModal
|
|
order={showTrackingModal}
|
|
onClose={() => setShowTrackingModal(null)}
|
|
onSaved={() => {
|
|
setShowTrackingModal(null);
|
|
loadOrders();
|
|
}}
|
|
/>
|
|
)}
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
function TrackingModal({ order, onClose, onSaved }) {
|
|
const [form, setForm] = useState({
|
|
carrier: 'UPS',
|
|
tracking_number: '',
|
|
estimated_delivery: '',
|
|
});
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
|
|
try {
|
|
await updateOrder(order.id, {
|
|
status: 'shipped',
|
|
...form,
|
|
});
|
|
onSaved();
|
|
} catch (err) {
|
|
alert('Failed: ' + err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={styles.modalOverlay} onClick={onClose}>
|
|
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
|
<h2 style={styles.modalTitle}>Add Tracking</h2>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div style={styles.formGroup}>
|
|
<label style={styles.formLabel}>Carrier</label>
|
|
<select
|
|
value={form.carrier}
|
|
onChange={(e) => setForm({ ...form, carrier: e.target.value })}
|
|
style={styles.formInput}
|
|
>
|
|
{CARRIERS.map((c) => (
|
|
<option key={c} value={c}>{c}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div style={styles.formGroup}>
|
|
<label style={styles.formLabel}>Tracking Number *</label>
|
|
<input
|
|
type="text"
|
|
value={form.tracking_number}
|
|
onChange={(e) => setForm({ ...form, tracking_number: e.target.value })}
|
|
style={styles.formInput}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div style={styles.formGroup}>
|
|
<label style={styles.formLabel}>Estimated Delivery</label>
|
|
<input
|
|
type="date"
|
|
value={form.estimated_delivery}
|
|
onChange={(e) => setForm({ ...form, estimated_delivery: e.target.value })}
|
|
style={styles.formInput}
|
|
/>
|
|
</div>
|
|
|
|
<div style={styles.modalActions}>
|
|
<button type="button" onClick={onClose} style={styles.cancelBtn}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" style={styles.submitBtn} disabled={loading}>
|
|
{loading ? 'Saving...' : 'Save & Ship'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }) {
|
|
const colors = {
|
|
paid: { bg: '#DBEAFE', color: '#1E40AF' },
|
|
preparing: { bg: '#FEF3C7', color: '#92400E' },
|
|
shipped: { bg: '#D1FAE5', color: '#065F46' },
|
|
delivered: { bg: '#E0E7FF', color: '#3730A3' },
|
|
installed: { bg: '#D1FAE5', color: '#065F46' },
|
|
};
|
|
const style = colors[status] || { bg: '#F3F4F6', color: '#6B7280' };
|
|
|
|
return (
|
|
<span style={{
|
|
padding: '6px 12px',
|
|
borderRadius: '16px',
|
|
fontSize: '13px',
|
|
fontWeight: '500',
|
|
background: style.bg,
|
|
color: style.color,
|
|
}}>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function getTrackingUrl(carrier, number) {
|
|
const urls = {
|
|
UPS: `https://www.ups.com/track?tracknum=${number}`,
|
|
FedEx: `https://www.fedex.com/fedextrack/?trknbr=${number}`,
|
|
USPS: `https://tools.usps.com/go/TrackConfirmAction?tLabels=${number}`,
|
|
DHL: `https://www.dhl.com/en/express/tracking.html?AWB=${number}`,
|
|
};
|
|
return urls[carrier] || '#';
|
|
}
|
|
|
|
const styles = {
|
|
header: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: '24px',
|
|
},
|
|
title: {
|
|
fontSize: '24px',
|
|
fontWeight: '600',
|
|
},
|
|
select: {
|
|
padding: '10px 16px',
|
|
fontSize: '14px',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '8px',
|
|
background: 'white',
|
|
cursor: 'pointer',
|
|
},
|
|
loading: {
|
|
color: 'var(--text-muted)',
|
|
},
|
|
empty: {
|
|
background: 'white',
|
|
padding: '48px',
|
|
borderRadius: '12px',
|
|
textAlign: 'center',
|
|
color: 'var(--text-muted)',
|
|
},
|
|
emptyHint: {
|
|
fontSize: '13px',
|
|
marginTop: '8px',
|
|
color: 'var(--text-muted)',
|
|
},
|
|
list: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '16px',
|
|
},
|
|
orderCard: {
|
|
background: 'white',
|
|
borderRadius: '12px',
|
|
padding: '20px',
|
|
},
|
|
orderHeader: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: '16px',
|
|
},
|
|
orderId: {
|
|
fontSize: '16px',
|
|
fontWeight: '600',
|
|
marginRight: '12px',
|
|
},
|
|
amount: {
|
|
fontSize: '14px',
|
|
color: 'var(--text-muted)',
|
|
},
|
|
orderInfo: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '8px',
|
|
marginBottom: '16px',
|
|
paddingBottom: '16px',
|
|
borderBottom: '1px solid var(--border)',
|
|
},
|
|
infoRow: {
|
|
display: 'flex',
|
|
fontSize: '14px',
|
|
gap: '8px',
|
|
},
|
|
label: {
|
|
color: 'var(--text-muted)',
|
|
minWidth: '80px',
|
|
},
|
|
address: {
|
|
color: 'var(--text-secondary)',
|
|
},
|
|
trackingLink: {
|
|
color: 'var(--primary)',
|
|
textDecoration: 'none',
|
|
},
|
|
actions: {
|
|
display: 'flex',
|
|
gap: '12px',
|
|
},
|
|
statusSelect: {
|
|
padding: '8px 12px',
|
|
fontSize: '13px',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '6px',
|
|
background: 'white',
|
|
cursor: 'pointer',
|
|
},
|
|
trackingBtn: {
|
|
padding: '8px 16px',
|
|
fontSize: '13px',
|
|
fontWeight: '500',
|
|
color: 'white',
|
|
background: 'var(--primary)',
|
|
border: 'none',
|
|
borderRadius: '6px',
|
|
cursor: 'pointer',
|
|
},
|
|
modalOverlay: {
|
|
position: 'fixed',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
background: 'rgba(0,0,0,0.5)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 1000,
|
|
},
|
|
modal: {
|
|
background: 'white',
|
|
borderRadius: '16px',
|
|
padding: '32px',
|
|
width: '100%',
|
|
maxWidth: '400px',
|
|
},
|
|
modalTitle: {
|
|
fontSize: '20px',
|
|
fontWeight: '600',
|
|
marginBottom: '24px',
|
|
},
|
|
formGroup: {
|
|
marginBottom: '16px',
|
|
},
|
|
formLabel: {
|
|
display: 'block',
|
|
fontSize: '13px',
|
|
fontWeight: '500',
|
|
color: 'var(--text-secondary)',
|
|
marginBottom: '6px',
|
|
},
|
|
formInput: {
|
|
width: '100%',
|
|
padding: '10px 12px',
|
|
fontSize: '14px',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '8px',
|
|
outline: 'none',
|
|
},
|
|
modalActions: {
|
|
display: 'flex',
|
|
gap: '12px',
|
|
justifyContent: 'flex-end',
|
|
marginTop: '24px',
|
|
},
|
|
cancelBtn: {
|
|
padding: '10px 20px',
|
|
fontSize: '14px',
|
|
color: 'var(--text-secondary)',
|
|
background: 'transparent',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '8px',
|
|
cursor: 'pointer',
|
|
},
|
|
submitBtn: {
|
|
padding: '10px 20px',
|
|
fontSize: '14px',
|
|
fontWeight: '600',
|
|
color: 'white',
|
|
background: 'var(--primary)',
|
|
border: 'none',
|
|
borderRadius: '8px',
|
|
cursor: 'pointer',
|
|
},
|
|
};
|