514 lines
16 KiB
TypeScript
514 lines
16 KiB
TypeScript
"use client";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useParams } from "next/navigation";
|
|
import {
|
|
ShieldCheck, AlertTriangle, XCircle, Ban, Star, Globe, Building2,
|
|
Recycle, Flag, Phone, Share2, Download, ShieldHalf,Info,
|
|
} from "lucide-react";
|
|
import { T, LANGS, detectLang, type Lang } from "@/lib/i18n";
|
|
import { apiBase } from "@/lib/api";
|
|
import { Pill, Package } from "lucide-react";
|
|
import { Mail } from "lucide-react";
|
|
|
|
type State = "genuine" | "unverified" | "not_found" | "recalled";
|
|
|
|
const STYLES: Record<State, { bg: string; ring: string; icon: any; color: string }> = {
|
|
genuine: { bg: "bg-emerald-500", ring: "ring-emerald-200", icon: ShieldCheck, color: "text-emerald-600" },
|
|
unverified: { bg: "bg-orange-500", ring: "ring-orange-200", icon: AlertTriangle, color: "text-orange-600" },
|
|
not_found: { bg: "bg-rose-500", ring: "ring-rose-200", icon: XCircle, color: "text-rose-600" },
|
|
recalled: { bg: "bg-rose-600", ring: "ring-rose-200", icon: Ban, color: "text-rose-700" },
|
|
};
|
|
|
|
export default function VerifyPage() {
|
|
const { qr_code } = useParams<{ qr_code: string }>();
|
|
const [data, setData] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [lang, setLang] = useState<Lang>("en");
|
|
|
|
const logged = useRef(false);
|
|
const now = new Date();
|
|
|
|
const scanDate = now.toLocaleDateString();
|
|
const scanTime = now.toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
useEffect(() => { setLang(detectLang()); }, []);
|
|
|
|
useEffect(() => {
|
|
fetch(`${apiBase}/verify/${qr_code}`)
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
setData(d);
|
|
// Log exactly one scan per real page view. The ref guard prevents
|
|
// React Strict Mode's double-mount from logging twice in dev.
|
|
if (!logged.current && d?.state && d.state !== "not_found") {
|
|
logged.current = true;
|
|
fetch(`${apiBase}/scans/log?qr_code=${encodeURIComponent(qr_code)}`, {
|
|
method: "POST",
|
|
}).catch(() => {});
|
|
}
|
|
})
|
|
.catch(() => setData({ state: "not_found" }))
|
|
.finally(() => setLoading(false));
|
|
}, [qr_code]);
|
|
|
|
const t = T[lang];
|
|
const state: State = data?.state ?? "not_found";
|
|
const cfg = STYLES[state];
|
|
const Icon = cfg.icon;
|
|
const product = data?.product ?? {};
|
|
const brand = data?.brand;
|
|
const batch = data?.batch;
|
|
const trust = data?.trust_score ?? product.trust_score ?? 0;
|
|
const stars = Math.round((trust / 100) * 5);
|
|
|
|
const compliance = (product.compliance ?? []).filter((c: any) => c?.type);
|
|
const stateMsg: Record<State, string> = {
|
|
genuine: t.genuine_msg, unverified: t.unverified_msg,
|
|
not_found: t.not_found_msg, recalled: t.recalled_msg,
|
|
};
|
|
|
|
if (loading)
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
|
<div className="w-full max-w-md p-6 space-y-4">
|
|
<div className="skeleton h-40 rounded-2xl" />
|
|
<div className="skeleton h-24 rounded-2xl" />
|
|
<div className="skeleton h-24 rounded-2xl" />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50">
|
|
<div className="max-w-md mx-auto pb-10 print-container">
|
|
{/* Brand header + language selector */}
|
|
<div className="flex items-center justify-between px-5 py-4 bg-white">
|
|
<div className="flex items-center gap-2">
|
|
{brand?.logo_url
|
|
? <img src={brand.logo_url} alt="" className="h-8 w-8 rounded-lg object-cover" />
|
|
: <div className="h-8 w-8 rounded-lg bg-brand-600 grid place-items-center text-white"><ShieldHalf className="h-4 w-4" /></div>}
|
|
<span className="font-bold text-slate-800">{brand?.name ?? "VerifyPack"}</span>
|
|
</div>
|
|
<div className="no-print">
|
|
<div className="flex items-center gap-1">
|
|
<Globe className="h-4 w-4 text-slate-400" />
|
|
<select value={lang} onChange={(e) => setLang(e.target.value as Lang)}
|
|
className="text-sm bg-transparent focus:outline-none text-slate-600">
|
|
{LANGS.map((l) => <option key={l.code} value={l.code}>{l.label}</option>)}
|
|
</select>
|
|
</div></div>
|
|
</div>
|
|
|
|
{/* Status hero */}
|
|
<div className={`mx-4 mt-4 rounded-2xl p-6 text-white ${cfg.bg}`}>
|
|
<div className="flex items-center gap-4">
|
|
{/* Icon */}
|
|
<div
|
|
className={`h-16 w-16 flex-shrink-0 rounded-full bg-white/20 grid place-items-center ring-4 ${cfg.ring}`}
|
|
>
|
|
<Icon className="h-8 w-8" />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1">
|
|
<h1 className="text-xl font-extrabold">{t[state]}</h1>
|
|
|
|
<p className="text-white/90 text-sm mt-1">
|
|
{stateMsg[state]}
|
|
</p>
|
|
|
|
{/* Scan Date & Time */}
|
|
<div className="mt-3 inline-flex items-center rounded-lg border border-green-300 px-3 py-1 text-xs font-medium text-green-100">
|
|
{t.scan_on}: {scanDate} • {scanTime}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{state !== "not_found" && (
|
|
<>
|
|
{/* Product */}
|
|
<div className="mx-4 mt-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
|
<div className="flex gap-4">
|
|
{/* Product Image */}
|
|
<div className="h-28 w-28 flex-shrink-0 rounded-xl border border-slate-200 bg-slate-50 p-2 flex items-center justify-center">
|
|
{product.image_url ? (
|
|
<img
|
|
src={product.image_url}
|
|
alt={product.name}
|
|
className="h-full w-full object-contain"
|
|
/>
|
|
) : (
|
|
<div className="h-full w-full rounded-lg bg-slate-100" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Product Details */}
|
|
<div className="flex flex-1 flex-col justify-center">
|
|
{/* Product Name */}
|
|
<h2 className="text-2xl font-bold text-slate-900">
|
|
{product.name}
|
|
</h2>
|
|
|
|
{/* Brand */}
|
|
<p className="mt-1 text-base font-semibold text-blue-600">
|
|
{brand?.name ?? "VerifyPack"}
|
|
</p>
|
|
|
|
{/* Category */}
|
|
<div className="mt-3">
|
|
<span className="inline-flex rounded-lg border border-blue-200 bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700">
|
|
{product.category}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Bottom Details */}
|
|
<div className="mt-4 flex items-center gap-8 text-slate-600">
|
|
<div className="flex items-center gap-2">
|
|
<Pill className="h-4 w-4" />
|
|
<span className="text-sm">
|
|
{product.details?.form_type || "-"}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Package className="h-4 w-4" />
|
|
<span className="text-sm font-medium">
|
|
{product.details?.net_weight || "-"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Trust score */}
|
|
{trust > 0 && (
|
|
<div className="mx-4 mt-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
|
<div className="flex items-center gap-4">
|
|
{/* Score Circle */}
|
|
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-green-50 border border-green-200">
|
|
<div className="text-center">
|
|
<div className="text-3xl font-bold text-green-600">{trust}</div>
|
|
<div className="text-xs text-slate-500">/100</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Score Details */}
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-semibold text-slate-800">{t.trust_score}</h3>
|
|
<Info className="h-4 w-4 text-slate-400" />
|
|
</div>
|
|
|
|
<div className="mt-2 flex items-center gap-4">
|
|
<span className="font-semibold text-green-600">
|
|
{trust >= 90
|
|
? t.excellent
|
|
: trust >= 75
|
|
? t.good
|
|
: trust >= 50
|
|
? t.average
|
|
: t.low}
|
|
</span>
|
|
|
|
<div className="flex gap-1">
|
|
{[...Array(5)].map((_, i) => (
|
|
<Star
|
|
key={i}
|
|
className={`h-5 w-5 ${
|
|
i < stars
|
|
? "fill-green-500 text-green-500"
|
|
: "text-slate-300"
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
)}
|
|
|
|
{/* Batch info */}
|
|
{batch && (
|
|
<div className="mx-4 mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<div className="grid grid-cols-3">
|
|
|
|
<div className="border-r border-b p-3">
|
|
<p className="text-[11px] text-slate-500">{t.sku}</p>
|
|
<p className="mt-1 text-sm font-semibold text-slate-900">
|
|
{product.sku || "-"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="border-r border-b p-3">
|
|
<p className="text-[11px] text-slate-500">{t.batch_no}</p>
|
|
<p className="mt-1 text-sm font-semibold text-slate-900 truncate">
|
|
{batch.number}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="border-b p-3">
|
|
<p className="text-[11px] text-slate-500">{t.mfg_date}
|
|
</p>
|
|
<p className="mt-1 text-sm font-semibold text-slate-900">
|
|
{fmt(batch.mfg_date)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="border-r p-3">
|
|
<p className="text-[11px] text-slate-500">{t.expiry_date}
|
|
</p>
|
|
<p className="mt-1 text-sm font-semibold text-slate-900">
|
|
{fmt(batch.expiry_date)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="border-r p-3">
|
|
<p className="text-[11px] text-slate-500">{t.net_weight}</p>
|
|
<p className="mt-1 text-sm font-semibold text-slate-900">
|
|
{product.details?.net_weight || "-"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-3">
|
|
<p className="text-[11px] text-slate-500">{t.gtin}</p>
|
|
<p className="mt-1 text-sm font-semibold text-slate-900 truncate">
|
|
{product.details?.gtin || "-"}
|
|
</p>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Manufacturer */}
|
|
{product.manufacturer?.company && (
|
|
<div className="mx-4 mt-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
|
|
|
<div className="flex items-start gap-4">
|
|
{/* Icon */}
|
|
<div className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-blue-50">
|
|
<Building2 className="h-7 w-7 text-blue-600" />
|
|
</div>
|
|
|
|
{/* Details */}
|
|
<div className="flex-1">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
|
{t.manufactured_by}
|
|
</p>
|
|
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<h3 className="text-lg font-bold text-slate-900">
|
|
{product.manufacturer.company}
|
|
</h3>
|
|
|
|
<ShieldCheck className="h-5 w-5 text-green-500 fill-green-500" />
|
|
</div>
|
|
|
|
{product.manufacturer.plant && (
|
|
<p className="mt-2 text-sm text-slate-700">
|
|
<span className="font-medium">{t.plant}</span>{" "}
|
|
{product.manufacturer.plant}
|
|
</p>
|
|
)}
|
|
|
|
{product.manufacturer.country && (
|
|
<p className="mt-1 text-sm text-slate-700">
|
|
<span className="font-medium">{t.country_origin}</span>{" "}
|
|
{product.manufacturer.country}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="mt-4 grid grid-cols-3 gap-2">
|
|
|
|
<button
|
|
disabled={!product.manufacturer?.website}
|
|
onClick={() =>
|
|
window.open(product.manufacturer.website, "_blank")
|
|
}
|
|
className="flex items-center justify-center gap-2 rounded-lg border py-2 text-sm disabled:opacity-40"
|
|
>
|
|
<Globe className="h-4 w-4 text-blue-600" />
|
|
{t.website}
|
|
</button>
|
|
|
|
<button
|
|
disabled={!product.manufacturer?.email}
|
|
onClick={() =>
|
|
window.location.href = `mailto:${product.manufacturer.email}`
|
|
}
|
|
className="flex items-center justify-center gap-2 rounded-lg border py-2 text-sm disabled:opacity-40"
|
|
>
|
|
<Mail className="h-4 w-4 text-indigo-600" />
|
|
{t.email}
|
|
</button>
|
|
|
|
<button
|
|
disabled={!product.manufacturer?.phone}
|
|
onClick={() =>
|
|
window.location.href = `tel:${product.manufacturer.phone}`
|
|
}
|
|
className="flex items-center justify-center gap-2 rounded-lg border py-2 text-sm disabled:opacity-40"
|
|
>
|
|
<Phone className="h-4 w-4 text-green-600" />
|
|
{t.call}
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
)}
|
|
|
|
{/* Compliance badges */}
|
|
{compliance.length > 0 && (
|
|
<Section title={t.compliance}>
|
|
<div className="flex flex-wrap gap-2">
|
|
{compliance.map((c: any, i: number) => (
|
|
<span key={i} className="text-xs font-semibold bg-emerald-50 text-emerald-700 px-3 py-1.5 rounded-full">
|
|
{c.type}{c.number ? ` · ${c.number}` : ""}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
)}
|
|
|
|
{/* Recycling */}
|
|
|
|
<div className="mx-4 mt-4 rounded-2xl border border-green-200 bg-green-50 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<Recycle className="mt-1 h-6 w-6 text-green-600" />
|
|
|
|
<div>
|
|
<h3 className="font-semibold text-green-800">
|
|
{t.recycling_info}
|
|
</h3>
|
|
|
|
<p className="mt-1 text-sm text-green-700">
|
|
{product.recycling_info || t.recycling_info}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{/* Actions */}
|
|
<div className="mx-4 mt-5 grid grid-cols-2 gap-3">
|
|
<Action
|
|
icon={<Flag className="h-5 w-5 text-red-500" />}
|
|
label="Report Issue"
|
|
/>
|
|
|
|
<Action
|
|
icon={<Phone className="h-5 w-5 text-blue-500" />}
|
|
label="Contact Brand"
|
|
/>
|
|
|
|
<Action
|
|
icon={<Share2 className="h-5 w-5 text-purple-600" />}
|
|
label="Share Product"
|
|
onClick={() =>
|
|
navigator.share?.({
|
|
title: product.name,
|
|
url: location.href,
|
|
})
|
|
}
|
|
/>
|
|
|
|
<Action
|
|
icon={<Download className="h-5 w-5 text-green-600" />}
|
|
label="Download Info"
|
|
onClick={() => {
|
|
document.title = `${product.name}-${batch?.number || "Product"}`;
|
|
window.print();
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="mt-8 border-t border-slate-200 pt-5">
|
|
<div className="flex items-center justify-center gap-2 text-sm text-slate-500">
|
|
<span>Powered by</span>
|
|
|
|
<ShieldCheck className="h-5 w-5 text-blue-600 fill-blue-600" />
|
|
|
|
<span className="font-bold text-blue-700">
|
|
VerifyPack
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-3 flex justify-center gap-3 text-xs text-slate-500">
|
|
<button className="hover:text-blue-600">
|
|
{t.privacy_policy}
|
|
</button>
|
|
|
|
<span>|</span>
|
|
|
|
<button className="hover:text-blue-600">
|
|
{t.terms_of_use}
|
|
</button>
|
|
|
|
<span>|</span>
|
|
|
|
<button className="hover:text-blue-600">
|
|
{t.help}
|
|
</button>
|
|
</div>
|
|
|
|
<p className="mt-3 text-center text-xs text-slate-400">
|
|
© {new Date().getFullYear()} VerifyPack. All rights reserved.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function fmt(d?: string) {
|
|
if (!d || d === "None") return "—";
|
|
const dt = new Date(d);
|
|
return isNaN(+dt) ? d : dt.toLocaleDateString();
|
|
}
|
|
|
|
function Section({ title, icon, children }: any) {
|
|
return (
|
|
<div className="mx-4 mt-4 card p-5">
|
|
<h3 className="font-semibold text-slate-800 text-sm flex items-center gap-2 mb-3">{icon}{title}</h3>
|
|
<div className="space-y-2">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ label, value }: { label: string; value?: string }) {
|
|
if (!value || value === "None") return null;
|
|
return (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-slate-500">{label}</span>
|
|
<span className="font-medium text-slate-800">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Action({ icon, label, onClick }: any) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className="flex flex-col items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white py-4 shadow-sm transition hover:border-blue-300 hover:shadow-md"
|
|
>
|
|
{icon}
|
|
<span className="text-sm font-medium text-slate-700">
|
|
{label}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|