feat: implement order synchronization with Stripe and enhance order management features
This commit is contained in:
+152
-38
@@ -228,6 +228,8 @@ export default function AdminPage() {
|
||||
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||
const [ordersError, setOrdersError] = useState('');
|
||||
const [totalRevenue, setTotalRevenue] = useState(0);
|
||||
const [syncingOrders, setSyncingOrders] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState('');
|
||||
|
||||
const loadOrders = useCallback(async () => {
|
||||
setOrdersLoading(true);
|
||||
@@ -246,6 +248,23 @@ export default function AdminPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSyncOrders = async () => {
|
||||
setSyncingOrders(true);
|
||||
setSyncMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/admin/sync-orders/', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Sync failed');
|
||||
setSyncMsg(`✓ Synced ${data.synced} order(s) from Stripe`);
|
||||
await loadOrders();
|
||||
} catch (err) {
|
||||
setSyncMsg(err instanceof Error ? err.message : 'Sync failed');
|
||||
} finally {
|
||||
setSyncingOrders(false);
|
||||
setTimeout(() => setSyncMsg(''), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'orders') loadOrders();
|
||||
}, [activeTab, loadOrders]);
|
||||
@@ -615,7 +634,11 @@ export default function AdminPage() {
|
||||
{/* ===== ORDERS TAB ===== */}
|
||||
{activeTab === 'orders' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginBottom: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{syncMsg && <span style={{ fontSize: '0.75rem', color: syncMsg.startsWith('✓') ? '#16a34a' : '#dc2626', marginRight: 'auto' }}>{syncMsg}</span>}
|
||||
<button onClick={handleSyncOrders} disabled={syncingOrders || ordersLoading} style={{ ...ghostBtnStyle, fontSize: '0.75rem' }} title="Pull all PaymentIntents from Stripe into DB">
|
||||
{syncingOrders ? 'Syncing…' : '⟳ Sync from Stripe'}
|
||||
</button>
|
||||
<button onClick={loadOrders} disabled={ordersLoading} style={secondaryBtnStyle}>
|
||||
{ordersLoading ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
@@ -789,6 +812,12 @@ function OrderRow({
|
||||
const name = m.customerName || '—';
|
||||
const email = m.customerEmail || '';
|
||||
|
||||
// Parse stored price breakdown JSON
|
||||
const priceLines: { label: string; price: number }[] = (() => {
|
||||
if (!m.priceItems) return [];
|
||||
try { return JSON.parse(m.priceItems); } catch { return []; }
|
||||
})();
|
||||
|
||||
const handleExpand = () => {
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
@@ -801,10 +830,24 @@ function OrderRow({
|
||||
}
|
||||
};
|
||||
|
||||
const fieldStyle: React.CSSProperties = {
|
||||
fontSize: '0.63rem',
|
||||
fontWeight: 600,
|
||||
color: '#94a3b8',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
};
|
||||
const valueStyle: React.CSSProperties = {
|
||||
fontSize: '0.8rem',
|
||||
color: '#374151',
|
||||
marginTop: '0.1rem',
|
||||
wordBreak: 'break-word',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ borderBottom: isLast ? 'none' : '1px solid rgba(0,0,0,0.04)' }}>
|
||||
{/* Main row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px 90px 100px 28px', padding: '0.75rem 1.25rem', alignItems: 'center' }}>
|
||||
{/* Collapsed row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 90px 100px 28px', padding: '0.75rem 1.25rem', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{name}</div>
|
||||
{email && <div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>{email}</div>}
|
||||
@@ -819,8 +862,8 @@ function OrderRow({
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
background: order.status === 'succeeded' ? 'rgba(34,197,94,0.1)' : 'rgba(148,163,184,0.15)',
|
||||
color: order.status === 'succeeded' ? '#16a34a' : '#64748b',
|
||||
background: order.status === 'succeeded' ? 'rgba(34,197,94,0.1)' : order.status === 'canceled' ? 'rgba(239,68,68,0.08)' : 'rgba(148,163,184,0.15)',
|
||||
color: order.status === 'succeeded' ? '#16a34a' : order.status === 'canceled' ? '#dc2626' : '#64748b',
|
||||
}}>
|
||||
{order.status}
|
||||
</span>
|
||||
@@ -837,53 +880,124 @@ function OrderRow({
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div style={{ padding: '0 1.25rem 1rem 1.25rem', display: 'grid', gridTemplateColumns: snapshot && snapshot !== 'loading' && snapshot !== 'none' ? '1fr 200px' : '1fr', gap: '1rem' }}>
|
||||
{/* Customer info grid */}
|
||||
<div style={{ background: 'rgba(248,248,246,0.8)', borderRadius: '0.5rem', padding: '0.875rem 1rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.625rem 1.5rem' }}>
|
||||
{[
|
||||
{ label: 'Full Name', value: m.customerName },
|
||||
{ label: 'Email', value: m.customerEmail },
|
||||
{ label: 'Phone', value: m.customerPhone },
|
||||
{ label: 'Address', value: m.customerAddress },
|
||||
{ label: 'City', value: m.customerCity },
|
||||
{ label: 'Country', value: m.customerCountry },
|
||||
{ label: 'Postal Code', value: m.customerPostalCode },
|
||||
{ label: 'Persona', value: m.persona },
|
||||
{ label: 'Color', value: m.color },
|
||||
].map(({ label, value }) => value ? (
|
||||
<div key={label}>
|
||||
<div style={{ fontSize: '0.63rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#374151', marginTop: '0.1rem', wordBreak: 'break-word', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
{label === 'Color' && (
|
||||
<span style={{ display: 'inline-block', width: 12, height: 12, borderRadius: 3, backgroundColor: value, border: '1px solid rgba(0,0,0,0.12)', flexShrink: 0 }} />
|
||||
)}
|
||||
{value}
|
||||
<div style={{ padding: '0 1.25rem 1.25rem 1.25rem', display: 'grid', gridTemplateColumns: snapshot && snapshot !== 'loading' && snapshot !== 'none' ? '1fr 220px' : '1fr', gap: '1rem' }}>
|
||||
|
||||
{/* Left: all info sections */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
|
||||
{/* Shipping details */}
|
||||
<SectionBox title="Shipping">
|
||||
<InfoGrid>
|
||||
<InfoField label="Full Name" value={m.customerName} />
|
||||
<InfoField label="Email" value={m.customerEmail} />
|
||||
<InfoField label="Phone" value={m.customerPhone} />
|
||||
<InfoField label="Address" value={m.customerAddress} />
|
||||
<InfoField label="City" value={m.customerCity} />
|
||||
<InfoField label="Country" value={m.customerCountry} />
|
||||
<InfoField label="Postal Code" value={m.customerPostalCode} />
|
||||
</InfoGrid>
|
||||
</SectionBox>
|
||||
|
||||
{/* Configuration */}
|
||||
<SectionBox title="Configuration">
|
||||
<InfoGrid>
|
||||
<InfoField label="Persona / Attire" value={m.persona} />
|
||||
<div>
|
||||
<div style={fieldStyle}>Color</div>
|
||||
{m.color ? (
|
||||
<div style={{ ...valueStyle, display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={{ display: 'inline-block', width: 13, height: 13, borderRadius: 3, backgroundColor: m.color, border: '1px solid rgba(0,0,0,0.12)', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace' }}>{m.color}</span>
|
||||
</div>
|
||||
) : <div style={valueStyle}>—</div>}
|
||||
</div>
|
||||
</div>
|
||||
) : null)}
|
||||
</InfoGrid>
|
||||
</SectionBox>
|
||||
|
||||
{/* Price Breakdown */}
|
||||
<SectionBox title="Price Breakdown">
|
||||
{priceLines.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
{priceLines.map((line, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.78rem', color: '#374151' }}>{line.label}</span>
|
||||
<span style={{ fontSize: '0.78rem', color: '#374151', fontFamily: 'monospace' }}>
|
||||
AED {new Intl.NumberFormat('en-AE').format(line.price)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '0.4rem', marginTop: '0.1rem', borderTop: '1px solid rgba(0,0,0,0.07)' }}>
|
||||
<span style={{ fontSize: '0.82rem', fontWeight: 700, color: '#1a1a2e' }}>Total</span>
|
||||
<span style={{ fontSize: '0.88rem', fontWeight: 700, color: '#1a1a2e', fontFamily: 'monospace' }}>
|
||||
{formatAmount(order.amount, order.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.78rem', color: '#64748b' }}>Total (legacy order)</span>
|
||||
<span style={{ fontSize: '0.88rem', fontWeight: 700, color: '#1a1a2e', fontFamily: 'monospace' }}>
|
||||
{formatAmount(order.amount, order.currency)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</SectionBox>
|
||||
|
||||
{/* Payment ID */}
|
||||
<div style={{ fontSize: '0.65rem', color: '#cbd5e1', fontFamily: 'monospace' }}>
|
||||
Payment ID: {order.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Robot snapshot */}
|
||||
{snapshot === 'loading' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(248,248,246,0.8)', borderRadius: '0.5rem', minHeight: 120, fontSize: '0.75rem', color: '#94a3b8' }}>Loading image…</div>
|
||||
)}
|
||||
{snapshot && snapshot !== 'loading' && snapshot !== 'none' && (
|
||||
<div>
|
||||
<div style={{ fontSize: '0.63rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.375rem' }}>Robot Configuration</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{/* Right: snapshot */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
<div style={{ fontSize: '0.63rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Robot Snapshot</div>
|
||||
{snapshot === 'loading' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(248,248,246,0.8)', borderRadius: '0.5rem', minHeight: 120, fontSize: '0.75rem', color: '#94a3b8' }}>Loading…</div>
|
||||
)}
|
||||
{snapshot && snapshot !== 'loading' && snapshot !== 'none' && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={snapshot}
|
||||
alt="Robot configuration snapshot"
|
||||
style={{ width: '100%', borderRadius: '0.5rem', border: '1px solid rgba(0,0,0,0.06)', display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{snapshot === 'none' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(248,248,246,0.8)', borderRadius: '0.5rem', minHeight: 80, fontSize: '0.72rem', color: '#cbd5e1' }}>No snapshot</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionBox({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ background: 'rgba(248,248,246,0.8)', borderRadius: '0.5rem', padding: '0.75rem 1rem' }}>
|
||||
<div style={{ fontSize: '0.6rem', fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '0.5rem' }}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoGrid({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem 1.5rem' }}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: '0.63rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#374151', marginTop: '0.1rem', wordBreak: 'break-word' }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div style={{ background: 'rgba(255,255,255,0.95)', border: '1px solid rgba(0,0,0,0.06)', borderRadius: '0.75rem', padding: '1rem 1.25rem' }}>
|
||||
|
||||
Reference in New Issue
Block a user