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

@@ -15,6 +15,7 @@ import { PageBody } from '@/components/PageBody';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
import { useAppCategories } from '@/queries/hooks';
interface FormState {
@@ -149,6 +150,27 @@ export function OnboardTenantPage() {
stocking the catalogue — pick products from the global catalogue, or upload the
tenant's own list as a spreadsheet.
</Text>
{/* The storefront code, at the one moment the person who provisioned
the shop is holding its details.
It was reachable only from the branch user's own header, which
is the wrong place for it: the code is what puts the store in
front of a shopper at all — nobody can order from a shop they
have not scanned — and the person onboarding it is the one who
sends it to the merchant. `createtenantuser` returns the tenant
and its primary outlet's id together, so it can be drawn here
without a second read. */}
{created?.tenantid && created?.locationid ? (
<Card padding={3} elevation="low" variant="transparent">
<StoreQrPanel
tenantid={created.tenantid}
locationid={created.locationid}
locationname={form.locationname}
where={[form.suburb, form.city].filter(Boolean).join(', ')}
/>
</Card>
) : null}
<HStack gap={1.5} wrap="wrap">
<Button
label="Stock from global catalogue"

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
@@ -7,7 +7,7 @@ import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { IndianRupee, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
import { IndianRupee, QrCode, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
import { DataState } from '@/components/DataState';
import { Freshness } from '@/components/Freshness';
import { KpiCard } from '@/components/KpiCard';
@@ -17,6 +17,8 @@ import { useLocationSummary, useOrderSummary, useTenantLocations, useTenants } f
import type { LocationOrderSummary, TenantInfo, TenantLocation } from '@/api/types';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { Drawer } from '@/features/store-admin/Drawer';
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
interface BranchRow extends Record<string, unknown> {
locationid: number;
@@ -91,6 +93,9 @@ export function StoreDetailPage() {
return { branches, active, totalOrders, totalRevenue, cancelled };
}, [rows, orders.data]);
/** The branch whose code is on screen, if any. */
const [qrFor, setQrFor] = useState<BranchRow | null>(null);
const columns: TableColumn<BranchRow>[] = [
{
key: 'locationname',
@@ -172,6 +177,26 @@ export function StoreDetailPage() {
/>
),
},
{
/* The storefront code, per branch, after the day it was commissioned.
Shown at creation too — but a poster gets lost, a shop reopens, a
merchant asks for it again a month later, and the person they ask is
whoever is looking at this page. The code is derived from the two ids
on this row, so there is nothing to look up and nothing to reissue. */
key: 'qr',
header: 'QR',
align: 'end',
width: { type: 'pixel', value: 84 },
renderCell: (row) => (
<Button
label="QR"
variant="ghost"
size="sm"
icon={<QrCode size={14} />}
onClick={() => setQrFor(row)}
/>
),
},
];
return (
@@ -267,6 +292,22 @@ export function StoreDetailPage() {
</DataState>
</Card>
</VStack>
{qrFor && tenantid ? (
<Drawer
title="Store QR code"
subtitle={qrFor.locationname}
width={400}
onClose={() => setQrFor(null)}
>
<StoreQrPanel
tenantid={tenantid}
locationid={qrFor.locationid}
locationname={qrFor.locationname}
{...(qrFor.city ? { where: String(qrFor.city) } : {})}
/>
</Drawer>
) : null}
</VStack>
);
}

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'
);
}

View File

@@ -97,3 +97,42 @@ test('the SVG path draws one square per dark module', () => {
const dark = matrix.flat().filter(Boolean).length;
assert.equal(qrPath(matrix).split('M').length - 1, dark);
});
/* ── Uniqueness: the whole reason the code exists ─────────────────────────── */
/*
A shopper scans this to add the store to their app; until they do, the shop is
not listed for them and they cannot order from it. So a code that is not unique
to its branch does not merely look wrong — it sends every customer of the second
shop to the first one.
The payload is `{"tenantid":N,"locationid":M}`, so uniqueness is by construction
rather than by a generator that has to be trusted. These pin that.
*/
const payloadFor = (tenantid: number, locationid: number) =>
JSON.stringify({ tenantid, locationid });
test('two branches of one merchant do not share a code', () => {
const first = encodeQr(payloadFor(1147, 1185));
const second = encodeQr(payloadFor(1147, 1186));
assert.notDeepEqual(first, second, 'a second outlet must not reuse the first outlet’s poster');
});
test('the same branch under two merchants does not share a code', () => {
// `locationid` is unique platform-wide today, but the payload carries the
// tenant too — so this holds even if that ever stops being true.
assert.notDeepEqual(encodeQr(payloadFor(1147, 1185)), encodeQr(payloadFor(1150, 1185)));
});
test('the same branch always encodes the same code', () => {
// Derived, not generated: reprinting a lost poster gives the poster back
// rather than issuing a new one that makes the old one dead.
assert.deepEqual(encodeQr(payloadFor(1147, 1185)), encodeQr(payloadFor(1147, 1185)));
});
test('a branch with no id yet is still encodable, and is not another branch', () => {
// The success screens draw the code from the created row; if an id were ever
// missing the panel must not silently render some other shop's code.
assert.notDeepEqual(encodeQr(payloadFor(1147, 0)), encodeQr(payloadFor(1147, 1185)));
});

View File

@@ -1,6 +1,8 @@
import { useState, useRef, useEffect } from 'react';
import { Check, ChevronDown, FileSpreadsheet, Monitor, Store, Users } from 'lucide-react';
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { QrCode } from 'lucide-react';
import { StoreQrDrawer } from '@/features/store-user/StoreQrDrawer';
import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { OnboardingGate } from '@/features/onboarding/OnboardingGate';
import { AssistantScope } from '@/features/console/AssistantScope';
import { BranchScopeProvider, useBranchScope } from './BranchScope';
@@ -86,19 +88,48 @@ export function StoreAdminShell() {
<LiveWatch />
<OnboardingGate />
<AssistantScope>
<AppShell
nav={NAV}
home="/admin/console"
navLabel="Store Admin"
scopeControl={<BranchSelector />}
manageItems={MANAGE}
/>
<AdminShell />
</AssistantScope>
</BranchScopeProvider>
</DateScopeProvider>
);
}
/**
* The shell itself, inside the providers so it can read the branch in scope.
*
* Split out for the QR button: it needs open/closed state and it needs to know
* which outlet is selected, and both of those have to sit under
* `BranchScopeProvider`.
*/
function AdminShell() {
const [isQrOpen, setQrOpen] = useState(false);
return (
<>
<AppShell
nav={NAV}
home="/admin/console"
navLabel="Store Admin"
scopeControl={<BranchSelector />}
manageItems={MANAGE}
/* The storefront code, in the same place the branch login has it.
A merchant runs the outlets — they are the one who prints the poster
for a new shop and reprints it when it is lost — and until now the
code existed only on the branch user's header, so an owner with five
shops could not reach any of them. It follows the branch selector, so
the code on screen is always the one for the outlet in scope. */
headerActions={
<IconButton label="Store QR code" onClick={() => setQrOpen(true)}>
<QrCode size={16} />
</IconButton>
}
/>
{isQrOpen ? <StoreQrDrawer onClose={() => setQrOpen(false)} /> : null}
</>
);
}
/**
* Opens the live stream for the branch currently in scope.
*

View File

@@ -18,6 +18,7 @@ import { PageBody } from '@/components/PageBody';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
import { useStaff, useTenants } from '@/queries/hooks';
import type { TenantInfo } from '@/api/types';
import { isUnplaced } from '../staffPlacement';
@@ -199,6 +200,22 @@ export function OnboardBranchPage() {
? 'The person you chose now runs it and can sign in with their own account. The branch has no catalogue yet — products are published to it per store.'
: 'A login was created for the outlet itself, using the email above. The branch has no catalogue yet — products are published to it per store.'}
</Text>
{/* Its own storefront code, and it has to be its own: the payload is
{'{'}tenantid, locationid{'}'}, so a second outlet that reused the
first one's poster would send every shopper to the first shop.
`createtenantlocation` is used precisely because it returns the
created row — the new locationid is what this needs. */}
{mutation.data?.locationid ? (
<Card padding={3} elevation="low" variant="transparent">
<StoreQrPanel
tenantid={Number(form.tenantid)}
locationid={mutation.data.locationid}
locationname={form.locationname}
where={[form.suburb, form.city].filter(Boolean).join(', ')}
/>
</Card>
) : null}
<HStack gap={1.5} wrap="wrap">
<Button
label="Commission another"

View File

@@ -1,19 +1,14 @@
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 { Drawer } from '@/features/store-admin/Drawer';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { encodeQr, qrPath } from './qr';
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
/**
* The storefront QR, from the header.
*
* What it encodes is the whole story: the literal JSON `{"tenantid":N,
* "locationid":M}` and nothing else — not a URL, not the shop's name. The
* customer app reads those two numbers and opens this outlet's storefront,
* which is why the code is only correct for the branch that printed it.
* The code itself lives in `StoreQrPanel`, because the same one has to appear
* at the two moments it is most needed — when a store is onboarded and when a
* branch is commissioned — as well as here, on any day after. This is the
* drawer around it.
*
* It opens in place rather than on a page of its own: this is looked at,
* printed once and closed, and a nav slot spent on it is a slot taken from
@@ -22,119 +17,19 @@ import { encodeQr, qrPath } from './qr';
export function StoreQrDrawer({ onClose }: { onClose: () => void }) {
const { current, tenantid } = useBranchScope();
const payload = useMemo(
() => JSON.stringify({ tenantid, locationid: current?.locationid ?? 0 }),
[tenantid, current?.locationid],
);
const matrix = useMemo(() => encodeQr(payload), [payload]);
const path = useMemo(() => qrPath(matrix), [matrix]);
const where = [current?.suburb, current?.city].filter(Boolean).join(', ');
const quiet = 4;
const span = matrix.length + quiet * 2;
/**
* 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 180px raster is
* soft, and a shop prints this once and lives with it.
*/
function download() {
const size = 1024;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) return;
const scale = size / span;
context.fillStyle = '#ffffff';
context.fillRect(0, 0, size, size);
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(current?.locationname ?? 'store')}-qr.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
return (
<Drawer
title="Store QR code"
subtitle={current?.locationname ?? undefined}
{...(current?.locationname ? { subtitle: current.locationname } : {})}
width={400}
onClose={onClose}
>
<VStack gap={2} style={{ alignItems: 'center' }}>
<VStack gap={0} style={{ alignItems: 'center' }}>
<Text type="label" size="lg" weight="semibold">
{current?.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={220}
height={220}
viewBox={`0 0 ${span} ${span}`}
role="img"
aria-label={`Storefront QR code for ${current?.locationname ?? 'this store'}`}
shapeRendering="crispEdges"
>
<rect width={span} height={span} fill="#ffffff" />
<g transform={`translate(${quiet} ${quiet})`}>
<path d={path} fill="#000000" />
</g>
</svg>
</div>
<Button
label="Download PNG"
variant="primary"
size="sm"
icon={<Download size={13} />}
onClick={download}
/>
</VStack>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
Customers scan this in the Nearle app to shop <strong>this</strong> branch. Print it and
keep it on the counter — a code printed at another shop opens that shop, not yours.
</Text>
<StoreQrPanel
tenantid={tenantid}
locationid={current?.locationid ?? 0}
{...(current?.locationname ? { locationname: current.locationname } : {})}
where={[current?.suburb, current?.city].filter(Boolean).join(', ')}
/>
</Drawer>
);
}
function slug(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'store'
);
}