@spree/dashboard-core 1.0.0-rc.2 → 1.0.0-rc.4

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.
Files changed (68) hide show
  1. package/dist/vite/discover.js +5 -2
  2. package/package.json +13 -16
  3. package/src/api-client.ts +22 -34
  4. package/src/components/address-form-dialog.test.ts +4 -4
  5. package/src/components/address-form-dialog.tsx +10 -10
  6. package/src/components/app-shell.tsx +12 -1
  7. package/src/components/app-sidebar.tsx +18 -2
  8. package/src/components/country-combobox.tsx +52 -29
  9. package/src/components/currency-select.tsx +18 -13
  10. package/src/components/file-upload-field.tsx +4 -4
  11. package/src/components/import-button.tsx +4 -1
  12. package/src/components/import-wizard-dialog.tsx +8 -11
  13. package/src/components/mobile-top-bar.tsx +6 -0
  14. package/src/components/money.tsx +23 -0
  15. package/src/components/preferences-form.tsx +263 -54
  16. package/src/components/resource-combobox.tsx +5 -0
  17. package/src/components/resource-picker-sheet.tsx +18 -12
  18. package/src/components/resource-table.tsx +7 -3
  19. package/src/components/table-toolbar.tsx +8 -5
  20. package/src/hooks/use-display-name.ts +11 -3
  21. package/src/hooks/use-file-upload.ts +51 -0
  22. package/src/hooks/use-import.ts +5 -5
  23. package/src/hooks/use-money-locale.ts +14 -0
  24. package/src/index.ts +8 -3
  25. package/src/lib/currencies.ts +32 -13
  26. package/src/lib/form-mappers.ts +25 -5
  27. package/src/lib/formatters.ts +34 -4
  28. package/src/lib/fulfillment-items.test.ts +2 -1
  29. package/src/lib/fulfillment-items.ts +9 -3
  30. package/src/lib/i18n.ts +4 -4
  31. package/src/lib/iso-codes.ts +47 -0
  32. package/src/lib/money.test.ts +63 -0
  33. package/src/lib/money.ts +99 -0
  34. package/src/{products → lib}/normalize-money.ts +2 -2
  35. package/src/lib/permissions.ts +2 -0
  36. package/src/lib/return-refunds.test.ts +62 -0
  37. package/src/lib/return-refunds.ts +70 -0
  38. package/src/lib/route-registry.ts +58 -15
  39. package/src/lib/settings-nav-registry.test.ts +34 -0
  40. package/src/lib/settings-nav-registry.ts +21 -4
  41. package/src/lib/slot-registry.ts +10 -0
  42. package/src/lib/storefront.ts +2 -2
  43. package/src/locales/ar.json +7 -2
  44. package/src/locales/de.json +7 -2
  45. package/src/locales/en.json +7 -2
  46. package/src/locales/es.json +7 -2
  47. package/src/locales/fr.json +7 -2
  48. package/src/locales/pl.json +7 -2
  49. package/src/locales/zh-CN.json +7 -2
  50. package/src/plugin.ts +5 -5
  51. package/src/products/bulk-variants-dialog.tsx +2 -8
  52. package/src/products/inventory-section.tsx +2 -2
  53. package/src/products/product-bulk-price-editor.tsx +18 -29
  54. package/src/products/product-form-cards.tsx +12 -8
  55. package/src/products/product-form-mapping.ts +15 -11
  56. package/src/products/product-schema.ts +6 -8
  57. package/src/products/product-types.ts +5 -3
  58. package/src/products/use-product-form-data.ts +0 -28
  59. package/src/products/variants-options-builder.tsx +30 -27
  60. package/src/products/variants-section.tsx +31 -25
  61. package/src/providers/store-provider.tsx +9 -9
  62. package/src/schemas/stock-location.ts +3 -2
  63. package/src/tables/package-types.tsx +1 -1
  64. package/src/tables/stock-locations.tsx +1 -1
  65. package/src/vite/discover.ts +19 -2
  66. package/src/hooks/use-direct-upload.ts +0 -117
  67. /package/src/{products → lib}/currency-parts.ts +0 -0
  68. /package/src/{products → lib}/normalize-money.test.ts +0 -0
@@ -0,0 +1,99 @@
1
+ import { compareMoney, decimalPlaces, isDecimalString, sumMoney } from '@spree/admin-sdk'
2
+
3
+ /** Whether an amount is a decimal string above zero. Blank or malformed is not. */
4
+ export function isPositiveMoney(value: string | null | undefined): boolean {
5
+ return isDecimalString(value) && compareMoney(value, '0') > 0
6
+ }
7
+
8
+ /**
9
+ * Moves the decimal point of a decimal string by whole places, exactly, and
10
+ * writes the result without trailing zeros ("0.077" shifted by 2 is "7.7").
11
+ * Anything that is not a decimal string comes back unchanged.
12
+ */
13
+ export function shiftDecimalPoint(value: string, places: number): string {
14
+ if (!isDecimalString(value)) return value
15
+
16
+ const negative = value.startsWith('-')
17
+ const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.')
18
+ let digits = whole + fraction
19
+ let point = whole.length + places
20
+ if (point < 0) {
21
+ digits = '0'.repeat(-point) + digits
22
+ point = 0
23
+ }
24
+ digits = digits.padEnd(point, '0')
25
+
26
+ const integer = digits.slice(0, point).replace(/^0+(?=\d)/, '') || '0'
27
+ const decimals = digits.slice(point).replace(/0+$/, '')
28
+ const text = decimals ? `${integer}.${decimals}` : integer
29
+ return negative && /[1-9]/.test(text) ? `-${text}` : text
30
+ }
31
+
32
+ /** A fraction rate ("0.23") as the percentage a merchant reads ("23"). Blank stays blank. */
33
+ export function fractionToPercent(fraction: string | null | undefined): string {
34
+ return fraction == null || fraction === '' ? '' : shiftDecimalPoint(fraction, 2)
35
+ }
36
+
37
+ /** A typed percentage ("23") as the fraction rate the API stores ("0.23"). Blank stays blank. */
38
+ export function percentToFraction(percent: string | null | undefined): string {
39
+ return percent == null || percent === '' ? '' : shiftDecimalPoint(percent.trim(), -2)
40
+ }
41
+
42
+ function scaled(value: string): { units: bigint; scale: number } {
43
+ const negative = value.startsWith('-')
44
+ const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.')
45
+ const units = BigInt(whole + fraction)
46
+ return { units: negative ? -units : units, scale: fraction.length }
47
+ }
48
+
49
+ /**
50
+ * numerator / denominator, where the numerator carries `scale` decimals,
51
+ * rounded half away from zero to the currency's decimals.
52
+ */
53
+ function roundToCurrency(
54
+ numerator: bigint,
55
+ scale: number,
56
+ denominator: bigint,
57
+ currency: string,
58
+ ): string {
59
+ const places = decimalPlaces(currency)
60
+ const negative = numerator < 0n !== denominator < 0n
61
+ let dividend = numerator < 0n ? -numerator : numerator
62
+ let divisor = denominator < 0n ? -denominator : denominator
63
+ if (scale > places) divisor *= 10n ** BigInt(scale - places)
64
+ else dividend *= 10n ** BigInt(places - scale)
65
+
66
+ let units = dividend / divisor
67
+ if ((dividend % divisor) * 2n >= divisor) units += 1n
68
+
69
+ const text = shiftDecimalPoint((negative ? -units : units).toString(), -places)
70
+ return sumMoney([text], currency)
71
+ }
72
+
73
+ /**
74
+ * A percentage of an amount, exactly, rounded half away from zero to the
75
+ * currency's decimals ("10" percent of "19.99" USD is "2.00").
76
+ */
77
+ export function percentOf(amount: string, percent: string, currency: string): string {
78
+ const left = scaled(amount)
79
+ const right = scaled(percent)
80
+ // The percentage is a hundredth, so the product carries two more places.
81
+ return roundToCurrency(left.units * right.units, left.scale + right.scale + 2, 1n, currency)
82
+ }
83
+
84
+ /**
85
+ * The share of an amount that `part` of `whole` units carries ("10.00" for 1
86
+ * of 3 is "3.33"), rounded half away from zero to the currency's decimals.
87
+ */
88
+ export function prorateMoney(
89
+ amount: string,
90
+ part: number,
91
+ whole: number,
92
+ currency: string,
93
+ ): string {
94
+ if (!Number.isInteger(part) || !Number.isInteger(whole) || whole === 0) {
95
+ throw new Error(`Cannot prorate ${part} of ${whole}`)
96
+ }
97
+ const { units, scale } = scaled(amount)
98
+ return roundToCurrency(units * BigInt(part), scale, BigInt(whole), currency)
99
+ }
@@ -4,8 +4,8 @@
4
4
  // dashboard converts here, in the browser, and never relies on the server to
5
5
  // parse comma-vs-period. See docs/plans/5.5-client-side-money-normalization.md.
6
6
  //
7
- // The locale is the currency's display locale (its market locale, e.g. EUR →
8
- // `de`), so the same locale drives both display formatting and this parse.
7
+ // The locale is the admin's own (`useMoneyLocale`), the same one the field is
8
+ // displayed in, so a value reads back exactly as it was shown.
9
9
 
10
10
  /**
11
11
  * Derives a locale's decimal and group separators via `Intl`. `fr` groups with
@@ -20,6 +20,7 @@ export const Subject = {
20
20
  CustomerGroup: 'customer_group',
21
21
  AdminUser: 'admin_user',
22
22
  ApiKey: 'api_key',
23
+ OauthApplication: 'oauth_application',
23
24
  AllowedOrigin: 'allowed_origin',
24
25
  Store: 'store',
25
26
  // The store's packaging vocabulary: the box it ships parcels in, and the
@@ -45,6 +46,7 @@ export const Subject = {
45
46
  ClaimReason: 'claim_reason',
46
47
  RefundReason: 'refund_reason',
47
48
  OrderCancellationReason: 'order_cancellation_reason',
49
+ TaxExemptionReason: 'tax_exemption_reason',
48
50
  CustomFieldDefinition: 'custom_field_definition',
49
51
  Integration: 'integration',
50
52
  PaymentMethod: 'payment_method',
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { paidForUnits, returnOwesNothing, returnRefundSummary } from './return-refunds'
3
+
4
+ const figures = {
5
+ status: 'received',
6
+ refund_total: '20.00',
7
+ refunded_total: '0.00',
8
+ }
9
+
10
+ describe('returnRefundSummary', () => {
11
+ it('reports what is owed until money goes back', () => {
12
+ expect(returnRefundSummary(figures, 'USD', 'en')).toEqual({ kind: 'owed', amount: '$20.00' })
13
+ })
14
+
15
+ it('names the full amount when less went back than was owed', () => {
16
+ expect(
17
+ returnRefundSummary(
18
+ {
19
+ ...figures,
20
+ status: 'refunded',
21
+ refunded_total: '15.00',
22
+ },
23
+ 'USD',
24
+ 'en',
25
+ ),
26
+ ).toEqual({ kind: 'refunded_short', amount: '$15.00', total: '$20.00' })
27
+ })
28
+
29
+ it('reports a full refund', () => {
30
+ expect(
31
+ returnRefundSummary(
32
+ {
33
+ ...figures,
34
+ status: 'refunded',
35
+ refunded_total: '20.00',
36
+ },
37
+ 'USD',
38
+ 'en',
39
+ ),
40
+ ).toEqual({ kind: 'refunded', amount: '$20.00' })
41
+ })
42
+ })
43
+
44
+ describe('returnOwesNothing', () => {
45
+ it('is true only for a zero amount', () => {
46
+ expect(returnOwesNothing('0.00')).toBe(true)
47
+ expect(returnOwesNothing('0.01')).toBe(false)
48
+ })
49
+ })
50
+
51
+ describe('paidForUnits', () => {
52
+ it('shares the paid amount and tax across the chosen units', () => {
53
+ const line = { quantity: 3, discounted_amount: '9.00', additional_tax_total: '1.00' }
54
+ expect(paidForUnits(line, 1, 'USD')).toBe('3.33')
55
+ expect(paidForUnits(line, 3, 'USD')).toBe('10.00')
56
+ })
57
+
58
+ it('is blank when nothing is chosen or the amount is unknown', () => {
59
+ expect(paidForUnits({ quantity: 2, discounted_amount: '5.00' }, 0, 'USD')).toBe('')
60
+ expect(paidForUnits({ quantity: 2, discounted_amount: null }, 1, 'USD')).toBe('')
61
+ })
62
+ })
@@ -0,0 +1,70 @@
1
+ import { compareMoney, isDecimalString, isZeroMoney, sumMoney } from '@spree/admin-sdk'
2
+ import { formatMoney } from './formatters'
3
+ import { prorateMoney } from './money'
4
+
5
+ /** True when a received return is owed nothing, so completing it moves no money. */
6
+ export function returnOwesNothing(refundableTotal: string): boolean {
7
+ return isZeroMoney(refundableTotal)
8
+ }
9
+
10
+ export type ReturnRefundFigures = {
11
+ status: string
12
+ refund_total: string
13
+ refunded_total: string
14
+ }
15
+
16
+ /**
17
+ * What a return's card reports: what it is owed until money goes back, then
18
+ * what actually went back — which a merchant keeping a restocking fee makes
19
+ * less than it was owed, so that case also names the full amount. Money that
20
+ * went back counts even before the return is marked refunded: a refund that
21
+ * succeeded on one payment and was declined on the next leaves it received.
22
+ *
23
+ * A return carries no currency of its own, so the caller passes its order's.
24
+ * Without one the amounts come back as the API wrote them.
25
+ */
26
+ export function returnRefundSummary(
27
+ returnRecord: ReturnRefundFigures,
28
+ currency?: string,
29
+ locale?: string,
30
+ ):
31
+ | { kind: 'owed'; amount: string }
32
+ | { kind: 'refunded'; amount: string }
33
+ | { kind: 'refunded_short'; amount: string; total: string } {
34
+ const format = (amount: string) => (currency ? formatMoney(amount, currency, locale) : amount)
35
+
36
+ if (returnRecord.status !== 'refunded' && isZeroMoney(returnRecord.refunded_total)) {
37
+ return { kind: 'owed', amount: format(returnRecord.refund_total) }
38
+ }
39
+
40
+ if (compareMoney(returnRecord.refunded_total || '0', returnRecord.refund_total || '0') < 0) {
41
+ return {
42
+ kind: 'refunded_short',
43
+ amount: format(returnRecord.refunded_total),
44
+ total: format(returnRecord.refund_total),
45
+ }
46
+ }
47
+
48
+ return { kind: 'refunded', amount: format(returnRecord.refunded_total) }
49
+ }
50
+
51
+ /**
52
+ * What the customer paid, tax included, for `quantity` units of a line — the
53
+ * refund a claim offers until the merchant types their own. Blank when the
54
+ * line's amounts are unknown.
55
+ */
56
+ export function paidForUnits(
57
+ line: {
58
+ quantity: number
59
+ discounted_amount?: string | null
60
+ additional_tax_total?: string | null
61
+ },
62
+ quantity: number,
63
+ currency: string,
64
+ ): string {
65
+ if (quantity <= 0 || line.quantity <= 0 || !isDecimalString(line.discounted_amount)) return ''
66
+
67
+ const tax = isDecimalString(line.additional_tax_total) ? line.additional_tax_total : '0'
68
+ const paid = sumMoney([line.discounted_amount, tax])
69
+ return prorateMoney(paid, quantity, line.quantity, currency)
70
+ }
@@ -3,22 +3,34 @@ import { useSyncExternalStore } from 'react'
3
3
  import type { SubjectName } from './permissions'
4
4
 
5
5
  /**
6
- * Dashboard route contributed by a plugin. Mounted under the dashboard's
7
- * `/_authenticated/$storeId/_plugins/$` catch-all, which reads this registry
8
- * at render time and dispatches based on the splat path.
6
+ * Where a registry route mounts:
7
+ *
8
+ * - `store` (default) — inside a store, under `/$storeId`, with the store's
9
+ * sidebar and top bar.
10
+ * - `authenticated` — at the root, for signed-in users, outside any store
11
+ * (account or onboarding pages). Signed-out visitors go to the login page.
12
+ * - `public` — at the root, with no sign-in required (a sign-up or terms page).
13
+ */
14
+ export type RouteScope = 'store' | 'authenticated' | 'public'
15
+
16
+ /**
17
+ * Dashboard route contributed by a plugin. Store routes mount under the
18
+ * dashboard's `/_authenticated/$storeId/$` catch-all, which reads this
19
+ * registry at render time and dispatches based on the splat path.
9
20
  *
10
21
  * Path patterns use TanStack-Router-style param tokens (`$brandId`) inside the
11
22
  * plugin's namespace. The dashboard strips the `/$storeId/` prefix before
12
23
  * matching, so a plugin pattern of `/brands/$brandId` matches the URL
13
24
  * `/store_xyz/brands/br_abc123` with `params: { brandId: 'br_abc123' }`.
14
25
  */
15
- export interface RouteEntry {
26
+ export interface StoreRouteEntry {
16
27
  /**
17
28
  * Stable identifier — used for register/remove/update and as the React key.
18
29
  * Mirrors the slug a plugin would use in its `nav.add({ key })` call so the
19
30
  * sidebar entry and route stay paired.
20
31
  */
21
32
  key: string
33
+ scope?: 'store'
22
34
  /**
23
35
  * Path pattern relative to `/$storeId`. Examples: `/brands`,
24
36
  * `/brands/$brandId`, `/wishlists/$wishlistId/items`.
@@ -30,7 +42,7 @@ export interface RouteEntry {
30
42
  * The component to render. Receives:
31
43
  * - `params`: extracted path params (e.g. matching `/brands/$brandId`
32
44
  * against `/brands/br_abc` yields `{ brandId: 'br_abc' }`)
33
- * - `storeId`: the current store — every plugin route is implicitly scoped
45
+ * - `storeId`: the current store — every store route is implicitly scoped
34
46
  * under `/$storeId/...`
35
47
  * - `searchParams`: the URL search-state object from TanStack Router. Use
36
48
  * this with `<ResourceTable searchParams={searchParams} ... />` so
@@ -49,6 +61,27 @@ export interface RouteEntry {
49
61
  subject?: SubjectName
50
62
  }
51
63
 
64
+ /**
65
+ * Route mounted at the dashboard's root, outside any store. Unlike store
66
+ * routes these become real router routes when `createDashboardRouter` runs,
67
+ * so they must be registered before it — `plugins.ts` and installed plugins
68
+ * always are.
69
+ */
70
+ export interface RootRouteEntry {
71
+ key: string
72
+ scope: 'authenticated' | 'public'
73
+ /** Full URL path, e.g. `/sign-up` or `/account/$section`. Must start with `/`. */
74
+ path: string
75
+ component: ComponentType<{
76
+ params: Record<string, string>
77
+ searchParams: Record<string, unknown>
78
+ }>
79
+ }
80
+
81
+ export type RouteEntry = StoreRouteEntry | RootRouteEntry
82
+
83
+ const ROUTE_SCOPES: readonly RouteScope[] = ['store', 'authenticated', 'public']
84
+
52
85
  const entries: RouteEntry[] = []
53
86
  const listeners = new Set<() => void>()
54
87
 
@@ -63,10 +96,6 @@ function subscribe(listener: () => void): () => void {
63
96
  }
64
97
  }
65
98
 
66
- function getSnapshot(): readonly RouteEntry[] {
67
- return entries
68
- }
69
-
70
99
  interface RouteMutator {
71
100
  /** Register a route. Throws if `key` is already registered. */
72
101
  add(entry: RouteEntry): void
@@ -82,6 +111,11 @@ export const pluginRoutes: RouteMutator = {
82
111
  if (!entry.path.startsWith('/')) {
83
112
  throw new Error(`Plugin route "${entry.key}" path must start with "/", got "${entry.path}".`)
84
113
  }
114
+ if (entry.scope !== undefined && !ROUTE_SCOPES.includes(entry.scope)) {
115
+ throw new Error(
116
+ `Plugin route "${entry.key}" scope must be one of ${ROUTE_SCOPES.join(', ')}, got "${entry.scope}".`,
117
+ )
118
+ }
85
119
  entries.push(entry)
86
120
  notify()
87
121
  },
@@ -93,17 +127,22 @@ export const pluginRoutes: RouteMutator = {
93
127
  },
94
128
  }
95
129
 
130
+ /** Every registered route, read outside React. */
131
+ export function getPluginRoutes(): readonly RouteEntry[] {
132
+ return entries
133
+ }
134
+
96
135
  /**
97
136
  * Subscribe to plugin route updates. Returns the full list of registered
98
137
  * routes; the catch-all renderer matches against this on every navigation.
99
138
  */
100
139
  export function usePluginRoutes(): readonly RouteEntry[] {
101
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
140
+ return useSyncExternalStore(subscribe, getPluginRoutes, getPluginRoutes)
102
141
  }
103
142
 
104
143
  /**
105
144
  * Match a URL splat (the bit after `/$storeId/`) against the registered
106
- * routes. Returns the matched entry plus the extracted path params, or
145
+ * store routes. Returns the matched entry plus the extracted path params, or
107
146
  * `null` if nothing matched.
108
147
  *
109
148
  * Path patterns support TanStack-style `$param` tokens. A pattern segment
@@ -117,12 +156,12 @@ export function usePluginRoutes(): readonly RouteEntry[] {
117
156
  export function matchPluginRoute(
118
157
  splat: string,
119
158
  routes: readonly RouteEntry[],
120
- ): { entry: RouteEntry; params: Record<string, string> } | null {
159
+ ): { entry: StoreRouteEntry; params: Record<string, string> } | null {
121
160
  // Normalize: strip leading slash, drop empty trailing segments.
122
161
  const urlSegments = splat.replace(/^\/+/, '').split('/').filter(Boolean)
123
- const bySpecificity = [...routes].sort(
124
- (a, b) => countDynamicSegments(a.path) - countDynamicSegments(b.path),
125
- )
162
+ const bySpecificity = routes
163
+ .filter(isStoreRoute)
164
+ .sort((a, b) => countDynamicSegments(a.path) - countDynamicSegments(b.path))
126
165
  for (const entry of bySpecificity) {
127
166
  const patternSegments = entry.path.replace(/^\/+/, '').split('/').filter(Boolean)
128
167
  if (patternSegments.length !== urlSegments.length) continue
@@ -144,6 +183,10 @@ export function matchPluginRoute(
144
183
  return null
145
184
  }
146
185
 
186
+ function isStoreRoute(entry: RouteEntry): entry is StoreRouteEntry {
187
+ return entry.scope === undefined || entry.scope === 'store'
188
+ }
189
+
147
190
  function countDynamicSegments(path: string): number {
148
191
  return path
149
192
  .replace(/^\/+/, '')
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { canSeeSettingsEntry, type SettingsNavEntry } from './settings-nav-registry'
3
+
4
+ const entry = (overrides: Partial<SettingsNavEntry>): SettingsNavEntry => ({
5
+ key: 'settings.example',
6
+ label: 'Example',
7
+ path: '/example',
8
+ group: 'store',
9
+ ...overrides,
10
+ })
11
+
12
+ const allowing =
13
+ (...subjects: string[]) =>
14
+ (_action: string, subject: string) =>
15
+ subjects.includes(subject)
16
+
17
+ describe('canSeeSettingsEntry', () => {
18
+ it('shows an entry without a subject to everyone', () => {
19
+ expect(canSeeSettingsEntry(entry({}), allowing())).toBe(true)
20
+ })
21
+
22
+ it('checks a single subject', () => {
23
+ expect(canSeeSettingsEntry(entry({ subject: 'tax_rate' }), allowing('tax_rate'))).toBe(true)
24
+ expect(canSeeSettingsEntry(entry({ subject: 'tax_rate' }), allowing())).toBe(false)
25
+ })
26
+
27
+ // A page of tabs is reachable through any one of them.
28
+ it('shows an entry with several subjects when any one is allowed', () => {
29
+ const taxes = entry({ subjects: ['tax_category', 'tax_rate', 'tax_exemption_reason'] })
30
+
31
+ expect(canSeeSettingsEntry(taxes, allowing('tax_exemption_reason'))).toBe(true)
32
+ expect(canSeeSettingsEntry(taxes, allowing('product'))).toBe(false)
33
+ })
34
+ })
@@ -60,6 +60,12 @@ export interface SettingsNavEntry {
60
60
  position?: number
61
61
  /** CanCanCan subject required to see this item. Omit for always-visible. */
62
62
  subject?: SubjectName
63
+ /**
64
+ * For a page holding several resources, as tabs: the entry is shown when
65
+ * `action` is allowed on any of these, so a role reaching one tab still
66
+ * finds the page. Takes precedence over `subject`.
67
+ */
68
+ subjects?: SubjectName[]
63
69
  /**
64
70
  * Action checked against `subject`. Defaults to `'read'`. Settings pages
65
71
  * that only exist to *change* something (store settings, emails) should
@@ -220,6 +226,19 @@ export function settingsEntryMatches(
220
226
  return haystack.some((value) => value?.toLowerCase().includes(needle))
221
227
  }
222
228
 
229
+ /**
230
+ * Whether a staff member may reach this settings entry. The sidebar, the
231
+ * settings landing page and the command palette all ask this, so they agree.
232
+ */
233
+ export function canSeeSettingsEntry(
234
+ entry: SettingsNavEntry,
235
+ can: (action: string, subject: string) => boolean,
236
+ ): boolean {
237
+ const action = entry.action ?? 'read'
238
+ if (entry.subjects?.length) return entry.subjects.some((subject) => can(action, subject))
239
+ return !entry.subject || can(action, entry.subject)
240
+ }
241
+
223
242
  /**
224
243
  * Drop entries the current staff member cannot reach. Exported because the
225
244
  * sidebar and the settings landing page must agree on what a role can see —
@@ -231,7 +250,7 @@ export function filterSettingsByPermissions(
231
250
  permissions: { can: (action: string, subject: string) => boolean },
232
251
  ): SettingsNavSnapshot {
233
252
  return regroup(snapshot, (entry) =>
234
- !entry.subject ? true : permissions.can(entry.action ?? 'read', entry.subject),
253
+ canSeeSettingsEntry(entry, (action, subject) => permissions.can(action, subject)),
235
254
  )
236
255
  }
237
256
 
@@ -265,9 +284,7 @@ export function hasVisibleSettingsEntries(permissions: unknown): boolean {
265
284
  ?.can
266
285
  if (typeof can !== 'function') return false
267
286
 
268
- return getSnapshot().all.some(
269
- (entry) => !entry.subject || can(entry.action ?? 'read', entry.subject),
270
- )
287
+ return getSnapshot().all.some((entry) => canSeeSettingsEntry(entry, can))
271
288
  }
272
289
 
273
290
  /** Test-only: clear the registry. */
@@ -44,6 +44,16 @@ export interface NoStoreAccessSlotContext {
44
44
  signOut: () => Promise<void>
45
45
  }
46
46
 
47
+ /**
48
+ * The page a signed-in user lands on at `/`, before entering a store. By
49
+ * default it opens their first store, or shows the `no_store_access` screen
50
+ * when they have none. Registering an entry replaces the whole page — for
51
+ * example with a list of stores to choose from and a way to create one.
52
+ */
53
+ export const STORE_SELECTION_SLOT = 'store_selection'
54
+
55
+ export type StoreSelectionSlotContext = NoStoreAccessSlotContext
56
+
47
57
  // ============================================================================
48
58
  // Registry — module-singleton keyed by slot name
49
59
  // ============================================================================
@@ -5,14 +5,14 @@ import type { Store } from '@spree/admin-sdk'
5
5
  * or `null` when no storefront URL is configured.
6
6
  *
7
7
  * The serialized `store.url` always resolves — the backend falls back to the
8
- * store's formatted URL — so gate on the explicit `preferred_storefront_url`
8
+ * store's formatted URL — so gate on the explicit `storefront_url`
9
9
  * preference: only return a link once a real storefront has been set.
10
10
  *
11
11
  * @param store the current store, or null/undefined while it loads
12
12
  * @returns an absolute URL, or null when the store has no configured storefront
13
13
  */
14
14
  export function storefrontHref(store: Store | null | undefined): string | null {
15
- if (!store?.preferred_storefront_url) return null
15
+ if (!store?.storefront_url) return null
16
16
 
17
17
  // Best-effort: prefix with https if the URL is just a hostname.
18
18
  return /^https?:\/\//.test(store.url) ? store.url : `https://${store.url}`
@@ -183,7 +183,9 @@
183
183
  "searching": "جارٍ البحث…",
184
184
  "select_placeholder": "اختر…",
185
185
  "unsaved_changes": "لديك تغييرات غير محفوظة.",
186
- "yes": "نعم"
186
+ "yes": "نعم",
187
+ "done": "تم",
188
+ "error": "حدث خطأ ما."
187
189
  },
188
190
  "components": {
189
191
  "address_block": {
@@ -1463,11 +1465,13 @@
1463
1465
  "seeded_report_read_only": "هذا تقرير مدمج ولا يمكن تغييره. احفظ نسخة بدلاً من ذلك.",
1464
1466
  "taken": "مستخدم بالفعل",
1465
1467
  "too_long": "طويل جدًا (الحد الأقصى {{count}} حرفًا)",
1468
+ "too_many_decimals": "يحتوي على أكثر من {{count}} منازل عشرية",
1466
1469
  "too_short": "قصير جدًا (الحد الأدنى {{count}} حرفًا)",
1467
1470
  "wrong_length": "الطول غير صحيح (يجب أن يكون {{count}} حرفًا)"
1468
1471
  },
1469
1472
  "country_required": "البلد مطلوب",
1470
1473
  "currency_required": "العملة مطلوبة",
1474
+ "locale_required": "اللغة مطلوبة",
1471
1475
  "first_name_required": "الاسم الأول مطلوب",
1472
1476
  "invalid_email": "أدخل بريدًا إلكترونيًا صالحًا",
1473
1477
  "invalid_email_or_password": "بريد إلكتروني أو كلمة مرور غير صالحة",
@@ -1479,7 +1483,8 @@
1479
1483
  "passwords_dont_match": "كلمتا المرور غير متطابقتين",
1480
1484
  "positive_number": "يجب أن يكون {{field}} أكبر من 0",
1481
1485
  "required": "{{field}} مطلوب",
1482
- "store_name_required": "اسم المتجر مطلوب"
1486
+ "store_name_required": "اسم المتجر مطلوب",
1487
+ "too_many_decimals": "يمكن أن يحتوي {{field}} على {{count}} منازل عشرية كحد أقصى"
1483
1488
  },
1484
1489
  "products": {
1485
1490
  "autocomplete": {
@@ -183,7 +183,9 @@
183
183
  "searching": "Wird gesucht…",
184
184
  "select_placeholder": "Auswählen…",
185
185
  "unsaved_changes": "Sie haben ungespeicherte Änderungen.",
186
- "yes": "Ja"
186
+ "yes": "Ja",
187
+ "done": "Fertig",
188
+ "error": "Etwas ist schiefgelaufen."
187
189
  },
188
190
  "components": {
189
191
  "address_block": {
@@ -1463,11 +1465,13 @@
1463
1465
  "seeded_report_read_only": "Dies ist ein integrierter Bericht und kann nicht geändert werden. Speichern Sie stattdessen eine Kopie.",
1464
1466
  "taken": "ist bereits vergeben",
1465
1467
  "too_long": "ist zu lang (maximal {{count}} Zeichen)",
1468
+ "too_many_decimals": "hat mehr als {{count}} Nachkommastellen",
1466
1469
  "too_short": "ist zu kurz (mindestens {{count}} Zeichen)",
1467
1470
  "wrong_length": "hat die falsche Länge (sollte {{count}} Zeichen haben)"
1468
1471
  },
1469
1472
  "country_required": "Land ist erforderlich",
1470
1473
  "currency_required": "Währung ist erforderlich",
1474
+ "locale_required": "Sprache ist erforderlich",
1471
1475
  "first_name_required": "Vorname ist erforderlich",
1472
1476
  "invalid_email": "Geben Sie eine gültige E-Mail-Adresse ein",
1473
1477
  "invalid_email_or_password": "Ungültige E-Mail-Adresse oder ungültiges Passwort",
@@ -1479,7 +1483,8 @@
1479
1483
  "passwords_dont_match": "Die Passwörter stimmen nicht überein",
1480
1484
  "positive_number": "{{field}} muss größer als 0 sein",
1481
1485
  "required": "{{field}} ist erforderlich",
1482
- "store_name_required": "Shopname ist erforderlich"
1486
+ "store_name_required": "Shopname ist erforderlich",
1487
+ "too_many_decimals": "{{field}} darf höchstens {{count}} Nachkommastellen haben"
1483
1488
  },
1484
1489
  "products": {
1485
1490
  "autocomplete": {
@@ -183,7 +183,9 @@
183
183
  "searching": "Searching…",
184
184
  "select_placeholder": "Select…",
185
185
  "unsaved_changes": "You have unsaved changes.",
186
- "yes": "Yes"
186
+ "yes": "Yes",
187
+ "done": "Done",
188
+ "error": "Something went wrong."
187
189
  },
188
190
  "components": {
189
191
  "address_block": {
@@ -1465,11 +1467,13 @@
1465
1467
  "seeded_report_read_only": "This is a built-in report and cannot be changed. Save a copy instead.",
1466
1468
  "taken": "has already been taken",
1467
1469
  "too_long": "is too long (maximum is {{count}} characters)",
1470
+ "too_many_decimals": "has more than {{count}} decimal places",
1468
1471
  "too_short": "is too short (minimum is {{count}} characters)",
1469
1472
  "wrong_length": "is the wrong length (should be {{count}} characters)"
1470
1473
  },
1471
1474
  "country_required": "Country is required",
1472
1475
  "currency_required": "Currency is required",
1476
+ "locale_required": "Language is required",
1473
1477
  "first_name_required": "First name is required",
1474
1478
  "invalid_email": "Enter a valid email",
1475
1479
  "invalid_email_or_password": "Invalid email or password",
@@ -1481,7 +1485,8 @@
1481
1485
  "passwords_dont_match": "Passwords do not match",
1482
1486
  "positive_number": "{{field}} must be greater than 0",
1483
1487
  "required": "{{field}} is required",
1484
- "store_name_required": "Store name is required"
1488
+ "store_name_required": "Store name is required",
1489
+ "too_many_decimals": "{{field}} can have at most {{count}} decimal places"
1485
1490
  },
1486
1491
  "products": {
1487
1492
  "autocomplete": {