diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 61d8caf..3412cfe 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -418,14 +418,22 @@ function OrderRow({ formatDate: (ts: number) => string; }) { const [expanded, setExpanded] = useState(false); + const [snapshot, setSnapshot] = useState(null); const m = order.metadata; const name = m.customerName || '—'; const email = m.customerEmail || ''; - const phone = m.customerPhone || ''; - const address = [m.customerAddress, m.customerCity, m.customerCountry, m.customerPostalCode] - .filter(Boolean).join(', ') || ''; - const persona = m.persona || ''; - const color = m.color || ''; + + const handleExpand = () => { + const next = !expanded; + setExpanded(next); + if (next && snapshot === null) { + setSnapshot('loading'); + fetch(`/api/admin/snapshots/${order.id}/`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => setSnapshot(data?.imageData ?? 'none')) + .catch(() => setSnapshot('none')); + } + }; return (
@@ -453,7 +461,7 @@ function OrderRow({
{formatDate(order.created)}
- {/* Expanded customer details */} + {/* 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: address }, - { label: 'Persona', value: persona }, - { label: 'Color', value: color }, - ].map(({ label, value }) => - value ? ( -
-
{label}
-
{value}
+ { 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}
- ) : null - )} +
+ ) : null)}
+ + {/* Robot snapshot */} + {snapshot === 'loading' && ( +
Loading image…
+ )} + {snapshot && snapshot !== 'loading' && snapshot !== 'none' && ( +
+
Robot Configuration
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Robot configuration snapshot +
+ )}
)}
diff --git a/src/app/api/admin/snapshots/[id]/route.ts b/src/app/api/admin/snapshots/[id]/route.ts new file mode 100644 index 0000000..2c8d33e --- /dev/null +++ b/src/app/api/admin/snapshots/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { jwtVerify } from 'jose'; +import { prisma } from '@/lib/prisma'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Verify admin JWT + const cookieStore = await cookies(); + const token = cookieStore.get('admin_token')?.value; + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const jwtSecret = process.env.ADMIN_JWT_SECRET; + if (!jwtSecret) return NextResponse.json({ error: 'Server error' }, { status: 500 }); + + await jwtVerify(token, new TextEncoder().encode(jwtSecret)); + + const { id } = await params; + if (!id || !id.startsWith('pi_')) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const snapshot = await prisma.snapshot.findUnique({ where: { paymentIntentId: id } }); + if (!snapshot) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + return NextResponse.json({ imageData: snapshot.imageData }); + } catch { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} diff --git a/src/app/api/snapshots/route.ts b/src/app/api/snapshots/route.ts new file mode 100644 index 0000000..120c3d4 --- /dev/null +++ b/src/app/api/snapshots/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function POST(request: Request) { + try { + const { paymentIntentId, imageData } = await request.json(); + + // Validate paymentIntentId format + if (!paymentIntentId || typeof paymentIntentId !== 'string' || !paymentIntentId.startsWith('pi_')) { + return NextResponse.json({ error: 'Invalid paymentIntentId' }, { status: 400 }); + } + + if (!imageData || typeof imageData !== 'string') { + return NextResponse.json({ error: 'Invalid imageData' }, { status: 400 }); + } + + // Only accept data URLs (JPEG or PNG) + if (!imageData.startsWith('data:image/jpeg;base64,') && !imageData.startsWith('data:image/png;base64,')) { + return NextResponse.json({ error: 'Invalid image format' }, { status: 400 }); + } + + await prisma.snapshot.upsert({ + where: { paymentIntentId }, + create: { paymentIntentId, imageData }, + update: { imageData }, + }); + + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/components/RobotCanvas.tsx b/src/components/RobotCanvas.tsx index 6c0c9c9..f0cdb2b 100644 --- a/src/components/RobotCanvas.tsx +++ b/src/components/RobotCanvas.tsx @@ -11,6 +11,7 @@ import { } from '@react-three/drei'; import { useThree } from '@react-three/fiber'; import { RobotModel } from './RobotModel'; +import { snapshotStore } from '@/store/useSnapshotStore'; import type { WebGLRenderer, Scene, Camera } from 'three'; function Loader() { @@ -70,6 +71,17 @@ export function RobotCanvas() { glRef.current = gl; sceneRef.current = scene; cameraRef.current = camera; + + // Register a programmatic capture function for the checkout snapshot + snapshotStore.getState().registerCapture(() => { + if (!glRef.current || !sceneRef.current || !cameraRef.current) return null; + try { + glRef.current.render(sceneRef.current, cameraRef.current); + return glRef.current.domElement.toDataURL('image/jpeg', 0.75); + } catch { + return null; + } + }); }, []); const handleShare = useCallback(async () => { diff --git a/src/components/checkout/ReviewStep.tsx b/src/components/checkout/ReviewStep.tsx index 87e817f..2a2ca8b 100644 --- a/src/components/checkout/ReviewStep.tsx +++ b/src/components/checkout/ReviewStep.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useStripe, useElements } from '@stripe/react-stripe-js'; import { useOrderStore, orderStore } from '@/store/useOrderStore'; +import { snapshotStore } from '@/store/useSnapshotStore'; function formatAED(price: number): string { return new Intl.NumberFormat('en-AE').format(price); @@ -25,6 +26,9 @@ export function ReviewStep() { setIsProcessing(true); setErrorMsg(''); + // Capture robot snapshot before payment (canvas still rendered behind overlay) + const snapshotDataUrl = snapshotStore.getState().capture(); + const { error } = await stripe.confirmPayment({ elements, clientSecret, @@ -42,7 +46,16 @@ export function ReviewStep() { return; } - // Payment succeeded + // Payment succeeded — upload snapshot + const paymentIntentId = orderStore.getState().payment.paymentIntentId; + if (snapshotDataUrl && paymentIntentId) { + fetch('/api/snapshots/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paymentIntentId, imageData: snapshotDataUrl }), + }).catch(() => {}); // fire-and-forget + } + orderStore.getState().setPayment({ status: 'succeeded' }); orderStore.getState().placeOrder(); };