1fe5ffee8d
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.
304 lines
14 KiB
TypeScript
304 lines
14 KiB
TypeScript
"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}"</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}" · 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>
|
||
);
|
||
} |