@spree/dashboard-core 1.0.0-rc.5 → 6.0.0-rc.7
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/package.json +11 -9
- package/src/api-client.ts +65 -1
- package/src/components/currency-amounts-field.tsx +93 -0
- package/src/index.ts +4 -0
- package/src/lib/currency-amounts.test.ts +147 -0
- package/src/lib/currency-amounts.ts +172 -0
- package/src/locales/ar.json +47 -0
- package/src/locales/de.json +47 -0
- package/src/locales/en.json +47 -0
- package/src/locales/es.json +47 -0
- package/src/locales/fr.json +47 -0
- package/src/locales/pl.json +47 -0
- package/src/locales/zh-CN.json +47 -0
- package/src/products/assignable-variants.ts +35 -0
- package/src/products/digital-asset-edit-sheet.tsx +192 -0
- package/src/products/digital-asset-provider-sheet.tsx +88 -0
- package/src/products/digital-assets-card.tsx +451 -0
- package/src/products/media-edit-sheet.tsx +1 -12
- package/src/products/provider-settings-fields.tsx +110 -0
- package/src/products/use-digital-assets.ts +66 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "6.0.0-rc.7",
|
|
5
5
|
"description": "Spree Dashboard framework — registries (table, nav, slot, settings-nav), providers (auth, permission, store), admin SDK client, generic infra hooks, and the defineDashboardPlugin extension API. Developer Preview.",
|
|
6
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"LICENSE"
|
|
50
50
|
],
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@spree/admin-sdk": ">=
|
|
53
|
-
"@spree/dashboard-ui": ">=
|
|
52
|
+
"@spree/admin-sdk": ">=6.0.0-rc.7 <7.0.0-0",
|
|
53
|
+
"@spree/dashboard-ui": ">=6.0.0-rc.7 <7.0.0-0",
|
|
54
54
|
"@tanstack/react-query": "^5.100.9",
|
|
55
55
|
"@tanstack/react-router": "^1.169.2",
|
|
56
56
|
"i18next": "^26.2.0",
|
|
@@ -63,24 +63,26 @@
|
|
|
63
63
|
"@dnd-kit/core": "^6.3.1",
|
|
64
64
|
"@dnd-kit/sortable": "^10.0.0",
|
|
65
65
|
"@dnd-kit/utilities": "^3.2.2",
|
|
66
|
-
"@hookform/resolvers": "^
|
|
66
|
+
"@hookform/resolvers": "^6.0.0",
|
|
67
67
|
"@tailwindcss/vite": "^4.2.4",
|
|
68
|
-
"@tanstack/react-hotkeys": "^0.
|
|
68
|
+
"@tanstack/react-hotkeys": "^0.16.0",
|
|
69
69
|
"date-fns": "^4.4.0",
|
|
70
70
|
"date-fns-tz": "^3.2.0",
|
|
71
|
-
"lucide-react": "^1.
|
|
71
|
+
"lucide-react": "^1.60.0",
|
|
72
72
|
"zod": "^4.6.7",
|
|
73
|
-
"@spree/core": "^0.
|
|
73
|
+
"@spree/core": "^6.0.0-rc.7"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
+
"@tanstack/react-query": "^5.104.5",
|
|
77
|
+
"@tanstack/react-router": "^1.170.45",
|
|
76
78
|
"@types/react": "^19.2.14",
|
|
77
79
|
"@types/react-dom": "^19.2.3",
|
|
78
80
|
"tsup": "^9.0.0",
|
|
79
81
|
"typescript": "~7.0.2",
|
|
80
82
|
"vite": "^8.3.4",
|
|
81
83
|
"vitest": "^5.0.5",
|
|
82
|
-
"@spree/admin-sdk": "^
|
|
83
|
-
"@spree/dashboard-ui": "^
|
|
84
|
+
"@spree/admin-sdk": "^6.0.0-rc.7",
|
|
85
|
+
"@spree/dashboard-ui": "^6.0.0-rc.7"
|
|
84
86
|
},
|
|
85
87
|
"scripts": {
|
|
86
88
|
"lint": "biome check src",
|
package/src/api-client.ts
CHANGED
|
@@ -15,7 +15,12 @@ import type { PackageTypeKind, PickupStockPolicy } from '@spree/core'
|
|
|
15
15
|
* promise the seller panel methods its API does not have.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
DigitalAssetProvider,
|
|
20
|
+
PaginationMeta,
|
|
21
|
+
PermissionRule,
|
|
22
|
+
UploadFileOptions,
|
|
23
|
+
} from '@spree/admin-sdk'
|
|
19
24
|
import type { CountryCode, DimensionUnit, WeightUnit } from '@spree/core'
|
|
20
25
|
|
|
21
26
|
/** What a panel may change on the signed-in person's own account. */
|
|
@@ -229,6 +234,29 @@ export interface PanelApiClient {
|
|
|
229
234
|
}
|
|
230
235
|
/** Removing a file already on a product. */
|
|
231
236
|
deleteProductMedia?(productId: string, mediaId: string): Promise<void>
|
|
237
|
+
/**
|
|
238
|
+
* The downloadable files a digital product delivers to its buyers.
|
|
239
|
+
*
|
|
240
|
+
* Registered rather than imported for the same reason as everything else
|
|
241
|
+
* here: the Digital files card is one component serving both panels.
|
|
242
|
+
* `providers` is operator-only — a provider is the marketplace's own code
|
|
243
|
+
* reaching into its internal systems — so a seller's client leaves it out
|
|
244
|
+
* and the card offers file uploads alone.
|
|
245
|
+
*/
|
|
246
|
+
digitalAssets?: {
|
|
247
|
+
list(
|
|
248
|
+
productId: string,
|
|
249
|
+
params?: { page?: number; limit?: number },
|
|
250
|
+
): Promise<{ data: PanelDigitalAsset[]; meta?: PaginationMeta }>
|
|
251
|
+
create(productId: string, params: PanelDigitalAssetParams): Promise<PanelDigitalAsset>
|
|
252
|
+
update(
|
|
253
|
+
productId: string,
|
|
254
|
+
id: string,
|
|
255
|
+
params: PanelDigitalAssetParams,
|
|
256
|
+
): Promise<PanelDigitalAsset>
|
|
257
|
+
delete(productId: string, id: string): Promise<void>
|
|
258
|
+
providers?(productId: string): Promise<{ data: DigitalAssetProvider[] }>
|
|
259
|
+
}
|
|
232
260
|
/**
|
|
233
261
|
* The store-wide media library, for placing a file already uploaded onto
|
|
234
262
|
* another product. Operator-only: a seller sees their own product's files,
|
|
@@ -497,6 +525,42 @@ export interface PanelExport {
|
|
|
497
525
|
filename?: string | null
|
|
498
526
|
}
|
|
499
527
|
|
|
528
|
+
/**
|
|
529
|
+
* A downloadable file, as the Digital files card reads it. Structural, like
|
|
530
|
+
* the other records here: both serializers carry these fields, and only the
|
|
531
|
+
* operator's adds `provider_settings`.
|
|
532
|
+
*/
|
|
533
|
+
export interface PanelDigitalAsset {
|
|
534
|
+
id: string
|
|
535
|
+
variant_id: string | null
|
|
536
|
+
filename: string | null
|
|
537
|
+
content_type: string | null
|
|
538
|
+
byte_size: number | null
|
|
539
|
+
/** Null means the store's download settings apply. */
|
|
540
|
+
authorized_clicks: number | null
|
|
541
|
+
authorized_days: number | null
|
|
542
|
+
/** The limits in force, after the store-settings fallback. */
|
|
543
|
+
effective_authorized_clicks: number
|
|
544
|
+
effective_authorized_days: number
|
|
545
|
+
provider_type: string | null
|
|
546
|
+
provider_name: string
|
|
547
|
+
download_url: string | null
|
|
548
|
+
provider_settings?: Record<string, unknown>
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* What either panel writes. The provider fields are the operator's alone; a
|
|
553
|
+
* seller's API refuses them.
|
|
554
|
+
*/
|
|
555
|
+
export interface PanelDigitalAssetParams {
|
|
556
|
+
signed_id?: string
|
|
557
|
+
variant_id?: string
|
|
558
|
+
authorized_clicks?: number | null
|
|
559
|
+
authorized_days?: number | null
|
|
560
|
+
provider_type?: string
|
|
561
|
+
provider_settings?: Record<string, unknown>
|
|
562
|
+
}
|
|
563
|
+
|
|
500
564
|
/** A library file, as the picker lists it. */
|
|
501
565
|
export interface PanelMediaRecord {
|
|
502
566
|
id: string
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { currencyParts, isCurrencyCode } from '@spree/core'
|
|
2
|
+
import {
|
|
3
|
+
Field,
|
|
4
|
+
FieldError,
|
|
5
|
+
FieldLabel,
|
|
6
|
+
InputGroup,
|
|
7
|
+
InputGroupAddon,
|
|
8
|
+
InputGroupInput,
|
|
9
|
+
InputGroupText,
|
|
10
|
+
} from '@spree/dashboard-ui'
|
|
11
|
+
import { useMemo } from 'react'
|
|
12
|
+
import { useTranslation } from 'react-i18next'
|
|
13
|
+
import { amountForCurrency, applyCurrencyAmount } from '../lib/currency-amounts'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The amount an amount-based delivery calculator charges, one input per
|
|
17
|
+
* currency the store sells in, written to the per-currency `amounts`
|
|
18
|
+
* preference. A currency left blank has no amount, and the method is not
|
|
19
|
+
* offered to those carts. A single-currency store sees one plain amount
|
|
20
|
+
* field.
|
|
21
|
+
*/
|
|
22
|
+
export function CurrencyAmountsField({
|
|
23
|
+
values,
|
|
24
|
+
onChange,
|
|
25
|
+
currencies,
|
|
26
|
+
defaultCurrency,
|
|
27
|
+
error,
|
|
28
|
+
}: {
|
|
29
|
+
values: Record<string, unknown>
|
|
30
|
+
onChange: (next: Record<string, unknown>) => void
|
|
31
|
+
/** The currencies the store sells in. */
|
|
32
|
+
currencies: readonly string[]
|
|
33
|
+
defaultCurrency: string
|
|
34
|
+
error?: { message?: string }
|
|
35
|
+
}) {
|
|
36
|
+
const { t, i18n } = useTranslation()
|
|
37
|
+
|
|
38
|
+
// The store's currency list is the source of truth, but a method may already
|
|
39
|
+
// carry an amount in a currency the store has since stopped selling in —
|
|
40
|
+
// dropping that input would silently discard the amount on the next save.
|
|
41
|
+
const amounts = (values.amounts ?? {}) as Record<string, unknown>
|
|
42
|
+
const extraCodes = Object.keys(amounts).join(',')
|
|
43
|
+
const codes = useMemo(() => {
|
|
44
|
+
const supported = currencies.length > 0 ? currencies : [defaultCurrency]
|
|
45
|
+
const extra = extraCodes
|
|
46
|
+
.split(',')
|
|
47
|
+
.filter(isCurrencyCode)
|
|
48
|
+
.filter((code) => !supported.includes(code))
|
|
49
|
+
return [defaultCurrency, ...supported.filter((code) => code !== defaultCurrency), ...extra]
|
|
50
|
+
}, [currencies, defaultCurrency, extraCodes])
|
|
51
|
+
|
|
52
|
+
const multiCurrency = codes.length > 1
|
|
53
|
+
|
|
54
|
+
function amountFor(code: string): string {
|
|
55
|
+
const raw = amountForCurrency(values, code, defaultCurrency)
|
|
56
|
+
return raw === null ? '' : String(raw)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Field>
|
|
61
|
+
<FieldLabel htmlFor={`calculator-amount-${defaultCurrency}`}>
|
|
62
|
+
{t('admin.currency_amounts.label')}
|
|
63
|
+
</FieldLabel>
|
|
64
|
+
<div className="flex flex-col gap-2">
|
|
65
|
+
{codes.map((code) => (
|
|
66
|
+
<InputGroup key={code}>
|
|
67
|
+
<InputGroupAddon>
|
|
68
|
+
<InputGroupText>{currencyParts(code, i18n.language).symbol}</InputGroupText>
|
|
69
|
+
</InputGroupAddon>
|
|
70
|
+
<InputGroupInput
|
|
71
|
+
id={`calculator-amount-${code}`}
|
|
72
|
+
type="number"
|
|
73
|
+
step="any"
|
|
74
|
+
min="0"
|
|
75
|
+
aria-label={t('admin.currency_amounts.amount_in_currency', { currency: code })}
|
|
76
|
+
value={amountFor(code)}
|
|
77
|
+
onChange={(event) =>
|
|
78
|
+
onChange(applyCurrencyAmount(values, code, event.target.value, defaultCurrency))
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
<InputGroupAddon align="inline-end">
|
|
82
|
+
<InputGroupText>{code}</InputGroupText>
|
|
83
|
+
</InputGroupAddon>
|
|
84
|
+
</InputGroup>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
{multiCurrency && (
|
|
88
|
+
<span className="text-muted-foreground text-xs">{t('admin.currency_amounts.help')}</span>
|
|
89
|
+
)}
|
|
90
|
+
<FieldError errors={[error]} />
|
|
91
|
+
</Field>
|
|
92
|
+
)
|
|
93
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export * from './components/bulk-action-bar'
|
|
|
34
34
|
export * from './components/can'
|
|
35
35
|
export * from './components/country-combobox'
|
|
36
36
|
export * from './components/country-state-fields'
|
|
37
|
+
export * from './components/currency-amounts-field'
|
|
37
38
|
export * from './components/currency-select'
|
|
38
39
|
export * from './components/export-button'
|
|
39
40
|
export * from './components/file-upload-field'
|
|
@@ -88,6 +89,7 @@ export * from './hooks/use-switch-admin-locale'
|
|
|
88
89
|
export * from './hooks/use-visual-viewport-offset'
|
|
89
90
|
export * from './lib/cancel-order-form'
|
|
90
91
|
export * from './lib/create-action-registry'
|
|
92
|
+
export * from './lib/currency-amounts'
|
|
91
93
|
// ---------------------------------------------------------------------------
|
|
92
94
|
// Registries — pluggable extension points (nav, route, slot, table,
|
|
93
95
|
// settings-nav, form fields, custom field components)
|
|
@@ -134,6 +136,8 @@ export * from './pages/stock-locations'
|
|
|
134
136
|
// Plugin facade — re-exported for convenience; same API as `/plugin` subpath
|
|
135
137
|
// ---------------------------------------------------------------------------
|
|
136
138
|
export * from './plugin'
|
|
139
|
+
export * from './products/assignable-variants'
|
|
140
|
+
export * from './products/digital-assets-card'
|
|
137
141
|
export * from './products/normalize-customs'
|
|
138
142
|
export * from './products/normalize-quantity'
|
|
139
143
|
export * from './products/product-form-cards'
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
amountForCurrency,
|
|
4
|
+
applyCurrencyAmount,
|
|
5
|
+
hasCurrencyAmount,
|
|
6
|
+
listedAmounts,
|
|
7
|
+
} from './currency-amounts'
|
|
8
|
+
|
|
9
|
+
describe('listedAmounts', () => {
|
|
10
|
+
it('reads the legacy single amount as the store default currency', () => {
|
|
11
|
+
expect(listedAmounts({ amount: '5' }, 'USD')).toEqual([{ amount: '5', currency: 'USD' }])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('attributes the legacy amount to its own currency preference', () => {
|
|
15
|
+
expect(listedAmounts({ amount: '7', currency: 'EUR' }, 'USD')).toEqual([
|
|
16
|
+
{ amount: '7', currency: 'EUR' },
|
|
17
|
+
])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('reads a non-default currency from the amounts hash when the default is empty', () => {
|
|
21
|
+
expect(listedAmounts({ amounts: { EUR: '7' } }, 'USD')).toEqual([
|
|
22
|
+
{ amount: '7', currency: 'EUR' },
|
|
23
|
+
])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('ignores the single amount once the hash holds an amount, as the server does', () => {
|
|
27
|
+
expect(listedAmounts({ amount: '0', amounts: { EUR: '11' } }, 'USD')).toEqual([
|
|
28
|
+
{ amount: '11', currency: 'EUR' },
|
|
29
|
+
])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('keeps each hash amount against the currency it was entered in', () => {
|
|
33
|
+
expect(listedAmounts({ amounts: { GBP: '8', EUR: '7' } }, 'USD')).toEqual([
|
|
34
|
+
{ amount: '7', currency: 'EUR' },
|
|
35
|
+
{ amount: '8', currency: 'GBP' },
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('lists the default-currency amount first', () => {
|
|
40
|
+
expect(listedAmounts({ amounts: { EUR: '7', USD: '5' } }, 'USD')).toEqual([
|
|
41
|
+
{ amount: '5', currency: 'USD' },
|
|
42
|
+
{ amount: '7', currency: 'EUR' },
|
|
43
|
+
])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('lets the amounts hash override the legacy amount for the same currency', () => {
|
|
47
|
+
expect(listedAmounts({ amount: '5', amounts: { USD: '6', EUR: '7' } }, 'USD')).toEqual([
|
|
48
|
+
{ amount: '6', currency: 'USD' },
|
|
49
|
+
{ amount: '7', currency: 'EUR' },
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('ignores blank hash entries so an emptied currency is not offered', () => {
|
|
54
|
+
expect(listedAmounts({ amounts: { EUR: '', GBP: '8' } }, 'USD')).toEqual([
|
|
55
|
+
{ amount: '8', currency: 'GBP' },
|
|
56
|
+
])
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('amountForCurrency', () => {
|
|
61
|
+
it('puts a pre-6.0 euro amount in the euro row, not the store default', () => {
|
|
62
|
+
const preferences = { amount: '7', currency: 'EUR' }
|
|
63
|
+
expect(amountForCurrency(preferences, 'EUR', 'USD')).toBe('7')
|
|
64
|
+
expect(amountForCurrency(preferences, 'USD', 'USD')).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('reads only the amounts hash once it holds an amount, so the single amount is not a price', () => {
|
|
68
|
+
const preferences = { amount: '0', amounts: { EUR: '11' } }
|
|
69
|
+
expect(amountForCurrency(preferences, 'EUR', 'USD')).toBe('11')
|
|
70
|
+
expect(amountForCurrency(preferences, 'USD', 'USD')).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('treats a blank currency preference as the store default', () => {
|
|
74
|
+
expect(amountForCurrency({ amount: '5' }, 'USD', 'USD')).toBe('5')
|
|
75
|
+
expect(amountForCurrency({ amount: '5' }, 'EUR', 'USD')).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('trims surrounding space on the legacy currency preference', () => {
|
|
79
|
+
const preferences = { amount: '7', currency: ' EUR ' }
|
|
80
|
+
expect(amountForCurrency(preferences, 'EUR', 'USD')).toBe('7')
|
|
81
|
+
expect(amountForCurrency(preferences, 'USD', 'USD')).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('reads a lowercase amounts-hash key as that currency', () => {
|
|
85
|
+
expect(amountForCurrency({ amounts: { eur: '7' } }, 'EUR', 'USD')).toBe('7')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('applyCurrencyAmount', () => {
|
|
90
|
+
it('moves a pre-6.0 euro price into the hash when the dollar row is edited', () => {
|
|
91
|
+
expect(applyCurrencyAmount({ amount: '7', currency: 'EUR' }, 'USD', '10', 'USD')).toEqual({
|
|
92
|
+
amount: null,
|
|
93
|
+
currency: 'EUR',
|
|
94
|
+
amounts: { EUR: '7', USD: '10' },
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('updates the named currency of a pre-6.0 method in the hash', () => {
|
|
99
|
+
expect(applyCurrencyAmount({ amount: '7', currency: 'EUR' }, 'EUR', '11', 'USD')).toEqual({
|
|
100
|
+
amount: null,
|
|
101
|
+
currency: 'EUR',
|
|
102
|
+
amounts: { EUR: '11' },
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('writes the store default into the hash like every other currency', () => {
|
|
107
|
+
expect(applyCurrencyAmount({ amount: '5', currency: 'USD' }, 'EUR', '11', 'USD')).toEqual({
|
|
108
|
+
amount: null,
|
|
109
|
+
currency: 'USD',
|
|
110
|
+
amounts: { USD: '5', EUR: '11' },
|
|
111
|
+
})
|
|
112
|
+
expect(applyCurrencyAmount({ amount: null }, 'USD', '9.99', 'USD')).toEqual({
|
|
113
|
+
amount: null,
|
|
114
|
+
amounts: { USD: '9.99' },
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('removes a cleared currency, the store default included, so the method is not offered in it', () => {
|
|
119
|
+
expect(applyCurrencyAmount({ amounts: { USD: '5', EUR: '7' } }, 'USD', '', 'USD')).toEqual({
|
|
120
|
+
amount: null,
|
|
121
|
+
amounts: { EUR: '7' },
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('updates and clears a lowercase stored hash key without leaving a stale entry', () => {
|
|
126
|
+
expect(applyCurrencyAmount({ amounts: { eur: '7' } }, 'EUR', '11', 'USD')).toEqual({
|
|
127
|
+
amount: null,
|
|
128
|
+
amounts: { EUR: '11' },
|
|
129
|
+
})
|
|
130
|
+
expect(applyCurrencyAmount({ amounts: { eur: '7' } }, 'EUR', '', 'USD')).toEqual({
|
|
131
|
+
amount: null,
|
|
132
|
+
amounts: {},
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('hasCurrencyAmount', () => {
|
|
138
|
+
it('is true for an amount in the hash or a pre-6.0 single amount', () => {
|
|
139
|
+
expect(hasCurrencyAmount({ amounts: { EUR: '0' } })).toBe(true)
|
|
140
|
+
expect(hasCurrencyAmount({ amount: '5', amounts: {} })).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('is false when no currency has an amount', () => {
|
|
144
|
+
expect(hasCurrencyAmount({ amount: null, amounts: { EUR: '' } })).toBe(false)
|
|
145
|
+
expect(hasCurrencyAmount({})).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { isDecimalString } from '@spree/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Delivery calculators whose price is a plain amount, quoted per currency
|
|
5
|
+
* through the `amounts` preference. Every other calculator (percent, tiered)
|
|
6
|
+
* is currency-neutral.
|
|
7
|
+
*/
|
|
8
|
+
export const AMOUNT_BASED_CALCULATORS: readonly string[] = [
|
|
9
|
+
'flat_rate',
|
|
10
|
+
'per_item',
|
|
11
|
+
'digital_delivery',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
/** The preferences the per-currency editor owns on those calculators. */
|
|
15
|
+
export const CURRENCY_AMOUNT_KEYS: readonly string[] = ['amount', 'currency', 'amounts']
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A stored preference as a decimal string. The API answers with strings; a
|
|
19
|
+
* number still sitting in preferences saved before 6.0 is read as its digits.
|
|
20
|
+
*/
|
|
21
|
+
function toDecimal(value: unknown): string | null {
|
|
22
|
+
const text =
|
|
23
|
+
typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : ''
|
|
24
|
+
return isDecimalString(text) ? text : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One stored delivery amount and the ISO currency it was entered in. */
|
|
28
|
+
export type ListedAmount = {
|
|
29
|
+
amount: string
|
|
30
|
+
currency: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function amountsHash(
|
|
34
|
+
preferences: Record<string, unknown> | null | undefined,
|
|
35
|
+
): Record<string, unknown> {
|
|
36
|
+
const amounts = preferences?.amounts
|
|
37
|
+
if (!amounts || typeof amounts !== 'object' || Array.isArray(amounts)) return {}
|
|
38
|
+
return amounts as Record<string, unknown>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hashKey(amounts: Record<string, unknown>, currency: string): string | undefined {
|
|
42
|
+
const code = currency.toUpperCase()
|
|
43
|
+
return Object.keys(amounts).find((key) => key.toUpperCase() === code)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hashEntry(amounts: Record<string, unknown>, currency: string): unknown {
|
|
47
|
+
const key = hashKey(amounts, currency)
|
|
48
|
+
return key === undefined ? undefined : amounts[key]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeHashAmount(
|
|
52
|
+
amounts: Record<string, unknown>,
|
|
53
|
+
currency: string,
|
|
54
|
+
value: string | null,
|
|
55
|
+
): void {
|
|
56
|
+
const existing = hashKey(amounts, currency)
|
|
57
|
+
if (existing !== undefined) delete amounts[existing]
|
|
58
|
+
if (value !== null) amounts[currency.toUpperCase()] = value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The currency the pre-6.0 single `amount` belongs to. */
|
|
62
|
+
function legacyCurrency(
|
|
63
|
+
preferences: Record<string, unknown> | null | undefined,
|
|
64
|
+
defaultCurrency: string,
|
|
65
|
+
): string {
|
|
66
|
+
if (typeof preferences?.currency === 'string' && preferences.currency.trim() !== '') {
|
|
67
|
+
return preferences.currency.trim().toUpperCase()
|
|
68
|
+
}
|
|
69
|
+
return defaultCurrency.toUpperCase()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether the per-currency hash holds an amount: from then on it alone decides. */
|
|
73
|
+
function hasAmounts(amounts: Record<string, unknown>): boolean {
|
|
74
|
+
return Object.values(amounts).some((value) => toDecimal(value) !== null)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The amount a calculator-priced method quotes for one currency — the same
|
|
79
|
+
* rule the calculator uses. Once the per-currency `amounts` hash holds an
|
|
80
|
+
* amount it alone decides; the legacy single `amount` quotes its own
|
|
81
|
+
* currency (the `currency` preference, or the store default when that
|
|
82
|
+
* preference is blank) only while the hash is empty.
|
|
83
|
+
*
|
|
84
|
+
* A pre-6.0 method that names EUR therefore returns 7 for EUR and nothing
|
|
85
|
+
* for USD.
|
|
86
|
+
*/
|
|
87
|
+
export function amountForCurrency(
|
|
88
|
+
preferences: Record<string, unknown> | null | undefined,
|
|
89
|
+
currency: string,
|
|
90
|
+
defaultCurrency: string,
|
|
91
|
+
): string | null {
|
|
92
|
+
const code = currency.toUpperCase()
|
|
93
|
+
const amounts = amountsHash(preferences)
|
|
94
|
+
if (hasAmounts(amounts)) return toDecimal(hashEntry(amounts, code))
|
|
95
|
+
|
|
96
|
+
if (legacyCurrency(preferences, defaultCurrency) !== code) return null
|
|
97
|
+
return toDecimal(preferences?.amount)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Writes one currency's amount the way the delivery-method editor does: into
|
|
102
|
+
* the per-currency `amounts` hash, the store default like every other
|
|
103
|
+
* currency, and a cleared currency removed so the method is not offered in
|
|
104
|
+
* it.
|
|
105
|
+
*
|
|
106
|
+
* The first edit of a method priced with the legacy single `amount` moves
|
|
107
|
+
* that figure into the hash under its own currency, since the hash alone
|
|
108
|
+
* decides once it holds an amount, and clears the single amount. Amounts are
|
|
109
|
+
* written as the exact decimal string typed, which is what the API accepts.
|
|
110
|
+
*/
|
|
111
|
+
export function applyCurrencyAmount(
|
|
112
|
+
preferences: Record<string, unknown>,
|
|
113
|
+
currency: string,
|
|
114
|
+
raw: string,
|
|
115
|
+
defaultCurrency: string,
|
|
116
|
+
): Record<string, unknown> {
|
|
117
|
+
const current = amountsHash(preferences)
|
|
118
|
+
const nextAmounts: Record<string, unknown> = hasAmounts(current) ? { ...current } : {}
|
|
119
|
+
|
|
120
|
+
if (!hasAmounts(current)) {
|
|
121
|
+
const legacy = toDecimal(preferences.amount)
|
|
122
|
+
if (legacy !== null) nextAmounts[legacyCurrency(preferences, defaultCurrency)] = legacy
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
writeHashAmount(nextAmounts, currency, toDecimal(raw))
|
|
126
|
+
return { ...preferences, amount: null, amounts: nextAmounts }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether a calculator-priced method states an amount in any currency. One
|
|
131
|
+
* without any would never be offered.
|
|
132
|
+
*/
|
|
133
|
+
export function hasCurrencyAmount(
|
|
134
|
+
preferences: Record<string, unknown> | null | undefined,
|
|
135
|
+
): boolean {
|
|
136
|
+
return hasAmounts(amountsHash(preferences)) || toDecimal(preferences?.amount) !== null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Every amount a calculator-priced method quotes, each with the currency it
|
|
141
|
+
* belongs to. The per-currency `amounts` hash decides once it holds an
|
|
142
|
+
* amount; the legacy single `amount` stands for its own currency (the
|
|
143
|
+
* `currency` preference, or the store default) only while the hash is empty.
|
|
144
|
+
*
|
|
145
|
+
* A method priced only in a non-default currency therefore returns that
|
|
146
|
+
* currency's row, not a number that the caller would stamp with the store
|
|
147
|
+
* default's symbol.
|
|
148
|
+
*/
|
|
149
|
+
export function listedAmounts(
|
|
150
|
+
preferences: Record<string, unknown> | null | undefined,
|
|
151
|
+
defaultCurrency: string,
|
|
152
|
+
): ListedAmount[] {
|
|
153
|
+
const listed: ListedAmount[] = []
|
|
154
|
+
const defaultCode = defaultCurrency.toUpperCase()
|
|
155
|
+
|
|
156
|
+
for (const [code, value] of Object.entries(amountsHash(preferences))) {
|
|
157
|
+
const parsed = toDecimal(value)
|
|
158
|
+
if (parsed === null) continue
|
|
159
|
+
listed.push({ amount: parsed, currency: code.toUpperCase() })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const legacyAmount = toDecimal(preferences?.amount)
|
|
163
|
+
if (listed.length === 0 && legacyAmount !== null) {
|
|
164
|
+
listed.push({ amount: legacyAmount, currency: legacyCurrency(preferences, defaultCurrency) })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return listed.sort((left, right) => {
|
|
168
|
+
if (left.currency === defaultCode) return -1
|
|
169
|
+
if (right.currency === defaultCode) return 1
|
|
170
|
+
return left.currency.localeCompare(right.currency)
|
|
171
|
+
})
|
|
172
|
+
}
|
package/src/locales/ar.json
CHANGED
|
@@ -117,6 +117,7 @@
|
|
|
117
117
|
"title": "إعادة تعيين كلمة المرور"
|
|
118
118
|
},
|
|
119
119
|
"login": {
|
|
120
|
+
"account_deactivated": "تم إلغاء تفعيل هذا الحساب. تواصل مع المسؤول لاستعادة الوصول.",
|
|
120
121
|
"account_not_provisioned": "هذا الحساب غير مصرح له بالوصول إلى هذا المتجر. اطلب دعوة من المسؤول.",
|
|
121
122
|
"divider": "أو",
|
|
122
123
|
"invalid_oauth_state": "انتهت صلاحية جلسة تسجيل الدخول أو أنها غير صالحة. يرجى بدء تسجيل الدخول من جديد.",
|
|
@@ -487,6 +488,12 @@
|
|
|
487
488
|
"discard_title": "تجاهل هذه المسودة؟"
|
|
488
489
|
}
|
|
489
490
|
},
|
|
491
|
+
"currency_amounts": {
|
|
492
|
+
"amount_in_currency": "المبلغ بعملة {{currency}}",
|
|
493
|
+
"help": "العملات المتروكة فارغة لا تُعرض لها طريقة التوصيل هذه.",
|
|
494
|
+
"label": "المبلغ",
|
|
495
|
+
"required": "أدخل مبلغًا بعملة واحدة على الأقل."
|
|
496
|
+
},
|
|
490
497
|
"custom_field_definitions": {
|
|
491
498
|
"errors": {
|
|
492
499
|
"failed_to_create": "فشل إنشاء تعريف الحقل المخصص",
|
|
@@ -941,6 +948,14 @@
|
|
|
941
948
|
"order_multiple": {
|
|
942
949
|
"label": "مضاعف الطلب",
|
|
943
950
|
"help": "كل زيادة فوق الحد الأدنى. اتركه فارغًا للسماح بأي كمية."
|
|
951
|
+
},
|
|
952
|
+
"store": {
|
|
953
|
+
"digital_asset_authorized_clicks": {
|
|
954
|
+
"label": "التنزيلات المسموح بها"
|
|
955
|
+
},
|
|
956
|
+
"digital_asset_authorized_days": {
|
|
957
|
+
"label": "الأيام المتاحة"
|
|
958
|
+
}
|
|
944
959
|
}
|
|
945
960
|
},
|
|
946
961
|
"image_upload": {
|
|
@@ -2043,6 +2058,38 @@
|
|
|
2043
2058
|
"description": "يجب على البائع تقديم عنوان للمرتجعات"
|
|
2044
2059
|
}
|
|
2045
2060
|
}
|
|
2061
|
+
},
|
|
2062
|
+
"digital_assets": {
|
|
2063
|
+
"add": "إضافة",
|
|
2064
|
+
"all_variants": "كل المتغيرات",
|
|
2065
|
+
"columns": {
|
|
2066
|
+
"file": "الملف",
|
|
2067
|
+
"variant": "المتغير",
|
|
2068
|
+
"size": "الحجم",
|
|
2069
|
+
"downloads": "التنزيلات",
|
|
2070
|
+
"days": "الأيام"
|
|
2071
|
+
},
|
|
2072
|
+
"delete_description": "لن يتمكن من اشترى «{{name}}» من تنزيله بعد الآن. يسري هذا فورًا.",
|
|
2073
|
+
"delete_title": "حذف هذا الملف؟",
|
|
2074
|
+
"description": "ملفات يمكن للمشترين تنزيلها بعد الشراء. أرفقها بمنتج رقمي، أو بمنتج مادي لتسليم دليل أو ضمان مع البضاعة.",
|
|
2075
|
+
"download": "تنزيل",
|
|
2076
|
+
"drop_hint": "اسحب الملفات وأفلتها هنا، أو انقر للتصفح",
|
|
2077
|
+
"empty": "لا توجد ملفات بعد",
|
|
2078
|
+
"limit_invalid": "يجب أن تكون الحدود أعدادًا صحيحة من 1 فأكثر، أو فارغة.",
|
|
2079
|
+
"limits_help": "اترك حدود التنزيل والأيام فارغة لاستخدام إعدادات المتجر.",
|
|
2080
|
+
"source": {
|
|
2081
|
+
"upload": "رفع ملف"
|
|
2082
|
+
},
|
|
2083
|
+
"store_default": "{{count}} (افتراضي المتجر)",
|
|
2084
|
+
"title": "الملفات الرقمية",
|
|
2085
|
+
"untitled": "ملف بدون اسم",
|
|
2086
|
+
"upload": "إضافة ملف",
|
|
2087
|
+
"upload_failed": "فشل رفع {{name}}: {{message}}",
|
|
2088
|
+
"uploading": "جارٍ رفع {{name}}…",
|
|
2089
|
+
"uploading_short": "جارٍ الرفع…",
|
|
2090
|
+
"replace": "استبدال الملف",
|
|
2091
|
+
"replace_description": "سيُحذف \"{{name}}\" وسيحمّل كل من اشتراه الملف الجديد من روابطه الحالية. يسري فورًا.",
|
|
2092
|
+
"replace_title": "استبدال هذا الملف؟"
|
|
2046
2093
|
}
|
|
2047
2094
|
}
|
|
2048
2095
|
}
|