302 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|