@spree/dashboard-ui 1.0.0-rc.2 → 1.0.0-rc.3
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 +1 -1
- package/src/index.ts +1 -1
- package/src/lib/validation-messages.ts +12 -0
- package/src/spree/calculator-summary.tsx +39 -34
- package/src/spree/claim-resolve-dialog.tsx +5 -2
- package/src/spree/icons.ts +1 -0
- package/src/spree/label-upload-dialog.tsx +1 -1
- package/src/spree/post-sale-create-dialogs.tsx +7 -14
- package/src/spree/quantity-tier-editor.tsx +1 -1
- package/src/spree/return-dialogs.tsx +4 -45
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spree/dashboard-ui",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.3",
|
|
5
5
|
"description": "Spree Dashboard design system — shadcn primitives, headless composed components, and design tokens. Source-only package; consumers compile via their own Vite/Tailwind setup. Developer Preview.",
|
|
6
6
|
"author": "Vendo Connect Inc.",
|
|
7
7
|
"homepage": "https://spreecommerce.org",
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ export {
|
|
|
26
26
|
} from './lib/date-range-presets'
|
|
27
27
|
export { sameRichText, shouldEmitRichTextChange } from './lib/same-rich-text'
|
|
28
28
|
export { cn } from './lib/utils'
|
|
29
|
-
export { requiredMessage } from './lib/validation-messages'
|
|
29
|
+
export { decimalPlacesMessage, requiredMessage } from './lib/validation-messages'
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// Spree composed components — headless, accept data via props
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
@@ -17,3 +17,15 @@ import i18n from 'i18next'
|
|
|
17
17
|
export function requiredMessage(field: string): () => string {
|
|
18
18
|
return () => i18n.t('admin.validation.required', { field: i18n.t(`admin.fields.${field}.label`) })
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lazy "<Field> can have at most <count> decimal places" message, for amounts
|
|
23
|
+
* and rates the server keeps to a fixed number of decimals.
|
|
24
|
+
*/
|
|
25
|
+
export function decimalPlacesMessage(field: string, count: number): () => string {
|
|
26
|
+
return () =>
|
|
27
|
+
i18n.t('admin.validation.too_many_decimals', {
|
|
28
|
+
field: i18n.t(`admin.fields.${field}.label`),
|
|
29
|
+
count,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* model (delivery methods, tax rates, …).
|
|
6
6
|
*
|
|
7
7
|
* The expected payload mirrors the admin API's calculator embed: a
|
|
8
|
-
* `{ label, preferences,
|
|
8
|
+
* `{ label, preferences, schema }` triple, `schema` being the JSON Schema of
|
|
9
|
+
* the calculator's preferences. The component is
|
|
9
10
|
* tolerant of partial input — missing schema or preferences just fall
|
|
10
11
|
* back to the label alone.
|
|
11
12
|
*
|
|
@@ -22,7 +23,14 @@ export interface CalculatorPayload {
|
|
|
22
23
|
/** STI class name (e.g. "Spree::Calculator::FlatRate"). Used as a label fallback. */
|
|
23
24
|
type?: string | null
|
|
24
25
|
preferences?: Record<string, unknown> | null
|
|
25
|
-
|
|
26
|
+
schema?: { properties?: Record<string, CalculatorPropertySchema> } | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The part of a preference's JSON Schema the summary reads. */
|
|
30
|
+
interface CalculatorPropertySchema {
|
|
31
|
+
type?: string | string[]
|
|
32
|
+
format?: string
|
|
33
|
+
pattern?: string
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
interface CalculatorSummaryProps {
|
|
@@ -60,14 +68,14 @@ export function formatCalculatorSummary(
|
|
|
60
68
|
calculator.label?.trim() ||
|
|
61
69
|
(calculator.type ? (calculator.type.split('::').pop() ?? calculator.type) : '')
|
|
62
70
|
|
|
63
|
-
const
|
|
71
|
+
const properties = Object.entries(calculator.schema?.properties ?? {})
|
|
64
72
|
const prefs = calculator.preferences
|
|
65
73
|
|
|
66
|
-
if (!
|
|
74
|
+
if (!properties.length || !prefs) return label || null
|
|
67
75
|
|
|
68
76
|
const currency = (typeof prefs.currency === 'string' && prefs.currency) || 'USD'
|
|
69
|
-
const details =
|
|
70
|
-
.map((
|
|
77
|
+
const details = properties
|
|
78
|
+
.map(([key, property]) => formatField(key, property, prefs[key], currency))
|
|
71
79
|
.filter((s): s is string => s !== null)
|
|
72
80
|
.join(', ')
|
|
73
81
|
|
|
@@ -76,50 +84,47 @@ export function formatCalculatorSummary(
|
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
/**
|
|
79
|
-
* Formats one preference value
|
|
80
|
-
* carry richer field metadata (no `display_format`, no `unit`), so we
|
|
81
|
-
* fall back to key-name heuristics that match the Ruby preference
|
|
82
|
-
* declarations in `Spree::Calculator::*`.
|
|
87
|
+
* Formats one preference value from its schema.
|
|
83
88
|
*
|
|
84
|
-
* -
|
|
85
|
-
* - `*_percent`
|
|
86
|
-
* -
|
|
89
|
+
* - money → currency-formatted.
|
|
90
|
+
* - `*_percent` values → `N%`.
|
|
91
|
+
* - currencies → omitted (folded into the money formatting).
|
|
87
92
|
* - booleans → humanized key when true; skipped when false.
|
|
88
|
-
* - lists (tiers) → their length;
|
|
93
|
+
* - lists (tiers) → their length; objects → omitted.
|
|
89
94
|
* - everything else → `key: value`.
|
|
90
95
|
*/
|
|
91
96
|
function formatField(
|
|
92
|
-
|
|
97
|
+
key: string,
|
|
98
|
+
property: CalculatorPropertySchema,
|
|
93
99
|
value: unknown,
|
|
94
100
|
currency: string,
|
|
95
101
|
): string | null {
|
|
96
102
|
if (value === null || value === undefined || value === '') return null
|
|
97
|
-
if (
|
|
103
|
+
if (property.format === 'currency') return null
|
|
104
|
+
if (property.format === 'money') return formatMoney(value, currency)
|
|
105
|
+
if (/(?:^|_)percent$/.test(key)) return `${value}%`
|
|
98
106
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return `${humanize(
|
|
105
|
-
}
|
|
106
|
-
if (field.type === 'boolean') {
|
|
107
|
-
return value ? humanize(field.key) : null
|
|
108
|
-
}
|
|
109
|
-
if (field.type === 'array') {
|
|
110
|
-
return Array.isArray(value) && value.length ? `${humanize(field.key)}: ${value.length}` : null
|
|
107
|
+
const type = (Array.isArray(property.type) ? property.type : [property.type]).find(
|
|
108
|
+
(t) => t !== 'null',
|
|
109
|
+
)
|
|
110
|
+
if (type === 'boolean') return value ? humanize(key) : null
|
|
111
|
+
if (type === 'array') {
|
|
112
|
+
return Array.isArray(value) && value.length ? `${humanize(key)}: ${value.length}` : null
|
|
111
113
|
}
|
|
112
|
-
if (
|
|
113
|
-
return `${humanize(
|
|
114
|
+
if (type === 'object') return null
|
|
115
|
+
return `${humanize(key)}: ${value}`
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
function formatMoney(value: unknown, currency: string): string {
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
+
const amount = String(value)
|
|
120
|
+
if (!/^-?\d+(\.\d+)?$/.test(amount)) return amount
|
|
119
121
|
try {
|
|
120
|
-
|
|
122
|
+
// `Intl` reads a numeric string exactly; its ES2022 typings only list numbers.
|
|
123
|
+
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(
|
|
124
|
+
amount as unknown as number,
|
|
125
|
+
)
|
|
121
126
|
} catch {
|
|
122
|
-
return `${
|
|
127
|
+
return `${amount} ${currency}`
|
|
123
128
|
}
|
|
124
129
|
}
|
|
125
130
|
|
|
@@ -93,8 +93,11 @@ export function ClaimResolveDialog({
|
|
|
93
93
|
|
|
94
94
|
// Refunding nothing, or replacing nothing, is what the server rejects —
|
|
95
95
|
// say so here instead of letting the request fail.
|
|
96
|
+
// The amount is the canonical decimal string a number input yields, so
|
|
97
|
+
// "above zero" is unsigned digits with a non-zero digit among them.
|
|
98
|
+
const positiveAmount = /^(\d+(\.\d+)?|\.\d+)$/.test(amount) && /[1-9]/.test(amount)
|
|
96
99
|
const ready =
|
|
97
|
-
(!refunding ||
|
|
100
|
+
(!refunding || positiveAmount) && (!sendingReplacement || chosenReplacements.length > 0)
|
|
98
101
|
|
|
99
102
|
return (
|
|
100
103
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
@@ -138,7 +141,7 @@ export function ClaimResolveDialog({
|
|
|
138
141
|
<InputGroupInput
|
|
139
142
|
id="claim-resolve-amount"
|
|
140
143
|
type="number"
|
|
141
|
-
step="
|
|
144
|
+
step="any"
|
|
142
145
|
min="0"
|
|
143
146
|
value={amount}
|
|
144
147
|
onChange={(event) => setAmount(event.target.value)}
|
package/src/spree/icons.ts
CHANGED
|
@@ -100,18 +100,11 @@ export function CreateReturnDialog({
|
|
|
100
100
|
|
|
101
101
|
/** A line the claim can name, with what the customer paid for it. */
|
|
102
102
|
export type ClaimableLine = PostSaleUnit & {
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
/** What the customer paid for `quantity` units of the line, or blank when that is unknown. */
|
|
110
|
-
function paidForUnits(line: ClaimableLine, quantity: number): string {
|
|
111
|
-
const paid = Number(line.discountedAmount) + Number(line.additionalTaxTotal ?? 0)
|
|
112
|
-
if (quantity <= 0 || line.discountedAmount == null || !Number.isFinite(paid)) return ''
|
|
113
|
-
|
|
114
|
-
return ((paid * quantity) / line.quantity).toFixed(2)
|
|
103
|
+
/**
|
|
104
|
+
* What the customer paid, tax included, for that many units of the line, as
|
|
105
|
+
* a decimal string — the default refund. Blank when it is unknown.
|
|
106
|
+
*/
|
|
107
|
+
paidFor?: (quantity: number) => string
|
|
115
108
|
}
|
|
116
109
|
|
|
117
110
|
/**
|
|
@@ -189,7 +182,7 @@ export function CreateClaimDialog({
|
|
|
189
182
|
if (!typedAmounts[line.id]) {
|
|
190
183
|
setAmounts((current) => ({
|
|
191
184
|
...current,
|
|
192
|
-
[line.id]:
|
|
185
|
+
[line.id]: quantity > 0 ? (line.paidFor?.(quantity) ?? '') : '',
|
|
193
186
|
}))
|
|
194
187
|
}
|
|
195
188
|
}}
|
|
@@ -207,7 +200,7 @@ export function CreateClaimDialog({
|
|
|
207
200
|
<InputGroupInput
|
|
208
201
|
id={`claim-amount-${line.id}`}
|
|
209
202
|
type="number"
|
|
210
|
-
step="
|
|
203
|
+
step="any"
|
|
211
204
|
min="0"
|
|
212
205
|
value={amounts[line.id] ?? ''}
|
|
213
206
|
onChange={(event) => {
|
|
@@ -139,49 +139,6 @@ export function ReturnReceiveDialog({
|
|
|
139
139
|
)
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/** True when a received return is owed nothing, so completing it moves no money. */
|
|
143
|
-
export function returnOwesNothing(refundableTotal: string): boolean {
|
|
144
|
-
return Number(refundableTotal) === 0
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export type ReturnRefundFigures = {
|
|
148
|
-
status: string
|
|
149
|
-
refund_total: string
|
|
150
|
-
display_refund_total: string
|
|
151
|
-
refunded_total: string
|
|
152
|
-
display_refunded_total: string
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* What a return's card reports: what it is owed until money goes back, then
|
|
157
|
-
* what actually went back — which a merchant keeping a restocking fee makes
|
|
158
|
-
* less than it was owed, so that case also names the full amount. Money that
|
|
159
|
-
* went back counts even before the return is marked refunded: a refund that
|
|
160
|
-
* succeeded on one payment and was declined on the next leaves it received.
|
|
161
|
-
*/
|
|
162
|
-
export function returnRefundSummary(
|
|
163
|
-
returnRecord: ReturnRefundFigures,
|
|
164
|
-
):
|
|
165
|
-
| { kind: 'owed'; amount: string }
|
|
166
|
-
| { kind: 'refunded'; amount: string }
|
|
167
|
-
| { kind: 'refunded_short'; amount: string; total: string } {
|
|
168
|
-
const refunded = Number(returnRecord.refunded_total)
|
|
169
|
-
|
|
170
|
-
if (returnRecord.status !== 'refunded' && refunded === 0) {
|
|
171
|
-
return { kind: 'owed', amount: returnRecord.display_refund_total }
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (refunded < Number(returnRecord.refund_total)) {
|
|
175
|
-
return {
|
|
176
|
-
kind: 'refunded_short',
|
|
177
|
-
amount: returnRecord.display_refunded_total,
|
|
178
|
-
total: returnRecord.display_refund_total,
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return { kind: 'refunded', amount: returnRecord.display_refunded_total }
|
|
183
|
-
}
|
|
184
|
-
|
|
185
142
|
/**
|
|
186
143
|
* Gives the money back: how much, and by what means. A return owed nothing
|
|
187
144
|
* (a free gift sent back) is completed instead, with nothing to choose.
|
|
@@ -193,6 +150,7 @@ export function returnRefundSummary(
|
|
|
193
150
|
*/
|
|
194
151
|
export function ReturnRefundDialog({
|
|
195
152
|
refundableTotal,
|
|
153
|
+
owesNothing,
|
|
196
154
|
refundTaxTotal,
|
|
197
155
|
currencySymbol,
|
|
198
156
|
onClose,
|
|
@@ -200,6 +158,8 @@ export function ReturnRefundDialog({
|
|
|
200
158
|
pending = false,
|
|
201
159
|
}: {
|
|
202
160
|
refundableTotal: string
|
|
161
|
+
/** True when the return is owed nothing, so it is completed rather than refunded. */
|
|
162
|
+
owesNothing: boolean
|
|
203
163
|
refundTaxTotal?: string
|
|
204
164
|
currencySymbol: string
|
|
205
165
|
onClose: () => void
|
|
@@ -209,7 +169,6 @@ export function ReturnRefundDialog({
|
|
|
209
169
|
const { t } = useTranslation()
|
|
210
170
|
const [refundMethod, setRefundMethod] = useState<RefundMethod>('original_payment')
|
|
211
171
|
const [amount, setAmount] = useState(refundableTotal)
|
|
212
|
-
const owesNothing = returnOwesNothing(refundableTotal)
|
|
213
172
|
|
|
214
173
|
const methodOptions = [
|
|
215
174
|
{
|
|
@@ -250,7 +209,7 @@ export function ReturnRefundDialog({
|
|
|
250
209
|
<InputGroupInput
|
|
251
210
|
id="refund-amount"
|
|
252
211
|
type="number"
|
|
253
|
-
step="
|
|
212
|
+
step="any"
|
|
254
213
|
min="0"
|
|
255
214
|
value={amount}
|
|
256
215
|
onChange={(event) => setAmount(event.target.value)}
|