qr code
This commit is contained in:
163
src/features/qr/StoreQrPanel.tsx
Normal file
163
src/features/qr/StoreQrPanel.tsx
Normal 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'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'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user