diff --git a/prisma/lootah.db b/prisma/lootah.db index f3d1d31..ef62704 100644 Binary files a/prisma/lootah.db and b/prisma/lootah.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a28da2a..e699111 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,7 @@ model Order { customerPostalCode String? persona String? color String? + priceItems String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3277624..85e4849 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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' && (
-
+
+ {syncMsg && {syncMsg}} + @@ -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 (
- {/* Main row */} -
+ {/* Collapsed row */} +
{name}
{email &&
{email}
} @@ -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} @@ -837,53 +880,124 @@ function OrderRow({ {/* Expanded details */} {expanded && ( -
- {/* Customer info grid */} -
- {[ - { 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 ? ( -
-
{label}
-
- {label === 'Color' && ( - - )} - {value} +
+ + {/* Left: all info sections */} +
+ + {/* Shipping details */} + + + + + + + + + + + + + {/* Configuration */} + + + +
+
Color
+ {m.color ? ( +
+ + {m.color} +
+ ) :
}
-
- ) : null)} + + + + {/* Price Breakdown */} + + {priceLines.length > 0 ? ( +
+ {priceLines.map((line, i) => ( +
+ {line.label} + + AED {new Intl.NumberFormat('en-AE').format(line.price)} + +
+ ))} +
+ Total + + {formatAmount(order.amount, order.currency)} + +
+
+ ) : ( +
+ Total (legacy order) + + {formatAmount(order.amount, order.currency)} + +
+ )} +
+ + {/* Payment ID */} +
+ Payment ID: {order.id} +
- {/* Robot snapshot */} - {snapshot === 'loading' && ( -
Loading image…
- )} - {snapshot && snapshot !== 'loading' && snapshot !== 'none' && ( -
-
Robot Configuration
- {/* eslint-disable-next-line @next/next/no-img-element */} + {/* Right: snapshot */} +
+
Robot Snapshot
+ {snapshot === 'loading' && ( +
Loading…
+ )} + {snapshot && snapshot !== 'loading' && snapshot !== 'none' && ( + /* eslint-disable-next-line @next/next/no-img-element */ Robot configuration snapshot -
- )} + )} + {snapshot === 'none' && ( +
No snapshot
+ )} +
)}
); } +function SectionBox({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function InfoGrid({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} + +function InfoField({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( +
+
{label}
+
{value}
+
+ ); +} + function StatCard({ label, value }: { label: string; value: string }) { return (
diff --git a/src/app/api/admin/orders/route.ts b/src/app/api/admin/orders/route.ts index 7722e56..9028a51 100644 --- a/src/app/api/admin/orders/route.ts +++ b/src/app/api/admin/orders/route.ts @@ -1,7 +1,24 @@ import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { jwtVerify } from 'jose'; 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) { + if (!(await verifyAdmin())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const { searchParams } = new URL(request.url); const limit = Math.min(parseInt(searchParams.get('limit') ?? '50', 10), 200); @@ -27,6 +44,7 @@ export async function GET(request: Request) { customerPostalCode: o.customerPostalCode ?? '', persona: o.persona ?? '', color: o.color ?? '', + priceItems: o.priceItems ?? '', }, })); diff --git a/src/app/api/admin/sync-orders/route.ts b/src/app/api/admin/sync-orders/route.ts new file mode 100644 index 0000000..c69eea8 --- /dev/null +++ b/src/app/api/admin/sync-orders/route.ts @@ -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 }); + } +} diff --git a/src/app/api/orders/save/route.ts b/src/app/api/orders/save/route.ts new file mode 100644 index 0000000..4eaa375 --- /dev/null +++ b/src/app/api/orders/save/route.ts @@ -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; + 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 }); +} diff --git a/src/components/PricingEngine.tsx b/src/components/PricingEngine.tsx index dcd7c86..63709af 100644 --- a/src/components/PricingEngine.tsx +++ b/src/components/PricingEngine.tsx @@ -32,10 +32,16 @@ export function PricingEngine() { const handleProceed = () => { 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.setConfigSummary( persona === 'none' ? 'Default' : personaLabel, - primaryColor + primaryColor, + lineItems, ); store.setStep('shipping'); }; diff --git a/src/components/checkout/ReviewStep.tsx b/src/components/checkout/ReviewStep.tsx index 2a2ca8b..023b7d2 100644 --- a/src/components/checkout/ReviewStep.tsx +++ b/src/components/checkout/ReviewStep.tsx @@ -46,8 +46,43 @@ export function ReviewStep() { return; } - // Payment succeeded — upload snapshot + // Payment succeeded — save order to DB and upload snapshot 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) { fetch('/api/snapshots/', { method: 'POST', diff --git a/src/store/useOrderStore.ts b/src/store/useOrderStore.ts index 21f8377..f2cce86 100644 --- a/src/store/useOrderStore.ts +++ b/src/store/useOrderStore.ts @@ -20,6 +20,11 @@ export interface PaymentInfo { errorMessage: string; } +export interface PriceLineItem { + label: string; + price: number; +} + export interface OrderState { step: CheckoutStep; shipping: ShippingInfo; @@ -28,6 +33,7 @@ export interface OrderState { orderTotal: number; personaSummary: string; colorSummary: string; + priceItems: PriceLineItem[]; } export interface OrderActions { @@ -35,7 +41,7 @@ export interface OrderActions { setShipping: (shipping: ShippingInfo) => void; setPayment: (payment: Partial) => void; setOrderTotal: (total: number) => void; - setConfigSummary: (persona: string, color: string) => void; + setConfigSummary: (persona: string, color: string, priceItems?: PriceLineItem[]) => void; createPaymentIntent: () => Promise; placeOrder: () => void; resetOrder: () => void; @@ -68,6 +74,7 @@ const defaultState: OrderState = { orderTotal: 0, personaSummary: '', colorSummary: '', + priceItems: [], }; function generateOrderId(): string { @@ -89,13 +96,14 @@ export const orderStore = createStore((set) => ({ setOrderTotal: (total: number) => set({ orderTotal: total }), - setConfigSummary: (persona: string, color: string) => set({ + setConfigSummary: (persona: string, color: string, priceItems: PriceLineItem[] = []) => set({ personaSummary: persona, colorSummary: color, + priceItems, }), createPaymentIntent: async (): Promise => { - const { orderTotal, personaSummary, colorSummary, shipping } = orderStore.getState(); + const { orderTotal, personaSummary, colorSummary, shipping, priceItems } = orderStore.getState(); try { const res: Response = await fetch('/api/create-payment-intent/', { method: 'POST', @@ -107,6 +115,7 @@ export const orderStore = createStore((set) => ({ metadata: { persona: personaSummary, color: colorSummary, + priceItems: JSON.stringify(priceItems), customerName: shipping.name, customerEmail: shipping.email, customerPhone: shipping.phone,