@withpotter/lib 0.13.0-next.64 → 0.13.0-next.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/mobile-routes.ts"],"sourcesContent":["/**\n * The mobile route contract.\n *\n * Every route under `/dashboard` declares how it behaves on a phone. There are\n * only four answers, and \"we haven't decided\" is not one of them — that is the\n * whole point of this file. `scripts/check-mobile-routes.mjs` fails CI when a\n * route exists without an entry here, or an entry here points at a route that\n * no longer exists.\n *\n * Why this exists: mobile had grown into a second app — 59 hand-built screens\n * covering 43% of the dashboard — and nothing broke when a route shipped\n * without a mobile twin. A fork with no failure mode loses ground every week.\n * This is the failure mode.\n *\n * ## The tiers\n *\n * `native` — a hand-built mobile screen under `components/mobile/screens/`,\n * drawing its own chrome from `components/mobile/primitives/chrome.tsx`\n * (`BackBar`, `BottomTabBar`). Reserved for jobs a merchant does **standing\n * up, one hand, under a minute**. That is the entire test. It is a closed\n * list; adding to it is a product decision, not a refactor. The five tab\n * roots are always native, because landing a tab press in a fallback layout\n * reads as a bug.\n *\n * `shell` — the desktop body, on a phone, with mobile chrome around it. The\n * correct answer for anything configured sitting down.\n *\n * `MobileShellChrome`, mounted in `app/dashboard/layout.tsx`, draws that\n * chrome from the live primitives. It reads `mobile-header-context`, so a\n * route names itself by publishing a title — `useScreenHeader`,\n * `useDetailHeader` or `<MobilePageHeader>`, and both page frames do it for\n * you. Publish nothing and you get a tab bar with no name on it.\n * `desktop-only` — genuinely cannot be a phone surface. Every entry carries a\n * reason so the call does not get re-litigated every six months.\n *\n * `redirect` — a stub that never renders its own UI, so it has no mobile\n * behaviour to declare. Two spellings: a server `redirect()`, or a client\n * `router.replace()` in an effect for routes that must wait on the tenant\n * before they know where to go. Tracked so the gate can tell a real gap from\n * a route that was always a signpost.\n */\n\nimport { matchRoutePattern } from './route-pattern';\n\nexport type MobileTier = 'native' | 'shell' | 'desktop-only' | 'redirect';\n\n/** Why a route can never be a phone surface. Shown nowhere; read by humans. */\nconst LAB = 'Internal design-system lab, not a merchant surface.';\nexport const DESKTOP_ONLY_REASONS: Record<string, string> = {\n '/dashboard/editor/[tenantSlug]':\n 'Canvas editor — direct manipulation of a desktop-sized artboard.',\n '/dashboard/editor':\n 'Canvas editor — direct manipulation of a desktop-sized artboard.',\n '/dashboard/buttons-lab': LAB,\n '/dashboard/charts-lab': LAB,\n '/dashboard/potter-lab': LAB,\n '/dashboard/states-lab': LAB,\n '/dashboard/design-system/fluid': LAB,\n '/dashboard/mobile-redesign':\n 'The mobile visual contract itself — a spec page for us, not a merchant surface.',\n};\n\nexport const MOBILE_ROUTES: Record<string, MobileTier> = {\n '/dashboard/design-system/fluid': 'desktop-only',\n // ── native ────────────────────────────────────────────────────────────────\n // The five tab roots first, taken from TAB_ROUTES in\n // `components/mobile/primitives/chrome.tsx` — the BottomTabBar that actually\n // renders, and now the only file that declares a tab map. Two others used to\n // (`mobile-tab-bar.tsx`, `mobile-bottom-nav.tsx`), each with a different set\n // and neither with an importer; both are deleted.\n '/dashboard': 'native', // today\n '/dashboard/orders': 'native', // manage\n '/dashboard/catalog': 'native', // catalog\n '/dashboard/store': 'native', // store\n // Me is Settings — see `app/dashboard/me/page.tsx`. Kept as an address\n // because the tab bar, the old bookmarks and `pb_recent_screens` all still\n // point at it.\n '/dashboard/me': 'redirect',\n\n // Screens the mobile IA already owns.\n // A view of the bookings surface now; this path only redirects.\n '/dashboard/today': 'redirect',\n '/dashboard/activity': 'native',\n '/dashboard/operations': 'native',\n '/dashboard/business': 'shell',\n '/dashboard/settings': 'shell',\n\n // What happened / what needs me / take the money.\n '/dashboard/analytics': 'native',\n // The hub's second view. Native because it resolves on a phone to\n // `MobileAnalyticsDrillScreen` — a drill-down the mobile IA already owned and\n // reached through `?drill=`. This is not new mobile surface area; it is the\n // same screen, addressable. Reading a report is also squarely the one-hand,\n // under-a-minute job the native tier is for.\n '/dashboard/analytics/retention': 'native',\n // The order report, which moved here from /dashboard/orders/explore. Shell,\n // not native: it is a table you scan and filter, not the one-hand job the\n // native tier is for — the retention view above earns `native` because the\n // mobile IA already owned a drill screen for it, and this has no equivalent.\n '/dashboard/analytics/orders': 'shell',\n // Attribution, which was /dashboard/marketing/attribution until 2026-08-27.\n // Shell for the same reason as the order report above: four ranked panels\n // and two range switches is a reading-and-comparing job that wants the\n // desktop body, not the one-hand native tier.\n '/dashboard/analytics/acquisition': 'shell',\n // Every module's headline numbers as stacked cards: a page you read down,\n // which is what the shell's title bar and tab bar are for.\n '/dashboard/analytics/all': 'shell',\n // What is waiting on the merchant, reached from the sales summary: stacked\n // tiles you read down, like the page above.\n '/dashboard/analytics/operations': 'shell',\n '/dashboard/payments': 'native',\n // The transaction detail has no dedicated mobile screen yet. Shell it so\n // the existing detail actions remain available instead of claiming native\n // coverage that does not exist.\n '/dashboard/payments/transactions/[transactionId]': 'shell',\n // The two other logs on the money surface. Both were `?view=` lenses of the\n // route above until they became addresses of their own; mobile draws its own\n // money screen for all three, so the tier follows the hub it sits under.\n '/dashboard/payments/payouts': 'native',\n // The disputes log is still the desktop implementation; render it through\n // the shared mobile shell until a dedicated mobile workflow exists.\n '/dashboard/payments/disputes': 'shell',\n // One payout, itemised. Shell rather than native, and it is the one route on\n // this hub that earns the difference: reconciling a deposit against a bank\n // statement is a desk job with a CSV at the end of it, not the one-hand job\n // the native tier is for. The phone gets the shell's title bar over the same\n // record the desktop draws.\n '/dashboard/payments/payouts/[payoutId]': 'shell',\n '/dashboard/money-preview': 'shell',\n // The wallet and its sections are read and set up sitting down: `shell`.\n '/dashboard/wallet': 'shell',\n '/dashboard/payments/bills': 'shell',\n '/dashboard/payments/bills/new': 'shell',\n '/dashboard/payments/bills/[id]': 'shell',\n '/dashboard/payments/vendors/[id]': 'shell',\n /**\n * Hardware. `shell`. Off the money hub on 2026-08-25, back as one row in the\n * Money group on 2026-10-05; the mobile money screen still leaves it out.\n *\n * Pairing is the job this whole surface most needs a phone for: somebody is\n * standing at a gate reading a code off a screen into a keypad. Answering\n * that with a takings summary would be a dead end at exactly the moment the\n * screen is needed. It is `shell` rather than `native` because the desktop\n * page already works one-handed — a list of rows and one dialog — so a\n * purpose-built screen would be a copy, not an improvement.\n */\n '/dashboard/hardware': 'shell',\n /** One machine. Same tier as the registry above it, for the same reason. */\n '/dashboard/hardware/[deviceId]': 'shell',\n /** Asking for hardware, and one order's journey. Desk jobs, so `shell`. */\n '/dashboard/hardware/requests': 'shell',\n '/dashboard/hardware/requests/new': 'shell',\n '/dashboard/hardware/requests/[orderId]': 'shell',\n '/dashboard/hardware/shifts/[shiftId]': 'shell',\n /**\n * Bands. `shell`, and excluded from the mobile money screen for the same\n * reason Devices is: this is counter work, done standing up with a phone in\n * one hand while somebody waits, and answering it with a takings summary\n * would be a dead end at the moment the screen is needed.\n */\n '/dashboard/payments/bands': 'shell',\n '/dashboard/orders/[orderId]': 'native',\n '/dashboard/bookings': 'native',\n '/dashboard/bookings/new': 'shell',\n // Setting a session up is a sitting-down job done once, not a thing anyone\n // does one-handed at a desk between customers.\n '/dashboard/bookings/[bookingId]': 'native',\n '/dashboard/bookings/attendees': 'native',\n '/dashboard/check-in': 'native',\n '/dashboard/customers': 'native',\n '/dashboard/customers/[customerId]': 'native',\n '/dashboard/catalog/[productId]': 'native',\n // ── desktop-only ──────────────────────────────────────────────────────────\n // Above `/dashboard/[...slug]`, which is a catch-all: declared below it, the\n // editor resolved to `shell` because the catch-all matched first.\n '/dashboard/editor': 'desktop-only',\n '/dashboard/editor/[tenantSlug]': 'desktop-only',\n '/dashboard/buttons-lab': 'desktop-only',\n '/dashboard/charts-lab': 'desktop-only',\n '/dashboard/potter-lab': 'desktop-only',\n '/dashboard/states-lab': 'desktop-only',\n '/dashboard/mobile-redesign': 'desktop-only',\n\n // ── shell ─────────────────────────────────────────────────────────────────\n '/dashboard/[...slug]': 'shell',\n '/dashboard/agency': 'shell',\n // The conversation with Box. Shell rather than native, and the tier is not\n // a placeholder: the screen is already a full-height column with a scrolling\n // transcript and a composer pinned at the foot, which is the shape a phone\n // wants anyway. What a native version would add is the keyboard handling, and\n // that is a real piece of work rather than a wrapper — it stays shell until\n // someone does it properly.\n '/dashboard/ask': 'shell',\n '/dashboard/blog': 'shell',\n '/dashboard/blog/attribution': 'shell',\n '/dashboard/blog/comments': 'shell',\n '/dashboard/blog/preview': 'shell',\n '/dashboard/build': 'shell',\n '/dashboard/builds/[buildId]': 'shell',\n '/dashboard/business/bio': 'shell',\n '/dashboard/business/checkout': 'redirect',\n '/dashboard/business/shipping': 'shell',\n '/dashboard/business/domains': 'shell',\n '/dashboard/business/domains/[domainId]': 'shell',\n '/dashboard/business/domains/add': 'shell',\n '/dashboard/business/gallery': 'shell',\n '/dashboard/business/preferences': 'shell',\n '/dashboard/business/traffic': 'shell',\n // Redirects to Settings → Operations → Calendar sync now.\n '/dashboard/calendar-sync': 'redirect',\n '/dashboard/catalog/[productId]/edit': 'shell',\n '/dashboard/catalog/new': 'shell',\n '/dashboard/categories': 'shell',\n '/dashboard/collections': 'shell',\n '/dashboard/design-system': 'shell',\n '/dashboard/collections/[collectionId]': 'shell',\n '/dashboard/collections/new': 'shell',\n '/dashboard/communications/whatsapp': 'shell',\n '/dashboard/companion': 'shell',\n '/dashboard/companion/[id]': 'shell',\n '/dashboard/content/[type]': 'shell',\n '/dashboard/content/blog/[id]': 'shell',\n '/dashboard/content/blog/[id]/edit': 'shell',\n '/dashboard/content/blog/new': 'shell',\n '/dashboard/customers/[customerId]/edit': 'shell',\n '/dashboard/customers/new': 'shell',\n '/dashboard/marketing/audiences': 'shell',\n // The builder, both ends of it. Shell: composing a segment is a two-column\n // desk job — a rule on the left, who it caught on the right — not the\n // one-hand, under-a-minute work the native tier is for.\n '/dashboard/marketing/audiences/new': 'shell',\n '/dashboard/marketing/audiences/[segmentId]': 'shell',\n '/dashboard/discounts/[discountId]': 'shell',\n '/dashboard/discounts/new': 'shell',\n '/dashboard/gift-cards/[cardId]': 'shell',\n '/dashboard/gift-cards/new': 'shell',\n '/dashboard/help': 'shell',\n // The support inbox and one case. Shell rather than native: reading a thread\n // and writing a considered reply is desk work, and the one-hand job the\n // native tier is for — \"has Box answered yet\" — is answered by the bell\n // before you ever open the list.\n '/dashboard/support': 'shell',\n '/dashboard/support/[caseId]': 'shell',\n '/dashboard/inbox': 'shell',\n '/dashboard/inquiries': 'redirect',\n '/dashboard/invoices': 'shell',\n '/dashboard/invoices/[invoiceId]': 'shell',\n '/dashboard/invoices/[invoiceId]/edit': 'shell',\n '/dashboard/invoices/new': 'shell',\n '/dashboard/loyalty': 'shell',\n '/dashboard/marketing': 'shell',\n '/dashboard/marketing/ads': 'shell',\n // A page again rather than a redirect: the always-on flows came off the top\n // of the campaigns list and back onto a row of their own. Shell because it\n // is a settings-shaped screen of switches, which is desk work.\n '/dashboard/marketing/automations': 'shell',\n // One automation's report: what it enrolled, what it completed, what it\n // earned, and the sequence it runs. Reading is desk work, so shell.\n '/dashboard/marketing/automations/[automationId]': 'shell',\n // Changing a flow's steps: a canvas of conditions and splits. Desk work.\n '/dashboard/marketing/automations/[automationId]/edit': 'shell',\n // Raising an order by hand: a customer, a product picker and a line-item\n // table. Shell like every other `/new` editor, and for the same reason the\n // segment builder is — picking products and quantities is desk work, not\n // the one-hand, under-a-minute job the native tier is for. The order you\n // then open IS native, because triaging the queue is exactly that job.\n '/dashboard/orders/new': 'shell',\n // Orders cut by a dimension, which is a reading job, not a standing-up\n // one: it wants the full desktop body and the width its bars need.\n '/dashboard/marketing/automations/abandoned-cart': 'shell',\n // Redirects to /dashboard/analytics/acquisition — the report moved to the\n // hub that measures things. The entry stays so the route is still declared.\n '/dashboard/marketing/attribution': 'redirect',\n '/dashboard/marketing/campaigns': 'shell',\n '/dashboard/marketing/campaigns/[campaignId]': 'shell',\n '/dashboard/marketing/campaigns/new': 'shell',\n '/dashboard/marketing/promotions': 'shell',\n // A list of addresses and a search: desk work, like the segments list.\n '/dashboard/marketing/opt-outs': 'shell',\n // A real page again rather than a redirect into a Loyalty tab.\n // A tab on Promotions since 2026-08-27; the path redirects.\n '/dashboard/referrals': 'redirect',\n '/dashboard/resources': 'shell',\n '/dashboard/resources/[resourceId]': 'shell',\n '/dashboard/reviews': 'redirect',\n '/dashboard/sales': 'redirect',\n '/dashboard/settings/accessibility': 'shell',\n '/dashboard/settings/activity': 'redirect',\n '/dashboard/settings/billing': 'shell',\n '/dashboard/settings/billing/credits': 'shell',\n '/dashboard/settings/billing/plan': 'shell',\n '/dashboard/settings/billing/payment-methods': 'shell',\n '/dashboard/settings/billing/rates': 'shell',\n '/dashboard/settings/billing/statements': 'shell',\n '/dashboard/settings/usage': 'shell',\n '/dashboard/settings/checkout': 'redirect',\n '/dashboard/settings/documents': 'redirect',\n '/dashboard/settings/data-privacy': 'shell',\n '/dashboard/settings/demo-content': 'shell',\n '/dashboard/settings/devices': 'shell',\n // Business type became Explore (2026-09-03); this path only redirects.\n '/dashboard/settings/features': 'redirect',\n // Add more and Connectors live under Toolkit; Explore only redirects.\n '/dashboard/explore': 'redirect',\n '/dashboard/toolkit': 'shell',\n '/dashboard/settings/branding': 'redirect',\n // Events: a list, the editor, and one night's page. Shell for all three.\n '/dashboard/events': 'shell',\n '/dashboard/events/new': 'shell',\n '/dashboard/toolkit/connectors/[id]': 'shell',\n '/dashboard/events/[eventId]': 'shell',\n '/dashboard/settings/features/navigation': 'shell',\n '/dashboard/settings/features/operating': 'shell',\n '/dashboard/settings/booking-policy': 'shell',\n '/dashboard/settings/calendar-sync': 'shell',\n // Connecting a marketplace is a sit-down job done once, with a key pasted\n // from somewhere else. The pause, which IS a standing-up one-handed act,\n // rides the orders list instead — the screen a kitchen already has open.\n '/dashboard/settings/sales-channels': 'shell',\n // Opening hours became a dialog on Business locations; this path only\n // redirects there now, so existing links still arrive somewhere.\n '/dashboard/settings/hours': 'redirect',\n '/dashboard/settings/language': 'shell',\n '/dashboard/settings/legal': 'shell',\n '/dashboard/settings/letting': 'redirect',\n '/dashboard/operations/letting': 'shell',\n '/dashboard/settings/locations': 'redirect',\n '/dashboard/operations/locations': 'shell',\n // A branch's own record: editing a location is a sit-down job.\n '/dashboard/operations/locations/[locationId]': 'shell',\n '/dashboard/settings/notifications': 'shell',\n '/dashboard/settings/notifications/email': 'shell',\n // Everything the business is plugged into, in one place instead of each\n // connection filed under the subject it affects.\n '/dashboard/settings/integrations': 'redirect',\n // One connection's health, grants and log. Read and decided sitting down: a\n // reconciliation choice needs the other system open beside it.\n '/dashboard/settings/integrations/[connectionId]': 'redirect',\n '/dashboard/toolkit/connections/[connectionId]': 'shell',\n // Payouts moved to the Payments hub; this path is a redirect stub now, and\n // the contract has a tier that says exactly that.\n '/dashboard/settings/payouts': 'redirect',\n // Where money lands moved in with what landed, on /payments/payouts.\n '/dashboard/payments/bank': 'redirect',\n /** Devices became Hardware on 2026-08-25; this path only redirects now. */\n '/dashboard/payments/devices': 'redirect',\n\n // Three rules about gift cards, set once. A sit-down job like every other\n // settings screen, reached from Promotions rather than the settings rail.\n '/dashboard/settings/gift-cards': 'shell',\n // The customer messages a merchant may reword, and one of them being edited.\n // The editor is a two-pane screen with a live email preview, which is a\n // sit-down job by definition.\n '/dashboard/settings/notifications/messages': 'shell',\n '/dashboard/settings/notifications/messages/[messageKey]': 'shell',\n // Read on a phone when something is not arriving, and the fix is pasting a\n // DNS record into a registrar — which happens on a desktop. Shell.\n '/dashboard/settings/notifications/senders': 'shell',\n '/dashboard/settings/policies': 'shell',\n '/dashboard/settings/profile': 'shell',\n // What prints on a receipt, and the two stock rules. Both are typed once and\n // left alone, which is the shell's job rather than a native screen's.\n '/dashboard/settings/receipts': 'redirect',\n '/dashboard/settings/regional': 'redirect',\n '/dashboard/settings/stock': 'redirect',\n '/dashboard/settings/roles': 'shell',\n '/dashboard/staff': 'shell',\n '/dashboard/analytics/reports': 'shell',\n '/dashboard/settings/shipping': 'redirect',\n '/dashboard/settings/taxes': 'redirect',\n '/dashboard/settings/tracking': 'redirect',\n '/dashboard/settings/team': 'shell',\n // One team member's record: role, scope, details, what they can see and what\n // they have done. Read and edited sitting down, like the roster it opens\n // from, so it takes the shell's title bar rather than a native screen.\n '/dashboard/settings/team/[memberId]': 'shell',\n '/dashboard/subscriptions': 'shell',\n '/dashboard/subscriptions/plans/[planId]/edit': 'shell',\n '/dashboard/subscriptions/plans/new': 'shell',\n '/dashboard/tabs': 'shell',\n '/dashboard/templates': 'shell',\n // Its own toolbar, its own fullscreen mode, and a viewport-sized preview\n // underneath. Chrome from the shell would sit on top of a surface whose whole\n // job is to be the customer's view of the site — and the toolbar already\n // names the template and offers a way out. Declared, and deliberately bare.\n '/dashboard/templates/preview/[templateId]': 'shell',\n '/dashboard/waitlist': 'shell',\n '/dashboard/waitlist/new': 'shell',\n '/dashboard/workspaces': 'shell',\n '/dashboard/workspaces/new': 'shell',\n\n // ── redirect ──────────────────────────────────────────────────────────────\n '/dashboard/appearance': 'redirect',\n '/dashboard/attendees': 'redirect',\n '/dashboard/blog/[id]': 'redirect',\n '/dashboard/blog/new': 'redirect',\n '/dashboard/blog_posts': 'redirect',\n '/dashboard/business/pages': 'redirect',\n '/dashboard/calendar': 'redirect',\n '/dashboard/content/blog': 'redirect',\n '/dashboard/content/blog_posts': 'redirect',\n '/dashboard/customers/analytics': 'redirect',\n // Into the Marketing hub's Discounts tab. The list stopped being a screen of\n // its own the day the sidebar row went.\n '/dashboard/discounts': 'redirect',\n // A real page again, beside the catalogue whose items issue the cards.\n '/dashboard/gift-cards': 'shell',\n '/dashboard/marketing/abandoned-cart': 'redirect',\n '/dashboard/marketing/offers': 'redirect',\n '/dashboard/finance': 'redirect',\n '/dashboard/availability': 'redirect',\n '/dashboard/bookings/sessions/new': 'redirect',\n '/dashboard/inventory': 'redirect',\n '/dashboard/inventory/[productId]': 'redirect',\n '/dashboard/inventory/[productId]/edit': 'redirect',\n '/dashboard/inventory/new': 'redirect',\n '/dashboard/marketing/email': 'redirect',\n '/dashboard/marketing/email/[id]': 'redirect',\n '/dashboard/marketing/new': 'redirect',\n '/dashboard/marketing/sms': 'redirect',\n '/dashboard/marketing/whatsapp': 'redirect',\n '/dashboard/schedule': 'redirect',\n '/dashboard/settings/domains': 'redirect',\n '/dashboard/settings/navigation': 'redirect',\n '/dashboard/settings/pages': 'redirect',\n '/dashboard/settings/email': 'redirect',\n '/dashboard/settings/help': 'redirect',\n '/dashboard/settings/seo': 'redirect',\n '/dashboard/settings/site': 'redirect',\n '/dashboard/settings/social': 'redirect',\n};\n\n/**\n * Shell-tier routes that STILL render a hand-built mobile screen.\n *\n * These are the Phase 2 deletion backlog: they failed the standing-up test, so\n * the tier says `shell`, but the screen has not been removed yet and the route\n * still branches on `useIsMobile`. Until it does, the frame must not add\n * fallback chrome on top — that is how a screen ends up with two headers.\n *\n * The list is transient and should shrink to nothing. The gate verifies it:\n * every entry must actually still branch, and no shell route may branch without\n * being listed here. Delete a mobile screen, drop its line, and the route picks\n * up the fallback automatically.\n */\n/**\n * Shell routes that deliberately draw no chrome.\n *\n * Not a gap and not a backlog: a surface whose whole job is to *be* the\n * customer's view of the site cannot have the app's furniture sitting on top of\n * it. The template preview has its own toolbar naming the template and its own\n * fullscreen mode, so it names itself and offers its own way out.\n */\nexport const CHROMELESS_BY_DESIGN: ReadonlySet<string> = new Set([\n '/dashboard/templates/preview/[templateId]',\n]);\n\nexport const LEGACY_MOBILE_SCREENS: ReadonlySet<string> = new Set([\n '/dashboard/business/domains/[domainId]',\n '/dashboard/marketing/campaigns',\n '/dashboard/settings/billing',\n]);\n\n/**\n * Whether the shared page frames should draw mobile chrome for this pathname.\n *\n * True only for a shell route that draws none of its own. A native screen\n * brings its own BackBar/BottomTabBar, and a legacy screen still does too.\n */\nexport function shouldRenderFallbackChrome(pathname: string): boolean {\n return (\n mobileTierFor(pathname) === 'shell' &&\n !LEGACY_MOBILE_SCREENS.has(pathname) &&\n !CHROMELESS_BY_DESIGN.has(pathname)\n );\n}\n\n/**\n * Resolve a live pathname to its tier, matching dynamic segments.\n *\n * Static keys win over dynamic ones, so `/dashboard/catalog/new` resolves to\n * its own entry rather than to `/dashboard/catalog/[productId]`.\n */\nexport function mobileTierFor(pathname: string): MobileTier | undefined {\n /* The walk itself lives in `route-pattern.ts` — the page-intro registry needs\n the identical match, and two copies of it would drift on the catch-all\n case without either registry looking broken. */\n const pattern = matchRoutePattern(pathname, Object.keys(MOBILE_ROUTES));\n return pattern ? MOBILE_ROUTES[pattern] : undefined;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/mobile-routes.ts"],"sourcesContent":["/**\n * The mobile route contract.\n *\n * Every route under `/dashboard` declares how it behaves on a phone. There are\n * only four answers, and \"we haven't decided\" is not one of them — that is the\n * whole point of this file. `scripts/check-mobile-routes.mjs` fails CI when a\n * route exists without an entry here, or an entry here points at a route that\n * no longer exists.\n *\n * Why this exists: mobile had grown into a second app — 59 hand-built screens\n * covering 43% of the dashboard — and nothing broke when a route shipped\n * without a mobile twin. A fork with no failure mode loses ground every week.\n * This is the failure mode.\n *\n * ## The tiers\n *\n * `native` — a hand-built mobile screen under `components/mobile/screens/`,\n * drawing its own chrome from `components/mobile/primitives/chrome.tsx`\n * (`BackBar`, `BottomTabBar`). Reserved for jobs a merchant does **standing\n * up, one hand, under a minute**. That is the entire test. It is a closed\n * list; adding to it is a product decision, not a refactor. The five tab\n * roots are always native, because landing a tab press in a fallback layout\n * reads as a bug.\n *\n * `shell` — the desktop body, on a phone, with mobile chrome around it. The\n * correct answer for anything configured sitting down.\n *\n * `MobileShellChrome`, mounted in `app/dashboard/layout.tsx`, draws that\n * chrome from the live primitives. It reads `mobile-header-context`, so a\n * route names itself by publishing a title — `useScreenHeader`,\n * `useDetailHeader` or `<MobilePageHeader>`, and both page frames do it for\n * you. Publish nothing and you get a tab bar with no name on it.\n * `desktop-only` — genuinely cannot be a phone surface. Every entry carries a\n * reason so the call does not get re-litigated every six months.\n *\n * `redirect` — a stub that never renders its own UI, so it has no mobile\n * behaviour to declare. Two spellings: a server `redirect()`, or a client\n * `router.replace()` in an effect for routes that must wait on the tenant\n * before they know where to go. Tracked so the gate can tell a real gap from\n * a route that was always a signpost.\n */\n\nimport { matchRoutePattern } from './route-pattern';\n\nexport type MobileTier = 'native' | 'shell' | 'desktop-only' | 'redirect';\n\n/** Why a route can never be a phone surface. Shown nowhere; read by humans. */\nconst LAB = 'Internal design-system lab, not a merchant surface.';\nexport const DESKTOP_ONLY_REASONS: Record<string, string> = {\n '/dashboard/editor/[tenantSlug]':\n 'Canvas editor — direct manipulation of a desktop-sized artboard.',\n '/dashboard/editor':\n 'Canvas editor — direct manipulation of a desktop-sized artboard.',\n '/dashboard/buttons-lab': LAB,\n '/dashboard/charts-lab': LAB,\n '/dashboard/potter-lab': LAB,\n '/dashboard/states-lab': LAB,\n '/dashboard/design-system/fluid': LAB,\n '/dashboard/mobile-redesign':\n 'The mobile visual contract itself — a spec page for us, not a merchant surface.',\n};\n\nexport const MOBILE_ROUTES: Record<string, MobileTier> = {\n '/dashboard/design-system/fluid': 'desktop-only',\n // ── native ────────────────────────────────────────────────────────────────\n // The five tab roots first, taken from TAB_ROUTES in\n // `components/mobile/primitives/chrome.tsx` — the BottomTabBar that actually\n // renders, and now the only file that declares a tab map. Two others used to\n // (`mobile-tab-bar.tsx`, `mobile-bottom-nav.tsx`), each with a different set\n // and neither with an importer; both are deleted.\n '/dashboard': 'native', // today\n '/dashboard/orders': 'native', // manage\n '/dashboard/catalog': 'native', // catalog\n '/dashboard/store': 'native', // store\n // Me is Settings — see `app/dashboard/me/page.tsx`. Kept as an address\n // because the tab bar, the old bookmarks and `pb_recent_screens` all still\n // point at it.\n '/dashboard/me': 'redirect',\n\n // Screens the mobile IA already owns.\n // A view of the bookings surface now; this path only redirects.\n '/dashboard/today': 'redirect',\n '/dashboard/activity': 'native',\n '/dashboard/operations': 'native',\n '/dashboard/business': 'shell',\n '/dashboard/settings': 'shell',\n\n // What happened / what needs me / take the money.\n '/dashboard/analytics': 'native',\n // The hub's second view. Native because it resolves on a phone to\n // `MobileAnalyticsDrillScreen` — a drill-down the mobile IA already owned and\n // reached through `?drill=`. This is not new mobile surface area; it is the\n // same screen, addressable. Reading a report is also squarely the one-hand,\n // under-a-minute job the native tier is for.\n '/dashboard/analytics/retention': 'native',\n // The order report, which moved here from /dashboard/orders/explore. Shell,\n // not native: it is a table you scan and filter, not the one-hand job the\n // native tier is for — the retention view above earns `native` because the\n // mobile IA already owned a drill screen for it, and this has no equivalent.\n '/dashboard/analytics/orders': 'shell',\n // Attribution, which was /dashboard/marketing/attribution until 2026-08-27.\n // Shell for the same reason as the order report above: four ranked panels\n // and two range switches is a reading-and-comparing job that wants the\n // desktop body, not the one-hand native tier.\n '/dashboard/analytics/acquisition': 'shell',\n // Every module's headline numbers as stacked cards: a page you read down,\n // which is what the shell's title bar and tab bar are for.\n '/dashboard/analytics/all': 'shell',\n // What is waiting on the merchant, reached from the sales summary: stacked\n // tiles you read down, like the page above.\n '/dashboard/analytics/operations': 'shell',\n '/dashboard/payments': 'native',\n // The transaction detail has no dedicated mobile screen yet. Shell it so\n // the existing detail actions remain available instead of claiming native\n // coverage that does not exist.\n '/dashboard/payments/transactions/[transactionId]': 'shell',\n // The two other logs on the money surface. Both were `?view=` lenses of the\n // route above until they became addresses of their own; mobile draws its own\n // money screen for all three, so the tier follows the hub it sits under.\n '/dashboard/payments/payouts': 'native',\n // The disputes log is still the desktop implementation; render it through\n // the shared mobile shell until a dedicated mobile workflow exists.\n '/dashboard/payments/disputes': 'shell',\n // One payout, itemised. Shell rather than native, and it is the one route on\n // this hub that earns the difference: reconciling a deposit against a bank\n // statement is a desk job with a CSV at the end of it, not the one-hand job\n // the native tier is for. The phone gets the shell's title bar over the same\n // record the desktop draws.\n '/dashboard/payments/payouts/[payoutId]': 'shell',\n '/dashboard/money-preview': 'shell',\n // The wallet and its sections are read and set up sitting down: `shell`.\n '/dashboard/wallet': 'shell',\n '/dashboard/payments/bills': 'shell',\n '/dashboard/payments/bills/new': 'shell',\n '/dashboard/payments/bills/[id]': 'shell',\n '/dashboard/payments/vendors/[id]': 'shell',\n /**\n * Hardware. `shell`. Off the money hub on 2026-08-25, back as one row in the\n * Money group on 2026-10-05; the mobile money screen still leaves it out.\n *\n * Pairing is the job this whole surface most needs a phone for: somebody is\n * standing at a gate reading a code off a screen into a keypad. Answering\n * that with a takings summary would be a dead end at exactly the moment the\n * screen is needed. It is `shell` rather than `native` because the desktop\n * page already works one-handed — a list of rows and one dialog — so a\n * purpose-built screen would be a copy, not an improvement.\n */\n '/dashboard/hardware': 'shell',\n /** One machine. Same tier as the registry above it, for the same reason. */\n '/dashboard/hardware/[deviceId]': 'shell',\n /** Asking for hardware, and one order's journey. Desk jobs, so `shell`. */\n '/dashboard/hardware/requests': 'shell',\n '/dashboard/hardware/requests/new': 'shell',\n '/dashboard/hardware/requests/[orderId]': 'shell',\n '/dashboard/hardware/shifts/[shiftId]': 'shell',\n /**\n * Bands. `shell`, and excluded from the mobile money screen for the same\n * reason Devices is: this is counter work, done standing up with a phone in\n * one hand while somebody waits, and answering it with a takings summary\n * would be a dead end at the moment the screen is needed.\n */\n '/dashboard/payments/bands': 'shell',\n '/dashboard/orders/[orderId]': 'native',\n '/dashboard/bookings': 'native',\n '/dashboard/bookings/new': 'shell',\n // Setting a session up is a sitting-down job done once, not a thing anyone\n // does one-handed at a desk between customers.\n '/dashboard/bookings/[bookingId]': 'native',\n '/dashboard/bookings/attendees': 'native',\n '/dashboard/check-in': 'native',\n '/dashboard/customers': 'native',\n '/dashboard/customers/[customerId]': 'native',\n '/dashboard/catalog/[productId]': 'native',\n // ── desktop-only ──────────────────────────────────────────────────────────\n // Above `/dashboard/[...slug]`, which is a catch-all: declared below it, the\n // editor resolved to `shell` because the catch-all matched first.\n '/dashboard/editor': 'desktop-only',\n '/dashboard/editor/[tenantSlug]': 'desktop-only',\n '/dashboard/buttons-lab': 'desktop-only',\n '/dashboard/charts-lab': 'desktop-only',\n '/dashboard/potter-lab': 'desktop-only',\n '/dashboard/states-lab': 'desktop-only',\n '/dashboard/mobile-redesign': 'desktop-only',\n\n // ── shell ─────────────────────────────────────────────────────────────────\n '/dashboard/[...slug]': 'shell',\n '/dashboard/agency': 'shell',\n // The conversation with Box. Shell rather than native, and the tier is not\n // a placeholder: the screen is already a full-height column with a scrolling\n // transcript and a composer pinned at the foot, which is the shape a phone\n // wants anyway. What a native version would add is the keyboard handling, and\n // that is a real piece of work rather than a wrapper — it stays shell until\n // someone does it properly.\n '/dashboard/ask': 'shell',\n '/dashboard/blog': 'shell',\n '/dashboard/blog/attribution': 'shell',\n '/dashboard/blog/comments': 'shell',\n '/dashboard/blog/preview': 'shell',\n '/dashboard/build': 'shell',\n '/dashboard/builds/[buildId]': 'shell',\n '/dashboard/business/bio': 'shell',\n '/dashboard/business/checkout': 'redirect',\n '/dashboard/business/shipping': 'shell',\n '/dashboard/business/domains': 'shell',\n '/dashboard/business/domains/[domainId]': 'shell',\n '/dashboard/business/domains/add': 'shell',\n '/dashboard/business/gallery': 'shell',\n '/dashboard/business/preferences': 'shell',\n '/dashboard/business/traffic': 'shell',\n // Redirects to Settings → Operations → Calendar sync now.\n '/dashboard/calendar-sync': 'redirect',\n '/dashboard/catalog/[productId]/edit': 'shell',\n '/dashboard/catalog/new': 'shell',\n '/dashboard/categories': 'shell',\n '/dashboard/collections': 'shell',\n '/dashboard/design-system': 'shell',\n '/dashboard/collections/[collectionId]': 'shell',\n '/dashboard/collections/new': 'shell',\n '/dashboard/communications/whatsapp': 'shell',\n '/dashboard/companion': 'shell',\n '/dashboard/companion/[id]': 'shell',\n '/dashboard/content/[type]': 'shell',\n '/dashboard/content/blog/[id]': 'shell',\n '/dashboard/content/blog/[id]/edit': 'shell',\n '/dashboard/content/blog/new': 'shell',\n '/dashboard/customers/[customerId]/edit': 'shell',\n '/dashboard/customers/new': 'shell',\n '/dashboard/marketing/audiences': 'shell',\n // The builder, both ends of it. Shell: composing a segment is a two-column\n // desk job — a rule on the left, who it caught on the right — not the\n // one-hand, under-a-minute work the native tier is for.\n '/dashboard/marketing/audiences/new': 'shell',\n '/dashboard/marketing/audiences/[segmentId]': 'shell',\n '/dashboard/discounts/[discountId]': 'shell',\n '/dashboard/discounts/new': 'shell',\n '/dashboard/gift-cards/[cardId]': 'shell',\n '/dashboard/gift-cards/new': 'shell',\n '/dashboard/help': 'shell',\n // The support inbox and one case. Shell rather than native: reading a thread\n // and writing a considered reply is desk work, and the one-hand job the\n // native tier is for — \"has Box answered yet\" — is answered by the bell\n // before you ever open the list.\n '/dashboard/support': 'shell',\n '/dashboard/support/[caseId]': 'shell',\n '/dashboard/inbox': 'shell',\n '/dashboard/inquiries': 'redirect',\n '/dashboard/invoices': 'shell',\n '/dashboard/invoices/[invoiceId]': 'shell',\n '/dashboard/invoices/[invoiceId]/edit': 'shell',\n '/dashboard/invoices/new': 'shell',\n '/dashboard/loyalty': 'shell',\n '/dashboard/marketing': 'shell',\n '/dashboard/marketing/ads': 'shell',\n // A page again rather than a redirect: the always-on flows came off the top\n // of the campaigns list and back onto a row of their own. Shell because it\n // is a settings-shaped screen of switches, which is desk work.\n '/dashboard/marketing/automations': 'shell',\n // One automation's report: what it enrolled, what it completed, what it\n // earned, and the sequence it runs. Reading is desk work, so shell.\n '/dashboard/marketing/automations/[automationId]': 'shell',\n // Changing a flow's steps: a canvas of conditions and splits. Desk work.\n '/dashboard/marketing/automations/[automationId]/edit': 'shell',\n // Raising an order by hand: a customer, a product picker and a line-item\n // table. Shell like every other `/new` editor, and for the same reason the\n // segment builder is — picking products and quantities is desk work, not\n // the one-hand, under-a-minute job the native tier is for. The order you\n // then open IS native, because triaging the queue is exactly that job.\n '/dashboard/orders/new': 'shell',\n // Orders cut by a dimension, which is a reading job, not a standing-up\n // one: it wants the full desktop body and the width its bars need.\n '/dashboard/marketing/automations/abandoned-cart': 'shell',\n // Redirects to /dashboard/analytics/acquisition — the report moved to the\n // hub that measures things. The entry stays so the route is still declared.\n '/dashboard/marketing/attribution': 'redirect',\n '/dashboard/marketing/campaigns': 'shell',\n '/dashboard/marketing/campaigns/[campaignId]': 'shell',\n '/dashboard/marketing/campaigns/new': 'shell',\n '/dashboard/marketing/promotions': 'shell',\n // A list of addresses and a search: desk work, like the segments list.\n '/dashboard/marketing/opt-outs': 'shell',\n // A real page again rather than a redirect into a Loyalty tab.\n // A tab on Promotions since 2026-08-27; the path redirects.\n '/dashboard/referrals': 'redirect',\n '/dashboard/resources': 'shell',\n '/dashboard/resources/[resourceId]': 'shell',\n '/dashboard/reviews': 'redirect',\n '/dashboard/sales': 'redirect',\n '/dashboard/settings/accessibility': 'shell',\n '/dashboard/settings/activity': 'redirect',\n '/dashboard/settings/billing': 'shell',\n '/dashboard/settings/billing/credits': 'shell',\n '/dashboard/settings/billing/plan': 'shell',\n '/dashboard/settings/billing/payment-methods': 'shell',\n '/dashboard/settings/billing/rates': 'shell',\n '/dashboard/settings/billing/statements': 'shell',\n '/dashboard/settings/usage': 'shell',\n '/dashboard/settings/checkout': 'redirect',\n '/dashboard/settings/documents': 'redirect',\n '/dashboard/settings/data-privacy': 'shell',\n '/dashboard/settings/demo-content': 'shell',\n '/dashboard/settings/devices': 'shell',\n // Business types became Explore (2026-09-03). This path is Features again:\n // the switches no business type implies, Blog first.\n '/dashboard/settings/features': 'shell',\n // Add more and Connectors live under Toolkit; Explore only redirects.\n '/dashboard/explore': 'redirect',\n '/dashboard/toolkit': 'shell',\n '/dashboard/settings/branding': 'redirect',\n // Events: a list, the editor, and one night's page. Shell for all three.\n '/dashboard/events': 'shell',\n '/dashboard/events/new': 'shell',\n '/dashboard/toolkit/connectors/[id]': 'shell',\n '/dashboard/events/[eventId]': 'shell',\n '/dashboard/settings/features/navigation': 'shell',\n '/dashboard/settings/features/operating': 'shell',\n '/dashboard/settings/booking-policy': 'shell',\n '/dashboard/settings/calendar-sync': 'shell',\n // Connecting a marketplace is a sit-down job done once, with a key pasted\n // from somewhere else. The pause, which IS a standing-up one-handed act,\n // rides the orders list instead — the screen a kitchen already has open.\n '/dashboard/settings/sales-channels': 'shell',\n // Opening hours became a dialog on Business locations; this path only\n // redirects there now, so existing links still arrive somewhere.\n '/dashboard/settings/hours': 'redirect',\n '/dashboard/settings/language': 'shell',\n '/dashboard/settings/legal': 'shell',\n '/dashboard/settings/letting': 'redirect',\n '/dashboard/operations/letting': 'shell',\n '/dashboard/settings/locations': 'redirect',\n '/dashboard/operations/locations': 'shell',\n // A branch's own record: editing a location is a sit-down job.\n '/dashboard/operations/locations/[locationId]': 'shell',\n '/dashboard/settings/notifications': 'shell',\n '/dashboard/settings/notifications/email': 'shell',\n // Everything the business is plugged into, in one place instead of each\n // connection filed under the subject it affects.\n '/dashboard/settings/integrations': 'redirect',\n // One connection's health, grants and log. Read and decided sitting down: a\n // reconciliation choice needs the other system open beside it.\n '/dashboard/settings/integrations/[connectionId]': 'redirect',\n '/dashboard/toolkit/connections/[connectionId]': 'shell',\n // Payouts moved to the Payments hub; this path is a redirect stub now, and\n // the contract has a tier that says exactly that.\n '/dashboard/settings/payouts': 'redirect',\n // Where money lands moved in with what landed, on /payments/payouts.\n '/dashboard/payments/bank': 'redirect',\n /** Devices became Hardware on 2026-08-25; this path only redirects now. */\n '/dashboard/payments/devices': 'redirect',\n\n // Three rules about gift cards, set once. A sit-down job like every other\n // settings screen, reached from Promotions rather than the settings rail.\n '/dashboard/settings/gift-cards': 'shell',\n // The customer messages a merchant may reword, and one of them being edited.\n // The editor is a two-pane screen with a live email preview, which is a\n // sit-down job by definition.\n '/dashboard/settings/notifications/messages': 'shell',\n '/dashboard/settings/notifications/messages/[messageKey]': 'shell',\n // Read on a phone when something is not arriving, and the fix is pasting a\n // DNS record into a registrar — which happens on a desktop. Shell.\n '/dashboard/settings/notifications/senders': 'shell',\n '/dashboard/settings/policies': 'shell',\n '/dashboard/settings/profile': 'shell',\n // What prints on a receipt, and the two stock rules. Both are typed once and\n // left alone, which is the shell's job rather than a native screen's.\n '/dashboard/settings/receipts': 'redirect',\n '/dashboard/settings/regional': 'redirect',\n '/dashboard/settings/stock': 'redirect',\n '/dashboard/settings/roles': 'shell',\n '/dashboard/staff': 'shell',\n '/dashboard/analytics/reports': 'shell',\n '/dashboard/settings/shipping': 'redirect',\n '/dashboard/settings/taxes': 'redirect',\n '/dashboard/settings/tracking': 'redirect',\n '/dashboard/settings/team': 'shell',\n // One team member's record: role, scope, details, what they can see and what\n // they have done. Read and edited sitting down, like the roster it opens\n // from, so it takes the shell's title bar rather than a native screen.\n '/dashboard/settings/team/[memberId]': 'shell',\n '/dashboard/subscriptions': 'shell',\n '/dashboard/subscriptions/plans/[planId]/edit': 'shell',\n '/dashboard/subscriptions/plans/new': 'shell',\n '/dashboard/tabs': 'shell',\n '/dashboard/templates': 'shell',\n // Its own toolbar, its own fullscreen mode, and a viewport-sized preview\n // underneath. Chrome from the shell would sit on top of a surface whose whole\n // job is to be the customer's view of the site — and the toolbar already\n // names the template and offers a way out. Declared, and deliberately bare.\n '/dashboard/templates/preview/[templateId]': 'shell',\n '/dashboard/waitlist': 'shell',\n '/dashboard/waitlist/new': 'shell',\n '/dashboard/workspaces': 'shell',\n '/dashboard/workspaces/new': 'shell',\n\n // ── redirect ──────────────────────────────────────────────────────────────\n '/dashboard/appearance': 'redirect',\n '/dashboard/attendees': 'redirect',\n '/dashboard/blog/[id]': 'redirect',\n '/dashboard/blog/new': 'redirect',\n '/dashboard/blog_posts': 'redirect',\n '/dashboard/business/pages': 'redirect',\n '/dashboard/calendar': 'redirect',\n '/dashboard/content/blog': 'redirect',\n '/dashboard/content/blog_posts': 'redirect',\n '/dashboard/customers/analytics': 'redirect',\n // Into the Marketing hub's Discounts tab. The list stopped being a screen of\n // its own the day the sidebar row went.\n '/dashboard/discounts': 'redirect',\n // A real page again, beside the catalogue whose items issue the cards.\n '/dashboard/gift-cards': 'shell',\n '/dashboard/marketing/abandoned-cart': 'redirect',\n '/dashboard/marketing/offers': 'redirect',\n '/dashboard/finance': 'redirect',\n '/dashboard/availability': 'redirect',\n '/dashboard/bookings/sessions/new': 'redirect',\n '/dashboard/inventory': 'redirect',\n '/dashboard/inventory/[productId]': 'redirect',\n '/dashboard/inventory/[productId]/edit': 'redirect',\n '/dashboard/inventory/new': 'redirect',\n '/dashboard/marketing/email': 'redirect',\n '/dashboard/marketing/email/[id]': 'redirect',\n '/dashboard/marketing/new': 'redirect',\n '/dashboard/marketing/sms': 'redirect',\n '/dashboard/marketing/whatsapp': 'redirect',\n '/dashboard/schedule': 'redirect',\n '/dashboard/settings/domains': 'redirect',\n '/dashboard/settings/navigation': 'redirect',\n '/dashboard/settings/pages': 'redirect',\n '/dashboard/settings/email': 'redirect',\n '/dashboard/settings/help': 'redirect',\n '/dashboard/settings/seo': 'redirect',\n '/dashboard/settings/site': 'redirect',\n '/dashboard/settings/social': 'redirect',\n};\n\n/**\n * Shell-tier routes that STILL render a hand-built mobile screen.\n *\n * These are the Phase 2 deletion backlog: they failed the standing-up test, so\n * the tier says `shell`, but the screen has not been removed yet and the route\n * still branches on `useIsMobile`. Until it does, the frame must not add\n * fallback chrome on top — that is how a screen ends up with two headers.\n *\n * The list is transient and should shrink to nothing. The gate verifies it:\n * every entry must actually still branch, and no shell route may branch without\n * being listed here. Delete a mobile screen, drop its line, and the route picks\n * up the fallback automatically.\n */\n/**\n * Shell routes that deliberately draw no chrome.\n *\n * Not a gap and not a backlog: a surface whose whole job is to *be* the\n * customer's view of the site cannot have the app's furniture sitting on top of\n * it. The template preview has its own toolbar naming the template and its own\n * fullscreen mode, so it names itself and offers its own way out.\n */\nexport const CHROMELESS_BY_DESIGN: ReadonlySet<string> = new Set([\n '/dashboard/templates/preview/[templateId]',\n]);\n\nexport const LEGACY_MOBILE_SCREENS: ReadonlySet<string> = new Set([\n '/dashboard/business/domains/[domainId]',\n '/dashboard/marketing/campaigns',\n '/dashboard/settings/billing',\n]);\n\n/**\n * Whether the shared page frames should draw mobile chrome for this pathname.\n *\n * True only for a shell route that draws none of its own. A native screen\n * brings its own BackBar/BottomTabBar, and a legacy screen still does too.\n */\nexport function shouldRenderFallbackChrome(pathname: string): boolean {\n return (\n mobileTierFor(pathname) === 'shell' &&\n !LEGACY_MOBILE_SCREENS.has(pathname) &&\n !CHROMELESS_BY_DESIGN.has(pathname)\n );\n}\n\n/**\n * Resolve a live pathname to its tier, matching dynamic segments.\n *\n * Static keys win over dynamic ones, so `/dashboard/catalog/new` resolves to\n * its own entry rather than to `/dashboard/catalog/[productId]`.\n */\nexport function mobileTierFor(pathname: string): MobileTier | undefined {\n /* The walk itself lives in `route-pattern.ts` — the page-intro registry needs\n the identical match, and two copies of it would drift on the catch-all\n case without either registry looking broken. */\n const pattern = matchRoutePattern(pathname, Object.keys(MOBILE_ROUTES));\n return pattern ? MOBILE_ROUTES[pattern] : undefined;\n}\n"],"mappings":"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","names":[]}
@@ -1,4 +1,6 @@
1
1
  import type { ContentBehavior, TemplateConfig } from './template-types';
2
+ /** The standalone behaviors this tenant has explicitly turned on. */
3
+ export declare function resolveStandaloneBehaviors(tenantSettings: Record<string, unknown> | undefined): ContentBehavior[];
2
4
  export declare function resolveBehaviorsFromIntents(intents: string[]): ContentBehavior[];
3
5
  /**
4
6
  * What a single model grants on its own — used by Settings → Features to show,
@@ -128,6 +128,7 @@ export {
128
128
  readBehaviorOverrides,
129
129
  resolveBaseBehaviors,
130
130
  resolveBehaviorsFromIntents,
131
- resolveEnabledBehaviors
131
+ resolveEnabledBehaviors,
132
+ resolveStandaloneBehaviors
132
133
  };
133
134
  //# sourceMappingURL=resolve-enabled-behaviors.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/resolve-enabled-behaviors.ts"],"sourcesContent":["import type { ContentBehavior, TemplateConfig } from './template-types';\n\n/**\n * Behaviors no business model implies, so `resolveBehaviorsFromIntents` can\n * never produce them and the intent-derived path would otherwise wipe them.\n *\n * Whether a business writes (`publishable`) or shows its work (`showcase`) has\n * nothing to do with whether it sells, books or rents. Both stay deliberate\n * opt-ins, read off the tenant's own `enabledBehaviors` and unioned onto\n * whatever the intents imply.\n */\nconst STANDALONE_BEHAVIORS: ContentBehavior[] = ['publishable', 'showcase'];\n\n/** The standalone behaviors this tenant has explicitly turned on. */\nfunction resolveStandaloneBehaviors(\n tenantSettings: Record<string, unknown> | undefined,\n): ContentBehavior[] {\n const stored = tenantSettings?.enabledBehaviors;\n if (!Array.isArray(stored)) return [];\n return STANDALONE_BEHAVIORS.filter((b) => stored.includes(b));\n}\n\n/**\n * What the merchant's *operating model* turns on, over and above the archetype.\n *\n * `OperatingModelProfile.behaviorKeys` has existed since the operating model\n * did, is persisted through `normalizeProfiles`, and was read by nothing. It is\n * the more honest place for several of these: \"we deliver\" is a fact about how\n * a business operates, not about what it sells — which is why a takeaway that\n * only does pickup currently has to switch `shippable` off by hand after the\n * archetype grants it.\n *\n * Additive on purpose. Intents stay authoritative for what an archetype\n * implies; a profile can only ever turn something else on, so no tenant loses a\n * behaviour by this being read at last.\n */\nfunction resolveProfileBehaviors(\n tenantSettings: Record<string, unknown> | undefined,\n): ContentBehavior[] {\n const operatingModel = tenantSettings?.operatingModel as\n | { profiles?: Array<{ enabled?: boolean; behaviorKeys?: string[] }> }\n | undefined;\n\n if (!Array.isArray(operatingModel?.profiles)) return [];\n\n return operatingModel.profiles\n .filter((profile) => profile.enabled !== false)\n .flatMap((profile) => profile.behaviorKeys ?? []) as ContentBehavior[];\n}\n\nexport function resolveBehaviorsFromIntents(\n intents: string[],\n): ContentBehavior[] {\n const behaviors = new Set<ContentBehavior>();\n\n for (const intent of intents) {\n switch (intent) {\n case 'sell-products':\n behaviors.add('sellable');\n behaviors.add('shippable');\n break;\n case 'take-bookings':\n behaviors.add('bookable');\n break;\n case 'rent-property':\n behaviors.add('bookable');\n behaviors.add('rentable');\n break;\n case 'food-orders':\n behaviors.add('sellable');\n // The archetype for food that LEAVES the counter — its operating\n // profiles are dine-in, walk-in, QR, pickup and delivery, and the\n // delivery one already says `hideShipping: false`. The storefront\n // gates its whole delivery-address step on `shippable`, and Shipping\n // and Service areas are gated on it here, so without it a takeaway\n // could not say where it delivers, what delivery costs, or collect an\n // address on its own site. A kitchen that never leaves the building\n // switches it off in Settings → Features.\n //\n // Mirrored by INTENT_BEHAVIORS in potter-api; the two maps are\n // separate and must move together or provisioning writes one thing\n // while the dashboard resolves another.\n behaviors.add('shippable');\n break;\n case 'dine-in':\n // Reserve & Seat: sellable menu + bookable tables (as resources).\n behaviors.add('sellable');\n behaviors.add('bookable');\n break;\n case 'subscriptions':\n behaviors.add('subscribable');\n break;\n case 'digital-products':\n behaviors.add('sellable');\n behaviors.add('downloadable');\n break;\n case 'event-tickets':\n behaviors.add('sellable');\n behaviors.add('ticketed');\n break;\n case 'send-invoices':\n // Deliver & Quote: billed directly, never checked out of a storefront.\n behaviors.add('billable');\n break;\n case 'capture-leads':\n // Enquire & Qualify: nothing is bought. The thing that arrives is a\n // person asking, and the storefront's job is to let them ask well.\n behaviors.add('leadCapture');\n break;\n }\n }\n\n return [...behaviors];\n}\n\n/**\n * What a single model grants on its own — used by Settings → Features to show,\n * on hover, which capabilities a model is responsible for, and to preview what\n * adding an unselected one would turn on.\n */\nexport function behaviorsForIntent(intentKey: string): ContentBehavior[] {\n return resolveBehaviorsFromIntents([intentKey]);\n}\n\n/**\n * Per-behavior adjustments layered on top of the business model.\n *\n * A key is present ONLY when the merchant has deliberately overruled what their\n * business model implies — `{ shippable: false }` for a shop that sells over a\n * counter and never posts anything, `{ downloadable: true }` for a bookings\n * business that also hands out a workbook. Absent key means \"follow the model\",\n * which is why this is a sparse map and not a full behavior list: a stored list\n * silently freezes, so adding a business model later would grant no new\n * behaviors. The map only ever records the deltas.\n */\nexport type BehaviorOverrides = Partial<Record<ContentBehavior, boolean>>;\n\nexport function readBehaviorOverrides(\n tenantSettings: Record<string, unknown> | undefined,\n): BehaviorOverrides {\n const raw = tenantSettings?.behaviorOverrides;\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};\n const out: BehaviorOverrides = {};\n for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {\n if (typeof value === 'boolean') out[key as ContentBehavior] = value;\n }\n return out;\n}\n\n/**\n * Applies the merchant's overrides to a base behavior set.\n *\n * The result can never be empty. A business with zero behaviors can't transact\n * at all — every behavior-gated nav item and storefront action disappears — so\n * the last remaining behavior is a floor, and an override that would clear the\n * set is ignored rather than honoured.\n */\nexport function applyBehaviorOverrides(\n base: ContentBehavior[],\n overrides: BehaviorOverrides,\n): ContentBehavior[] {\n const next = new Set(base);\n for (const [key, enabled] of Object.entries(overrides)) {\n if (enabled) next.add(key as ContentBehavior);\n else next.delete(key as ContentBehavior);\n }\n if (next.size === 0) return base.length > 0 ? base : ['sellable'];\n return [...next];\n}\n\n/**\n * Resolves which behaviors are enabled for a tenant.\n *\n * The business model (`selectedIntents`) is the control; it derives a base set.\n * `behaviorOverrides` then layers the merchant's deliberate adjustments on top.\n * Everything below that is fallback for tenants that predate intents.\n *\n * Priority:\n * 1. Intent-derived base + `behaviorOverrides`\n * 2. Legacy `enabledBehaviors` (tenants with no intents) + overrides\n * 3. Template-level `behaviors` + overrides\n * 4. Union of all content type behaviors + overrides\n * 5. `['sellable']` + overrides\n */\nexport function resolveEnabledBehaviors(\n tenantSettings: Record<string, unknown> | undefined,\n template: TemplateConfig | null,\n): ContentBehavior[] {\n const overrides = readBehaviorOverrides(tenantSettings);\n const withOverrides = (base: ContentBehavior[]) =>\n applyBehaviorOverrides(base, overrides);\n\n // 1. Intent selection is authoritative when the merchant has actually picked\n // something. An EMPTY array is treated as unset, not as \"this business\n // does nothing\": resolveTenantIntentConfigs already falls back to\n // default-commerce for the empty case, so honouring empty here produced a\n // split state — the commerce nav still rendered while zero behaviors\n // resolved, silently stripping every behavior-gated item and leaving the\n // storefront with no way to transact. A merchant can reach that state by\n // deselecting their last business model in Settings → Features.\n const selectedIntents = tenantSettings?.selectedIntents;\n if (Array.isArray(selectedIntents) && selectedIntents.length > 0) {\n // Overrides stay authoritative: they are applied last, so a switch turned\n // off in Business model still wins over anything the union puts in.\n return withOverrides([\n ...resolveBehaviorsFromIntents(selectedIntents),\n ...resolveProfileBehaviors(tenantSettings),\n ...resolveStandaloneBehaviors(tenantSettings),\n ]);\n }\n\n // 2. Legacy tenants that set behaviors directly, before intents existed.\n const tenantBehaviors = tenantSettings?.enabledBehaviors;\n if (Array.isArray(tenantBehaviors) && tenantBehaviors.length > 0) {\n return withOverrides(tenantBehaviors as ContentBehavior[]);\n }\n\n // 3. Template declares behaviors — use as defaults\n if (template?.behaviors && template.behaviors.length > 0) {\n return withOverrides(template.behaviors);\n }\n\n // 4. Collect from template content types\n if (template?.contentTypes) {\n const behaviors = new Set<ContentBehavior>();\n for (const ct of Object.values(template.contentTypes)) {\n ct.behaviors?.forEach((b) => behaviors.add(b));\n }\n if (behaviors.size > 0) return withOverrides(Array.from(behaviors));\n }\n\n // 5. Absolute fallback\n return withOverrides(['sellable']);\n}\n\n/**\n * The behaviors the business model implies, BEFORE the merchant's overrides.\n *\n * Settings → Features needs both halves to tell the truth: this is what your\n * model turns on, and this is where you've overruled it. Nothing else should\n * need the pre-override set — read {@link resolveEnabledBehaviors} instead.\n */\nexport function resolveBaseBehaviors(\n tenantSettings: Record<string, unknown> | undefined,\n template: TemplateConfig | null,\n): ContentBehavior[] {\n const selectedIntents = tenantSettings?.selectedIntents;\n if (Array.isArray(selectedIntents) && selectedIntents.length > 0) {\n return resolveBehaviorsFromIntents(selectedIntents);\n }\n const tenantBehaviors = tenantSettings?.enabledBehaviors;\n if (Array.isArray(tenantBehaviors) && tenantBehaviors.length > 0) {\n return tenantBehaviors as ContentBehavior[];\n }\n if (template?.behaviors && template.behaviors.length > 0) {\n return template.behaviors;\n }\n if (template?.contentTypes) {\n const behaviors = new Set<ContentBehavior>();\n for (const ct of Object.values(template.contentTypes)) {\n ct.behaviors?.forEach((b) => behaviors.add(b));\n }\n if (behaviors.size > 0) return Array.from(behaviors);\n }\n return ['sellable'];\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/resolve-enabled-behaviors.ts"],"sourcesContent":["import type { ContentBehavior, TemplateConfig } from './template-types';\n\n/**\n * Behaviors no business model implies, so `resolveBehaviorsFromIntents` can\n * never produce them and the intent-derived path would otherwise wipe them.\n *\n * Whether a business writes (`publishable`) or shows its work (`showcase`) has\n * nothing to do with whether it sells, books or rents. Both stay deliberate\n * opt-ins, read off the tenant's own `enabledBehaviors` and unioned onto\n * whatever the intents imply.\n */\nconst STANDALONE_BEHAVIORS: ContentBehavior[] = ['publishable', 'showcase'];\n\n/** The standalone behaviors this tenant has explicitly turned on. */\nexport function resolveStandaloneBehaviors(\n tenantSettings: Record<string, unknown> | undefined,\n): ContentBehavior[] {\n const stored = tenantSettings?.enabledBehaviors;\n if (!Array.isArray(stored)) return [];\n return STANDALONE_BEHAVIORS.filter((b) => stored.includes(b));\n}\n\n/**\n * What the merchant's *operating model* turns on, over and above the archetype.\n *\n * `OperatingModelProfile.behaviorKeys` has existed since the operating model\n * did, is persisted through `normalizeProfiles`, and was read by nothing. It is\n * the more honest place for several of these: \"we deliver\" is a fact about how\n * a business operates, not about what it sells — which is why a takeaway that\n * only does pickup currently has to switch `shippable` off by hand after the\n * archetype grants it.\n *\n * Additive on purpose. Intents stay authoritative for what an archetype\n * implies; a profile can only ever turn something else on, so no tenant loses a\n * behaviour by this being read at last.\n */\nfunction resolveProfileBehaviors(\n tenantSettings: Record<string, unknown> | undefined,\n): ContentBehavior[] {\n const operatingModel = tenantSettings?.operatingModel as\n | { profiles?: Array<{ enabled?: boolean; behaviorKeys?: string[] }> }\n | undefined;\n\n if (!Array.isArray(operatingModel?.profiles)) return [];\n\n return operatingModel.profiles\n .filter((profile) => profile.enabled !== false)\n .flatMap((profile) => profile.behaviorKeys ?? []) as ContentBehavior[];\n}\n\nexport function resolveBehaviorsFromIntents(\n intents: string[],\n): ContentBehavior[] {\n const behaviors = new Set<ContentBehavior>();\n\n for (const intent of intents) {\n switch (intent) {\n case 'sell-products':\n behaviors.add('sellable');\n behaviors.add('shippable');\n break;\n case 'take-bookings':\n behaviors.add('bookable');\n break;\n case 'rent-property':\n behaviors.add('bookable');\n behaviors.add('rentable');\n break;\n case 'food-orders':\n behaviors.add('sellable');\n // The archetype for food that LEAVES the counter — its operating\n // profiles are dine-in, walk-in, QR, pickup and delivery, and the\n // delivery one already says `hideShipping: false`. The storefront\n // gates its whole delivery-address step on `shippable`, and Shipping\n // and Service areas are gated on it here, so without it a takeaway\n // could not say where it delivers, what delivery costs, or collect an\n // address on its own site. A kitchen that never leaves the building\n // switches it off in Settings → Features.\n //\n // Mirrored by INTENT_BEHAVIORS in potter-api; the two maps are\n // separate and must move together or provisioning writes one thing\n // while the dashboard resolves another.\n behaviors.add('shippable');\n break;\n case 'dine-in':\n // Reserve & Seat: sellable menu + bookable tables (as resources).\n behaviors.add('sellable');\n behaviors.add('bookable');\n break;\n case 'subscriptions':\n behaviors.add('subscribable');\n break;\n case 'digital-products':\n behaviors.add('sellable');\n behaviors.add('downloadable');\n break;\n case 'event-tickets':\n behaviors.add('sellable');\n behaviors.add('ticketed');\n break;\n case 'send-invoices':\n // Deliver & Quote: billed directly, never checked out of a storefront.\n behaviors.add('billable');\n break;\n case 'capture-leads':\n // Enquire & Qualify: nothing is bought. The thing that arrives is a\n // person asking, and the storefront's job is to let them ask well.\n behaviors.add('leadCapture');\n break;\n }\n }\n\n return [...behaviors];\n}\n\n/**\n * What a single model grants on its own — used by Settings → Features to show,\n * on hover, which capabilities a model is responsible for, and to preview what\n * adding an unselected one would turn on.\n */\nexport function behaviorsForIntent(intentKey: string): ContentBehavior[] {\n return resolveBehaviorsFromIntents([intentKey]);\n}\n\n/**\n * Per-behavior adjustments layered on top of the business model.\n *\n * A key is present ONLY when the merchant has deliberately overruled what their\n * business model implies — `{ shippable: false }` for a shop that sells over a\n * counter and never posts anything, `{ downloadable: true }` for a bookings\n * business that also hands out a workbook. Absent key means \"follow the model\",\n * which is why this is a sparse map and not a full behavior list: a stored list\n * silently freezes, so adding a business model later would grant no new\n * behaviors. The map only ever records the deltas.\n */\nexport type BehaviorOverrides = Partial<Record<ContentBehavior, boolean>>;\n\nexport function readBehaviorOverrides(\n tenantSettings: Record<string, unknown> | undefined,\n): BehaviorOverrides {\n const raw = tenantSettings?.behaviorOverrides;\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};\n const out: BehaviorOverrides = {};\n for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {\n if (typeof value === 'boolean') out[key as ContentBehavior] = value;\n }\n return out;\n}\n\n/**\n * Applies the merchant's overrides to a base behavior set.\n *\n * The result can never be empty. A business with zero behaviors can't transact\n * at all — every behavior-gated nav item and storefront action disappears — so\n * the last remaining behavior is a floor, and an override that would clear the\n * set is ignored rather than honoured.\n */\nexport function applyBehaviorOverrides(\n base: ContentBehavior[],\n overrides: BehaviorOverrides,\n): ContentBehavior[] {\n const next = new Set(base);\n for (const [key, enabled] of Object.entries(overrides)) {\n if (enabled) next.add(key as ContentBehavior);\n else next.delete(key as ContentBehavior);\n }\n if (next.size === 0) return base.length > 0 ? base : ['sellable'];\n return [...next];\n}\n\n/**\n * Resolves which behaviors are enabled for a tenant.\n *\n * The business model (`selectedIntents`) is the control; it derives a base set.\n * `behaviorOverrides` then layers the merchant's deliberate adjustments on top.\n * Everything below that is fallback for tenants that predate intents.\n *\n * Priority:\n * 1. Intent-derived base + `behaviorOverrides`\n * 2. Legacy `enabledBehaviors` (tenants with no intents) + overrides\n * 3. Template-level `behaviors` + overrides\n * 4. Union of all content type behaviors + overrides\n * 5. `['sellable']` + overrides\n */\nexport function resolveEnabledBehaviors(\n tenantSettings: Record<string, unknown> | undefined,\n template: TemplateConfig | null,\n): ContentBehavior[] {\n const overrides = readBehaviorOverrides(tenantSettings);\n const withOverrides = (base: ContentBehavior[]) =>\n applyBehaviorOverrides(base, overrides);\n\n // 1. Intent selection is authoritative when the merchant has actually picked\n // something. An EMPTY array is treated as unset, not as \"this business\n // does nothing\": resolveTenantIntentConfigs already falls back to\n // default-commerce for the empty case, so honouring empty here produced a\n // split state — the commerce nav still rendered while zero behaviors\n // resolved, silently stripping every behavior-gated item and leaving the\n // storefront with no way to transact. A merchant can reach that state by\n // deselecting their last business model in Settings → Features.\n const selectedIntents = tenantSettings?.selectedIntents;\n if (Array.isArray(selectedIntents) && selectedIntents.length > 0) {\n // Overrides stay authoritative: they are applied last, so a switch turned\n // off in Business model still wins over anything the union puts in.\n return withOverrides([\n ...resolveBehaviorsFromIntents(selectedIntents),\n ...resolveProfileBehaviors(tenantSettings),\n ...resolveStandaloneBehaviors(tenantSettings),\n ]);\n }\n\n // 2. Legacy tenants that set behaviors directly, before intents existed.\n const tenantBehaviors = tenantSettings?.enabledBehaviors;\n if (Array.isArray(tenantBehaviors) && tenantBehaviors.length > 0) {\n return withOverrides(tenantBehaviors as ContentBehavior[]);\n }\n\n // 3. Template declares behaviors — use as defaults\n if (template?.behaviors && template.behaviors.length > 0) {\n return withOverrides(template.behaviors);\n }\n\n // 4. Collect from template content types\n if (template?.contentTypes) {\n const behaviors = new Set<ContentBehavior>();\n for (const ct of Object.values(template.contentTypes)) {\n ct.behaviors?.forEach((b) => behaviors.add(b));\n }\n if (behaviors.size > 0) return withOverrides(Array.from(behaviors));\n }\n\n // 5. Absolute fallback\n return withOverrides(['sellable']);\n}\n\n/**\n * The behaviors the business model implies, BEFORE the merchant's overrides.\n *\n * Settings → Features needs both halves to tell the truth: this is what your\n * model turns on, and this is where you've overruled it. Nothing else should\n * need the pre-override set — read {@link resolveEnabledBehaviors} instead.\n */\nexport function resolveBaseBehaviors(\n tenantSettings: Record<string, unknown> | undefined,\n template: TemplateConfig | null,\n): ContentBehavior[] {\n const selectedIntents = tenantSettings?.selectedIntents;\n if (Array.isArray(selectedIntents) && selectedIntents.length > 0) {\n return resolveBehaviorsFromIntents(selectedIntents);\n }\n const tenantBehaviors = tenantSettings?.enabledBehaviors;\n if (Array.isArray(tenantBehaviors) && tenantBehaviors.length > 0) {\n return tenantBehaviors as ContentBehavior[];\n }\n if (template?.behaviors && template.behaviors.length > 0) {\n return template.behaviors;\n }\n if (template?.contentTypes) {\n const behaviors = new Set<ContentBehavior>();\n for (const ct of Object.values(template.contentTypes)) {\n ct.behaviors?.forEach((b) => behaviors.add(b));\n }\n if (behaviors.size > 0) return Array.from(behaviors);\n }\n return ['sellable'];\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1 @@
1
+ export declare function removeLegacyToken(): void;
@@ -0,0 +1,11 @@
1
+ const LEGACY_TOKEN_KEY = "pb_token";
2
+ function removeLegacyToken() {
3
+ try {
4
+ localStorage.removeItem(LEGACY_TOKEN_KEY);
5
+ } catch {
6
+ }
7
+ }
8
+ export {
9
+ removeLegacyToken
10
+ };
11
+ //# sourceMappingURL=stored-token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/stored-token.ts"],"sourcesContent":["/**\n * The key older versions kept the session token under. The session is now only\n * the HttpOnly `pb_auth_token` cookie, so a copy here is a credential any script\n * on the page could read. Nothing writes it; this removes one left behind.\n */\nconst LEGACY_TOKEN_KEY = 'pb_token';\n\nexport function removeLegacyToken(): void {\n try {\n localStorage.removeItem(LEGACY_TOKEN_KEY);\n } catch {\n // Storage blocked: nothing was kept there.\n }\n}\n"],"mappings":"AAKA,MAAM,mBAAmB;AAElB,SAAS,oBAA0B;AACxC,MAAI;AACF,iBAAa,WAAW,gBAAgB;AAAA,EAC1C,QAAQ;AAAA,EAER;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withpotter/lib",
3
- "version": "0.13.0-next.64",
3
+ "version": "0.13.0-next.66",
4
4
  "description": "Box dashboard foundation: API client, contexts, types, intent config, and utilities.",
5
5
  "license": "UNLICENSED",
6
6
  "sideEffects": false,