164 lines
5.4 KiB
TypeScript
164 lines
5.4 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Button } from '@astryxdesign/core/Button';
|
|
import { Text } from '@astryxdesign/core/Text';
|
|
import { VStack } from '@astryxdesign/core/VStack';
|
|
import { Download } from 'lucide-react';
|
|
import { encodeQr, qrPath } from './qr';
|
|
|
|
/**
|
|
* One branch's storefront QR — the code, and a print-quality copy of it.
|
|
*
|
|
* ── Why the code is derived and not stored ──────────────────────────────────
|
|
*
|
|
* It encodes the literal JSON `{"tenantid":N,"locationid":M}` and nothing else:
|
|
* not a URL, not the shop's name, not a token. The customer app reads those two
|
|
* numbers and opens THAT outlet's storefront, which is the whole mechanism —
|
|
* until somebody scans it the shop is not listed for them and they cannot
|
|
* order.
|
|
*
|
|
* Two numbers that already exist means the code is unique per branch by
|
|
* construction, and it cannot drift from the branch it belongs to, be lost, or
|
|
* need a migration to reissue. Nothing is written to Fiesta and nothing needs
|
|
* to be: reprinting a lost poster is opening this panel again.
|
|
*
|
|
* ── Why it is a component and not a page ────────────────────────────────────
|
|
*
|
|
* The same code has to appear at every moment somebody needs it — when a store
|
|
* is onboarded, when a branch is commissioned, and any day after that from
|
|
* either login. One component, one payload, so the poster taped to a counter
|
|
* cannot disagree with the one printed a month later.
|
|
*/
|
|
export interface StoreQrPanelProps {
|
|
tenantid: number;
|
|
locationid: number;
|
|
/** The branch's name, printed above the code and used for the file name. */
|
|
locationname?: string;
|
|
/** Suburb, city — whatever is worth reading under the name. */
|
|
where?: string;
|
|
/** On-screen size in px. The download is always 1024. */
|
|
size?: number;
|
|
}
|
|
|
|
export function StoreQrPanel({
|
|
tenantid,
|
|
locationid,
|
|
locationname,
|
|
where,
|
|
size = 220,
|
|
}: StoreQrPanelProps) {
|
|
const payload = useMemo(
|
|
() => JSON.stringify({ tenantid, locationid }),
|
|
[tenantid, locationid],
|
|
);
|
|
const matrix = useMemo(() => encodeQr(payload), [payload]);
|
|
const path = useMemo(() => qrPath(matrix), [matrix]);
|
|
|
|
const quiet = 4;
|
|
const span = matrix.length + quiet * 2;
|
|
const name = locationname ?? 'your store';
|
|
|
|
/**
|
|
* The print copy, drawn at 1024px into a canvas that never enters the page.
|
|
*
|
|
* The on-screen SVG would scale, but a poster printed from a 220px raster is
|
|
* soft, and a shop prints this once and lives with it.
|
|
*/
|
|
function download() {
|
|
const px = 1024;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = px;
|
|
canvas.height = px;
|
|
const context = canvas.getContext('2d');
|
|
if (!context) return;
|
|
const scale = px / span;
|
|
context.fillStyle = '#ffffff';
|
|
context.fillRect(0, 0, px, px);
|
|
context.fillStyle = '#000000';
|
|
for (let y = 0; y < matrix.length; y += 1) {
|
|
const row = matrix[y] as boolean[];
|
|
for (let x = 0; x < row.length; x += 1) {
|
|
if (!row[x]) continue;
|
|
// Ceil, not round: a fractional module edge left hairline gaps between
|
|
// neighbouring dark squares, which a scanner reads as a broken pattern.
|
|
context.fillRect(
|
|
Math.floor((x + quiet) * scale),
|
|
Math.floor((y + quiet) * scale),
|
|
Math.ceil(scale),
|
|
Math.ceil(scale),
|
|
);
|
|
}
|
|
}
|
|
const link = document.createElement('a');
|
|
link.download = `${slug(locationname ?? 'store')}-qr.png`;
|
|
link.href = canvas.toDataURL('image/png');
|
|
link.click();
|
|
}
|
|
|
|
return (
|
|
<VStack gap={2} style={{ alignItems: 'center' }}>
|
|
<VStack gap={0} style={{ alignItems: 'center' }}>
|
|
<Text type="label" size="lg" weight="semibold">
|
|
{locationname ?? 'Your store'}
|
|
</Text>
|
|
{where ? (
|
|
<Text type="body" size="sm" color="secondary">
|
|
{where}
|
|
</Text>
|
|
) : null}
|
|
</VStack>
|
|
|
|
<div
|
|
style={{
|
|
padding: 10,
|
|
borderRadius: 'var(--card-radius)',
|
|
background: 'var(--card-bg)',
|
|
border: 'var(--card-border)',
|
|
lineHeight: 0,
|
|
}}
|
|
>
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox={`0 0 ${span} ${span}`}
|
|
role="img"
|
|
aria-label={`Storefront QR code for ${name}`}
|
|
shapeRendering="crispEdges"
|
|
>
|
|
<rect width={span} height={span} fill="#ffffff" />
|
|
<g transform={`translate(${quiet} ${quiet})`}>
|
|
<path d={path} fill="#000000" />
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
|
|
<Text
|
|
type="body"
|
|
size="sm"
|
|
color="secondary"
|
|
style={{ textAlign: 'center', lineHeight: 1.6, maxWidth: 300 }}
|
|
>
|
|
This code is this branch's alone. A shopper scans it to add the store to their app —
|
|
until they do, it is not listed for them and they cannot order from it.
|
|
</Text>
|
|
|
|
<Button
|
|
label="Download for print"
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={<Download size={14} />}
|
|
onClick={download}
|
|
/>
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
/** A file name that survives a shop called "R mart — RS Puram". */
|
|
function slug(value: string): string {
|
|
return (
|
|
value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '') || 'store'
|
|
);
|
|
}
|