This commit is contained in:
2026-09-09 11:19:05 +05:30
parent a94c23c20e
commit 32c612a10d
8 changed files with 335 additions and 127 deletions

View File

@@ -0,0 +1,163 @@
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: 16,
background: '#ffffff',
border: '1px solid var(--color-line)',
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&apos;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'
);
}