Files
verify/frontend/components/QrDesigner.tsx
Mohamed Mathar Irfan ed6610d5d8 Initial project upload
2026-07-28 17:57:02 +05:30

302 lines
12 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from "react";
import { Upload, X, Smartphone } from "lucide-react";
// qr-code-styling is browser-only; import dynamically inside effects.
export type QrStyle = {
dot_shape: string;
corner_square_shape: string;
corner_dot_shape: string;
fg_color: string;
bg_color: string;
transparent_bg: boolean;
gradient: boolean;
gradient_color: string;
logo_url: string; // data URL (preview) — uploaded separately if persisted
logo_size: number;
error_correction: string;
quiet_zone: number;
};
export const defaultStyle: QrStyle = {
dot_shape: "square",
corner_square_shape: "square",
corner_dot_shape: "square",
fg_color: "#0b1f4d",
bg_color: "#ffffff",
transparent_bg: false,
gradient: false,
gradient_color: "#2563eb",
logo_url: "",
logo_size: 0.4,
error_correction: "M",
quiet_zone: 4,
};
const DOT_TYPES = ["square", "rounded", "dots", "classy", "classy-rounded", "extra-rounded"];
const CORNER_SQUARE = ["square", "dot", "extra-rounded"];
const CORNER_DOT = ["square", "dot"];
const EC = ["L", "M", "Q", "H"];
export type QrDesignerHandle = {
getRawData: (fmt: "png" | "svg") => Promise<Blob | null>;
getDataUrl: (fmt: "png" | "svg") => Promise<string>;
};
const QrDesigner = forwardRef<QrDesignerHandle, {
value: QrStyle;
onChange: (s: QrStyle) => void;
data: string; // the verify URL the QR encodes
}>(function QrDesigner({ value, onChange, data }, ref) {
const previewRef = useRef<HTMLDivElement>(null);
const qrRef = useRef<any>(null);
const [tab, setTab] = useState<"qr" | "preview">("qr");
const set = <K extends keyof QrStyle>(k: K, v: QrStyle[K]) =>
onChange({ ...value, [k]: v });
function buildOptions() {
const dotsColor = value.gradient
? undefined
: value.fg_color;
const gradient = value.gradient
? {
type: "linear" as const,
rotation: 0.785,
colorStops: [
{ offset: 0, color: value.fg_color },
{ offset: 1, color: value.gradient_color },
],
}
: undefined;
return {
width: 220,
height: 220,
type: "canvas" as const,
data: data || "https://verifypack.example",
margin: value.quiet_zone * 4,
qrOptions: { errorCorrectionLevel: value.error_correction as any },
image: value.logo_url || undefined,
imageOptions: { crossOrigin: "anonymous", margin: 4, imageSize: value.logo_size },
dotsOptions: { type: value.dot_shape as any, color: dotsColor, gradient },
cornersSquareOptions: { type: value.corner_square_shape as any, color: value.fg_color },
cornersDotOptions: { type: value.corner_dot_shape as any, color: value.fg_color },
backgroundOptions: {
color: value.transparent_bg ? "transparent" : value.bg_color,
},
};
}
// init once
useEffect(() => {
let mounted = true;
(async () => {
const QRCodeStyling = (await import("qr-code-styling")).default;
if (!mounted) return;
qrRef.current = new QRCodeStyling(buildOptions());
if (previewRef.current) {
previewRef.current.innerHTML = "";
qrRef.current.append(previewRef.current);
}
})();
return () => { mounted = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// live update on any style/data change
useEffect(() => {
if (qrRef.current) qrRef.current.update(buildOptions());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, data]);
useImperativeHandle(ref, () => ({
async getRawData(fmt) {
if (!qrRef.current) return null;
return (await qrRef.current.getRawData(fmt)) as Blob;
},
async getDataUrl(fmt) {
const blob = await this.getRawData(fmt);
if (!blob) return "";
return await new Promise<string>((res) => {
const r = new FileReader();
r.onloadend = () => res(r.result as string);
r.readAsDataURL(blob);
});
},
}));
function onLogo(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 1024 * 1024) { alert("Logo must be under 1 MB"); return; }
const reader = new FileReader();
reader.onload = () => {
onChange({ ...value, logo_url: reader.result as string, error_correction: "H" });
};
reader.readAsDataURL(file);
}
return (
<div className="grid md:grid-cols-2 gap-5">
{/* Controls */}
<div className="space-y-5">
<Panel title="Pattern" subtitle="Dot shape and colors">
<div className="grid grid-cols-3 gap-2">
{DOT_TYPES.map((d) => (
<Swatch key={d} active={value.dot_shape === d} onClick={() => set("dot_shape", d)} label={d} />
))}
</div>
<div className="grid grid-cols-2 gap-3 mt-3">
<Color label="Foreground" value={value.fg_color} onChange={(v) => set("fg_color", v)} />
<Color label="Background" value={value.bg_color} onChange={(v) => set("bg_color", v)} disabled={value.transparent_bg} />
</div>
<div className="flex items-center justify-between mt-3">
<Toggle label="Transparent background" on={value.transparent_bg} onClick={() => set("transparent_bg", !value.transparent_bg)} />
<Toggle label="Gradient pattern" on={value.gradient} onClick={() => set("gradient", !value.gradient)} />
</div>
{value.gradient && (
<div className="mt-3">
<Color label="Gradient end color" value={value.gradient_color} onChange={(v) => set("gradient_color", v)} />
</div>
)}
</Panel>
<Panel title="QR Code Corners" subtitle="Frame and corner dot style">
<div className="text-xs font-medium text-slate-500 mb-1">Frame around corner</div>
<div className="flex gap-2">
{CORNER_SQUARE.map((c) => (
<Swatch key={c} active={value.corner_square_shape === c} onClick={() => set("corner_square_shape", c)} label={c} />
))}
</div>
<div className="text-xs font-medium text-slate-500 mt-3 mb-1">Corner dots</div>
<div className="flex gap-2">
{CORNER_DOT.map((c) => (
<Swatch key={c} active={value.corner_dot_shape === c} onClick={() => set("corner_dot_shape", c)} label={c} />
))}
</div>
</Panel>
<Panel title="Add Logo" subtitle="Make your QR unique (max 1 MB)">
{value.logo_url ? (
<div className="flex items-center gap-3">
<img src={value.logo_url} alt="logo" className="h-16 w-16 object-contain rounded-lg border border-slate-200" />
<button onClick={() => set("logo_url", "")}
className="flex items-center gap-1 text-sm text-rose-600">
<X className="h-4 w-4" /> Remove
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center h-28 w-28 rounded-xl border-2 border-dashed border-blue-300 cursor-pointer text-blue-500">
<Upload className="h-6 w-6" />
<span className="text-[11px] mt-1">Upload</span>
<input type="file" accept="image/png,image/jpeg,image/svg+xml" className="hidden" onChange={onLogo} />
</label>
)}
{value.logo_url && (
<label className="block mt-3">
<span className="text-xs font-medium text-slate-500">Logo size: {Math.round(value.logo_size * 100)}%</span>
<input type="range" min={0.1} max={0.6} step={0.05} value={value.logo_size}
onChange={(e) => set("logo_size", Number(e.target.value))} className="w-full" />
</label>
)}
</Panel>
<Panel title="Advanced" subtitle="Error correction & quiet zone">
<div className="text-xs font-medium text-slate-500 mb-1">Error correction</div>
<div className="flex gap-2">
{EC.map((e) => (
<button key={e} onClick={() => set("error_correction", e)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${value.error_correction === e ? "bg-brand-600 text-white" : "bg-slate-100"}`}>
{e}
</button>
))}
</div>
<label className="block mt-3">
<span className="text-xs font-medium text-slate-500">Quiet zone: {value.quiet_zone}</span>
<input type="range" min={0} max={10} value={value.quiet_zone}
onChange={(e) => set("quiet_zone", Number(e.target.value))} className="w-full" />
</label>
</Panel>
</div>
{/* Live preview (phone mockup) */}
<div className="md:sticky md:top-4 h-fit">
<div className="flex justify-center gap-1 mb-4">
<button onClick={() => setTab("preview")}
className={`px-4 py-1.5 rounded-full text-sm font-semibold ${tab === "preview" ? "bg-blue-500 text-white" : "text-blue-500 border border-blue-200"}`}>
Preview
</button>
<button onClick={() => setTab("qr")}
className={`px-4 py-1.5 rounded-full text-sm font-semibold ${tab === "qr" ? "bg-blue-500 text-white" : "text-blue-500 border border-blue-200"}`}>
QR code
</button>
</div>
<div className="mx-auto w-[260px] h-[520px] rounded-[2.5rem] border-[10px] border-slate-900 bg-white shadow-xl relative overflow-hidden">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-24 h-6 bg-slate-900 rounded-b-2xl" />
{tab === "qr" ? (
<div className="h-full grid place-items-center">
<div ref={previewRef} />
</div>
) : (
<div className="h-full grid place-items-center text-slate-400 text-sm px-3 text-center">
<div>
<Smartphone className="h-8 w-8 mx-auto mb-2 opacity-50" />
Consumer verification page preview appears here after publish.
</div>
</div>
)}
</div>
</div>
</div>
);
});
export default QrDesigner;
function Panel({ title, subtitle, children }: any) {
return (
<div className="card p-5">
<div className="mb-3">
<h4 className="font-bold text-slate-800">{title}</h4>
{subtitle && <p className="text-xs text-slate-500">{subtitle}</p>}
</div>
{children}
</div>
);
}
function Swatch({ active, onClick, label }: any) {
return (
<button onClick={onClick}
className={`px-2 py-2 rounded-lg text-xs font-medium capitalize border ${active ? "border-blue-500 ring-2 ring-blue-500/20 bg-blue-50/40" : "border-slate-200"}`}>
{label.replace(/-/g, " ")}
</button>
);
}
function Color({ label, value, onChange, disabled }: {
label: string; value: string; onChange: (v: string) => void; disabled?: boolean;
}) {
return (
<label className="block">
<span className="text-xs font-medium text-slate-500">{label}</span>
<div className={`mt-1 flex items-center gap-2 border border-slate-200 rounded-lg px-2 py-1.5 ${disabled ? "opacity-50" : ""}`}>
<input type="color" value={value} disabled={disabled}
onChange={(e) => onChange(e.target.value)} className="h-7 w-7" />
<span className="text-sm text-slate-500">{value}</span>
</div>
</label>
);
}
function Toggle({ label, on, onClick }: any) {
return (
<button onClick={onClick} className="flex items-center gap-2 text-sm">
<span className={`w-9 h-5 rounded-full transition relative ${on ? "bg-blue-500" : "bg-slate-300"}`}>
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition ${on ? "left-4" : "left-0.5"}`} />
</span>
<span className="text-slate-600">{label}</span>
</button>
);
}