diff --git a/src/features/nearle-admin/pages/OnboardTenantPage.tsx b/src/features/nearle-admin/pages/OnboardTenantPage.tsx
index b13a955..f3ca1cc 100644
--- a/src/features/nearle-admin/pages/OnboardTenantPage.tsx
+++ b/src/features/nearle-admin/pages/OnboardTenantPage.tsx
@@ -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.
+
+ {/* 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 ? (
+
+
+
+ ) : null}
{
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(null);
+
const columns: TableColumn[] = [
{
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) => (
+ }
+ onClick={() => setQrFor(row)}
+ />
+ ),
+ },
];
return (
@@ -267,6 +292,22 @@ export function StoreDetailPage() {
+
+ {qrFor && tenantid ? (
+ setQrFor(null)}
+ >
+
+
+ ) : null}
);
}
diff --git a/src/features/qr/StoreQrPanel.tsx b/src/features/qr/StoreQrPanel.tsx
new file mode 100644
index 0000000..9e6ad24
--- /dev/null
+++ b/src/features/qr/StoreQrPanel.tsx
@@ -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 (
+
+
+
+ {locationname ?? 'Your store'}
+
+ {where ? (
+
+ {where}
+
+ ) : null}
+
+
+
+
+
+ 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.
+
+
+ }
+ onClick={download}
+ />
+
+ );
+}
+
+/** 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'
+ );
+}
diff --git a/src/features/store-user/qr.test.ts b/src/features/qr/qr.test.ts
similarity index 66%
rename from src/features/store-user/qr.test.ts
rename to src/features/qr/qr.test.ts
index 282b665..e451756 100644
--- a/src/features/store-user/qr.test.ts
+++ b/src/features/qr/qr.test.ts
@@ -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)));
+});
diff --git a/src/features/store-user/qr.ts b/src/features/qr/qr.ts
similarity index 100%
rename from src/features/store-user/qr.ts
rename to src/features/qr/qr.ts
diff --git a/src/features/store-admin/StoreAdminShell.tsx b/src/features/store-admin/StoreAdminShell.tsx
index dd94b0e..f25c2b0 100644
--- a/src/features/store-admin/StoreAdminShell.tsx
+++ b/src/features/store-admin/StoreAdminShell.tsx
@@ -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() {
- }
- manageItems={MANAGE}
- />
+
);
}
+/**
+ * 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 (
+ <>
+ }
+ 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={
+ setQrOpen(true)}>
+
+
+ }
+ />
+ {isQrOpen ? setQrOpen(false)} /> : null}
+ >
+ );
+}
+
/**
* Opens the live stream for the branch currently in scope.
*
diff --git a/src/features/store-admin/pages/OnboardBranchPage.tsx b/src/features/store-admin/pages/OnboardBranchPage.tsx
index 2376b5b..f039ca0 100644
--- a/src/features/store-admin/pages/OnboardBranchPage.tsx
+++ b/src/features/store-admin/pages/OnboardBranchPage.tsx
@@ -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.'}
+
+ {/* 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 ? (
+
+
+
+ ) : null}
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 (
-
-
-
- {current?.locationname ?? 'Your store'}
-
- {where ? (
-
- {where}
-
- ) : null}
-
-
-
-
- }
- onClick={download}
- />
-
-
-
- Customers scan this in the Nearle app to shop this branch. Print it and
- keep it on the counter — a code printed at another shop opens that shop, not yours.
-
+
);
}
-
-function slug(value: string): string {
- return (
- value
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-|-$/g, '') || 'store'
- );
-}