feat: implement snapshot capture and storage for robot configurations
This commit is contained in:
parent
05b540997e
commit
dc42aeb72a
@ -418,14 +418,22 @@ function OrderRow({
|
|||||||
formatDate: (ts: number) => string;
|
formatDate: (ts: number) => string;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [snapshot, setSnapshot] = useState<string | null | 'loading'>(null);
|
||||||
const m = order.metadata;
|
const m = order.metadata;
|
||||||
const name = m.customerName || '—';
|
const name = m.customerName || '—';
|
||||||
const email = m.customerEmail || '';
|
const email = m.customerEmail || '';
|
||||||
const phone = m.customerPhone || '';
|
|
||||||
const address = [m.customerAddress, m.customerCity, m.customerCountry, m.customerPostalCode]
|
const handleExpand = () => {
|
||||||
.filter(Boolean).join(', ') || '';
|
const next = !expanded;
|
||||||
const persona = m.persona || '';
|
setExpanded(next);
|
||||||
const color = m.color || '';
|
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 (
|
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)' }}>
|
||||||
@ -453,7 +461,7 @@ function OrderRow({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{formatDate(order.created)}</div>
|
<div style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{formatDate(order.created)}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={handleExpand}
|
||||||
title={expanded ? 'Collapse' : 'Show details'}
|
title={expanded ? 'Collapse' : 'Show details'}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '0.7rem', padding: '2px 4px', borderRadius: 4 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '0.7rem', padding: '2px 4px', borderRadius: 4 }}
|
||||||
>
|
>
|
||||||
@ -461,27 +469,50 @@ function OrderRow({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded customer details */}
|
{/* Expanded details */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div style={{ padding: '0 1.25rem 0.875rem 1.25rem' }}>
|
<div style={{ padding: '0 1.25rem 1rem 1.25rem', display: 'grid', gridTemplateColumns: snapshot && snapshot !== 'loading' && snapshot !== 'none' ? '1fr 200px' : '1fr', gap: '1rem' }}>
|
||||||
<div style={{ background: 'rgba(248,248,246,0.8)', borderRadius: '0.5rem', padding: '0.75rem 1rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem 1.5rem' }}>
|
{/* 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: 'Full Name', value: m.customerName },
|
||||||
{ label: 'Email', value: m.customerEmail },
|
{ label: 'Email', value: m.customerEmail },
|
||||||
{ label: 'Phone', value: m.customerPhone },
|
{ label: 'Phone', value: m.customerPhone },
|
||||||
{ label: 'Address', value: address },
|
{ label: 'Address', value: m.customerAddress },
|
||||||
{ label: 'Persona', value: persona },
|
{ label: 'City', value: m.customerCity },
|
||||||
{ label: 'Color', value: color },
|
{ label: 'Country', value: m.customerCountry },
|
||||||
].map(({ label, value }) =>
|
{ label: 'Postal Code', value: m.customerPostalCode },
|
||||||
value ? (
|
{ label: 'Persona', value: m.persona },
|
||||||
|
{ label: 'Color', value: m.color },
|
||||||
|
].map(({ label, value }) => value ? (
|
||||||
<div key={label}>
|
<div key={label}>
|
||||||
<div style={{ fontSize: '0.65rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</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.125rem', wordBreak: 'break-word' }}>{value}</div>
|
<div style={{ fontSize: '0.8rem', color: '#374151', marginTop: '0.1rem', wordBreak: 'break-word', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||||
</div>
|
{label === 'Color' && (
|
||||||
) : null
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null)}
|
||||||
|
</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 */}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
33
src/app/api/admin/snapshots/[id]/route.ts
Normal file
33
src/app/api/admin/snapshots/[id]/route.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/snapshots/route.ts
Normal file
33
src/app/api/snapshots/route.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from '@react-three/drei';
|
} from '@react-three/drei';
|
||||||
import { useThree } from '@react-three/fiber';
|
import { useThree } from '@react-three/fiber';
|
||||||
import { RobotModel } from './RobotModel';
|
import { RobotModel } from './RobotModel';
|
||||||
|
import { snapshotStore } from '@/store/useSnapshotStore';
|
||||||
import type { WebGLRenderer, Scene, Camera } from 'three';
|
import type { WebGLRenderer, Scene, Camera } from 'three';
|
||||||
|
|
||||||
function Loader() {
|
function Loader() {
|
||||||
@ -70,6 +71,17 @@ export function RobotCanvas() {
|
|||||||
glRef.current = gl;
|
glRef.current = gl;
|
||||||
sceneRef.current = scene;
|
sceneRef.current = scene;
|
||||||
cameraRef.current = camera;
|
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 () => {
|
const handleShare = useCallback(async () => {
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useStripe, useElements } from '@stripe/react-stripe-js';
|
import { useStripe, useElements } from '@stripe/react-stripe-js';
|
||||||
import { useOrderStore, orderStore } from '@/store/useOrderStore';
|
import { useOrderStore, orderStore } from '@/store/useOrderStore';
|
||||||
|
import { snapshotStore } from '@/store/useSnapshotStore';
|
||||||
|
|
||||||
function formatAED(price: number): string {
|
function formatAED(price: number): string {
|
||||||
return new Intl.NumberFormat('en-AE').format(price);
|
return new Intl.NumberFormat('en-AE').format(price);
|
||||||
@ -25,6 +26,9 @@ export function ReviewStep() {
|
|||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setErrorMsg('');
|
setErrorMsg('');
|
||||||
|
|
||||||
|
// Capture robot snapshot before payment (canvas still rendered behind overlay)
|
||||||
|
const snapshotDataUrl = snapshotStore.getState().capture();
|
||||||
|
|
||||||
const { error } = await stripe.confirmPayment({
|
const { error } = await stripe.confirmPayment({
|
||||||
elements,
|
elements,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
@ -42,7 +46,16 @@ export function ReviewStep() {
|
|||||||
return;
|
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().setPayment({ status: 'succeeded' });
|
||||||
orderStore.getState().placeOrder();
|
orderStore.getState().placeOrder();
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user