@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-core",
3
3
  "license": "MIT",
4
- "version": "1.0.0-rc.5",
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": ">=1.0.0-rc.4 <2.0.0-0",
53
- "@spree/dashboard-ui": ">=0.10.0 <1.0.0-0 || >=1.0.0-0 <2.0.0-0",
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": "^5.2.2",
66
+ "@hookform/resolvers": "^6.0.0",
67
67
  "@tailwindcss/vite": "^4.2.4",
68
- "@tanstack/react-hotkeys": "^0.14.3",
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.58.0",
71
+ "lucide-react": "^1.60.0",
72
72
  "zod": "^4.6.7",
73
- "@spree/core": "^0.2.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": "^1.0.0-rc.5",
83
- "@spree/dashboard-ui": "^1.0.0-rc.5"
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 { PermissionRule, UploadFileOptions } from '@spree/admin-sdk'
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
+ }
@@ -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
  }