@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.
- package/dist/vite/discover.js +5 -2
- package/package.json +13 -16
- package/src/api-client.ts +22 -34
- package/src/components/address-form-dialog.test.ts +4 -4
- package/src/components/address-form-dialog.tsx +10 -10
- package/src/components/app-shell.tsx +12 -1
- package/src/components/app-sidebar.tsx +18 -2
- package/src/components/country-combobox.tsx +52 -29
- package/src/components/currency-select.tsx +18 -13
- package/src/components/file-upload-field.tsx +4 -4
- package/src/components/import-button.tsx +4 -1
- package/src/components/import-wizard-dialog.tsx +8 -11
- package/src/components/mobile-top-bar.tsx +6 -0
- package/src/components/money.tsx +23 -0
- package/src/components/preferences-form.tsx +263 -54
- package/src/components/resource-combobox.tsx +5 -0
- package/src/components/resource-picker-sheet.tsx +18 -12
- package/src/components/resource-table.tsx +7 -3
- package/src/components/table-toolbar.tsx +8 -5
- package/src/hooks/use-display-name.ts +11 -3
- package/src/hooks/use-file-upload.ts +51 -0
- package/src/hooks/use-import.ts +5 -5
- package/src/hooks/use-money-locale.ts +14 -0
- package/src/index.ts +8 -3
- package/src/lib/currencies.ts +32 -13
- package/src/lib/form-mappers.ts +25 -5
- package/src/lib/formatters.ts +34 -4
- package/src/lib/fulfillment-items.test.ts +2 -1
- package/src/lib/fulfillment-items.ts +9 -3
- package/src/lib/i18n.ts +4 -4
- package/src/lib/iso-codes.ts +47 -0
- package/src/lib/money.test.ts +63 -0
- package/src/lib/money.ts +99 -0
- package/src/{products → lib}/normalize-money.ts +2 -2
- package/src/lib/permissions.ts +2 -0
- package/src/lib/return-refunds.test.ts +62 -0
- package/src/lib/return-refunds.ts +70 -0
- package/src/lib/route-registry.ts +58 -15
- package/src/lib/settings-nav-registry.test.ts +34 -0
- package/src/lib/settings-nav-registry.ts +21 -4
- package/src/lib/slot-registry.ts +10 -0
- package/src/lib/storefront.ts +2 -2
- package/src/locales/ar.json +7 -2
- package/src/locales/de.json +7 -2
- package/src/locales/en.json +7 -2
- package/src/locales/es.json +7 -2
- package/src/locales/fr.json +7 -2
- package/src/locales/pl.json +7 -2
- package/src/locales/zh-CN.json +7 -2
- package/src/plugin.ts +5 -5
- package/src/products/bulk-variants-dialog.tsx +2 -8
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/product-bulk-price-editor.tsx +18 -29
- package/src/products/product-form-cards.tsx +12 -8
- package/src/products/product-form-mapping.ts +15 -11
- package/src/products/product-schema.ts +6 -8
- package/src/products/product-types.ts +5 -3
- package/src/products/use-product-form-data.ts +0 -28
- package/src/products/variants-options-builder.tsx +30 -27
- package/src/products/variants-section.tsx +31 -25
- package/src/providers/store-provider.tsx +9 -9
- package/src/schemas/stock-location.ts +3 -2
- package/src/tables/package-types.tsx +1 -1
- package/src/tables/stock-locations.tsx +1 -1
- package/src/vite/discover.ts +19 -2
- package/src/hooks/use-direct-upload.ts +0 -117
- /package/src/{products → lib}/currency-parts.ts +0 -0
- /package/src/{products → lib}/normalize-money.test.ts +0 -0
package/src/lib/money.ts
ADDED
|
@@ -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
|
|
8
|
-
//
|
|
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
|
package/src/lib/permissions.ts
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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 =
|
|
124
|
-
|
|
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
|
-
|
|
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. */
|
package/src/lib/slot-registry.ts
CHANGED
|
@@ -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
|
// ============================================================================
|
package/src/lib/storefront.ts
CHANGED
|
@@ -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 `
|
|
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?.
|
|
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}`
|
package/src/locales/ar.json
CHANGED
|
@@ -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": {
|
package/src/locales/de.json
CHANGED
|
@@ -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": {
|
package/src/locales/en.json
CHANGED
|
@@ -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": {
|