Sergei ec63a2c1e2 Add admin panel, optimized API, OTP auth, migrations
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>
2025-12-20 11:05:39 -08:00

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',
},
};