@spree/dashboard-ui 1.0.0-rc.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-ui",
3
3
  "license": "MIT",
4
- "version": "1.0.0-rc.1",
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",
@@ -63,9 +63,11 @@
63
63
  "@fontsource-variable/geist": "5.2.8",
64
64
  "@fontsource-variable/geist-mono": "5.2.7",
65
65
  "@tanstack/react-table": "8.21.3",
66
+ "@tiptap/core": "3.31.3",
66
67
  "@tiptap/extension-image": "3.31.3",
67
68
  "@tiptap/extension-link": "3.31.3",
68
69
  "@tiptap/extension-placeholder": "3.31.3",
70
+ "@tiptap/pm": "3.31.3",
69
71
  "@tiptap/react": "3.31.3",
70
72
  "@tiptap/starter-kit": "3.31.3",
71
73
  "@uiw/react-json-view": "1.12.2",
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, preference_schema }` triple. The component is
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
- preference_schema?: ReadonlyArray<{ key: string; type: string }> | null
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 schema = calculator.preference_schema
71
+ const properties = Object.entries(calculator.schema?.properties ?? {})
64
72
  const prefs = calculator.preferences
65
73
 
66
- if (!schema?.length || !prefs) return label || null
74
+ if (!properties.length || !prefs) return label || null
67
75
 
68
76
  const currency = (typeof prefs.currency === 'string' && prefs.currency) || 'USD'
69
- const details = schema
70
- .map((field) => formatField(field, prefs[field.key], currency))
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 by naming convention. Calculators don't
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
- * - `amount` / `*_amount` decimals → currency-formatted.
85
- * - `*_percent` decimals → `N%`.
86
- * - `currency` strings → omitted (folded into the money formatting).
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; other objects → omitted.
93
+ * - lists (tiers) → their length; objects → omitted.
89
94
  * - everything else → `key: value`.
90
95
  */
91
96
  function formatField(
92
- field: { key: string; type: string },
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 (field.key === 'currency') return null
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
- if (field.type === 'decimal') {
100
- if (/(?:^|_)percent$/.test(field.key)) return `${value}%`
101
- if (field.key === 'amount' || field.key.endsWith('_amount')) {
102
- return formatMoney(value, currency)
103
- }
104
- return `${humanize(field.key)}: ${value}`
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 (field.type === 'hash') return null
113
- return `${humanize(field.key)}: ${value}`
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 n = typeof value === 'number' ? value : Number(value)
118
- if (Number.isNaN(n)) return String(value)
119
+ const amount = String(value)
120
+ if (!/^-?\d+(\.\d+)?$/.test(amount)) return amount
119
121
  try {
120
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(n)
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 `${n} ${currency}`
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 || Number(amount) > 0) && (!sendingReplacement || chosenReplacements.length > 0)
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="0.01"
144
+ step="any"
142
145
  min="0"
143
146
  value={amount}
144
147
  onChange={(event) => setAmount(event.target.value)}
@@ -18,6 +18,7 @@ export {
18
18
  BanIcon,
19
19
  BanknoteIcon,
20
20
  BarChart3Icon as AnalyticsIcon,
21
+ BellIcon,
21
22
  BoldIcon,
22
23
  BookOpenIcon,
23
24
  BoxesIcon,
@@ -127,7 +127,7 @@ export function LabelUploadDialog({
127
127
  <InputGroupInput
128
128
  id="label-cost"
129
129
  type="number"
130
- step="0.01"
130
+ step="any"
131
131
  min="0"
132
132
  {...form.register('cost')}
133
133
  />
@@ -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
- /** The whole line after discounts, used to default the refund to what was actually paid. */
104
- discountedAmount?: string | null
105
- /** Tax charged on top of the line's price, which a claim refunds with it. */
106
- additionalTaxTotal?: string | null
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]: paidForUnits(line, quantity),
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="0.01"
203
+ step="any"
211
204
  min="0"
212
205
  value={amounts[line.id] ?? ''}
213
206
  onChange={(event) => {
@@ -106,7 +106,7 @@ export function QuantityTierEditor({
106
106
  )}
107
107
  <InputGroupInput
108
108
  type="number"
109
- step="0.01"
109
+ step="any"
110
110
  value={row.value}
111
111
  disabled={disabled}
112
112
  aria-label={labels.value}
@@ -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="0.01"
212
+ step="any"
254
213
  min="0"
255
214
  value={amount}
256
215
  onChange={(event) => setAmount(event.target.value)}