forked from hazem/yslootahrobotics
feat: implement order synchronization with Stripe and enhance order management features
This commit is contained in:
parent
a501d94645
commit
9a1a562fc6
BIN
prisma/lootah.db
BIN
prisma/lootah.db
Binary file not shown.
@ -42,6 +42,7 @@ model Order {
|
|||||||
customerPostalCode String?
|
customerPostalCode String?
|
||||||
persona String?
|
persona String?
|
||||||
color String?
|
color String?
|
||||||
|
priceItems String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
@ -228,6 +228,8 @@ export default function AdminPage() {
|
|||||||
const [ordersLoading, setOrdersLoading] = useState(false);
|
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||||
const [ordersError, setOrdersError] = useState('');
|
const [ordersError, setOrdersError] = useState('');
|
||||||
const [totalRevenue, setTotalRevenue] = useState(0);
|
const [totalRevenue, setTotalRevenue] = useState(0);
|
||||||
|
const [syncingOrders, setSyncingOrders] = useState(false);
|
||||||
|
const [syncMsg, setSyncMsg] = useState('');
|
||||||
|
|
||||||
const loadOrders = useCallback(async () => {
|
const loadOrders = useCallback(async () => {
|
||||||
setOrdersLoading(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'orders') loadOrders();
|
if (activeTab === 'orders') loadOrders();
|
||||||
}, [activeTab, loadOrders]);
|
}, [activeTab, loadOrders]);
|
||||||
@ -615,7 +634,11 @@ export default function AdminPage() {
|
|||||||
{/* ===== ORDERS TAB ===== */}
|
{/* ===== ORDERS TAB ===== */}
|
||||||
{activeTab === 'orders' && (
|
{activeTab === 'orders' && (
|
||||||
<div>
|
<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}>
|
<button onClick={loadOrders} disabled={ordersLoading} style={secondaryBtnStyle}>
|
||||||
{ordersLoading ? 'Loading…' : 'Refresh'}
|
{ordersLoading ? 'Loading…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
@ -789,6 +812,12 @@ function OrderRow({
|
|||||||
const name = m.customerName || '—';
|
const name = m.customerName || '—';
|
||||||
const email = m.customerEmail || '';
|
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 handleExpand = () => {
|
||||||
const next = !expanded;
|
const next = !expanded;
|
||||||
setExpanded(next);
|
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 (
|
return (
|
||||||
<div style={{ borderBottom: isLast ? 'none' : '1px solid rgba(0,0,0,0.04)' }}>
|
<div style={{ borderBottom: isLast ? 'none' : '1px solid rgba(0,0,0,0.04)' }}>
|
||||||
{/* Main row */}
|
{/* Collapsed row */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px 90px 100px 28px', padding: '0.75rem 1.25rem', alignItems: 'center' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 90px 100px 28px', padding: '0.75rem 1.25rem', alignItems: 'center' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{name}</div>
|
<div style={{ fontSize: '0.85rem', color: '#374151', fontWeight: 500 }}>{name}</div>
|
||||||
{email && <div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>{email}</div>}
|
{email && <div style={{ fontSize: '0.7rem', color: '#94a3b8' }}>{email}</div>}
|
||||||
@ -819,8 +862,8 @@ function OrderRow({
|
|||||||
fontSize: '0.65rem',
|
fontSize: '0.65rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
background: order.status === 'succeeded' ? 'rgba(34,197,94,0.1)' : 'rgba(148,163,184,0.15)',
|
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' : '#64748b',
|
color: order.status === 'succeeded' ? '#16a34a' : order.status === 'canceled' ? '#dc2626' : '#64748b',
|
||||||
}}>
|
}}>
|
||||||
{order.status}
|
{order.status}
|
||||||
</span>
|
</span>
|
||||||
@ -837,49 +880,120 @@ function OrderRow({
|
|||||||
|
|
||||||
{/* Expanded details */}
|
{/* Expanded details */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div style={{ padding: '0 1.25rem 1rem 1.25rem', display: 'grid', gridTemplateColumns: snapshot && snapshot !== 'loading' && snapshot !== 'none' ? '1fr 200px' : '1fr', gap: '1rem' }}>
|
<div style={{ padding: '0 1.25rem 1.25rem 1.25rem', display: 'grid', gridTemplateColumns: snapshot && snapshot !== 'loading' && snapshot !== 'none' ? '1fr 220px' : '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' }}>
|
{/* Left: all info sections */}
|
||||||
{[
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
{ label: 'Full Name', value: m.customerName },
|
|
||||||
{ label: 'Email', value: m.customerEmail },
|
{/* Shipping details */}
|
||||||
{ label: 'Phone', value: m.customerPhone },
|
<SectionBox title="Shipping">
|
||||||
{ label: 'Address', value: m.customerAddress },
|
<InfoGrid>
|
||||||
{ label: 'City', value: m.customerCity },
|
<InfoField label="Full Name" value={m.customerName} />
|
||||||
{ label: 'Country', value: m.customerCountry },
|
<InfoField label="Email" value={m.customerEmail} />
|
||||||
{ label: 'Postal Code', value: m.customerPostalCode },
|
<InfoField label="Phone" value={m.customerPhone} />
|
||||||
{ label: 'Persona', value: m.persona },
|
<InfoField label="Address" value={m.customerAddress} />
|
||||||
{ label: 'Color', value: m.color },
|
<InfoField label="City" value={m.customerCity} />
|
||||||
].map(({ label, value }) => value ? (
|
<InfoField label="Country" value={m.customerCountry} />
|
||||||
<div key={label}>
|
<InfoField label="Postal Code" value={m.customerPostalCode} />
|
||||||
<div style={{ fontSize: '0.63rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
|
</InfoGrid>
|
||||||
<div style={{ fontSize: '0.8rem', color: '#374151', marginTop: '0.1rem', wordBreak: 'break-word', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
</SectionBox>
|
||||||
{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 }} />
|
{/* 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>
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
{value}
|
</SectionBox>
|
||||||
|
|
||||||
|
{/* Payment ID */}
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#cbd5e1', fontFamily: 'monospace' }}>
|
||||||
|
Payment ID: {order.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Robot snapshot */}
|
{/* 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' && (
|
{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>
|
<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' && (
|
{snapshot && snapshot !== 'loading' && snapshot !== 'none' && (
|
||||||
<div>
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<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 */}
|
|
||||||
<img
|
<img
|
||||||
src={snapshot}
|
src={snapshot}
|
||||||
alt="Robot configuration snapshot"
|
alt="Robot configuration snapshot"
|
||||||
style={{ width: '100%', borderRadius: '0.5rem', border: '1px solid rgba(0,0,0,0.06)', display: 'block' }}
|
style={{ width: '100%', borderRadius: '0.5rem', border: '1px solid rgba(0,0,0,0.06)', display: 'block' }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,24 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
async function verifyAdmin() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('admin_token')?.value;
|
||||||
|
if (!token) return false;
|
||||||
|
const jwtSecret = process.env.ADMIN_JWT_SECRET;
|
||||||
|
if (!jwtSecret) return false;
|
||||||
|
try {
|
||||||
|
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') ?? '50', 10), 200);
|
const limit = Math.min(parseInt(searchParams.get('limit') ?? '50', 10), 200);
|
||||||
|
|
||||||
@ -27,6 +44,7 @@ export async function GET(request: Request) {
|
|||||||
customerPostalCode: o.customerPostalCode ?? '',
|
customerPostalCode: o.customerPostalCode ?? '',
|
||||||
persona: o.persona ?? '',
|
persona: o.persona ?? '',
|
||||||
color: o.color ?? '',
|
color: o.color ?? '',
|
||||||
|
priceItems: o.priceItems ?? '',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
66
src/app/api/admin/sync-orders/route.ts
Normal file
66
src/app/api/admin/sync-orders/route.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
apiVersion: '2026-03-25.dahlia' as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function verifyAdmin() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('admin_token')?.value;
|
||||||
|
if (!token) return false;
|
||||||
|
const jwtSecret = process.env.ADMIN_JWT_SECRET;
|
||||||
|
if (!jwtSecret) return false;
|
||||||
|
try {
|
||||||
|
await jwtVerify(token, new TextEncoder().encode(jwtSecret));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/sync-orders/
|
||||||
|
// Fetches recent PaymentIntents from Stripe and upserts them into the DB
|
||||||
|
export async function POST() {
|
||||||
|
if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const list = await stripe.paymentIntents.list({ limit: 100 });
|
||||||
|
let synced = 0;
|
||||||
|
|
||||||
|
for (const pi of list.data) {
|
||||||
|
if (pi.status !== 'succeeded' && pi.status !== 'canceled') continue;
|
||||||
|
const m = pi.metadata ?? {};
|
||||||
|
const data = {
|
||||||
|
amount: pi.amount,
|
||||||
|
currency: pi.currency,
|
||||||
|
status: pi.status,
|
||||||
|
customerName: m.customerName ?? null,
|
||||||
|
customerEmail: m.customerEmail ?? pi.receipt_email ?? null,
|
||||||
|
customerPhone: m.customerPhone ?? null,
|
||||||
|
customerAddress: m.customerAddress ?? null,
|
||||||
|
customerCity: m.customerCity ?? null,
|
||||||
|
customerCountry: m.customerCountry ?? null,
|
||||||
|
customerPostalCode: m.customerPostalCode ?? null,
|
||||||
|
persona: m.persona ?? null,
|
||||||
|
color: m.color ?? null,
|
||||||
|
priceItems: m.priceItems ?? null,
|
||||||
|
};
|
||||||
|
await prisma.order.upsert({
|
||||||
|
where: { paymentIntentId: pi.id },
|
||||||
|
create: { paymentIntentId: pi.id, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
synced++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ synced });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Sync failed';
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/app/api/orders/save/route.ts
Normal file
60
src/app/api/orders/save/route.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
apiVersion: '2026-03-25.dahlia' as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { paymentIntentId } = body;
|
||||||
|
if (!paymentIntentId || typeof paymentIntentId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing paymentIntentId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify with Stripe that this PaymentIntent actually succeeded — prevents spoofing
|
||||||
|
let pi: Stripe.PaymentIntent;
|
||||||
|
try {
|
||||||
|
pi = await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid paymentIntentId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pi.status !== 'succeeded') {
|
||||||
|
return NextResponse.json({ error: `Payment not succeeded (status: ${pi.status})` }, { status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Stripe's authoritative data (not client-submitted values) for financial fields
|
||||||
|
const m = pi.metadata ?? {};
|
||||||
|
const data = {
|
||||||
|
amount: pi.amount,
|
||||||
|
currency: pi.currency,
|
||||||
|
status: pi.status,
|
||||||
|
customerName: (body.customerName as string | null) ?? m.customerName ?? null,
|
||||||
|
customerEmail: (body.customerEmail as string | null) ?? m.customerEmail ?? null,
|
||||||
|
customerPhone: (body.customerPhone as string | null) ?? m.customerPhone ?? null,
|
||||||
|
customerAddress: (body.customerAddress as string | null) ?? m.customerAddress ?? null,
|
||||||
|
customerCity: (body.customerCity as string | null) ?? m.customerCity ?? null,
|
||||||
|
customerCountry: (body.customerCountry as string | null) ?? m.customerCountry ?? null,
|
||||||
|
customerPostalCode: (body.customerPostalCode as string | null) ?? m.customerPostalCode ?? null,
|
||||||
|
persona: (body.persona as string | null) ?? m.persona ?? null,
|
||||||
|
color: (body.color as string | null) ?? m.color ?? null,
|
||||||
|
priceItems: (body.priceItems as string | null) ?? m.priceItems ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.order.upsert({
|
||||||
|
where: { paymentIntentId },
|
||||||
|
create: { paymentIntentId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ saved: true });
|
||||||
|
}
|
||||||
@ -32,10 +32,16 @@ export function PricingEngine() {
|
|||||||
|
|
||||||
const handleProceed = () => {
|
const handleProceed = () => {
|
||||||
const store = orderStore.getState();
|
const store = orderStore.getState();
|
||||||
|
const lineItems: { label: string; price: number }[] = [
|
||||||
|
{ label: 'G1 Robot Base', price: basePrice },
|
||||||
|
...(personaPrice > 0 ? [{ label: personaLabel, price: personaPrice }] : []),
|
||||||
|
...(colorPrice > 0 ? [{ label: 'Custom Color', price: colorPrice }] : []),
|
||||||
|
];
|
||||||
store.setOrderTotal(total);
|
store.setOrderTotal(total);
|
||||||
store.setConfigSummary(
|
store.setConfigSummary(
|
||||||
persona === 'none' ? 'Default' : personaLabel,
|
persona === 'none' ? 'Default' : personaLabel,
|
||||||
primaryColor
|
primaryColor,
|
||||||
|
lineItems,
|
||||||
);
|
);
|
||||||
store.setStep('shipping');
|
store.setStep('shipping');
|
||||||
};
|
};
|
||||||
|
|||||||
@ -46,8 +46,43 @@ export function ReviewStep() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment succeeded — upload snapshot
|
// Payment succeeded — save order to DB and upload snapshot
|
||||||
const paymentIntentId = orderStore.getState().payment.paymentIntentId;
|
const paymentIntentId = orderStore.getState().payment.paymentIntentId;
|
||||||
|
const s = orderStore.getState().shipping;
|
||||||
|
const configSummary = orderStore.getState().personaSummary;
|
||||||
|
const colorVal = orderStore.getState().colorSummary;
|
||||||
|
const priceItems = orderStore.getState().priceItems;
|
||||||
|
const total = orderStore.getState().orderTotal;
|
||||||
|
|
||||||
|
// Retrieve the resolved PaymentIntent to get final status + amount
|
||||||
|
const pi = await stripe.retrievePaymentIntent(clientSecret);
|
||||||
|
const piStatus = pi.paymentIntent?.status ?? 'succeeded';
|
||||||
|
const piAmount = pi.paymentIntent?.amount ?? Math.round(total * 100);
|
||||||
|
|
||||||
|
// Save order to DB directly (covers local dev where webhook can't reach localhost)
|
||||||
|
if (paymentIntentId) {
|
||||||
|
fetch('/api/orders/save/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
paymentIntentId,
|
||||||
|
amount: piAmount,
|
||||||
|
currency: 'aed',
|
||||||
|
status: piStatus,
|
||||||
|
customerName: s.name,
|
||||||
|
customerEmail: s.email,
|
||||||
|
customerPhone: s.phone,
|
||||||
|
customerAddress: s.address,
|
||||||
|
customerCity: s.city,
|
||||||
|
customerCountry: s.country,
|
||||||
|
customerPostalCode: s.postalCode,
|
||||||
|
persona: configSummary,
|
||||||
|
color: colorVal,
|
||||||
|
priceItems: JSON.stringify(priceItems),
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (snapshotDataUrl && paymentIntentId) {
|
if (snapshotDataUrl && paymentIntentId) {
|
||||||
fetch('/api/snapshots/', {
|
fetch('/api/snapshots/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@ -20,6 +20,11 @@ export interface PaymentInfo {
|
|||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PriceLineItem {
|
||||||
|
label: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OrderState {
|
export interface OrderState {
|
||||||
step: CheckoutStep;
|
step: CheckoutStep;
|
||||||
shipping: ShippingInfo;
|
shipping: ShippingInfo;
|
||||||
@ -28,6 +33,7 @@ export interface OrderState {
|
|||||||
orderTotal: number;
|
orderTotal: number;
|
||||||
personaSummary: string;
|
personaSummary: string;
|
||||||
colorSummary: string;
|
colorSummary: string;
|
||||||
|
priceItems: PriceLineItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderActions {
|
export interface OrderActions {
|
||||||
@ -35,7 +41,7 @@ export interface OrderActions {
|
|||||||
setShipping: (shipping: ShippingInfo) => void;
|
setShipping: (shipping: ShippingInfo) => void;
|
||||||
setPayment: (payment: Partial<PaymentInfo>) => void;
|
setPayment: (payment: Partial<PaymentInfo>) => void;
|
||||||
setOrderTotal: (total: number) => void;
|
setOrderTotal: (total: number) => void;
|
||||||
setConfigSummary: (persona: string, color: string) => void;
|
setConfigSummary: (persona: string, color: string, priceItems?: PriceLineItem[]) => void;
|
||||||
createPaymentIntent: () => Promise<string | null>;
|
createPaymentIntent: () => Promise<string | null>;
|
||||||
placeOrder: () => void;
|
placeOrder: () => void;
|
||||||
resetOrder: () => void;
|
resetOrder: () => void;
|
||||||
@ -68,6 +74,7 @@ const defaultState: OrderState = {
|
|||||||
orderTotal: 0,
|
orderTotal: 0,
|
||||||
personaSummary: '',
|
personaSummary: '',
|
||||||
colorSummary: '',
|
colorSummary: '',
|
||||||
|
priceItems: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function generateOrderId(): string {
|
function generateOrderId(): string {
|
||||||
@ -89,13 +96,14 @@ export const orderStore = createStore<OrderStore>((set) => ({
|
|||||||
|
|
||||||
setOrderTotal: (total: number) => set({ orderTotal: total }),
|
setOrderTotal: (total: number) => set({ orderTotal: total }),
|
||||||
|
|
||||||
setConfigSummary: (persona: string, color: string) => set({
|
setConfigSummary: (persona: string, color: string, priceItems: PriceLineItem[] = []) => set({
|
||||||
personaSummary: persona,
|
personaSummary: persona,
|
||||||
colorSummary: color,
|
colorSummary: color,
|
||||||
|
priceItems,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createPaymentIntent: async (): Promise<string | null> => {
|
createPaymentIntent: async (): Promise<string | null> => {
|
||||||
const { orderTotal, personaSummary, colorSummary, shipping } = orderStore.getState();
|
const { orderTotal, personaSummary, colorSummary, shipping, priceItems } = orderStore.getState();
|
||||||
try {
|
try {
|
||||||
const res: Response = await fetch('/api/create-payment-intent/', {
|
const res: Response = await fetch('/api/create-payment-intent/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -107,6 +115,7 @@ export const orderStore = createStore<OrderStore>((set) => ({
|
|||||||
metadata: {
|
metadata: {
|
||||||
persona: personaSummary,
|
persona: personaSummary,
|
||||||
color: colorSummary,
|
color: colorSummary,
|
||||||
|
priceItems: JSON.stringify(priceItems),
|
||||||
customerName: shipping.name,
|
customerName: shipping.name,
|
||||||
customerEmail: shipping.email,
|
customerEmail: shipping.email,
|
||||||
customerPhone: shipping.phone,
|
customerPhone: shipping.phone,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user