@withpotter/lib 0.13.0-next.85 → 0.13.0-next.87

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.
@@ -17,6 +17,7 @@ export declare const accountsApi: {
17
17
  email: string;
18
18
  name: string;
19
19
  avatarUrl?: string;
20
+ phone?: string | null;
20
21
  authProvider: string;
21
22
  status: string;
22
23
  mfaEnabled?: boolean;
@@ -29,6 +30,7 @@ export declare const accountsApi: {
29
30
  email: string;
30
31
  name: string;
31
32
  avatarUrl?: string;
33
+ phone?: string | null;
32
34
  authProvider: string;
33
35
  status: string;
34
36
  mfaEnabled?: boolean;
@@ -29,6 +29,8 @@ export interface SignupResult {
29
29
  email: string;
30
30
  name: string;
31
31
  avatarUrl?: string;
32
+ /** Optional; null or absent when the account has none. */
33
+ phone?: string | null;
32
34
  authProvider: string;
33
35
  status: string;
34
36
  mfaEnabled?: boolean;
@@ -39,6 +41,8 @@ export interface SignupResult {
39
41
  export interface UpdateAccountInput {
40
42
  name?: string;
41
43
  avatarUrl?: string;
44
+ /** Optional. An empty string clears it. */
45
+ phone?: string;
42
46
  }
43
47
  export interface ChangePasswordInput {
44
48
  currentPassword: string;
@@ -69,6 +73,7 @@ export interface AuthAccount {
69
73
  email: string;
70
74
  name: string;
71
75
  avatarUrl?: string | null;
76
+ phone?: string | null;
72
77
  authProvider: string;
73
78
  status: string;
74
79
  emailVerified?: boolean;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/api/types/auth.ts"],"sourcesContent":["import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser';\n\n/**\n * AUTH / ACCOUNTS API — as the API sends it.\n *\n * One slice of what was a 4,164-line `types.ts`. The sections were already\n * here as comment fences; they are files now, so a type is found by the\n * endpoint it belongs to rather than by scrolling.\n */\n\n/**\n * Every request/response shape the API layer uses.\n *\n * Kept in one module on purpose: interfaces and types are erased at build\n * time, so grouping them costs nothing at runtime while keeping the domain\n * modules free of cross-imports for shared shapes.\n */\nexport interface FetchOptions extends RequestInit {\n tenantSlug?: string;\n token?: string;\n accountId?: string;\n}\n\nexport interface SignupInput {\n email: string;\n name: string;\n password?: string;\n}\n\nexport interface SignupResult {\n account: {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string;\n authProvider: string;\n status: string;\n mfaEnabled?: boolean;\n };\n organizationId: string;\n token?: string;\n}\n\nexport interface UpdateAccountInput {\n name?: string;\n avatarUrl?: string;\n}\n\nexport interface ChangePasswordInput {\n currentPassword: string;\n newPassword: string;\n}\n\nexport interface ChangeEmailInput {\n newEmail: string;\n password: string;\n}\n\nexport interface LoginTenantInfo {\n id: string;\n name: string;\n slug: string;\n templateId: string;\n role: string;\n permissions?: Record<string, boolean>;\n organizationId: string | null;\n businessLogo: string | null;\n contactEmail: string | null;\n contactPhone: string | null;\n customDomain: string | null;\n status: string;\n settings: Record<string, unknown> | null;\n}\n\n/** Account payload from authenticated endpoints. */\nexport interface AuthAccount {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string | null;\n authProvider: string;\n status: string;\n emailVerified?: boolean;\n mfaEnabled?: boolean;\n organizationId?: string | null;\n}\n\n/** Successful session payload (Google sign-in `signed-in`/`created`, and login). */\nexport interface AuthSessionPayload {\n account: AuthAccount;\n /** Only a native client gets one; the browser session is a cookie. Never stored. */\n token?: string;\n tenants: LoginTenantInfo[];\n organizationId: string | null;\n sessionId?: string;\n}\n\n/** Returned when a Google email matches an existing password-only account. */\nexport interface GoogleSignInCollisionPayload {\n email: string;\n accountId: string;\n googleSub: string;\n name: string | null;\n avatarUrl: string | null;\n}\n\n/** /accounts/google/callback response. `kind` discriminates the next step. */\nexport interface GoogleSignInCallbackResult extends Partial<AuthSessionPayload> {\n kind: 'signed-in' | 'created' | 'collision';\n collision?: GoogleSignInCollisionPayload;\n}\n\nexport interface GoogleLinkInput {\n accountId: string;\n password: string;\n googleSub: string;\n}\n\n/** Per-account communication + analytics consent. */\nexport interface AccountConsent {\n productEmails: boolean;\n productSms: boolean;\n analytics: boolean;\n /** ISO timestamp of the last change, when the account has ever set it. */\n updatedAt?: string;\n}\n\nexport type ConsentInput = Pick<\n AccountConsent,\n 'productEmails' | 'productSms' | 'analytics'\n>;\n\nexport interface MfaSetupResult {\n /** Base32 secret, for manual entry when a QR can't be scanned. */\n secret: string;\n /** otpauth:// URI encoded in the QR. */\n otpauthUrl: string;\n /** Ready-to-render data: URL of the QR image. */\n qrDataUri: string;\n}\n\nexport interface MfaVerifyResult {\n enabled: true;\n /** One-time backup codes — display once, never retrievable again. */\n backupCodes: string[];\n}\n\nexport interface SendLoginCodeResult {\n message: string;\n}\n\n/** One enrolled authenticator, as the settings list sees it. */\nexport interface Passkey {\n id: string;\n /** What the owner named it. Null until they do. */\n name: string | null;\n /** `singleDevice` keys die with the hardware; `multiDevice` ones sync. */\n deviceType: string | null;\n backedUp: boolean;\n createdAt: string;\n lastUsedAt: string | null;\n}\n\nexport interface BeginPasskeyLoginResult {\n /** Ties the response back to the challenge the server issued. */\n handle: string;\n options: PublicKeyCredentialRequestOptionsJSON;\n}\n\nexport interface PasskeyLoginResult extends AuthSessionPayload {\n /**\n * Whether the authenticator verified the person, not just held the key.\n *\n * True means the gesture already carried both factors, so an MFA-enabled\n * account must NOT then be asked for a TOTP code — that would make the safer\n * route the more annoying one.\n */\n mfaSatisfied: boolean;\n}\n\nexport interface VerifyLoginCodeSession extends AuthSessionPayload {\n /** True when this verify created the account (route to onboarding). */\n isNewAccount: boolean;\n}\n\n/**\n * What a door returns when the first factor passed but the account owes a\n * second.\n *\n * No token and no account: nothing here authenticates anything. The `mfaToken`\n * is single-use, short-lived and attempt-capped, and is accepted only by\n * /accounts/mfa/challenge.\n */\nexport interface MfaChallengeRequired {\n mfaRequired: true;\n mfaToken: string;\n}\n\n/**\n * A union rather than an optional field, so a caller cannot read `.token` off a\n * challenge by accident. It caught a real mismatch on the API side.\n */\nexport type VerifyLoginCodeResult =\n | VerifyLoginCodeSession\n | MfaChallengeRequired;\n\nexport function isMfaChallenge(\n result: AuthSessionPayload | MfaChallengeRequired,\n): result is MfaChallengeRequired {\n return (result as MfaChallengeRequired).mfaRequired === true;\n}\n\nexport type DeviceKind = 'mobile' | 'tablet' | 'desktop' | 'web';\n\nexport interface ConnectedDeviceRecord {\n id: string;\n name: string;\n kind: DeviceKind;\n location?: string;\n lastSeen: string;\n isCurrent?: boolean;\n}\n\nexport interface ProvisionTenantInput {\n organizationId: string;\n /** Omit it — the API derives the starting look from `intents`. Send one only\n * when the merchant deliberately chose a template. */\n templateId?: string;\n businessName: string;\n slug?: string;\n email?: string;\n phone?: string;\n category?: string;\n /** ISO 4217 default currency, derived from country. */\n currency?: string;\n intents?: string[];\n isSolo?: boolean;\n}\n"],"mappings":"AA8MO,SAAS,eACd,QACgC;AAChC,SAAQ,OAAgC,gBAAgB;AAC1D;","names":[]}
1
+ {"version":3,"sources":["../../../src/api/types/auth.ts"],"sourcesContent":["import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser';\n\n/**\n * AUTH / ACCOUNTS API — as the API sends it.\n *\n * One slice of what was a 4,164-line `types.ts`. The sections were already\n * here as comment fences; they are files now, so a type is found by the\n * endpoint it belongs to rather than by scrolling.\n */\n\n/**\n * Every request/response shape the API layer uses.\n *\n * Kept in one module on purpose: interfaces and types are erased at build\n * time, so grouping them costs nothing at runtime while keeping the domain\n * modules free of cross-imports for shared shapes.\n */\nexport interface FetchOptions extends RequestInit {\n tenantSlug?: string;\n token?: string;\n accountId?: string;\n}\n\nexport interface SignupInput {\n email: string;\n name: string;\n password?: string;\n}\n\nexport interface SignupResult {\n account: {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string;\n /** Optional; null or absent when the account has none. */\n phone?: string | null;\n authProvider: string;\n status: string;\n mfaEnabled?: boolean;\n };\n organizationId: string;\n token?: string;\n}\n\nexport interface UpdateAccountInput {\n name?: string;\n avatarUrl?: string;\n /** Optional. An empty string clears it. */\n phone?: string;\n}\n\nexport interface ChangePasswordInput {\n currentPassword: string;\n newPassword: string;\n}\n\nexport interface ChangeEmailInput {\n newEmail: string;\n password: string;\n}\n\nexport interface LoginTenantInfo {\n id: string;\n name: string;\n slug: string;\n templateId: string;\n role: string;\n permissions?: Record<string, boolean>;\n organizationId: string | null;\n businessLogo: string | null;\n contactEmail: string | null;\n contactPhone: string | null;\n customDomain: string | null;\n status: string;\n settings: Record<string, unknown> | null;\n}\n\n/** Account payload from authenticated endpoints. */\nexport interface AuthAccount {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string | null;\n phone?: string | null;\n authProvider: string;\n status: string;\n emailVerified?: boolean;\n mfaEnabled?: boolean;\n organizationId?: string | null;\n}\n\n/** Successful session payload (Google sign-in `signed-in`/`created`, and login). */\nexport interface AuthSessionPayload {\n account: AuthAccount;\n /** Only a native client gets one; the browser session is a cookie. Never stored. */\n token?: string;\n tenants: LoginTenantInfo[];\n organizationId: string | null;\n sessionId?: string;\n}\n\n/** Returned when a Google email matches an existing password-only account. */\nexport interface GoogleSignInCollisionPayload {\n email: string;\n accountId: string;\n googleSub: string;\n name: string | null;\n avatarUrl: string | null;\n}\n\n/** /accounts/google/callback response. `kind` discriminates the next step. */\nexport interface GoogleSignInCallbackResult extends Partial<AuthSessionPayload> {\n kind: 'signed-in' | 'created' | 'collision';\n collision?: GoogleSignInCollisionPayload;\n}\n\nexport interface GoogleLinkInput {\n accountId: string;\n password: string;\n googleSub: string;\n}\n\n/** Per-account communication + analytics consent. */\nexport interface AccountConsent {\n productEmails: boolean;\n productSms: boolean;\n analytics: boolean;\n /** ISO timestamp of the last change, when the account has ever set it. */\n updatedAt?: string;\n}\n\nexport type ConsentInput = Pick<\n AccountConsent,\n 'productEmails' | 'productSms' | 'analytics'\n>;\n\nexport interface MfaSetupResult {\n /** Base32 secret, for manual entry when a QR can't be scanned. */\n secret: string;\n /** otpauth:// URI encoded in the QR. */\n otpauthUrl: string;\n /** Ready-to-render data: URL of the QR image. */\n qrDataUri: string;\n}\n\nexport interface MfaVerifyResult {\n enabled: true;\n /** One-time backup codes — display once, never retrievable again. */\n backupCodes: string[];\n}\n\nexport interface SendLoginCodeResult {\n message: string;\n}\n\n/** One enrolled authenticator, as the settings list sees it. */\nexport interface Passkey {\n id: string;\n /** What the owner named it. Null until they do. */\n name: string | null;\n /** `singleDevice` keys die with the hardware; `multiDevice` ones sync. */\n deviceType: string | null;\n backedUp: boolean;\n createdAt: string;\n lastUsedAt: string | null;\n}\n\nexport interface BeginPasskeyLoginResult {\n /** Ties the response back to the challenge the server issued. */\n handle: string;\n options: PublicKeyCredentialRequestOptionsJSON;\n}\n\nexport interface PasskeyLoginResult extends AuthSessionPayload {\n /**\n * Whether the authenticator verified the person, not just held the key.\n *\n * True means the gesture already carried both factors, so an MFA-enabled\n * account must NOT then be asked for a TOTP code — that would make the safer\n * route the more annoying one.\n */\n mfaSatisfied: boolean;\n}\n\nexport interface VerifyLoginCodeSession extends AuthSessionPayload {\n /** True when this verify created the account (route to onboarding). */\n isNewAccount: boolean;\n}\n\n/**\n * What a door returns when the first factor passed but the account owes a\n * second.\n *\n * No token and no account: nothing here authenticates anything. The `mfaToken`\n * is single-use, short-lived and attempt-capped, and is accepted only by\n * /accounts/mfa/challenge.\n */\nexport interface MfaChallengeRequired {\n mfaRequired: true;\n mfaToken: string;\n}\n\n/**\n * A union rather than an optional field, so a caller cannot read `.token` off a\n * challenge by accident. It caught a real mismatch on the API side.\n */\nexport type VerifyLoginCodeResult =\n | VerifyLoginCodeSession\n | MfaChallengeRequired;\n\nexport function isMfaChallenge(\n result: AuthSessionPayload | MfaChallengeRequired,\n): result is MfaChallengeRequired {\n return (result as MfaChallengeRequired).mfaRequired === true;\n}\n\nexport type DeviceKind = 'mobile' | 'tablet' | 'desktop' | 'web';\n\nexport interface ConnectedDeviceRecord {\n id: string;\n name: string;\n kind: DeviceKind;\n location?: string;\n lastSeen: string;\n isCurrent?: boolean;\n}\n\nexport interface ProvisionTenantInput {\n organizationId: string;\n /** Omit it — the API derives the starting look from `intents`. Send one only\n * when the merchant deliberately chose a template. */\n templateId?: string;\n businessName: string;\n slug?: string;\n email?: string;\n phone?: string;\n category?: string;\n /** ISO 4217 default currency, derived from country. */\n currency?: string;\n intents?: string[];\n isSolo?: boolean;\n}\n"],"mappings":"AAmNO,SAAS,eACd,QACgC;AAChC,SAAQ,OAAgC,gBAAgB;AAC1D;","names":[]}
@@ -264,6 +264,12 @@ export interface TenantSettings {
264
264
  perHours?: number;
265
265
  applyToCampaigns?: boolean;
266
266
  };
267
+ /**
268
+ * Moves a paid order from Pending to Confirmed by itself. Off unless this says
269
+ * `true`; in-person orders and cash or transfer orders confirmed by hand are
270
+ * never touched by it.
271
+ */
272
+ autoConfirmPaidOrders?: boolean;
267
273
  businessName?: string;
268
274
  tagline?: string;
269
275
  logo?: {
@@ -11,6 +11,8 @@ export interface User {
11
11
  email: string;
12
12
  name: string;
13
13
  avatarUrl?: string;
14
+ /** The account's own phone number, when they gave one. */
15
+ phone?: string | null;
14
16
  emailVerified: boolean;
15
17
  /** Two-factor authentication is active on this account. */
16
18
  mfaEnabled?: boolean;
@@ -259,6 +259,7 @@ function AuthProvider({ children }) {
259
259
  email: userData.email,
260
260
  name: userData.name || userData.email.split("@")[0],
261
261
  avatarUrl: userData.avatarUrl || void 0,
262
+ phone: userData.phone ?? null,
262
263
  emailVerified: userData.emailVerified ?? false,
263
264
  mfaEnabled: userData.mfaEnabled ?? false,
264
265
  organizationId: storedOrgId || userData?.organizationId || cachedWorkspace.tenant.organizationId || "",
@@ -302,6 +303,7 @@ function AuthProvider({ children }) {
302
303
  email: userData.email,
303
304
  name: userData.name || userData.email.split("@")[0],
304
305
  avatarUrl: userData.avatarUrl || void 0,
306
+ phone: userData.phone ?? null,
305
307
  emailVerified: userData.emailVerified ?? false,
306
308
  mfaEnabled: userData.mfaEnabled ?? false,
307
309
  organizationId: userOrgId ?? "",
@@ -375,6 +377,7 @@ function AuthProvider({ children }) {
375
377
  email: account.email,
376
378
  name: account.name || account.email.split("@")[0],
377
379
  avatarUrl: account.avatarUrl || void 0,
380
+ phone: account.phone ?? null,
378
381
  emailVerified: account.emailVerified ?? false,
379
382
  mfaEnabled: account.mfaEnabled ?? false,
380
383
  organizationId: userOrgId ?? "",
@@ -633,6 +636,8 @@ function AuthProvider({ children }) {
633
636
  name: userData?.name || prev.name,
634
637
  email: userData?.email || prev.email,
635
638
  avatarUrl: userData?.avatarUrl || prev.avatarUrl,
639
+ // Absent from an older API response: keep what we had.
640
+ phone: userData && "phone" in userData ? userData.phone ?? null : prev.phone,
636
641
  emailVerified: userData?.emailVerified ?? prev.emailVerified,
637
642
  mfaEnabled: userData?.mfaEnabled ?? prev.mfaEnabled,
638
643
  workspaces,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/auth-context.tsx"],"sourcesContent":["'use client';\n\nimport {\n createContext,\n useContext,\n useState,\n useEffect,\n ReactNode,\n useCallback,\n} from 'react';\nimport {\n getApiUrl,\n apiFetch,\n accountsApi,\n tenantApi,\n type AuthSessionPayload,\n type Tenant,\n} from './api';\nimport { removeLegacyToken } from './stored-token';\nimport { resetPostHogIdentity } from '@withpotter/components/analytics';\nimport { uploadImage } from '@withpotter/lib/media-service';\nimport { endSession } from '@withpotter/lib/end-session';\nimport {\n pickWorkspace,\n readJoinedWorkspace,\n clearJoinedWorkspace,\n} from './active-workspace';\n\n// Tenant is defined once, canonically, in ./api. Re-exported here so the many\n// modules that historically imported it from auth-context keep working — there\n// is now a single Tenant shape (with the correct string id) across the app.\nexport type { Tenant };\n\nexport interface Workspace {\n tenant: Tenant;\n role: 'owner' | 'manager' | 'staff' | 'viewer';\n permissions?: Record<string, boolean>;\n}\n\nexport interface User {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string;\n emailVerified: boolean;\n /** Two-factor authentication is active on this account. */\n mfaEnabled?: boolean;\n organizationId: string; // Created during signup\n workspaces: Workspace[];\n currentWorkspace: Workspace | null;\n}\n\ninterface AuthContextType {\n user: User | null;\n tenant: Tenant | null;\n workspaces: Workspace[];\n loading: boolean;\n connectionError: boolean;\n login: (email: string, password: string) => Promise<void>;\n loginWithGoogle: (payload: AuthSessionPayload) => Promise<void>;\n loginWithCode: (\n payload: AuthSessionPayload,\n method?: SignInMethod,\n connection?: string,\n ) => Promise<void>;\n logout: () => Promise<void>;\n refreshTenant: () => Promise<void>;\n refreshUser: () => Promise<void>;\n switchWorkspace: (tenantSlug: string) => Promise<void>;\n createWorkspace: (input: CreateWorkspaceInput) => Promise<Tenant>;\n duplicateWorkspace: (\n tenantId: string,\n businessName?: string,\n ) => Promise<Tenant>;\n deleteWorkspace: (tenantSlug: string) => void;\n retryConnection: () => Promise<void>;\n}\n\n/**\n * The single input for creating a business. One shape for both entry paths\n * (first-run /onboarding and in-dashboard /workspaces/new) so every business is\n * provisioned identically. Fields not in the provision contract (salesChannel,\n * address) are persisted onto tenant.settings right after.\n */\nexport interface CreateWorkspaceInput {\n businessName: string;\n /**\n * Only for a deliberate choice of storefront look, which today means the\n * Website hub. Business creation leaves it unset: the API derives the\n * starting template from `intents`, so no client keeps a list of template ids\n * it cannot validate. Three copies of that list had already drifted.\n */\n templateId?: string;\n slug?: string;\n currency?: string;\n intents?: string[];\n isSolo?: boolean;\n category?: string;\n salesChannel?: string;\n address?: string;\n phone?: string;\n /** ISO-3166 alpha-2. Not in the provision contract, so it rides the same\n * settings patch as salesChannel. Onboarding used to collect this, derive a\n * currency from it and then throw it away — which is why Settings › General\n * showed an empty Country on every workspace ever created. */\n country?: string;\n /** Optional business logo, uploaded after provision (the media endpoint needs\n * the tenant to exist). Non-fatal — a failed upload never fails creation. */\n logoFile?: File | null;\n}\n\nconst AuthContext = createContext<AuthContextType | undefined>(undefined);\n\n/**\n * Local-dev open access: when running `next dev` with NEXT_PUBLIC_DEV_OPEN_ACCESS=1,\n * skip auth entirely so any dashboard page is directly viewable without a\n * login/OTP (backend data just shows empty states if the API isn't running).\n * NEVER active in staging/prod — those build with NODE_ENV=production, so this\n * is false regardless of the flag.\n */\nconst DEV_OPEN_ACCESS =\n process.env.NODE_ENV === 'development' &&\n process.env.NEXT_PUBLIC_DEV_OPEN_ACCESS === '1';\n\n/**\n * The door an account came through. `connection` is a sign-in the host app provides, such as a payment or bank\n * provider's login; which one is kept beside it (see `LastAccount.connection`).\n */\nexport type SignInMethod = 'google' | 'email' | 'passkey' | 'connection';\n\n/**\n * THE PRECEDENCE RULE FOR \"WHICH WORKSPACE AM I IN\".\n *\n * `user.currentWorkspace` is the only authoritative answer. Four storage keys\n * cache it — `currentWorkspace`, `pb_current_tenant`, `tenant` and\n * `pb_last_workspace` — and they exist because consumers outside React need the\n * answer synchronously: the media proxy resolves `x-tenant-slug` straight out\n * of them (lib/media-service.ts), and the login door names the business it is\n * about to resume into before any provider has mounted.\n *\n * They are a projection of the resolved workspace, never an input to it, so\n * every path that resolves one must write all four together through here.\n * Written piecemeal they drift apart permanently: resolution falls back to\n * `workspaces[0]` whenever the stored slug is no longer in the account's tenant\n * list, and the paths that resolved by fallback used to write nothing back — so\n * storage kept naming a business the app was not in, uploads were tagged with\n * it, and the slug-keyed arrival flag never matched.\n *\n * Passing null clears the active-workspace cache without touching\n * `pb_last_workspace`, which deliberately outlives a session so a returning\n * owner resumes where they left off.\n */\nfunction persistActiveWorkspace(workspace: Workspace | null): void {\n if (!workspace) {\n localStorage.removeItem('currentWorkspace');\n localStorage.removeItem('pb_current_tenant');\n localStorage.removeItem('tenant');\n return;\n }\n const { slug } = workspace.tenant;\n localStorage.setItem('currentWorkspace', slug);\n localStorage.setItem('pb_current_tenant', slug);\n localStorage.setItem('tenant', JSON.stringify(workspace.tenant));\n localStorage.setItem('pb_last_workspace', slug);\n}\n\n/**\n * The slug to prefer when resolving the active workspace, in one order for\n * every entry path. Reload and login used to disagree on it, which let the two\n * doors into the same account land in two different businesses.\n *\n * It is a preference, not an answer: a slug that is not in the account's tenant\n * list loses to `workspaces[0]`, and the winner is written back immediately.\n */\nfunction readPreferredSlug(): string | null {\n return (\n localStorage.getItem('currentWorkspace') ||\n localStorage.getItem('pb_last_workspace') ||\n localStorage.getItem('pb_current_tenant')\n );\n}\n\n/**\n * The workspace this load lands in, for every entry path.\n *\n * Reload, login and refresh each wrote `find(slug) || workspaces[0]` inline,\n * which is why the accept-invite screen could not say \"land me in the business\n * I just joined\" — there was nowhere to say it. `pass authoritative` when the\n * list came from the API: a just-joined tenant missing from a real list is a\n * membership that no longer exists, and the handoff is dropped rather than\n * retried against every later load.\n */\nfunction resolveActiveWorkspace(\n workspaces: Workspace[],\n authoritative: boolean,\n): Workspace | null {\n const joinedTenantId = readJoinedWorkspace();\n const picked = pickWorkspace(workspaces, {\n joinedTenantId,\n preferredSlug: readPreferredSlug(),\n });\n if (\n joinedTenantId &&\n (authoritative || picked?.tenant.id === joinedTenantId)\n ) {\n clearJoinedWorkspace();\n }\n return picked;\n}\n\n/**\n * The workspace list cache, written as one unit. `workspaces` (slugs) is the\n * legacy shape, `pb_workspaces_cache` the full one; both are read as fallbacks\n * when the API is unreachable, so a business missing from either reads as a\n * business the owner does not have.\n */\nfunction persistWorkspaceList(workspaces: Workspace[]): void {\n localStorage.setItem(\n 'workspaces',\n JSON.stringify(workspaces.map((w) => w.tenant.slug)),\n );\n localStorage.setItem('pb_workspaces_cache', JSON.stringify(workspaces));\n}\n\n/**\n * Everything that makes the browser believe it is signed in.\n *\n * Hoisted out of the auth check because it was trapped in one effect's closure,\n * so any other place that discovered a dead session had no way to end it and\n * fell back to cached state instead.\n */\nfunction clearStoredAuth(): void {\n localStorage.removeItem('pb_token');\n localStorage.removeItem('pb_user');\n localStorage.removeItem('pb_account_id');\n localStorage.removeItem('userEmail');\n localStorage.removeItem('organizationId');\n persistActiveWorkspace(null);\n}\n\n/**\n * The workspace we can name WITHOUT asking the server.\n *\n * Every tenant-scoped query in the app is gated on `loading` being false, and\n * `loading` was only cleared after `/team/my-tenants` came back. So the whole\n * dashboard — twenty-odd requests on Home alone — sat waiting on one round trip\n * before it was allowed to ask for anything, on every single page load, even\n * though the answer was already sitting in localStorage from last time.\n *\n * This reads that cache so the app can start immediately. It is a HINT, not an\n * answer: `checkAuth` still calls the API and reconciles, so a workspace the\n * account has lost is corrected a moment later. Nothing is trusted for\n * authorisation either way — the API enforces access on every request, so the\n * worst case is a card that renders and then corrects itself.\n */\nfunction readCachedWorkspace(): Workspace | null {\n try {\n const raw = localStorage.getItem('pb_workspaces_cache');\n if (!raw) return null;\n const cached = JSON.parse(raw) as Workspace[];\n if (!Array.isArray(cached) || cached.length === 0) return null;\n const match =\n pickWorkspace(cached, {\n joinedTenantId: readJoinedWorkspace(),\n preferredSlug: readPreferredSlug(),\n }) ?? cached[0];\n // A slug-only legacy entry has no real id; starting on one would fire every\n // query against a tenant id that does not exist.\n return match?.tenant?.slug && match.tenant.id !== match.tenant.slug\n ? match\n : null;\n } catch {\n return null;\n }\n}\n\nexport function AuthProvider({ children }: { children: ReactNode }) {\n const [user, setUser] = useState<User | null>(null);\n const [loading, setLoading] = useState(true);\n const [connectionError, setConnectionError] = useState(false);\n\n const fetchTenant = useCallback(\n async (slug: string): Promise<Tenant | null> => {\n try {\n // Auth is handled via HttpOnly cookies (credentials: 'include')\n const res = await fetch(`${getApiUrl()}/tenants/${slug}`, {\n credentials: 'include',\n });\n if (!res.ok) return null;\n\n const data = await res.json();\n return {\n id: data.id,\n slug: data.slug,\n businessName: data.businessName,\n // Synthesize businessLogo from settings.logo.url (GET /tenants/:slug returns raw entity without businessLogo)\n businessLogo:\n data.businessLogo || data.settings?.logo?.url || undefined,\n contactEmail: data.contactEmail || data.email,\n contactPhone: data.contactPhone || data.phone,\n templateId: data.templateId || 'luxe-fashion-01',\n customDomain: data.customDomain || undefined,\n status: data.status,\n settings: data.settings,\n organizationId: data.organizationId,\n };\n } catch {\n return null;\n }\n },\n [],\n );\n\n // Load workspaces from API\n // Returns { workspaces, fromApi } to track if data came from API\n const loadWorkspaces = useCallback(async (): Promise<{\n workspaces: Workspace[];\n fromApi: boolean;\n }> => {\n // Auth is handled via HttpOnly cookies (credentials: 'include')\n try {\n const res = await fetch(`${getApiUrl()}/team/my-tenants`, {\n credentials: 'include',\n });\n\n // 401 means the session/token is invalid — force re-login\n if (res.status === 401 || res.status === 403) {\n if (process.env.NODE_ENV === 'development') {\n console.warn(\n 'Auth expired (status',\n res.status,\n '), redirecting to login',\n );\n }\n // Clear stale auth data\n clearStoredAuth();\n // Redirect to login — throw to prevent checkAuth from updating\n // React state, which would race with this hard navigation\n if (typeof window !== 'undefined') {\n window.location.href = '/login';\n }\n throw new Error('AUTH_EXPIRED');\n }\n\n if (res.ok) {\n const apiTenants = (await res.json()) as Array<{\n id: string;\n tenantId: string;\n tenantSlug: string;\n tenantName: string;\n role: string;\n permissions?: Record<string, boolean>;\n templateId?: string | null;\n organizationId?: string | null;\n tenant?: {\n id: string;\n slug: string;\n businessName: string;\n businessLogo: string | null;\n contactEmail: string | null;\n contactPhone: string | null;\n customDomain: string | null;\n status: string;\n industryKey?: string | null;\n settings: Record<string, unknown> | null;\n };\n }>;\n\n /**\n * A DELETED WORKSPACE IS NOT A WORKSPACE.\n *\n * The membership row outlives the tenant, so deleting a workspace left\n * it in this list and therefore in the switcher. Choosing one looked\n * completely normal and then every request 401'd with \"Tenant context\n * required\" — the API's tenant middleware resolves only ACTIVE and\n * PENDING — so the whole dashboard rendered empty: no orders, no\n * customers, no analytics, no marketing. Nothing anywhere said the\n * workspace was gone, so it read as the product being broken rather\n * than the workspace not existing.\n *\n * Measured on staging: three of six workspaces in this list were\n * deleted, and all three 401 on every endpoint.\n */\n const liveTenants = apiTenants.filter(\n (t) => (t.tenant?.status ?? 'active') !== 'deleted',\n );\n\n // Use the full tenant details from the API response (no N+1 calls needed)\n const workspaces: Workspace[] = liveTenants.map((t) => ({\n tenant: {\n id: t.tenant?.id || t.tenantId,\n slug: t.tenant?.slug || t.tenantSlug,\n businessName: t.tenant?.businessName || t.tenantName,\n businessLogo: t.tenant?.businessLogo || undefined,\n contactEmail: t.tenant?.contactEmail || undefined,\n contactPhone: t.tenant?.contactPhone || undefined,\n customDomain: t.tenant?.customDomain || undefined,\n templateId: t.templateId || 'luxe-fashion-01',\n status:\n (t.tenant?.status as\n | 'active'\n | 'suspended'\n | 'pending'\n | 'deleted') || 'active',\n // What kind of business this is. The switcher labels every row\n // with it so four workspaces are four distinguishable things, and\n // dropping it here was why they all read as a bare name.\n industryKey: t.tenant?.industryKey ?? null,\n settings: t.tenant?.settings || undefined,\n organizationId: t.organizationId || undefined,\n },\n role: t.role as Workspace['role'],\n permissions: t.permissions,\n }));\n\n // Sync localStorage with API response (cache full workspace data for resilient fallback)\n if (workspaces.length > 0) {\n persistWorkspaceList(workspaces);\n }\n\n return { workspaces, fromApi: true };\n }\n\n // API returned non-ok status (e.g., 429, 500) — fall back to cached workspaces\n if (process.env.NODE_ENV === 'development') {\n console.warn('API returned error status:', res.status);\n }\n // Try full workspace cache first (has proper UUIDs)\n try {\n const cachedRaw = localStorage.getItem('pb_workspaces_cache');\n if (cachedRaw) {\n const cachedWorkspaces = JSON.parse(cachedRaw) as Workspace[];\n if (cachedWorkspaces.length > 0) {\n return { workspaces: cachedWorkspaces, fromApi: false };\n }\n }\n } catch {\n // Corrupted cache — fall through\n }\n // Legacy fallback: slugs-only cache (no UUIDs available — use connection error state)\n const cachedSlugs = JSON.parse(\n localStorage.getItem('workspaces') || '[]',\n ) as string[];\n if (cachedSlugs.length > 0) {\n const cachedWorkspaces: Workspace[] = cachedSlugs.map((slug) => ({\n tenant: {\n id: slug,\n slug,\n businessName: slug,\n templateId: 'luxe-fashion-01',\n status: 'active' as const,\n },\n role: 'owner' as const,\n }));\n return { workspaces: cachedWorkspaces, fromApi: false };\n }\n return { workspaces: [], fromApi: false };\n } catch (error) {\n if (process.env.NODE_ENV === 'development') {\n console.warn('Failed to fetch workspaces from API:', error);\n }\n // Network error or other failure\n return { workspaces: [], fromApi: false };\n }\n }, []);\n\n useEffect(() => {\n const checkAuth = async () => {\n // Local-dev open access: bypass auth so any page renders directly.\n if (DEV_OPEN_ACCESS) {\n const slug = process.env.NEXT_PUBLIC_DEV_TENANT || 'demo';\n const tenant = {\n id: slug,\n slug,\n businessName: 'Dev Business',\n templateId: 'luxe-fashion-01',\n status: 'active' as const,\n };\n const workspace = { tenant, role: 'owner' as const };\n setUser({\n id: 'dev-user',\n email: 'dev@local',\n name: 'Dev',\n emailVerified: true,\n mfaEnabled: false,\n organizationId: '',\n workspaces: [workspace],\n currentWorkspace: workspace,\n });\n setLoading(false);\n return;\n }\n\n // The session is the HttpOnly cookie, which this code cannot see.\n // The cached account only says this browser has signed in before; the\n // calls below are the proof, and a 401 ends the session.\n removeLegacyToken();\n const pbUser = localStorage.getItem('pb_user');\n const storedOrgId = localStorage.getItem('organizationId');\n\n // No user data = invalid session\n if (!pbUser) {\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n let userData: {\n id: string;\n email: string;\n name?: string;\n avatarUrl?: string;\n emailVerified?: boolean;\n mfaEnabled?: boolean;\n organizationId?: string;\n } | null = null;\n try {\n userData = JSON.parse(pbUser);\n } catch {\n if (process.env.NODE_ENV === 'development') {\n console.warn('Invalid user data, clearing auth state');\n }\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n if (!userData?.email) {\n if (process.env.NODE_ENV === 'development') {\n console.warn('No email in user data, clearing auth state');\n }\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n // ── Optimistic start ──────────────────────────────────────────────────\n // Everything tenant-scoped is gated on `loading`, so clearing it here\n // lets the dashboard begin fetching against the workspace we already\n // know, instead of idling through a round trip first. The API call below\n // still runs and reconciles; this only decides when the app is allowed\n // to START, not what is true.\n const cachedWorkspace = readCachedWorkspace();\n if (cachedWorkspace && !cancelled) {\n setUser({\n id: userData.id,\n email: userData.email,\n name: userData.name || userData.email.split('@')[0],\n avatarUrl: userData.avatarUrl || undefined,\n emailVerified: userData.emailVerified ?? false,\n mfaEnabled: userData.mfaEnabled ?? false,\n organizationId:\n storedOrgId ||\n userData?.organizationId ||\n cachedWorkspace.tenant.organizationId ||\n '',\n workspaces: [cachedWorkspace],\n currentWorkspace: cachedWorkspace,\n });\n setLoading(false);\n }\n\n try {\n const freshAccount = await apiFetch<typeof userData>('/accounts/me');\n if (freshAccount) {\n userData = freshAccount;\n localStorage.setItem('pb_user', JSON.stringify(freshAccount));\n }\n } catch {\n // Keep the cached account when the API is temporarily unavailable.\n }\n\n // Load workspaces — may throw AUTH_EXPIRED if session is invalid\n let result;\n try {\n result = await loadWorkspaces();\n } catch (e: unknown) {\n if (e instanceof Error && e.message === 'AUTH_EXPIRED') return;\n throw e;\n }\n if (cancelled) return;\n const workspaces = result.workspaces;\n setConnectionError(!result.fromApi && workspaces.length === 0);\n\n const signupTenantSlug = localStorage.getItem('pb_current_tenant');\n\n // Find current workspace or use first one\n let currentWorkspace = resolveActiveWorkspace(workspaces, result.fromApi);\n\n // If no workspaces but we have signup tenant, fetch it\n if (workspaces.length === 0 && signupTenantSlug) {\n const signupTenant = await fetchTenant(signupTenantSlug);\n if (signupTenant) {\n currentWorkspace = { tenant: signupTenant, role: 'owner' };\n workspaces.push(currentWorkspace);\n persistWorkspaceList(workspaces);\n }\n }\n\n // Write the resolution back before rendering it. This is the reload path\n // that fell through with a stored slug the account no longer has: it\n // resolved `workspaces[0]` and left the cache naming the old business.\n if (currentWorkspace) persistActiveWorkspace(currentWorkspace);\n\n // Get organizationId - must be a valid UUID\n const userOrgId =\n storedOrgId ||\n userData?.organizationId ||\n currentWorkspace?.tenant?.organizationId;\n\n setUser({\n id: userData.id,\n email: userData.email,\n name: userData.name || userData.email.split('@')[0],\n avatarUrl: userData.avatarUrl || undefined,\n emailVerified: userData.emailVerified ?? false,\n mfaEnabled: userData.mfaEnabled ?? false,\n organizationId: userOrgId ?? '',\n workspaces,\n currentWorkspace,\n });\n setLoading(false);\n };\n\n let cancelled = false;\n checkAuth();\n return () => {\n cancelled = true;\n };\n }, [fetchTenant, loadWorkspaces]);\n\n // Establish the client session from an auth payload (the /accounts/login\n // response, or a Google callback result of the same shape). Both the email\n // and Google doors funnel through here so they hydrate identically.\n /** Which door an account came through, remembered for the next visit. */\n // `method` is the door the account actually came through. It is stored on the\n // recognition hint so /login can offer that same door on return: greeting a\n // Google user by name and then emailing them a code is worse than not\n // recognising them at all.\n const establishSession = async (\n data: AuthSessionPayload,\n method: SignInMethod = 'email',\n connection?: string,\n ) => {\n const { account, tenants, organizationId } = data;\n\n // Build workspaces directly from enriched tenant data in the payload\n const workspaces: Workspace[] = [];\n for (const t of tenants || []) {\n workspaces.push({\n tenant: {\n id: t.id,\n slug: t.slug,\n businessName: t.name,\n businessLogo:\n t.businessLogo ||\n (t.settings as { logo?: { url?: string } } | null | undefined)?.logo\n ?.url ||\n undefined,\n contactEmail: t.contactEmail || undefined,\n contactPhone: t.contactPhone || undefined,\n customDomain: t.customDomain || undefined,\n templateId: t.templateId || 'luxe-fashion-01',\n status:\n (t.status as 'active' | 'suspended' | 'pending' | 'deleted') ||\n 'active',\n settings: t.settings || undefined,\n organizationId: t.organizationId || undefined,\n },\n role: t.role as Workspace['role'],\n permissions: t.permissions,\n });\n }\n\n // Resume the business the owner last worked in (matches the reload path),\n // falling back to the first workspace. Without this, a multi-business owner\n // whose session expired lands in an arbitrary business, not where they left.\n const signupTenantSlug = localStorage.getItem('pb_current_tenant');\n // `pb_last_workspace` deliberately survives logout (like pb_last_account),\n // so a returning owner resumes into the business they were last in rather\n // than an arbitrary first workspace — and the \"Welcome back\" card names it.\n let currentWorkspace = resolveActiveWorkspace(workspaces, true);\n if (!currentWorkspace && signupTenantSlug) {\n const signupTenant = await fetchTenant(signupTenantSlug);\n if (signupTenant) {\n currentWorkspace = { tenant: signupTenant, role: 'owner' };\n workspaces.push(currentWorkspace);\n }\n }\n\n // Get organizationId from response, or from first tenant\n const userOrgId =\n organizationId ||\n tenants?.[0]?.organizationId ||\n currentWorkspace?.tenant?.organizationId ||\n null;\n\n // Save auth data\n localStorage.setItem('pb_account_id', account.id);\n localStorage.setItem('pb_user', JSON.stringify(account));\n localStorage.setItem('userEmail', account.email);\n persistWorkspaceList(workspaces);\n if (userOrgId) {\n localStorage.setItem('organizationId', userOrgId);\n }\n persistActiveWorkspace(currentWorkspace);\n\n // Recognition hint: a small, non-sensitive record of the last account to\n // sign in on this device. Unlike the session keys above it deliberately\n // survives logout, so /login can greet a returning owner by name. It holds\n // no token — the emailed code is still the only proof of identity.\n try {\n localStorage.setItem(\n 'pb_last_account',\n JSON.stringify({\n email: account.email,\n name: account.name || account.email.split('@')[0],\n avatarUrl: account.avatarUrl || undefined,\n businessName:\n currentWorkspace?.tenant.businessName ||\n tenants?.[0]?.name ||\n undefined,\n method,\n connection: method === 'connection' ? connection : undefined,\n }),\n );\n } catch {\n // storage full / disabled — recognition is a convenience, never a gate\n }\n\n setUser({\n id: account.id,\n email: account.email,\n name: account.name || account.email.split('@')[0],\n avatarUrl: account.avatarUrl || undefined,\n emailVerified: account.emailVerified ?? false,\n mfaEnabled: account.mfaEnabled ?? false,\n organizationId: userOrgId ?? '',\n workspaces,\n currentWorkspace,\n });\n };\n\n const login = async (email: string, password: string) => {\n setLoading(true);\n try {\n // Call real login API\n const res = await fetch(`${getApiUrl()}/accounts/login`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password }),\n credentials: 'include', // Required to receive and send cookies\n });\n\n if (!res.ok) {\n const error = await res\n .json()\n .catch(() => ({ message: 'Login failed' }));\n throw new Error(error.message || 'Invalid email or password');\n }\n\n const data = await res.json();\n await establishSession(data);\n } finally {\n setLoading(false);\n }\n };\n\n // The Google door. The callback page already exchanged the OAuth code for a\n // session payload; here we just hydrate from it, same as email login.\n const loginWithGoogle = async (payload: AuthSessionPayload) => {\n setLoading(true);\n try {\n await establishSession(payload, 'google');\n } finally {\n setLoading(false);\n }\n };\n\n // The passwordless door. Verify already exchanged the emailed code for a\n // session payload; hydrate from it identically.\n const loginWithCode = async (\n payload: AuthSessionPayload,\n method: SignInMethod = 'email',\n connection?: string,\n ) => {\n setLoading(true);\n try {\n // The door is passed through so a passkey sign-in is REMEMBERED as one.\n // Without it the recognition card would greet a passkey user and then\n // email them a code — the exact mismatch the Google branch exists to\n // avoid, just with a different door.\n await establishSession(payload, method, connection);\n } finally {\n setLoading(false);\n }\n };\n\n const logout = async () => {\n resetPostHogIdentity();\n // The key list moved to `lib/end-session` so the invite screen can end a\n // session too, without a second copy of it drifting from this one.\n await endSession();\n setUser(null);\n };\n\n const switchWorkspace = useCallback(\n async (tenantSlug: string) => {\n if (!user) return;\n\n const workspace = user.workspaces.find(\n (w) => w.tenant.slug === tenantSlug,\n );\n if (workspace) {\n persistActiveWorkspace(workspace);\n try {\n const prev = JSON.parse(\n localStorage.getItem('pb_last_account') || '{}',\n );\n if (prev.email) {\n localStorage.setItem(\n 'pb_last_account',\n JSON.stringify({\n ...prev,\n businessName: workspace.tenant.businessName,\n }),\n );\n }\n } catch {\n // recognition hint is a convenience, never a gate\n }\n setUser((prev) =>\n prev ? { ...prev, currentWorkspace: workspace } : null,\n );\n }\n },\n [user],\n );\n\n const createWorkspace = useCallback(\n async (input: CreateWorkspaceInput): Promise<Tenant> => {\n const {\n businessName,\n templateId,\n currency,\n intents,\n isSolo,\n category,\n salesChannel,\n address,\n phone,\n country,\n logoFile,\n } = input;\n\n const slug =\n input.slug ||\n businessName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/(^-|-$)/g, '');\n\n // organizationId is optional — server resolves it from account membership\n // when omitted; cache it for later calls.\n let organizationId = user?.organizationId || '';\n if (!organizationId) {\n const orgs = await accountsApi.getMyOrganizations();\n if (!orgs || orgs.length === 0) {\n throw new Error('No organization found. Please sign up first.');\n }\n organizationId = orgs[0].id;\n localStorage.setItem('organizationId', organizationId);\n setUser((prev) => (prev ? { ...prev, organizationId } : null));\n }\n\n // 1. Provision — the single business-creation call in the app.\n const result = await apiFetch<{ tenant: Tenant }>(\n '/onboarding/provision',\n {\n method: 'POST',\n body: JSON.stringify({\n organizationId,\n businessName,\n slug,\n // Only sent when the merchant actually chose a look. Omitted, the\n // API derives it from `intents` against the templates table it\n // owns; named, an unknown id is rejected rather than replaced.\n ...(templateId && { templateId }),\n ...(category && { category }),\n ...(intents?.length && { intents }),\n ...(isSolo != null && { isSolo }),\n ...(currency && { currency }),\n // phone is part of the provision contract — the usecase stores it\n // canonically on tenant.phone AND settings.phone, so send it here\n // rather than patching settings after (which would land it in the\n // wrong place and nothing would read it).\n ...(phone?.trim() && { phone: phone.trim() }),\n }),\n },\n );\n\n let tenant = result.tenant;\n\n // Make the new tenant the active one BEFORE the logo upload: the media\n // proxy tags the upload with `x-tenant-slug` read from stored tenant, so\n // it must point at this fresh tenant for the file to land in its namespace.\n persistActiveWorkspace({ tenant, role: 'owner' });\n\n // 2a. Upload the logo, if one was picked. The media endpoint needs the\n // tenant to exist, so this only runs post-provision. Non-fatal: a\n // failed upload must never fail business creation.\n let logoUrl: string | undefined;\n if (logoFile) {\n try {\n const uploaded = await uploadImage(logoFile);\n logoUrl = uploaded.url;\n } catch {\n // non-fatal — the merchant can add a logo later from the editor\n }\n }\n\n // 2b. Persist fields NOT in the provision contract onto settings (server\n // deep-merges). These are set here on a fresh tenant, so a shallow set\n // is safe. `logo` mirrors the editor's brand shape ({ url }), from which\n // businessLogo is synthesized. Refinements, never gates — a failure\n const settingsPatch: Record<string, unknown> = {};\n if (salesChannel) settingsPatch.salesChannel = salesChannel;\n if (intents?.length) settingsPatch.selectedIntents = intents;\n if (country) settingsPatch.country = country;\n if (address?.trim()) settingsPatch.contact = { address: address.trim() };\n if (logoUrl) settingsPatch.logo = { url: logoUrl };\n if (Object.keys(settingsPatch).length > 0) {\n try {\n await tenantApi.updateSettings(slug, settingsPatch);\n // Reflect what we just wrote onto the in-memory tenant so a soft-nav\n // dashboard reads fresh settings without a round-trip. (We merge our\n // known patch rather than the API's return, whose Tenant shape differs\n // from auth-context's.)\n tenant = {\n ...tenant,\n businessLogo: logoUrl ?? tenant.businessLogo,\n settings: { ...(tenant.settings ?? {}), ...settingsPatch },\n };\n } catch {\n // non-fatal — these refine, they don't gate\n }\n }\n\n // 3. Register in context + make active, so the dashboard renders it\n // immediately instead of bouncing back to onboarding.\n const workspace: Workspace = { tenant, role: 'owner' };\n const updatedWorkspaces = [...(user?.workspaces || []), workspace];\n persistWorkspaceList(updatedWorkspaces);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: workspace,\n }\n : null,\n );\n persistActiveWorkspace(workspace);\n\n return tenant;\n },\n [user],\n );\n\n const duplicateWorkspace = useCallback(\n async (tenantId: string, businessName?: string): Promise<Tenant> => {\n const result = await apiFetch<{ tenant: Tenant }>(\n `/onboarding/${tenantId}/duplicate`,\n {\n method: 'POST',\n body: JSON.stringify({ businessName }),\n },\n );\n\n const tenant = result.tenant;\n const workspace: Workspace = { tenant, role: 'owner' };\n const updatedWorkspaces = [...(user?.workspaces || []), workspace];\n persistWorkspaceList(updatedWorkspaces);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: workspace,\n }\n : null,\n );\n persistActiveWorkspace(workspace);\n return tenant;\n },\n [user],\n );\n\n const deleteWorkspace = useCallback(\n (tenantSlug: string) => {\n // Immediately remove the deleted workspace from in-memory state\n // so no component re-renders with a stale tenant reference\n const remaining = (user?.workspaces || []).filter(\n (w) => w.tenant.slug !== tenantSlug,\n );\n const next = remaining[0] || null;\n\n // Update localStorage to reflect the new workspace selection\n persistActiveWorkspace(next);\n if (next) {\n persistWorkspaceList(remaining);\n } else {\n localStorage.removeItem('workspaces');\n localStorage.removeItem('pb_workspaces_cache');\n }\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: remaining,\n currentWorkspace: next,\n }\n : null,\n );\n },\n [user?.workspaces],\n );\n\n const refreshUser = useCallback(async () => {\n if (!user) return;\n\n try {\n // Re-fetch account from API to get latest data (e.g. emailVerified)\n let userData: Record<string, unknown> | null = null;\n {\n try {\n const freshAccount = await apiFetch<Record<string, unknown>>(\n '/accounts/me',\n );\n if (freshAccount) {\n userData = freshAccount;\n // Update localStorage with fresh data\n localStorage.setItem('pb_user', JSON.stringify(freshAccount));\n }\n } catch (err) {\n /**\n * Two very different failures were being treated as one.\n *\n * The server being unreachable is temporary, and falling back to the\n * cached account is right: the merchant keeps working.\n *\n * The server answering \"this account does not exist\" is not\n * temporary. Swallowing that left a dead session rendering as a\n * signed-in one, reading a cached copy of a user the API has never\n * heard of. Nothing surfaced until a write went deep enough to check\n * — provisioning a business then failed with \"You do not have access\n * to this organization\", which is true, unactionable, and describes\n * the wrong problem.\n */\n const status = (err as { status?: number })?.status;\n if (status === 401 || status === 403 || status === 404) {\n clearStoredAuth();\n if (typeof window !== 'undefined') window.location.href = '/login';\n return;\n }\n // Unreachable, not refused: keep going on what we have.\n const pbUser = localStorage.getItem('pb_user');\n if (pbUser) userData = JSON.parse(pbUser);\n }\n }\n\n if (!userData) return;\n\n // Reload workspaces from API\n const result = await loadWorkspaces();\n const workspaces = result.workspaces;\n const currentWorkspace = resolveActiveWorkspace(\n workspaces,\n result.fromApi,\n );\n\n if (currentWorkspace) persistActiveWorkspace(currentWorkspace);\n\n // Get organizationId from workspace if not stored\n const storedOrgId = localStorage.getItem('organizationId');\n const orgId =\n storedOrgId ||\n currentWorkspace?.tenant?.organizationId ||\n user.organizationId;\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n name: (userData?.name as string) || prev.name,\n email: (userData?.email as string) || prev.email,\n avatarUrl: (userData?.avatarUrl as string) || prev.avatarUrl,\n emailVerified:\n (userData?.emailVerified as boolean) ?? prev.emailVerified,\n mfaEnabled: (userData?.mfaEnabled as boolean) ?? prev.mfaEnabled,\n workspaces,\n currentWorkspace,\n organizationId: orgId,\n }\n : null,\n );\n } catch {\n // Ignore errors\n }\n }, [user, loadWorkspaces]);\n\n const refreshTenant = useCallback(async () => {\n const currentSlug = user?.currentWorkspace?.tenant?.slug;\n const currentWorkspace = user?.currentWorkspace;\n const workspaces = user?.workspaces;\n\n if (!currentSlug || !currentWorkspace || !workspaces) return;\n\n const freshTenant = await fetchTenant(currentSlug);\n if (freshTenant) {\n const updatedWorkspace = { ...currentWorkspace, tenant: freshTenant };\n const updatedWorkspaces = workspaces.map((w) =>\n w.tenant.slug === freshTenant.slug ? updatedWorkspace : w,\n );\n persistActiveWorkspace(updatedWorkspace);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: updatedWorkspace,\n }\n : null,\n );\n }\n }, [user?.currentWorkspace, user?.workspaces, fetchTenant]);\n\n const retryConnection = useCallback(async () => {\n setLoading(true);\n setConnectionError(false);\n const result = await loadWorkspaces();\n const workspaces = result.workspaces;\n\n if (result.fromApi && workspaces.length > 0) {\n const currentWorkspace = resolveActiveWorkspace(workspaces, true);\n persistActiveWorkspace(currentWorkspace);\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces,\n currentWorkspace,\n }\n : null,\n );\n } else if (!result.fromApi) {\n setConnectionError(true);\n }\n setLoading(false);\n }, [loadWorkspaces]);\n\n return (\n <AuthContext.Provider\n value={{\n user,\n tenant: user?.currentWorkspace?.tenant ?? null,\n workspaces: user?.workspaces ?? [],\n loading,\n connectionError,\n retryConnection,\n login,\n loginWithGoogle,\n loginWithCode,\n logout,\n refreshTenant,\n refreshUser,\n switchWorkspace,\n createWorkspace,\n duplicateWorkspace,\n deleteWorkspace,\n }}\n >\n {children}\n </AuthContext.Provider>\n );\n}\n\nexport function useAuth() {\n const context = useContext(AuthContext);\n if (context === undefined) {\n throw new Error('useAuth must be used within an AuthProvider');\n }\n return context;\n}\n\n/**\n * The current business, or null when there is no provider above.\n *\n * For shared primitives, which cannot assume they are inside an AuthProvider:\n * the root layout mounts none, each section mounts its own, and `useAuth`\n * throws. A page frame that threw would take the whole screen white rather\n * than degrade, which is the failure /auth already shipped once.\n */\nexport function useOptionalTenant(): Tenant | null {\n return useContext(AuthContext)?.tenant ?? null;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/auth-context.tsx"],"sourcesContent":["'use client';\n\nimport {\n createContext,\n useContext,\n useState,\n useEffect,\n ReactNode,\n useCallback,\n} from 'react';\nimport {\n getApiUrl,\n apiFetch,\n accountsApi,\n tenantApi,\n type AuthSessionPayload,\n type Tenant,\n} from './api';\nimport { removeLegacyToken } from './stored-token';\nimport { resetPostHogIdentity } from '@withpotter/components/analytics';\nimport { uploadImage } from '@withpotter/lib/media-service';\nimport { endSession } from '@withpotter/lib/end-session';\nimport {\n pickWorkspace,\n readJoinedWorkspace,\n clearJoinedWorkspace,\n} from './active-workspace';\n\n// Tenant is defined once, canonically, in ./api. Re-exported here so the many\n// modules that historically imported it from auth-context keep working — there\n// is now a single Tenant shape (with the correct string id) across the app.\nexport type { Tenant };\n\nexport interface Workspace {\n tenant: Tenant;\n role: 'owner' | 'manager' | 'staff' | 'viewer';\n permissions?: Record<string, boolean>;\n}\n\nexport interface User {\n id: string;\n email: string;\n name: string;\n avatarUrl?: string;\n /** The account's own phone number, when they gave one. */\n phone?: string | null;\n emailVerified: boolean;\n /** Two-factor authentication is active on this account. */\n mfaEnabled?: boolean;\n organizationId: string; // Created during signup\n workspaces: Workspace[];\n currentWorkspace: Workspace | null;\n}\n\ninterface AuthContextType {\n user: User | null;\n tenant: Tenant | null;\n workspaces: Workspace[];\n loading: boolean;\n connectionError: boolean;\n login: (email: string, password: string) => Promise<void>;\n loginWithGoogle: (payload: AuthSessionPayload) => Promise<void>;\n loginWithCode: (\n payload: AuthSessionPayload,\n method?: SignInMethod,\n connection?: string,\n ) => Promise<void>;\n logout: () => Promise<void>;\n refreshTenant: () => Promise<void>;\n refreshUser: () => Promise<void>;\n switchWorkspace: (tenantSlug: string) => Promise<void>;\n createWorkspace: (input: CreateWorkspaceInput) => Promise<Tenant>;\n duplicateWorkspace: (\n tenantId: string,\n businessName?: string,\n ) => Promise<Tenant>;\n deleteWorkspace: (tenantSlug: string) => void;\n retryConnection: () => Promise<void>;\n}\n\n/**\n * The single input for creating a business. One shape for both entry paths\n * (first-run /onboarding and in-dashboard /workspaces/new) so every business is\n * provisioned identically. Fields not in the provision contract (salesChannel,\n * address) are persisted onto tenant.settings right after.\n */\nexport interface CreateWorkspaceInput {\n businessName: string;\n /**\n * Only for a deliberate choice of storefront look, which today means the\n * Website hub. Business creation leaves it unset: the API derives the\n * starting template from `intents`, so no client keeps a list of template ids\n * it cannot validate. Three copies of that list had already drifted.\n */\n templateId?: string;\n slug?: string;\n currency?: string;\n intents?: string[];\n isSolo?: boolean;\n category?: string;\n salesChannel?: string;\n address?: string;\n phone?: string;\n /** ISO-3166 alpha-2. Not in the provision contract, so it rides the same\n * settings patch as salesChannel. Onboarding used to collect this, derive a\n * currency from it and then throw it away — which is why Settings › General\n * showed an empty Country on every workspace ever created. */\n country?: string;\n /** Optional business logo, uploaded after provision (the media endpoint needs\n * the tenant to exist). Non-fatal — a failed upload never fails creation. */\n logoFile?: File | null;\n}\n\nconst AuthContext = createContext<AuthContextType | undefined>(undefined);\n\n/**\n * Local-dev open access: when running `next dev` with NEXT_PUBLIC_DEV_OPEN_ACCESS=1,\n * skip auth entirely so any dashboard page is directly viewable without a\n * login/OTP (backend data just shows empty states if the API isn't running).\n * NEVER active in staging/prod — those build with NODE_ENV=production, so this\n * is false regardless of the flag.\n */\nconst DEV_OPEN_ACCESS =\n process.env.NODE_ENV === 'development' &&\n process.env.NEXT_PUBLIC_DEV_OPEN_ACCESS === '1';\n\n/**\n * The door an account came through. `connection` is a sign-in the host app provides, such as a payment or bank\n * provider's login; which one is kept beside it (see `LastAccount.connection`).\n */\nexport type SignInMethod = 'google' | 'email' | 'passkey' | 'connection';\n\n/**\n * THE PRECEDENCE RULE FOR \"WHICH WORKSPACE AM I IN\".\n *\n * `user.currentWorkspace` is the only authoritative answer. Four storage keys\n * cache it — `currentWorkspace`, `pb_current_tenant`, `tenant` and\n * `pb_last_workspace` — and they exist because consumers outside React need the\n * answer synchronously: the media proxy resolves `x-tenant-slug` straight out\n * of them (lib/media-service.ts), and the login door names the business it is\n * about to resume into before any provider has mounted.\n *\n * They are a projection of the resolved workspace, never an input to it, so\n * every path that resolves one must write all four together through here.\n * Written piecemeal they drift apart permanently: resolution falls back to\n * `workspaces[0]` whenever the stored slug is no longer in the account's tenant\n * list, and the paths that resolved by fallback used to write nothing back — so\n * storage kept naming a business the app was not in, uploads were tagged with\n * it, and the slug-keyed arrival flag never matched.\n *\n * Passing null clears the active-workspace cache without touching\n * `pb_last_workspace`, which deliberately outlives a session so a returning\n * owner resumes where they left off.\n */\nfunction persistActiveWorkspace(workspace: Workspace | null): void {\n if (!workspace) {\n localStorage.removeItem('currentWorkspace');\n localStorage.removeItem('pb_current_tenant');\n localStorage.removeItem('tenant');\n return;\n }\n const { slug } = workspace.tenant;\n localStorage.setItem('currentWorkspace', slug);\n localStorage.setItem('pb_current_tenant', slug);\n localStorage.setItem('tenant', JSON.stringify(workspace.tenant));\n localStorage.setItem('pb_last_workspace', slug);\n}\n\n/**\n * The slug to prefer when resolving the active workspace, in one order for\n * every entry path. Reload and login used to disagree on it, which let the two\n * doors into the same account land in two different businesses.\n *\n * It is a preference, not an answer: a slug that is not in the account's tenant\n * list loses to `workspaces[0]`, and the winner is written back immediately.\n */\nfunction readPreferredSlug(): string | null {\n return (\n localStorage.getItem('currentWorkspace') ||\n localStorage.getItem('pb_last_workspace') ||\n localStorage.getItem('pb_current_tenant')\n );\n}\n\n/**\n * The workspace this load lands in, for every entry path.\n *\n * Reload, login and refresh each wrote `find(slug) || workspaces[0]` inline,\n * which is why the accept-invite screen could not say \"land me in the business\n * I just joined\" — there was nowhere to say it. `pass authoritative` when the\n * list came from the API: a just-joined tenant missing from a real list is a\n * membership that no longer exists, and the handoff is dropped rather than\n * retried against every later load.\n */\nfunction resolveActiveWorkspace(\n workspaces: Workspace[],\n authoritative: boolean,\n): Workspace | null {\n const joinedTenantId = readJoinedWorkspace();\n const picked = pickWorkspace(workspaces, {\n joinedTenantId,\n preferredSlug: readPreferredSlug(),\n });\n if (\n joinedTenantId &&\n (authoritative || picked?.tenant.id === joinedTenantId)\n ) {\n clearJoinedWorkspace();\n }\n return picked;\n}\n\n/**\n * The workspace list cache, written as one unit. `workspaces` (slugs) is the\n * legacy shape, `pb_workspaces_cache` the full one; both are read as fallbacks\n * when the API is unreachable, so a business missing from either reads as a\n * business the owner does not have.\n */\nfunction persistWorkspaceList(workspaces: Workspace[]): void {\n localStorage.setItem(\n 'workspaces',\n JSON.stringify(workspaces.map((w) => w.tenant.slug)),\n );\n localStorage.setItem('pb_workspaces_cache', JSON.stringify(workspaces));\n}\n\n/**\n * Everything that makes the browser believe it is signed in.\n *\n * Hoisted out of the auth check because it was trapped in one effect's closure,\n * so any other place that discovered a dead session had no way to end it and\n * fell back to cached state instead.\n */\nfunction clearStoredAuth(): void {\n localStorage.removeItem('pb_token');\n localStorage.removeItem('pb_user');\n localStorage.removeItem('pb_account_id');\n localStorage.removeItem('userEmail');\n localStorage.removeItem('organizationId');\n persistActiveWorkspace(null);\n}\n\n/**\n * The workspace we can name WITHOUT asking the server.\n *\n * Every tenant-scoped query in the app is gated on `loading` being false, and\n * `loading` was only cleared after `/team/my-tenants` came back. So the whole\n * dashboard — twenty-odd requests on Home alone — sat waiting on one round trip\n * before it was allowed to ask for anything, on every single page load, even\n * though the answer was already sitting in localStorage from last time.\n *\n * This reads that cache so the app can start immediately. It is a HINT, not an\n * answer: `checkAuth` still calls the API and reconciles, so a workspace the\n * account has lost is corrected a moment later. Nothing is trusted for\n * authorisation either way — the API enforces access on every request, so the\n * worst case is a card that renders and then corrects itself.\n */\nfunction readCachedWorkspace(): Workspace | null {\n try {\n const raw = localStorage.getItem('pb_workspaces_cache');\n if (!raw) return null;\n const cached = JSON.parse(raw) as Workspace[];\n if (!Array.isArray(cached) || cached.length === 0) return null;\n const match =\n pickWorkspace(cached, {\n joinedTenantId: readJoinedWorkspace(),\n preferredSlug: readPreferredSlug(),\n }) ?? cached[0];\n // A slug-only legacy entry has no real id; starting on one would fire every\n // query against a tenant id that does not exist.\n return match?.tenant?.slug && match.tenant.id !== match.tenant.slug\n ? match\n : null;\n } catch {\n return null;\n }\n}\n\nexport function AuthProvider({ children }: { children: ReactNode }) {\n const [user, setUser] = useState<User | null>(null);\n const [loading, setLoading] = useState(true);\n const [connectionError, setConnectionError] = useState(false);\n\n const fetchTenant = useCallback(\n async (slug: string): Promise<Tenant | null> => {\n try {\n // Auth is handled via HttpOnly cookies (credentials: 'include')\n const res = await fetch(`${getApiUrl()}/tenants/${slug}`, {\n credentials: 'include',\n });\n if (!res.ok) return null;\n\n const data = await res.json();\n return {\n id: data.id,\n slug: data.slug,\n businessName: data.businessName,\n // Synthesize businessLogo from settings.logo.url (GET /tenants/:slug returns raw entity without businessLogo)\n businessLogo:\n data.businessLogo || data.settings?.logo?.url || undefined,\n contactEmail: data.contactEmail || data.email,\n contactPhone: data.contactPhone || data.phone,\n templateId: data.templateId || 'luxe-fashion-01',\n customDomain: data.customDomain || undefined,\n status: data.status,\n settings: data.settings,\n organizationId: data.organizationId,\n };\n } catch {\n return null;\n }\n },\n [],\n );\n\n // Load workspaces from API\n // Returns { workspaces, fromApi } to track if data came from API\n const loadWorkspaces = useCallback(async (): Promise<{\n workspaces: Workspace[];\n fromApi: boolean;\n }> => {\n // Auth is handled via HttpOnly cookies (credentials: 'include')\n try {\n const res = await fetch(`${getApiUrl()}/team/my-tenants`, {\n credentials: 'include',\n });\n\n // 401 means the session/token is invalid — force re-login\n if (res.status === 401 || res.status === 403) {\n if (process.env.NODE_ENV === 'development') {\n console.warn(\n 'Auth expired (status',\n res.status,\n '), redirecting to login',\n );\n }\n // Clear stale auth data\n clearStoredAuth();\n // Redirect to login — throw to prevent checkAuth from updating\n // React state, which would race with this hard navigation\n if (typeof window !== 'undefined') {\n window.location.href = '/login';\n }\n throw new Error('AUTH_EXPIRED');\n }\n\n if (res.ok) {\n const apiTenants = (await res.json()) as Array<{\n id: string;\n tenantId: string;\n tenantSlug: string;\n tenantName: string;\n role: string;\n permissions?: Record<string, boolean>;\n templateId?: string | null;\n organizationId?: string | null;\n tenant?: {\n id: string;\n slug: string;\n businessName: string;\n businessLogo: string | null;\n contactEmail: string | null;\n contactPhone: string | null;\n customDomain: string | null;\n status: string;\n industryKey?: string | null;\n settings: Record<string, unknown> | null;\n };\n }>;\n\n /**\n * A DELETED WORKSPACE IS NOT A WORKSPACE.\n *\n * The membership row outlives the tenant, so deleting a workspace left\n * it in this list and therefore in the switcher. Choosing one looked\n * completely normal and then every request 401'd with \"Tenant context\n * required\" — the API's tenant middleware resolves only ACTIVE and\n * PENDING — so the whole dashboard rendered empty: no orders, no\n * customers, no analytics, no marketing. Nothing anywhere said the\n * workspace was gone, so it read as the product being broken rather\n * than the workspace not existing.\n *\n * Measured on staging: three of six workspaces in this list were\n * deleted, and all three 401 on every endpoint.\n */\n const liveTenants = apiTenants.filter(\n (t) => (t.tenant?.status ?? 'active') !== 'deleted',\n );\n\n // Use the full tenant details from the API response (no N+1 calls needed)\n const workspaces: Workspace[] = liveTenants.map((t) => ({\n tenant: {\n id: t.tenant?.id || t.tenantId,\n slug: t.tenant?.slug || t.tenantSlug,\n businessName: t.tenant?.businessName || t.tenantName,\n businessLogo: t.tenant?.businessLogo || undefined,\n contactEmail: t.tenant?.contactEmail || undefined,\n contactPhone: t.tenant?.contactPhone || undefined,\n customDomain: t.tenant?.customDomain || undefined,\n templateId: t.templateId || 'luxe-fashion-01',\n status:\n (t.tenant?.status as\n | 'active'\n | 'suspended'\n | 'pending'\n | 'deleted') || 'active',\n // What kind of business this is. The switcher labels every row\n // with it so four workspaces are four distinguishable things, and\n // dropping it here was why they all read as a bare name.\n industryKey: t.tenant?.industryKey ?? null,\n settings: t.tenant?.settings || undefined,\n organizationId: t.organizationId || undefined,\n },\n role: t.role as Workspace['role'],\n permissions: t.permissions,\n }));\n\n // Sync localStorage with API response (cache full workspace data for resilient fallback)\n if (workspaces.length > 0) {\n persistWorkspaceList(workspaces);\n }\n\n return { workspaces, fromApi: true };\n }\n\n // API returned non-ok status (e.g., 429, 500) — fall back to cached workspaces\n if (process.env.NODE_ENV === 'development') {\n console.warn('API returned error status:', res.status);\n }\n // Try full workspace cache first (has proper UUIDs)\n try {\n const cachedRaw = localStorage.getItem('pb_workspaces_cache');\n if (cachedRaw) {\n const cachedWorkspaces = JSON.parse(cachedRaw) as Workspace[];\n if (cachedWorkspaces.length > 0) {\n return { workspaces: cachedWorkspaces, fromApi: false };\n }\n }\n } catch {\n // Corrupted cache — fall through\n }\n // Legacy fallback: slugs-only cache (no UUIDs available — use connection error state)\n const cachedSlugs = JSON.parse(\n localStorage.getItem('workspaces') || '[]',\n ) as string[];\n if (cachedSlugs.length > 0) {\n const cachedWorkspaces: Workspace[] = cachedSlugs.map((slug) => ({\n tenant: {\n id: slug,\n slug,\n businessName: slug,\n templateId: 'luxe-fashion-01',\n status: 'active' as const,\n },\n role: 'owner' as const,\n }));\n return { workspaces: cachedWorkspaces, fromApi: false };\n }\n return { workspaces: [], fromApi: false };\n } catch (error) {\n if (process.env.NODE_ENV === 'development') {\n console.warn('Failed to fetch workspaces from API:', error);\n }\n // Network error or other failure\n return { workspaces: [], fromApi: false };\n }\n }, []);\n\n useEffect(() => {\n const checkAuth = async () => {\n // Local-dev open access: bypass auth so any page renders directly.\n if (DEV_OPEN_ACCESS) {\n const slug = process.env.NEXT_PUBLIC_DEV_TENANT || 'demo';\n const tenant = {\n id: slug,\n slug,\n businessName: 'Dev Business',\n templateId: 'luxe-fashion-01',\n status: 'active' as const,\n };\n const workspace = { tenant, role: 'owner' as const };\n setUser({\n id: 'dev-user',\n email: 'dev@local',\n name: 'Dev',\n emailVerified: true,\n mfaEnabled: false,\n organizationId: '',\n workspaces: [workspace],\n currentWorkspace: workspace,\n });\n setLoading(false);\n return;\n }\n\n // The session is the HttpOnly cookie, which this code cannot see.\n // The cached account only says this browser has signed in before; the\n // calls below are the proof, and a 401 ends the session.\n removeLegacyToken();\n const pbUser = localStorage.getItem('pb_user');\n const storedOrgId = localStorage.getItem('organizationId');\n\n // No user data = invalid session\n if (!pbUser) {\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n let userData: {\n id: string;\n email: string;\n name?: string;\n avatarUrl?: string;\n phone?: string | null;\n emailVerified?: boolean;\n mfaEnabled?: boolean;\n organizationId?: string;\n } | null = null;\n try {\n userData = JSON.parse(pbUser);\n } catch {\n if (process.env.NODE_ENV === 'development') {\n console.warn('Invalid user data, clearing auth state');\n }\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n if (!userData?.email) {\n if (process.env.NODE_ENV === 'development') {\n console.warn('No email in user data, clearing auth state');\n }\n clearStoredAuth();\n setLoading(false);\n return;\n }\n\n // ── Optimistic start ──────────────────────────────────────────────────\n // Everything tenant-scoped is gated on `loading`, so clearing it here\n // lets the dashboard begin fetching against the workspace we already\n // know, instead of idling through a round trip first. The API call below\n // still runs and reconciles; this only decides when the app is allowed\n // to START, not what is true.\n const cachedWorkspace = readCachedWorkspace();\n if (cachedWorkspace && !cancelled) {\n setUser({\n id: userData.id,\n email: userData.email,\n name: userData.name || userData.email.split('@')[0],\n avatarUrl: userData.avatarUrl || undefined,\n phone: userData.phone ?? null,\n emailVerified: userData.emailVerified ?? false,\n mfaEnabled: userData.mfaEnabled ?? false,\n organizationId:\n storedOrgId ||\n userData?.organizationId ||\n cachedWorkspace.tenant.organizationId ||\n '',\n workspaces: [cachedWorkspace],\n currentWorkspace: cachedWorkspace,\n });\n setLoading(false);\n }\n\n try {\n const freshAccount = await apiFetch<typeof userData>('/accounts/me');\n if (freshAccount) {\n userData = freshAccount;\n localStorage.setItem('pb_user', JSON.stringify(freshAccount));\n }\n } catch {\n // Keep the cached account when the API is temporarily unavailable.\n }\n\n // Load workspaces — may throw AUTH_EXPIRED if session is invalid\n let result;\n try {\n result = await loadWorkspaces();\n } catch (e: unknown) {\n if (e instanceof Error && e.message === 'AUTH_EXPIRED') return;\n throw e;\n }\n if (cancelled) return;\n const workspaces = result.workspaces;\n setConnectionError(!result.fromApi && workspaces.length === 0);\n\n const signupTenantSlug = localStorage.getItem('pb_current_tenant');\n\n // Find current workspace or use first one\n let currentWorkspace = resolveActiveWorkspace(workspaces, result.fromApi);\n\n // If no workspaces but we have signup tenant, fetch it\n if (workspaces.length === 0 && signupTenantSlug) {\n const signupTenant = await fetchTenant(signupTenantSlug);\n if (signupTenant) {\n currentWorkspace = { tenant: signupTenant, role: 'owner' };\n workspaces.push(currentWorkspace);\n persistWorkspaceList(workspaces);\n }\n }\n\n // Write the resolution back before rendering it. This is the reload path\n // that fell through with a stored slug the account no longer has: it\n // resolved `workspaces[0]` and left the cache naming the old business.\n if (currentWorkspace) persistActiveWorkspace(currentWorkspace);\n\n // Get organizationId - must be a valid UUID\n const userOrgId =\n storedOrgId ||\n userData?.organizationId ||\n currentWorkspace?.tenant?.organizationId;\n\n setUser({\n id: userData.id,\n email: userData.email,\n name: userData.name || userData.email.split('@')[0],\n avatarUrl: userData.avatarUrl || undefined,\n phone: userData.phone ?? null,\n emailVerified: userData.emailVerified ?? false,\n mfaEnabled: userData.mfaEnabled ?? false,\n organizationId: userOrgId ?? '',\n workspaces,\n currentWorkspace,\n });\n setLoading(false);\n };\n\n let cancelled = false;\n checkAuth();\n return () => {\n cancelled = true;\n };\n }, [fetchTenant, loadWorkspaces]);\n\n // Establish the client session from an auth payload (the /accounts/login\n // response, or a Google callback result of the same shape). Both the email\n // and Google doors funnel through here so they hydrate identically.\n /** Which door an account came through, remembered for the next visit. */\n // `method` is the door the account actually came through. It is stored on the\n // recognition hint so /login can offer that same door on return: greeting a\n // Google user by name and then emailing them a code is worse than not\n // recognising them at all.\n const establishSession = async (\n data: AuthSessionPayload,\n method: SignInMethod = 'email',\n connection?: string,\n ) => {\n const { account, tenants, organizationId } = data;\n\n // Build workspaces directly from enriched tenant data in the payload\n const workspaces: Workspace[] = [];\n for (const t of tenants || []) {\n workspaces.push({\n tenant: {\n id: t.id,\n slug: t.slug,\n businessName: t.name,\n businessLogo:\n t.businessLogo ||\n (t.settings as { logo?: { url?: string } } | null | undefined)?.logo\n ?.url ||\n undefined,\n contactEmail: t.contactEmail || undefined,\n contactPhone: t.contactPhone || undefined,\n customDomain: t.customDomain || undefined,\n templateId: t.templateId || 'luxe-fashion-01',\n status:\n (t.status as 'active' | 'suspended' | 'pending' | 'deleted') ||\n 'active',\n settings: t.settings || undefined,\n organizationId: t.organizationId || undefined,\n },\n role: t.role as Workspace['role'],\n permissions: t.permissions,\n });\n }\n\n // Resume the business the owner last worked in (matches the reload path),\n // falling back to the first workspace. Without this, a multi-business owner\n // whose session expired lands in an arbitrary business, not where they left.\n const signupTenantSlug = localStorage.getItem('pb_current_tenant');\n // `pb_last_workspace` deliberately survives logout (like pb_last_account),\n // so a returning owner resumes into the business they were last in rather\n // than an arbitrary first workspace — and the \"Welcome back\" card names it.\n let currentWorkspace = resolveActiveWorkspace(workspaces, true);\n if (!currentWorkspace && signupTenantSlug) {\n const signupTenant = await fetchTenant(signupTenantSlug);\n if (signupTenant) {\n currentWorkspace = { tenant: signupTenant, role: 'owner' };\n workspaces.push(currentWorkspace);\n }\n }\n\n // Get organizationId from response, or from first tenant\n const userOrgId =\n organizationId ||\n tenants?.[0]?.organizationId ||\n currentWorkspace?.tenant?.organizationId ||\n null;\n\n // Save auth data\n localStorage.setItem('pb_account_id', account.id);\n localStorage.setItem('pb_user', JSON.stringify(account));\n localStorage.setItem('userEmail', account.email);\n persistWorkspaceList(workspaces);\n if (userOrgId) {\n localStorage.setItem('organizationId', userOrgId);\n }\n persistActiveWorkspace(currentWorkspace);\n\n // Recognition hint: a small, non-sensitive record of the last account to\n // sign in on this device. Unlike the session keys above it deliberately\n // survives logout, so /login can greet a returning owner by name. It holds\n // no token — the emailed code is still the only proof of identity.\n try {\n localStorage.setItem(\n 'pb_last_account',\n JSON.stringify({\n email: account.email,\n name: account.name || account.email.split('@')[0],\n avatarUrl: account.avatarUrl || undefined,\n businessName:\n currentWorkspace?.tenant.businessName ||\n tenants?.[0]?.name ||\n undefined,\n method,\n connection: method === 'connection' ? connection : undefined,\n }),\n );\n } catch {\n // storage full / disabled — recognition is a convenience, never a gate\n }\n\n setUser({\n id: account.id,\n email: account.email,\n name: account.name || account.email.split('@')[0],\n avatarUrl: account.avatarUrl || undefined,\n phone: account.phone ?? null,\n emailVerified: account.emailVerified ?? false,\n mfaEnabled: account.mfaEnabled ?? false,\n organizationId: userOrgId ?? '',\n workspaces,\n currentWorkspace,\n });\n };\n\n const login = async (email: string, password: string) => {\n setLoading(true);\n try {\n // Call real login API\n const res = await fetch(`${getApiUrl()}/accounts/login`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password }),\n credentials: 'include', // Required to receive and send cookies\n });\n\n if (!res.ok) {\n const error = await res\n .json()\n .catch(() => ({ message: 'Login failed' }));\n throw new Error(error.message || 'Invalid email or password');\n }\n\n const data = await res.json();\n await establishSession(data);\n } finally {\n setLoading(false);\n }\n };\n\n // The Google door. The callback page already exchanged the OAuth code for a\n // session payload; here we just hydrate from it, same as email login.\n const loginWithGoogle = async (payload: AuthSessionPayload) => {\n setLoading(true);\n try {\n await establishSession(payload, 'google');\n } finally {\n setLoading(false);\n }\n };\n\n // The passwordless door. Verify already exchanged the emailed code for a\n // session payload; hydrate from it identically.\n const loginWithCode = async (\n payload: AuthSessionPayload,\n method: SignInMethod = 'email',\n connection?: string,\n ) => {\n setLoading(true);\n try {\n // The door is passed through so a passkey sign-in is REMEMBERED as one.\n // Without it the recognition card would greet a passkey user and then\n // email them a code — the exact mismatch the Google branch exists to\n // avoid, just with a different door.\n await establishSession(payload, method, connection);\n } finally {\n setLoading(false);\n }\n };\n\n const logout = async () => {\n resetPostHogIdentity();\n // The key list moved to `lib/end-session` so the invite screen can end a\n // session too, without a second copy of it drifting from this one.\n await endSession();\n setUser(null);\n };\n\n const switchWorkspace = useCallback(\n async (tenantSlug: string) => {\n if (!user) return;\n\n const workspace = user.workspaces.find(\n (w) => w.tenant.slug === tenantSlug,\n );\n if (workspace) {\n persistActiveWorkspace(workspace);\n try {\n const prev = JSON.parse(\n localStorage.getItem('pb_last_account') || '{}',\n );\n if (prev.email) {\n localStorage.setItem(\n 'pb_last_account',\n JSON.stringify({\n ...prev,\n businessName: workspace.tenant.businessName,\n }),\n );\n }\n } catch {\n // recognition hint is a convenience, never a gate\n }\n setUser((prev) =>\n prev ? { ...prev, currentWorkspace: workspace } : null,\n );\n }\n },\n [user],\n );\n\n const createWorkspace = useCallback(\n async (input: CreateWorkspaceInput): Promise<Tenant> => {\n const {\n businessName,\n templateId,\n currency,\n intents,\n isSolo,\n category,\n salesChannel,\n address,\n phone,\n country,\n logoFile,\n } = input;\n\n const slug =\n input.slug ||\n businessName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/(^-|-$)/g, '');\n\n // organizationId is optional — server resolves it from account membership\n // when omitted; cache it for later calls.\n let organizationId = user?.organizationId || '';\n if (!organizationId) {\n const orgs = await accountsApi.getMyOrganizations();\n if (!orgs || orgs.length === 0) {\n throw new Error('No organization found. Please sign up first.');\n }\n organizationId = orgs[0].id;\n localStorage.setItem('organizationId', organizationId);\n setUser((prev) => (prev ? { ...prev, organizationId } : null));\n }\n\n // 1. Provision — the single business-creation call in the app.\n const result = await apiFetch<{ tenant: Tenant }>(\n '/onboarding/provision',\n {\n method: 'POST',\n body: JSON.stringify({\n organizationId,\n businessName,\n slug,\n // Only sent when the merchant actually chose a look. Omitted, the\n // API derives it from `intents` against the templates table it\n // owns; named, an unknown id is rejected rather than replaced.\n ...(templateId && { templateId }),\n ...(category && { category }),\n ...(intents?.length && { intents }),\n ...(isSolo != null && { isSolo }),\n ...(currency && { currency }),\n // phone is part of the provision contract — the usecase stores it\n // canonically on tenant.phone AND settings.phone, so send it here\n // rather than patching settings after (which would land it in the\n // wrong place and nothing would read it).\n ...(phone?.trim() && { phone: phone.trim() }),\n }),\n },\n );\n\n let tenant = result.tenant;\n\n // Make the new tenant the active one BEFORE the logo upload: the media\n // proxy tags the upload with `x-tenant-slug` read from stored tenant, so\n // it must point at this fresh tenant for the file to land in its namespace.\n persistActiveWorkspace({ tenant, role: 'owner' });\n\n // 2a. Upload the logo, if one was picked. The media endpoint needs the\n // tenant to exist, so this only runs post-provision. Non-fatal: a\n // failed upload must never fail business creation.\n let logoUrl: string | undefined;\n if (logoFile) {\n try {\n const uploaded = await uploadImage(logoFile);\n logoUrl = uploaded.url;\n } catch {\n // non-fatal — the merchant can add a logo later from the editor\n }\n }\n\n // 2b. Persist fields NOT in the provision contract onto settings (server\n // deep-merges). These are set here on a fresh tenant, so a shallow set\n // is safe. `logo` mirrors the editor's brand shape ({ url }), from which\n // businessLogo is synthesized. Refinements, never gates — a failure\n const settingsPatch: Record<string, unknown> = {};\n if (salesChannel) settingsPatch.salesChannel = salesChannel;\n if (intents?.length) settingsPatch.selectedIntents = intents;\n if (country) settingsPatch.country = country;\n if (address?.trim()) settingsPatch.contact = { address: address.trim() };\n if (logoUrl) settingsPatch.logo = { url: logoUrl };\n if (Object.keys(settingsPatch).length > 0) {\n try {\n await tenantApi.updateSettings(slug, settingsPatch);\n // Reflect what we just wrote onto the in-memory tenant so a soft-nav\n // dashboard reads fresh settings without a round-trip. (We merge our\n // known patch rather than the API's return, whose Tenant shape differs\n // from auth-context's.)\n tenant = {\n ...tenant,\n businessLogo: logoUrl ?? tenant.businessLogo,\n settings: { ...(tenant.settings ?? {}), ...settingsPatch },\n };\n } catch {\n // non-fatal — these refine, they don't gate\n }\n }\n\n // 3. Register in context + make active, so the dashboard renders it\n // immediately instead of bouncing back to onboarding.\n const workspace: Workspace = { tenant, role: 'owner' };\n const updatedWorkspaces = [...(user?.workspaces || []), workspace];\n persistWorkspaceList(updatedWorkspaces);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: workspace,\n }\n : null,\n );\n persistActiveWorkspace(workspace);\n\n return tenant;\n },\n [user],\n );\n\n const duplicateWorkspace = useCallback(\n async (tenantId: string, businessName?: string): Promise<Tenant> => {\n const result = await apiFetch<{ tenant: Tenant }>(\n `/onboarding/${tenantId}/duplicate`,\n {\n method: 'POST',\n body: JSON.stringify({ businessName }),\n },\n );\n\n const tenant = result.tenant;\n const workspace: Workspace = { tenant, role: 'owner' };\n const updatedWorkspaces = [...(user?.workspaces || []), workspace];\n persistWorkspaceList(updatedWorkspaces);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: workspace,\n }\n : null,\n );\n persistActiveWorkspace(workspace);\n return tenant;\n },\n [user],\n );\n\n const deleteWorkspace = useCallback(\n (tenantSlug: string) => {\n // Immediately remove the deleted workspace from in-memory state\n // so no component re-renders with a stale tenant reference\n const remaining = (user?.workspaces || []).filter(\n (w) => w.tenant.slug !== tenantSlug,\n );\n const next = remaining[0] || null;\n\n // Update localStorage to reflect the new workspace selection\n persistActiveWorkspace(next);\n if (next) {\n persistWorkspaceList(remaining);\n } else {\n localStorage.removeItem('workspaces');\n localStorage.removeItem('pb_workspaces_cache');\n }\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: remaining,\n currentWorkspace: next,\n }\n : null,\n );\n },\n [user?.workspaces],\n );\n\n const refreshUser = useCallback(async () => {\n if (!user) return;\n\n try {\n // Re-fetch account from API to get latest data (e.g. emailVerified)\n let userData: Record<string, unknown> | null = null;\n {\n try {\n const freshAccount = await apiFetch<Record<string, unknown>>(\n '/accounts/me',\n );\n if (freshAccount) {\n userData = freshAccount;\n // Update localStorage with fresh data\n localStorage.setItem('pb_user', JSON.stringify(freshAccount));\n }\n } catch (err) {\n /**\n * Two very different failures were being treated as one.\n *\n * The server being unreachable is temporary, and falling back to the\n * cached account is right: the merchant keeps working.\n *\n * The server answering \"this account does not exist\" is not\n * temporary. Swallowing that left a dead session rendering as a\n * signed-in one, reading a cached copy of a user the API has never\n * heard of. Nothing surfaced until a write went deep enough to check\n * — provisioning a business then failed with \"You do not have access\n * to this organization\", which is true, unactionable, and describes\n * the wrong problem.\n */\n const status = (err as { status?: number })?.status;\n if (status === 401 || status === 403 || status === 404) {\n clearStoredAuth();\n if (typeof window !== 'undefined') window.location.href = '/login';\n return;\n }\n // Unreachable, not refused: keep going on what we have.\n const pbUser = localStorage.getItem('pb_user');\n if (pbUser) userData = JSON.parse(pbUser);\n }\n }\n\n if (!userData) return;\n\n // Reload workspaces from API\n const result = await loadWorkspaces();\n const workspaces = result.workspaces;\n const currentWorkspace = resolveActiveWorkspace(\n workspaces,\n result.fromApi,\n );\n\n if (currentWorkspace) persistActiveWorkspace(currentWorkspace);\n\n // Get organizationId from workspace if not stored\n const storedOrgId = localStorage.getItem('organizationId');\n const orgId =\n storedOrgId ||\n currentWorkspace?.tenant?.organizationId ||\n user.organizationId;\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n name: (userData?.name as string) || prev.name,\n email: (userData?.email as string) || prev.email,\n avatarUrl: (userData?.avatarUrl as string) || prev.avatarUrl,\n // Absent from an older API response: keep what we had.\n phone:\n userData && 'phone' in userData\n ? ((userData.phone as string | null) ?? null)\n : prev.phone,\n emailVerified:\n (userData?.emailVerified as boolean) ?? prev.emailVerified,\n mfaEnabled: (userData?.mfaEnabled as boolean) ?? prev.mfaEnabled,\n workspaces,\n currentWorkspace,\n organizationId: orgId,\n }\n : null,\n );\n } catch {\n // Ignore errors\n }\n }, [user, loadWorkspaces]);\n\n const refreshTenant = useCallback(async () => {\n const currentSlug = user?.currentWorkspace?.tenant?.slug;\n const currentWorkspace = user?.currentWorkspace;\n const workspaces = user?.workspaces;\n\n if (!currentSlug || !currentWorkspace || !workspaces) return;\n\n const freshTenant = await fetchTenant(currentSlug);\n if (freshTenant) {\n const updatedWorkspace = { ...currentWorkspace, tenant: freshTenant };\n const updatedWorkspaces = workspaces.map((w) =>\n w.tenant.slug === freshTenant.slug ? updatedWorkspace : w,\n );\n persistActiveWorkspace(updatedWorkspace);\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces: updatedWorkspaces,\n currentWorkspace: updatedWorkspace,\n }\n : null,\n );\n }\n }, [user?.currentWorkspace, user?.workspaces, fetchTenant]);\n\n const retryConnection = useCallback(async () => {\n setLoading(true);\n setConnectionError(false);\n const result = await loadWorkspaces();\n const workspaces = result.workspaces;\n\n if (result.fromApi && workspaces.length > 0) {\n const currentWorkspace = resolveActiveWorkspace(workspaces, true);\n persistActiveWorkspace(currentWorkspace);\n\n setUser((prev) =>\n prev\n ? {\n ...prev,\n workspaces,\n currentWorkspace,\n }\n : null,\n );\n } else if (!result.fromApi) {\n setConnectionError(true);\n }\n setLoading(false);\n }, [loadWorkspaces]);\n\n return (\n <AuthContext.Provider\n value={{\n user,\n tenant: user?.currentWorkspace?.tenant ?? null,\n workspaces: user?.workspaces ?? [],\n loading,\n connectionError,\n retryConnection,\n login,\n loginWithGoogle,\n loginWithCode,\n logout,\n refreshTenant,\n refreshUser,\n switchWorkspace,\n createWorkspace,\n duplicateWorkspace,\n deleteWorkspace,\n }}\n >\n {children}\n </AuthContext.Provider>\n );\n}\n\nexport function useAuth() {\n const context = useContext(AuthContext);\n if (context === undefined) {\n throw new Error('useAuth must be used within an AuthProvider');\n }\n return context;\n}\n\n/**\n * The current business, or null when there is no provider above.\n *\n * For shared primitives, which cannot assume they are inside an AuthProvider:\n * the root layout mounts none, each section mounts its own, and `useAuth`\n * throws. A page frame that threw would take the whole screen white rather\n * than degrade, which is the failure /auth already shipped once.\n */\nexport function useOptionalTenant(): Tenant | null {\n return useContext(AuthContext)?.tenant ?? null;\n}\n"],"mappings":"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","names":[]}
@@ -7,8 +7,22 @@ export declare function formatPlanPrice(minor: string, currency: string, exponen
7
7
  /** NGN first, then the rest by code. */
8
8
  export declare const byCurrency: (a: string, b: string) => number;
9
9
  /**
10
- * The price lines to show: the one currency asked for when the plan has a
11
- * price in it, otherwise every currency it has, one line each.
10
+ * The one price to show for a plan whose prices are plain numbers in major
11
+ * units: naira when the plan has a naira price, then the preferred currency
12
+ * if it has one, then the first other currency by code. Never a conversion,
13
+ * and the currency comes back with the amount so it is never shown under
14
+ * another currency's sign.
15
+ */
16
+ export declare function pickPlanPrice(prices: Record<string, number> | null | undefined, preferred?: string | null): {
17
+ currency: string;
18
+ amount: number;
19
+ } | null;
20
+ /**
21
+ * The price lines to show. Asked for one currency (the single price a
22
+ * merchant sees): a plan with a naira price shows just that, else the
23
+ * currency asked for if the plan has it, else every currency it has. Asked
24
+ * for none, every currency it has, one line each, naira first. The exponent
25
+ * only applies to the currency asked for.
12
26
  */
13
27
  export declare function planPriceLines(prices: Prices, currency?: string | null, exponent?: number | null): string[];
14
28
  /** "₦15,000 a month", or "₦15,000 a month · $10 a month" across currencies. */
@@ -23,7 +23,15 @@ function formatPlanPrice(minor, currency, exponent) {
23
23
  return text.replace(/[.,]0+(?=\D*$)/, "");
24
24
  }
25
25
  const byCurrency = (a, b) => a === "NGN" ? -1 : b === "NGN" ? 1 : a.localeCompare(b);
26
+ function pickPlanPrice(prices, preferred) {
27
+ if (!prices) return null;
28
+ const code = prices["NGN"] !== void 0 ? "NGN" : preferred && prices[preferred] !== void 0 ? preferred : Object.keys(prices).sort((a, b) => a.localeCompare(b))[0];
29
+ return code === void 0 ? null : { currency: code, amount: prices[code] };
30
+ }
26
31
  function planPriceLines(prices, currency, exponent) {
32
+ if (currency && prices["NGN"] !== void 0) {
33
+ return [formatPlanPrice(prices["NGN"], "NGN", currency === "NGN" ? exponent : void 0)];
34
+ }
27
35
  if (currency && prices[currency] !== void 0) {
28
36
  return [formatPlanPrice(prices[currency], currency, exponent)];
29
37
  }
@@ -42,6 +50,7 @@ export {
42
50
  byCurrency,
43
51
  compareMinor,
44
52
  formatPlanPrice,
53
+ pickPlanPrice,
45
54
  planPriceLines,
46
55
  planPriceText
47
56
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/plan-price.ts"],"sourcesContent":["import { formatMinor } from \"./money-minor\";\nimport type { Prices } from \"./admin-pricing-api\";\n\n/**\n * Plan prices as people read them. Amounts stay minor-unit strings and are\n * split at the currency's exponent as text: never divided by 100, never\n * added across currencies.\n */\n\nconst fixed = (currency: string, exponent: number) =>\n new Intl.NumberFormat(\"en\", {\n style: \"currency\",\n currency,\n currencyDisplay: \"narrowSymbol\",\n minimumFractionDigits: exponent,\n maximumFractionDigits: exponent,\n });\n\n/**\n * \"₦15,000\" from \"1500000\" NGN. With the exponent the API sent, the digits\n * are split there; without one, Intl's own exponent for the code is used.\n */\nexport function formatPlanPrice(\n minor: string,\n currency: string,\n exponent?: number | null\n): string {\n if (exponent === undefined || exponent === null) {\n return formatMinor(minor, currency, { trimZeros: true });\n }\n\n const digits = minor.replace(/^0+(?=\\d)/, \"\").padStart(exponent + 1, \"0\");\n const whole = digits.slice(0, digits.length - exponent);\n const fraction = digits.slice(digits.length - exponent);\n const decimal = fraction ? `${whole}.${fraction}` : whole;\n let text: string;\n\n try {\n text = fixed(currency, exponent).format(decimal as unknown as number);\n } catch {\n text = `${currency} ${decimal}`;\n }\n\n return text.replace(/[.,]0+(?=\\D*$)/, \"\");\n}\n\n/** NGN first, then the rest by code. */\nexport const byCurrency = (a: string, b: string) =>\n a === \"NGN\" ? -1 : b === \"NGN\" ? 1 : a.localeCompare(b);\n\n/**\n * The price lines to show: the one currency asked for when the plan has a\n * price in it, otherwise every currency it has, one line each.\n */\nexport function planPriceLines(\n prices: Prices,\n currency?: string | null,\n exponent?: number | null\n): string[] {\n if (currency && prices[currency] !== undefined) {\n return [formatPlanPrice(prices[currency], currency, exponent)];\n }\n\n return Object.keys(prices)\n .sort(byCurrency)\n .map((code) => formatPlanPrice(prices[code], code));\n}\n\n/** \"₦15,000 a month\", or \"₦15,000 a month · $10 a month\" across currencies. */\nexport function planPriceText(\n prices: Prices,\n currency?: string | null,\n exponent?: number | null\n): string {\n const lines = planPriceLines(prices, currency, exponent);\n\n return lines.length ? lines.map((l) => `${l} a month`).join(\" · \") : \"No price\";\n}\n\n/**\n * Orders two minor-unit amounts as bigints: negative when `a` is cheaper.\n * A missing amount sorts after any amount.\n */\nexport function compareMinor(\n a: string | null | undefined,\n b: string | null | undefined\n): number {\n if (a == null || b == null) return a == null ? (b == null ? 0 : 1) : -1;\n\n const diff = BigInt(a) - BigInt(b);\n\n return diff < BigInt(0) ? -1 : diff > BigInt(0) ? 1 : 0;\n}\n"],"mappings":"AAAA,SAAS,mBAAmB;AAS5B,MAAM,QAAQ,CAAC,UAAkB,aAC/B,IAAI,KAAK,aAAa,MAAM;AAAA,EAC1B,OAAO;AAAA,EACP;AAAA,EACA,iBAAiB;AAAA,EACjB,uBAAuB;AAAA,EACvB,uBAAuB;AACzB,CAAC;AAMI,SAAS,gBACd,OACA,UACA,UACQ;AACR,MAAI,aAAa,UAAa,aAAa,MAAM;AAC/C,WAAO,YAAY,OAAO,UAAU,EAAE,WAAW,KAAK,CAAC;AAAA,EACzD;AAEA,QAAM,SAAS,MAAM,QAAQ,aAAa,EAAE,EAAE,SAAS,WAAW,GAAG,GAAG;AACxE,QAAM,QAAQ,OAAO,MAAM,GAAG,OAAO,SAAS,QAAQ;AACtD,QAAM,WAAW,OAAO,MAAM,OAAO,SAAS,QAAQ;AACtD,QAAM,UAAU,WAAW,GAAG,KAAK,IAAI,QAAQ,KAAK;AACpD,MAAI;AAEJ,MAAI;AACF,WAAO,MAAM,UAAU,QAAQ,EAAE,OAAO,OAA4B;AAAA,EACtE,QAAQ;AACN,WAAO,GAAG,QAAQ,IAAI,OAAO;AAAA,EAC/B;AAEA,SAAO,KAAK,QAAQ,kBAAkB,EAAE;AAC1C;AAGO,MAAM,aAAa,CAAC,GAAW,MACpC,MAAM,QAAQ,KAAK,MAAM,QAAQ,IAAI,EAAE,cAAc,CAAC;AAMjD,SAAS,eACd,QACA,UACA,UACU;AACV,MAAI,YAAY,OAAO,QAAQ,MAAM,QAAW;AAC9C,WAAO,CAAC,gBAAgB,OAAO,QAAQ,GAAG,UAAU,QAAQ,CAAC;AAAA,EAC/D;AAEA,SAAO,OAAO,KAAK,MAAM,EACtB,KAAK,UAAU,EACf,IAAI,CAAC,SAAS,gBAAgB,OAAO,IAAI,GAAG,IAAI,CAAC;AACtD;AAGO,SAAS,cACd,QACA,UACA,UACQ;AACR,QAAM,QAAQ,eAAe,QAAQ,UAAU,QAAQ;AAEvD,SAAO,MAAM,SAAS,MAAM,IAAI,CAAC,MAAM,GAAG,CAAC,UAAU,EAAE,KAAK,QAAK,IAAI;AACvE;AAMO,SAAS,aACd,GACA,GACQ;AACR,MAAI,KAAK,QAAQ,KAAK,KAAM,QAAO,KAAK,OAAQ,KAAK,OAAO,IAAI,IAAK;AAErE,QAAM,OAAO,OAAO,CAAC,IAAI,OAAO,CAAC;AAEjC,SAAO,OAAO,OAAO,CAAC,IAAI,KAAK,OAAO,OAAO,CAAC,IAAI,IAAI;AACxD;","names":[]}
1
+ {"version":3,"sources":["../src/plan-price.ts"],"sourcesContent":["import { formatMinor } from \"./money-minor\";\nimport type { Prices } from \"./admin-pricing-api\";\n\n/**\n * Plan prices as people read them. Amounts stay minor-unit strings and are\n * split at the currency's exponent as text: never divided by 100, never\n * added across currencies.\n */\n\nconst fixed = (currency: string, exponent: number) =>\n new Intl.NumberFormat(\"en\", {\n style: \"currency\",\n currency,\n currencyDisplay: \"narrowSymbol\",\n minimumFractionDigits: exponent,\n maximumFractionDigits: exponent,\n });\n\n/**\n * \"₦15,000\" from \"1500000\" NGN. With the exponent the API sent, the digits\n * are split there; without one, Intl's own exponent for the code is used.\n */\nexport function formatPlanPrice(\n minor: string,\n currency: string,\n exponent?: number | null\n): string {\n if (exponent === undefined || exponent === null) {\n return formatMinor(minor, currency, { trimZeros: true });\n }\n\n const digits = minor.replace(/^0+(?=\\d)/, \"\").padStart(exponent + 1, \"0\");\n const whole = digits.slice(0, digits.length - exponent);\n const fraction = digits.slice(digits.length - exponent);\n const decimal = fraction ? `${whole}.${fraction}` : whole;\n let text: string;\n\n try {\n text = fixed(currency, exponent).format(decimal as unknown as number);\n } catch {\n text = `${currency} ${decimal}`;\n }\n\n return text.replace(/[.,]0+(?=\\D*$)/, \"\");\n}\n\n/** NGN first, then the rest by code. */\nexport const byCurrency = (a: string, b: string) =>\n a === \"NGN\" ? -1 : b === \"NGN\" ? 1 : a.localeCompare(b);\n\n/**\n * The one price to show for a plan whose prices are plain numbers in major\n * units: naira when the plan has a naira price, then the preferred currency\n * if it has one, then the first other currency by code. Never a conversion,\n * and the currency comes back with the amount so it is never shown under\n * another currency's sign.\n */\nexport function pickPlanPrice(\n prices: Record<string, number> | null | undefined,\n preferred?: string | null\n): { currency: string; amount: number } | null {\n if (!prices) return null;\n\n const code =\n prices[\"NGN\"] !== undefined\n ? \"NGN\"\n : preferred && prices[preferred] !== undefined\n ? preferred\n : Object.keys(prices).sort((a, b) => a.localeCompare(b))[0];\n\n return code === undefined ? null : { currency: code, amount: prices[code] as number };\n}\n\n/**\n * The price lines to show. Asked for one currency (the single price a\n * merchant sees): a plan with a naira price shows just that, else the\n * currency asked for if the plan has it, else every currency it has. Asked\n * for none, every currency it has, one line each, naira first. The exponent\n * only applies to the currency asked for.\n */\nexport function planPriceLines(\n prices: Prices,\n currency?: string | null,\n exponent?: number | null\n): string[] {\n if (currency && prices[\"NGN\"] !== undefined) {\n return [formatPlanPrice(prices[\"NGN\"], \"NGN\", currency === \"NGN\" ? exponent : undefined)];\n }\n\n if (currency && prices[currency] !== undefined) {\n return [formatPlanPrice(prices[currency], currency, exponent)];\n }\n\n return Object.keys(prices)\n .sort(byCurrency)\n .map((code) => formatPlanPrice(prices[code], code));\n}\n\n/** \"₦15,000 a month\", or \"₦15,000 a month · $10 a month\" across currencies. */\nexport function planPriceText(\n prices: Prices,\n currency?: string | null,\n exponent?: number | null\n): string {\n const lines = planPriceLines(prices, currency, exponent);\n\n return lines.length ? lines.map((l) => `${l} a month`).join(\" · \") : \"No price\";\n}\n\n/**\n * Orders two minor-unit amounts as bigints: negative when `a` is cheaper.\n * A missing amount sorts after any amount.\n */\nexport function compareMinor(\n a: string | null | undefined,\n b: string | null | undefined\n): number {\n if (a == null || b == null) return a == null ? (b == null ? 0 : 1) : -1;\n\n const diff = BigInt(a) - BigInt(b);\n\n return diff < BigInt(0) ? -1 : diff > BigInt(0) ? 1 : 0;\n}\n"],"mappings":"AAAA,SAAS,mBAAmB;AAS5B,MAAM,QAAQ,CAAC,UAAkB,aAC/B,IAAI,KAAK,aAAa,MAAM;AAAA,EAC1B,OAAO;AAAA,EACP;AAAA,EACA,iBAAiB;AAAA,EACjB,uBAAuB;AAAA,EACvB,uBAAuB;AACzB,CAAC;AAMI,SAAS,gBACd,OACA,UACA,UACQ;AACR,MAAI,aAAa,UAAa,aAAa,MAAM;AAC/C,WAAO,YAAY,OAAO,UAAU,EAAE,WAAW,KAAK,CAAC;AAAA,EACzD;AAEA,QAAM,SAAS,MAAM,QAAQ,aAAa,EAAE,EAAE,SAAS,WAAW,GAAG,GAAG;AACxE,QAAM,QAAQ,OAAO,MAAM,GAAG,OAAO,SAAS,QAAQ;AACtD,QAAM,WAAW,OAAO,MAAM,OAAO,SAAS,QAAQ;AACtD,QAAM,UAAU,WAAW,GAAG,KAAK,IAAI,QAAQ,KAAK;AACpD,MAAI;AAEJ,MAAI;AACF,WAAO,MAAM,UAAU,QAAQ,EAAE,OAAO,OAA4B;AAAA,EACtE,QAAQ;AACN,WAAO,GAAG,QAAQ,IAAI,OAAO;AAAA,EAC/B;AAEA,SAAO,KAAK,QAAQ,kBAAkB,EAAE;AAC1C;AAGO,MAAM,aAAa,CAAC,GAAW,MACpC,MAAM,QAAQ,KAAK,MAAM,QAAQ,IAAI,EAAE,cAAc,CAAC;AASjD,SAAS,cACd,QACA,WAC6C;AAC7C,MAAI,CAAC,OAAQ,QAAO;AAEpB,QAAM,OACJ,OAAO,KAAK,MAAM,SACd,QACA,aAAa,OAAO,SAAS,MAAM,SACjC,YACA,OAAO,KAAK,MAAM,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC;AAEhE,SAAO,SAAS,SAAY,OAAO,EAAE,UAAU,MAAM,QAAQ,OAAO,IAAI,EAAY;AACtF;AASO,SAAS,eACd,QACA,UACA,UACU;AACV,MAAI,YAAY,OAAO,KAAK,MAAM,QAAW;AAC3C,WAAO,CAAC,gBAAgB,OAAO,KAAK,GAAG,OAAO,aAAa,QAAQ,WAAW,MAAS,CAAC;AAAA,EAC1F;AAEA,MAAI,YAAY,OAAO,QAAQ,MAAM,QAAW;AAC9C,WAAO,CAAC,gBAAgB,OAAO,QAAQ,GAAG,UAAU,QAAQ,CAAC;AAAA,EAC/D;AAEA,SAAO,OAAO,KAAK,MAAM,EACtB,KAAK,UAAU,EACf,IAAI,CAAC,SAAS,gBAAgB,OAAO,IAAI,GAAG,IAAI,CAAC;AACtD;AAGO,SAAS,cACd,QACA,UACA,UACQ;AACR,QAAM,QAAQ,eAAe,QAAQ,UAAU,QAAQ;AAEvD,SAAO,MAAM,SAAS,MAAM,IAAI,CAAC,MAAM,GAAG,CAAC,UAAU,EAAE,KAAK,QAAK,IAAI;AACvE;AAMO,SAAS,aACd,GACA,GACQ;AACR,MAAI,KAAK,QAAQ,KAAK,KAAM,QAAO,KAAK,OAAQ,KAAK,OAAO,IAAI,IAAK;AAErE,QAAM,OAAO,OAAO,CAAC,IAAI,OAAO,CAAC;AAEjC,SAAO,OAAO,OAAO,CAAC,IAAI,KAAK,OAAO,OAAO,CAAC,IAAI,IAAI;AACxD;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withpotter/lib",
3
- "version": "0.13.0-next.85",
3
+ "version": "0.13.0-next.87",
4
4
  "description": "Box dashboard foundation: API client, contexts, types, intent config, and utilities.",
5
5
  "license": "UNLICENSED",
6
6
  "sideEffects": false,
@@ -51,7 +51,7 @@
51
51
  "typescript": "^5"
52
52
  },
53
53
  "publishConfig": {
54
- "registry": "https://registry.npmjs.org",
54
+ "registry": "https://registry.npmjs.org/",
55
55
  "access": "restricted"
56
56
  }
57
57
  }