qr code
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user