Files
route-commerce/src/components/route-trace/StickerPreviewModal.tsx
T
tyler 1fe5ffee8d Refactor: move public storefront stop data to server-side + parallel agent work
Server-side / caching refactor (Grok):
- New RPC get_public_stops_for_brand (migration 148) for public storefront stops
- New server action getPublicStopsForBrand with revalidate=300 + tags
- Add revalidateTag invalidation to createStopsBatch + publishStop
- Convert /tuxedo/stops and /indian-river-direct/stops to Server Components
- Extract TuxedoStopsList + IndianRiverStopsList as client islands (GSAP only)
- Removes supabase-js from browser bundle on those routes
- Both pages now statically prerendered (5m ISR)

Parallel agent changes also staged:
- AI provider model list refresh (claude-sonnet-4-5, etc.)
- ESLint directive patches for react-hooks/set-state-in-effect
- Admin + storefront + checkout + cart updates
- New admin_create_stop_rpcs migration (147)
- Misc fixes across ~90 files

Build verified: typecheck clean, lint clean on new files, production build succeeds.
2026-06-03 02:04:21 +00:00

304 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { LotDetail } from "@/actions/route-trace/lots";
// One-color outline icons
const Icons = {
printer: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
<path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6"/>
<rect x="6" y="14" width="12" height="8" rx="1"/>
</svg>
),
leaf: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/>
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/>
</svg>
),
warehouse: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 8.35V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8.35A2 2 0 0 1 3.26 6.5l8-3.2a2 2 0 0 1 1.48 0l8 3.2A2 2 0 0 1 22 8.35Z"/>
<path d="M6 18h12"/>
<path d="M6 14h12"/>
<rect x="6" y="10" width="12" height="4" rx="1"/>
</svg>
),
};
type StickerType = "field" | "shed";
type StickerSize = "4x2" | "4x3";
const COPY_OPTIONS = [1, 2, 3, 5, 10];
export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail; onClose: () => void }) {
const [stickerType, setStickerType] = useState<StickerType>("field");
const [stickerSize, setStickerSize] = useState<StickerSize>("4x2");
const [copies, setCopies] = useState(1);
const [loading, setLoading] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const baseUrl = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000";
const traceUrl = `${baseUrl}/trace/${lot.lot_number}`;
useEffect(() => {
import("qrcode").then(({ toDataURL }) => {
toDataURL(traceUrl, { width: 200, margin: 1, color: { dark: "#000000", light: "#FFFFFF" } })
.then(setQrDataUrl)
.catch(() => {});
});
}, [traceUrl]);
async function handlePrint() {
setLoading(true);
try {
const url = `/api/route-trace/sticker-pdf?lotId=${lot.lot_id}&type=${stickerType}&size=${stickerSize}&copies=${copies}`;
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to generate PDF");
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = `${lot.lot_number}-${stickerType}-${stickerSize}.pdf`;
a.target = "_blank";
a.click();
URL.revokeObjectURL(blobUrl);
} catch (e) {
alert("Failed to generate PDF. Please try again.");
} finally {
setLoading(false);
}
}
// Actual thermal label dimensions in points: 4x2" = 288×144, 4x3" = 288×216
const LABEL_W = 288;
const LABEL_H = stickerSize === "4x3" ? 216 : 144;
// Scale preview to ~1.8x so it's readable in the modal
const scale = 1.8;
const previewW = LABEL_W * scale;
const previewH = LABEL_H * scale;
const qrSize = stickerSize === "4x3" ? 130 : 110;
const qrPreviewSize = qrSize * scale * 0.55; // scaled down for preview
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-lg rounded-2xl bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
<div>
<h3 className="text-base font-semibold text-stone-900 flex items-center gap-2">{Icons.printer("h-5 w-5")} Print Sticker</h3>
<p className="text-xs text-stone-400 mt-0.5">{lot.lot_number} · {lot.crop_type}</p>
</div>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-5">
{/* Type + Size selectors */}
<div className="grid grid-cols-3 gap-4">
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Sticker Type</p>
<div className="space-y-1.5">
{(["field", "shed"] as StickerType[]).map((t) => {
const label = t === "field" ? "Field" : "Shed";
const desc = t === "field" ? "After harvest" : "At shed arrival";
return (
<button
key={t}
onClick={() => setStickerType(t)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerType === t
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
>
<div className="text-sm font-semibold">{label} Sticker</div>
<div className={`text-[10px] mt-0.5 ${stickerType === t ? "text-stone-300" : "text-stone-400"}`}>{desc}</div>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Label Size</p>
<div className="space-y-1.5">
{(["4x2", "4x3"] as StickerSize[]).map((s) => {
const w = s === "4x2" ? "2 in" : "3 in";
const h = s === "4x2" ? "2 in" : "3 in";
return (
<button
key={s}
onClick={() => setStickerSize(s)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerSize === s
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
>
<div className="text-sm font-semibold">{w} × {h}</div>
<div className={`text-[10px] mt-0.5 ${stickerSize === s ? "text-stone-300" : "text-stone-400"}`}>
{s === "4x2" ? "Field sticker" : "Shed sticker"}
</div>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Copies</p>
<div className="grid grid-cols-5 gap-1.5">
{COPY_OPTIONS.map((n) => (
<button
key={n}
onClick={() => setCopies(n)}
className={`rounded-lg border py-2 text-center text-sm font-bold transition-colors ${
copies === n
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
>
{n}
</button>
))}
</div>
<p className="mt-1.5 text-[10px] text-stone-400 text-center">labels per sheet</p>
</div>
</div>
{/* Sticker preview — actual proportions, scaled */}
<div className="flex flex-col items-center gap-2">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Preview</p>
<div
className="relative rounded-lg border-2 border-stone-300 overflow-hidden bg-white"
style={{ width: previewW, height: previewH }}
>
<div className="absolute inset-0 p-3.5 flex flex-col text-black select-none">
{/* Brand row */}
<div className="flex items-center justify-between">
<span className="text-[6px] font-bold uppercase tracking-widest text-stone-400">Route Trace</span>
<span className="text-[5px] text-stone-300">{stickerSize}&quot;</span>
</div>
{/* Lot number — dominant */}
<div className="text-[18px] font-black leading-tight text-stone-950 tracking-tight mt-0.5">
{lot.lot_number}
</div>
{/* Crop */}
<div className="text-[9px] font-bold text-stone-700 mt-0.5">{lot.crop_type}</div>
{/* Data rows */}
<div className="flex flex-1 mt-1 gap-2">
<div className="flex-1 space-y-0.5">
{stickerType === "field" ? (
<>
{lot.harvest_date && <div className="text-[6px] text-stone-600">Harvested: {lot.harvest_date}</div>}
{lot.quantity_lbs && (
<div className="text-[8px] font-black text-stone-950 mt-1">
{Number(lot.quantity_lbs).toLocaleString()} lbs
</div>
)}
{lot.field_location && <div className="text-[6px] text-stone-600">{lot.field_location}</div>}
{lot.field_block && <div className="text-[6px] text-stone-500">Block: {lot.field_block}</div>}
{lot.worker_name && <div className="text-[6px] text-stone-600">{lot.worker_name}</div>}
{lot.yield_estimate_lbs && (
<div className="text-[6px] text-stone-600">Est: {Number(lot.yield_estimate_lbs).toLocaleString()} {lot.yield_unit ?? "lbs"}</div>
)}
</>
) : (
<>
{lot.quantity_lbs && (
<div className="text-[10px] font-black text-stone-950">
{Number(lot.quantity_lbs).toLocaleString()} {lot.yield_unit ?? "lbs"}
</div>
)}
{lot.harvest_date && <div className="text-[6px] text-stone-600">Packed: {lot.harvest_date}</div>}
{lot.field_location && <div className="text-[6px] text-stone-600">From: {lot.field_location}</div>}
{lot.worker_name && <div className="text-[6px] text-stone-600">{lot.worker_name}</div>}
{lot.destination_stop_id && (
<div className="text-[6px] text-stone-600">Dest: #{lot.destination_stop_id.slice(0, 8)}</div>
)}
</>
)}
</div>
{/* Right — bin/container */}
<div className="w-20 text-right space-y-0.5">
{lot.bin_id && (
<div className="text-[6px] font-black text-stone-950">BIN {lot.bin_id}</div>
)}
{lot.container_id && (
<div className="text-[6px] font-black text-stone-950">CONT {lot.container_id}</div>
)}
{lot.pallets && (
<div className="text-[5px] text-stone-500">{lot.pallets} PLT</div>
)}
{lot.field_block && stickerType === "shed" && (
<div className="text-[5px] text-stone-400">Block: {lot.field_block}</div>
)}
</div>
</div>
{/* QR — bottom right */}
<div className="absolute bottom-1.5 right-1.5">
{qrDataUrl ? (
<img
src={qrDataUrl}
alt="QR"
className="rounded border border-stone-300"
style={{ width: qrPreviewSize, height: qrPreviewSize }}
/>
) : (
<div
className="bg-stone-100 border border-stone-300 rounded flex items-center justify-center"
style={{ width: qrPreviewSize, height: qrPreviewSize }}
>
<span className="text-[5px] text-stone-400">QR</span>
</div>
)}
</div>
{/* /trace url — bottom left */}
<div className="absolute bottom-1.5 left-1.5 text-[4px] text-stone-300 truncate max-w-[90px]">
/trace/{lot.lot_number}
</div>
</div>
</div>
</div>
{/* Print specs */}
<div className="rounded-xl bg-stone-50 border border-stone-200 px-4 py-2.5 flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-stone-700">Direct Thermal · {stickerSize}&quot; · Black on White · Large QR</p>
<p className="text-[10px] text-stone-400 mt-0.5">Helvetica Bold · High-contrast QR · No margins · 2 per sheet</p>
</div>
<div className="text-right">
<p className="text-xs font-bold text-stone-600">{lot.lot_number}</p>
<p className="text-[10px] text-stone-400">2 per sheet</p>
</div>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button
onClick={onClose}
className="rounded-xl border border-stone-200 px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={handlePrint}
disabled={loading}
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Generating PDF..." : <><span className="inline-flex items-center gap-1.5">{Icons.printer("h-4 w-4")} Print {copies === 1 ? "" : `${copies}× `}{stickerType === "field" ? "Field" : "Shed"} Sticker{copies > 1 ? "s" : ""}</span></>}
</button>
</div>
</div>
</div>
);
}