diff --git a/frontend/package/CreateShipmentWizard.tsx b/frontend/package/CreateShipmentWizard.tsx new file mode 100644 index 00000000..0e1a6988 --- /dev/null +++ b/frontend/package/CreateShipmentWizard.tsx @@ -0,0 +1,427 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useForm, type Resolver } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { apiClient } from '@/lib/api/client'; + +// ── Step Schemas ─────────────────────────────────────────────────────────────── + +const step1Schema = z.object({ + origin: z.string().min(2, 'Origin is required'), + destination: z.string().min(2, 'Destination is required'), +}); + +const step2Schema = z.object({ + cargoDescription: z.string().min(10, 'Describe the cargo (min 10 chars)'), + weightKg: z.coerce.number().positive('Weight must be positive'), + volumeCbm: z.coerce.number().positive().optional().or(z.literal('')), +}); + +const step3Schema = z.object({ + pickupDate: z.string().optional(), + estimatedDeliveryDate: z.string().optional(), + requiresRefrigeration: z.boolean().optional(), + isHazardous: z.boolean().optional(), +}); + +const step4Schema = z.object({ + price: z.coerce.number().min(0.01, 'Price must be greater than 0'), + currency: z.string().length(3, 'Must be 3 characters').default('USD'), +}); + +const step5Schema = z.object({ + notes: z.string().max(2000).optional(), +}); + +const fullSchema = step1Schema + .merge(step2Schema) + .merge(step3Schema) + .merge(step4Schema) + .merge(step5Schema); + +type FormValues = z.infer; + +// ── Step Metadata ────────────────────────────────────────────────────────────── + +const STEPS = [ + { label: 'Route', description: 'Origin & destination' }, + { label: 'Cargo', description: 'What are you shipping?' }, + { label: 'Logistics', description: 'Pickup & handling' }, + { label: 'Pricing', description: 'Cost details' }, + { label: 'Review', description: 'Confirm and submit' }, +]; + +// ── Component ────────────────────────────────────────────────────────────────── + +export function CreateShipmentWizard() { + const router = useRouter(); + const [step, setStep] = useState(0); + + const form = useForm({ + resolver: zodResolver(fullSchema) as Resolver, + defaultValues: { currency: 'USD', requiresRefrigeration: false, isHazardous: false }, + mode: 'onTouched', + }); + + const { + register, + handleSubmit, + trigger, + getValues, + setValue, + watch, + formState: { errors, isSubmitting }, + } = form; + + const requiresRefrigeration = watch('requiresRefrigeration'); + const isHazardous = watch('isHazardous'); + + const stepFields: (keyof FormValues)[][] = [ + ['origin', 'destination'], + ['cargoDescription', 'weightKg', 'volumeCbm'], + ['pickupDate', 'estimatedDeliveryDate'], + ['price', 'currency'], + ['notes'], + ]; + + const advance = async () => { + const valid = await trigger(stepFields[step]); + if (valid) setStep((s) => s + 1); + }; + + const createShipmentMutation = useMutation({ + mutationFn: (data: FormValues) => + apiClient('/shipments', { + method: 'POST', + body: JSON.stringify({ + ...data, + volumeCbm: data.volumeCbm === '' ? undefined : Number(data.volumeCbm), + }), + }), + onSuccess: (shipment: { id: string }) => { + toast.success('Shipment created successfully!'); + router.push(`/shipments/${shipment.id}`); + }, + onError: () => { + toast.error('Failed to create shipment. Please try again.'); + }, + }); + + const onSubmit = async (data: FormValues) => { + createShipmentMutation.mutate(data); + }; + + const values = getValues(); + + return ( +
+
+

Create Shipment

+

+ Post a new shipment for carriers to accept. +

+
+ + {/* Progress indicator */} +
+ {STEPS.map((s, i) => ( +
+
+
+ {i < step ? '✓' : i + 1} +
+ + {s.label} + +
+ {i < STEPS.length - 1 && ( +
+ )} +
+ ))} +
+ +
+ {/* Step 1 – Route */} + {step === 0 && ( + + + Route Details + + +
+ + + {errors.origin && ( +

{errors.origin.message}

+ )} +
+
+ + + {errors.destination && ( +

{errors.destination.message}

+ )} +
+
+
+ )} + + {/* Step 2 – Cargo */} + {step === 1 && ( + + + Cargo Details + + +
+ +