From 9a1a562fc6764f1bf3690d32bf881f03e84c3f74 Mon Sep 17 00:00:00 2001 From: "Najjar\\NajjarV02" Date: Tue, 14 Apr 2026 10:34:19 +0400 Subject: [PATCH] feat: implement order synchronization with Stripe and enhance order management features --- prisma/lootah.db | Bin 45056 -> 65536 bytes prisma/schema.prisma | 1 + src/app/admin/page.tsx | 190 ++++++++++++++++++++----- src/app/api/admin/orders/route.ts | 18 +++ src/app/api/admin/sync-orders/route.ts | 66 +++++++++ src/app/api/orders/save/route.ts | 60 ++++++++ src/components/PricingEngine.tsx | 8 +- src/components/checkout/ReviewStep.tsx | 37 ++++- src/store/useOrderStore.ts | 15 +- 9 files changed, 352 insertions(+), 43 deletions(-) create mode 100644 src/app/api/admin/sync-orders/route.ts create mode 100644 src/app/api/orders/save/route.ts diff --git a/prisma/lootah.db b/prisma/lootah.db index f3d1d31b099a357be4355252655ef0de9d6094b9..ef627047abfbe67525c49aff0160715ca6f488d7 100644 GIT binary patch literal 65536 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 delta 153 zcmZo@U}<>3G(lRBgMop88;D_mZK95`A_s$BRRJ&m4+bvIjSPH8_>b~F=j-Jy;;G_w zJma4@L4=J9WEz6Yi^@flMdbnj DB(Ecd 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,