@spree/dashboard-ui 0.1.0
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/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +86 -0
- package/src/hooks/use-copy-to-clipboard.ts +44 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-scrolled.ts +30 -0
- package/src/index.ts +99 -0
- package/src/lib/date-locale.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/validation-messages.ts +19 -0
- package/src/spree/address-block.tsx +74 -0
- package/src/spree/back-button.tsx +48 -0
- package/src/spree/bulk-dialog.tsx +71 -0
- package/src/spree/bulk-price-table.tsx +254 -0
- package/src/spree/calculator-summary.tsx +120 -0
- package/src/spree/color-picker.tsx +202 -0
- package/src/spree/confirm-dialog.tsx +82 -0
- package/src/spree/copy-to-clipboard-button.tsx +90 -0
- package/src/spree/country-flag.tsx +30 -0
- package/src/spree/data-grid/cells.tsx +415 -0
- package/src/spree/data-grid/context.ts +59 -0
- package/src/spree/data-grid/data-grid.tsx +214 -0
- package/src/spree/data-grid/fill-handle.tsx +153 -0
- package/src/spree/data-grid/helpers.ts +18 -0
- package/src/spree/data-grid/index.ts +5 -0
- package/src/spree/data-grid/types.ts +36 -0
- package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
- package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
- package/src/spree/drag-handle.tsx +51 -0
- package/src/spree/form-actions.tsx +140 -0
- package/src/spree/json-preview-drawer.tsx +254 -0
- package/src/spree/json-value-view.tsx +149 -0
- package/src/spree/language-menu-items.tsx +54 -0
- package/src/spree/metadata/metadata-card.tsx +62 -0
- package/src/spree/relative-time.tsx +49 -0
- package/src/spree/resource-combobox.tsx +108 -0
- package/src/spree/resource-layout.tsx +31 -0
- package/src/spree/resource-multi-autocomplete.tsx +180 -0
- package/src/spree/resource-name-cell.tsx +44 -0
- package/src/spree/route-error-boundary.tsx +102 -0
- package/src/spree/row-actions.tsx +85 -0
- package/src/spree/row-click-bridge.tsx +17 -0
- package/src/spree/secret-input.tsx +137 -0
- package/src/spree/storefront-visible-switch.tsx +58 -0
- package/src/spree/tag-list.tsx +26 -0
- package/src/spree/theme-provider.tsx +83 -0
- package/src/spree/theme-toggle.tsx +35 -0
- package/src/styles.css +338 -0
- package/src/ui/attachment.tsx +187 -0
- package/src/ui/avatar.tsx +94 -0
- package/src/ui/badge.tsx +161 -0
- package/src/ui/breadcrumb.tsx +109 -0
- package/src/ui/button.tsx +60 -0
- package/src/ui/calendar.tsx +66 -0
- package/src/ui/card.tsx +72 -0
- package/src/ui/chart.tsx +94 -0
- package/src/ui/checkbox.tsx +50 -0
- package/src/ui/collapsible.tsx +18 -0
- package/src/ui/combobox.tsx +270 -0
- package/src/ui/command.tsx +172 -0
- package/src/ui/data-table.tsx +60 -0
- package/src/ui/date-picker.tsx +253 -0
- package/src/ui/date-range-picker.tsx +150 -0
- package/src/ui/dialog.tsx +214 -0
- package/src/ui/dropdown-menu.tsx +345 -0
- package/src/ui/empty.tsx +94 -0
- package/src/ui/field.tsx +221 -0
- package/src/ui/input-group.tsx +141 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/label.tsx +29 -0
- package/src/ui/pagination.tsx +123 -0
- package/src/ui/popover.tsx +76 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/rich-text-editor.tsx +263 -0
- package/src/ui/select.tsx +213 -0
- package/src/ui/separator.tsx +31 -0
- package/src/ui/sheet.tsx +208 -0
- package/src/ui/sidebar.tsx +679 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/slot.tsx +35 -0
- package/src/ui/sonner.tsx +47 -0
- package/src/ui/switch.tsx +39 -0
- package/src/ui/textarea.tsx +18 -0
- package/src/ui/tooltip.tsx +100 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
2
|
+
import { useMemo } from 'react'
|
|
3
|
+
import { Button } from '../ui/button'
|
|
4
|
+
import { Input } from '../ui/input'
|
|
5
|
+
import { DataGrid, editableRowIndex, MoneyCell, ReadOnlyCell } from './data-grid'
|
|
6
|
+
|
|
7
|
+
export interface BulkPriceRow {
|
|
8
|
+
// Unique row id. For header rows use a synthetic key (e.g. `header:<groupId>`);
|
|
9
|
+
// the table renders headers via `renderSectionHeader` without an editable cell.
|
|
10
|
+
id: string
|
|
11
|
+
kind: 'header' | 'item'
|
|
12
|
+
// Header rows: the section title (typically the product name).
|
|
13
|
+
groupLabel?: string
|
|
14
|
+
// Item rows.
|
|
15
|
+
variantLabel?: string | null
|
|
16
|
+
sku?: string | null
|
|
17
|
+
// Display strings — the caller decides whether to ship the locale-formatted
|
|
18
|
+
// user input or the API's canonical decimal. The table passes them through.
|
|
19
|
+
amount?: string | null
|
|
20
|
+
compareAt?: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BulkPriceTableLabels {
|
|
24
|
+
/** Column header for the variant label column. */
|
|
25
|
+
variant: string
|
|
26
|
+
/** Column header for the SKU column. */
|
|
27
|
+
sku: string
|
|
28
|
+
/** Column header for the amount column. */
|
|
29
|
+
price: string
|
|
30
|
+
/** Column header for the compare-at-amount column. */
|
|
31
|
+
compareAt: string
|
|
32
|
+
/** Placeholder for a variant row with no options text (e.g. "Default"). */
|
|
33
|
+
variantDefault: string
|
|
34
|
+
/** Placeholder text for the search input. Omit to hide the search input. */
|
|
35
|
+
searchPlaceholder?: string
|
|
36
|
+
/** A short message shown above the grid summarising the matching row count. */
|
|
37
|
+
countSummary?: string
|
|
38
|
+
/** Shown when the loading flag is set. */
|
|
39
|
+
loading: string
|
|
40
|
+
/** Page-of indicator template, e.g. "Page {page} of {total}". `{page}` and `{total}` are substituted. */
|
|
41
|
+
pageOf: string
|
|
42
|
+
/** Previous-page button label. */
|
|
43
|
+
prev: string
|
|
44
|
+
/** Next-page button label. */
|
|
45
|
+
next: string
|
|
46
|
+
/** Shown when not loading and rows.length === 0 and no search query. */
|
|
47
|
+
emptyMessage?: string
|
|
48
|
+
/** Shown when not loading and rows.length === 0 and a search query is set. */
|
|
49
|
+
emptySearchMessage?: string
|
|
50
|
+
/** Aria label on the DataGrid for screen readers. */
|
|
51
|
+
gridAriaLabel?: string
|
|
52
|
+
/** Aria-label template for the amount input. `{label}` is replaced with the variant label. */
|
|
53
|
+
priceAriaTemplate?: string
|
|
54
|
+
/** Aria-label template for the compare-at input. `{label}` is replaced with the variant label. */
|
|
55
|
+
compareAtAriaTemplate?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BulkPriceTableProps {
|
|
59
|
+
rows: BulkPriceRow[]
|
|
60
|
+
/** Currency symbol shown inside the money cells (e.g. "$", "€"). */
|
|
61
|
+
symbol: string
|
|
62
|
+
/** Decimal separator used by the cell formatter (e.g. "." or ","). */
|
|
63
|
+
decimal: string
|
|
64
|
+
/** Localized strings — the caller owns translations. */
|
|
65
|
+
labels: BulkPriceTableLabels
|
|
66
|
+
/** Called when the user commits a value to a cell. The id is the row id. */
|
|
67
|
+
onChange: (rowId: string, field: 'amount' | 'compareAt', value: string | null) => void
|
|
68
|
+
|
|
69
|
+
// Optional state for the toolbar + pagination footer. Omitting all of these
|
|
70
|
+
// hides the toolbar/pagination entirely (use for in-memory single-page lists).
|
|
71
|
+
search?: string
|
|
72
|
+
onSearchChange?: (next: string) => void
|
|
73
|
+
page?: number
|
|
74
|
+
totalPages?: number
|
|
75
|
+
onPageChange?: (next: number) => void
|
|
76
|
+
isLoading?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Presentational primitive for any "spreadsheet of variant prices" UI. Owns
|
|
81
|
+
* the DataGrid columns, money-cell wiring, currency-aware formatting, search
|
|
82
|
+
* input, and pagination footer. Owns NO data fetching, edit tracking, or save
|
|
83
|
+
* logic — the caller projects its rows into BulkPriceRow shape and writes
|
|
84
|
+
* edits back through `onChange`.
|
|
85
|
+
*/
|
|
86
|
+
export function BulkPriceTable({
|
|
87
|
+
rows,
|
|
88
|
+
symbol,
|
|
89
|
+
decimal,
|
|
90
|
+
labels,
|
|
91
|
+
onChange,
|
|
92
|
+
search,
|
|
93
|
+
onSearchChange,
|
|
94
|
+
page,
|
|
95
|
+
totalPages,
|
|
96
|
+
onPageChange,
|
|
97
|
+
isLoading,
|
|
98
|
+
}: BulkPriceTableProps) {
|
|
99
|
+
const columns = useMemo<ColumnDef<BulkPriceRow>[]>(
|
|
100
|
+
() => [
|
|
101
|
+
{
|
|
102
|
+
id: 'variant',
|
|
103
|
+
header: labels.variant,
|
|
104
|
+
cell: ({ row }) => {
|
|
105
|
+
const r = row.original
|
|
106
|
+
if (r.kind !== 'item') return null
|
|
107
|
+
return (
|
|
108
|
+
<ReadOnlyCell className="text-muted-foreground">
|
|
109
|
+
{r.variantLabel ?? labels.variantDefault}
|
|
110
|
+
</ReadOnlyCell>
|
|
111
|
+
)
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'sku',
|
|
116
|
+
header: labels.sku,
|
|
117
|
+
cell: ({ row }) => {
|
|
118
|
+
const r = row.original
|
|
119
|
+
if (r.kind !== 'item') return null
|
|
120
|
+
return (
|
|
121
|
+
<ReadOnlyCell className="font-mono text-xs text-muted-foreground">
|
|
122
|
+
{r.sku ?? '—'}
|
|
123
|
+
</ReadOnlyCell>
|
|
124
|
+
)
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'amount',
|
|
129
|
+
header: () => <span className="block text-right">{labels.price}</span>,
|
|
130
|
+
cell: ({ row, table }) => {
|
|
131
|
+
const r = row.original
|
|
132
|
+
if (r.kind !== 'item') return null
|
|
133
|
+
const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 2 }
|
|
134
|
+
const label = r.variantLabel ?? labels.variantDefault
|
|
135
|
+
return (
|
|
136
|
+
<MoneyCell
|
|
137
|
+
coords={coords}
|
|
138
|
+
value={r.amount ?? null}
|
|
139
|
+
onChange={(next) => onChange(r.id, 'amount', next)}
|
|
140
|
+
symbol={symbol}
|
|
141
|
+
decimal={decimal}
|
|
142
|
+
ariaLabel={labels.priceAriaTemplate?.replace('{label}', label) ?? label}
|
|
143
|
+
/>
|
|
144
|
+
)
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'compare_at',
|
|
149
|
+
header: () => <span className="block text-right">{labels.compareAt}</span>,
|
|
150
|
+
cell: ({ row, table }) => {
|
|
151
|
+
const r = row.original
|
|
152
|
+
if (r.kind !== 'item') return null
|
|
153
|
+
const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 3 }
|
|
154
|
+
const label = r.variantLabel ?? labels.variantDefault
|
|
155
|
+
return (
|
|
156
|
+
<MoneyCell
|
|
157
|
+
coords={coords}
|
|
158
|
+
value={r.compareAt ?? null}
|
|
159
|
+
onChange={(next) => onChange(r.id, 'compareAt', next)}
|
|
160
|
+
symbol={symbol}
|
|
161
|
+
decimal={decimal}
|
|
162
|
+
ariaLabel={labels.compareAtAriaTemplate?.replace('{label}', label) ?? label}
|
|
163
|
+
/>
|
|
164
|
+
)
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
[symbol, decimal, onChange, labels],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const showToolbar =
|
|
172
|
+
labels.countSummary !== undefined ||
|
|
173
|
+
(onSearchChange !== undefined && labels.searchPlaceholder !== undefined)
|
|
174
|
+
const showPagination =
|
|
175
|
+
page !== undefined && totalPages !== undefined && totalPages > 1 && onPageChange !== undefined
|
|
176
|
+
const isEmpty = !isLoading && rows.length === 0
|
|
177
|
+
const hasSearch = !!search && search.length > 0
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className="flex h-full flex-col gap-3">
|
|
181
|
+
{showToolbar && (
|
|
182
|
+
// Always-mounted toolbar. Conditionally rendering the search input
|
|
183
|
+
// would unmount it whenever a deferred query refetches into the
|
|
184
|
+
// loading state, blurring the field mid-keystroke.
|
|
185
|
+
<div className="flex shrink-0 items-center justify-between gap-3">
|
|
186
|
+
{labels.countSummary !== undefined && (
|
|
187
|
+
<p className="text-xs text-muted-foreground">{labels.countSummary}</p>
|
|
188
|
+
)}
|
|
189
|
+
{onSearchChange !== undefined && labels.searchPlaceholder !== undefined && (
|
|
190
|
+
<Input
|
|
191
|
+
type="search"
|
|
192
|
+
placeholder={labels.searchPlaceholder}
|
|
193
|
+
value={search ?? ''}
|
|
194
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
195
|
+
className="h-9 max-w-sm"
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
202
|
+
{isLoading ? (
|
|
203
|
+
<p className="text-sm text-muted-foreground">{labels.loading}</p>
|
|
204
|
+
) : isEmpty ? (
|
|
205
|
+
<p className="text-sm text-muted-foreground">
|
|
206
|
+
{hasSearch
|
|
207
|
+
? (labels.emptySearchMessage ?? labels.emptyMessage ?? '')
|
|
208
|
+
: (labels.emptyMessage ?? '')}
|
|
209
|
+
</p>
|
|
210
|
+
) : (
|
|
211
|
+
<DataGrid<BulkPriceRow>
|
|
212
|
+
rows={rows}
|
|
213
|
+
columns={columns}
|
|
214
|
+
getRowId={(row) => row.id}
|
|
215
|
+
renderSectionHeader={(row) =>
|
|
216
|
+
row.kind === 'header' ? (
|
|
217
|
+
<div className="truncate font-medium">{row.groupLabel}</div>
|
|
218
|
+
) : null
|
|
219
|
+
}
|
|
220
|
+
aria-label={labels.gridAriaLabel}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{showPagination && (
|
|
226
|
+
<div className="flex shrink-0 items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
227
|
+
<span>
|
|
228
|
+
{labels.pageOf.replace('{page}', String(page)).replace('{total}', String(totalPages))}
|
|
229
|
+
</span>
|
|
230
|
+
<div className="flex gap-1">
|
|
231
|
+
<Button
|
|
232
|
+
type="button"
|
|
233
|
+
size="sm"
|
|
234
|
+
variant="outline"
|
|
235
|
+
onClick={() => onPageChange(Math.max(1, page - 1))}
|
|
236
|
+
disabled={page === 1 || isLoading}
|
|
237
|
+
>
|
|
238
|
+
{labels.prev}
|
|
239
|
+
</Button>
|
|
240
|
+
<Button
|
|
241
|
+
type="button"
|
|
242
|
+
size="sm"
|
|
243
|
+
variant="outline"
|
|
244
|
+
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
|
245
|
+
disabled={page === totalPages || isLoading}
|
|
246
|
+
>
|
|
247
|
+
{labels.next}
|
|
248
|
+
</Button>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-line preview of a calculator's configuration — "Flat Rate · $5.00",
|
|
3
|
+
* "Percent on line item · 10%", etc. Used for promotion-action rows
|
|
4
|
+
* today; intended as the shared row-summary for any calculator-backed
|
|
5
|
+
* model (delivery methods, tax rates, …).
|
|
6
|
+
*
|
|
7
|
+
* The expected payload mirrors the admin API's calculator embed: a
|
|
8
|
+
* `{ label, preferences, preference_schema }` triple. The component is
|
|
9
|
+
* tolerant of partial input — missing schema or preferences just fall
|
|
10
|
+
* back to the label alone.
|
|
11
|
+
*
|
|
12
|
+
* Lives on the frontend (rather than as a server-rendered string) so
|
|
13
|
+
* draft edits update the row immediately, before any save.
|
|
14
|
+
*/
|
|
15
|
+
export interface CalculatorPayload {
|
|
16
|
+
/** Human-readable name (e.g. "Flat Rate"). Optional; if missing the type is demodulized. */
|
|
17
|
+
label?: string | null
|
|
18
|
+
/** STI class name (e.g. "Spree::Calculator::FlatRate"). Used as a label fallback. */
|
|
19
|
+
type?: string | null
|
|
20
|
+
preferences?: Record<string, unknown> | null
|
|
21
|
+
preference_schema?: ReadonlyArray<{ key: string; type: string }> | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CalculatorSummaryProps {
|
|
25
|
+
calculator: CalculatorPayload | null | undefined
|
|
26
|
+
/**
|
|
27
|
+
* What to show when `calculator` is missing or empty. Use this to
|
|
28
|
+
* differentiate "no calculator picked" from "calculator with no
|
|
29
|
+
* preferences yet." Defaults to nothing (returns null).
|
|
30
|
+
*/
|
|
31
|
+
fallback?: React.ReactNode
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function CalculatorSummary({
|
|
36
|
+
calculator,
|
|
37
|
+
fallback = null,
|
|
38
|
+
className,
|
|
39
|
+
}: CalculatorSummaryProps) {
|
|
40
|
+
const text = formatCalculatorSummary(calculator)
|
|
41
|
+
if (!text) return <>{fallback}</>
|
|
42
|
+
return <span className={className}>{text}</span>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pure formatter — exported for callers that want to embed the string
|
|
47
|
+
* inside a larger sentence (e.g. "Free shipping · {summary}") or use
|
|
48
|
+
* it in a non-React context.
|
|
49
|
+
*/
|
|
50
|
+
export function formatCalculatorSummary(
|
|
51
|
+
calculator: CalculatorPayload | null | undefined,
|
|
52
|
+
): string | null {
|
|
53
|
+
if (!calculator) return null
|
|
54
|
+
|
|
55
|
+
const label =
|
|
56
|
+
calculator.label?.trim() ||
|
|
57
|
+
(calculator.type ? (calculator.type.split('::').pop() ?? calculator.type) : '')
|
|
58
|
+
|
|
59
|
+
const schema = calculator.preference_schema
|
|
60
|
+
const prefs = calculator.preferences
|
|
61
|
+
|
|
62
|
+
if (!schema?.length || !prefs) return label || null
|
|
63
|
+
|
|
64
|
+
const currency = (typeof prefs.currency === 'string' && prefs.currency) || 'USD'
|
|
65
|
+
const details = schema
|
|
66
|
+
.map((field) => formatField(field, prefs[field.key], currency))
|
|
67
|
+
.filter((s): s is string => s !== null)
|
|
68
|
+
.join(', ')
|
|
69
|
+
|
|
70
|
+
if (!details) return label || null
|
|
71
|
+
return label ? `${label} · ${details}` : details
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Formats one preference value by naming convention. Calculators don't
|
|
76
|
+
* carry richer field metadata (no `display_format`, no `unit`), so we
|
|
77
|
+
* fall back to key-name heuristics that match the Ruby preference
|
|
78
|
+
* declarations in `Spree::Calculator::*`.
|
|
79
|
+
*
|
|
80
|
+
* - `amount` / `*_amount` decimals → currency-formatted.
|
|
81
|
+
* - `*_percent` decimals → `N%`.
|
|
82
|
+
* - `currency` strings → omitted (folded into the money formatting).
|
|
83
|
+
* - booleans → humanized key when true; skipped when false.
|
|
84
|
+
* - everything else → `key: value`.
|
|
85
|
+
*/
|
|
86
|
+
function formatField(
|
|
87
|
+
field: { key: string; type: string },
|
|
88
|
+
value: unknown,
|
|
89
|
+
currency: string,
|
|
90
|
+
): string | null {
|
|
91
|
+
if (value === null || value === undefined || value === '') return null
|
|
92
|
+
if (field.key === 'currency') return null
|
|
93
|
+
|
|
94
|
+
if (field.type === 'decimal') {
|
|
95
|
+
if (/(?:^|_)percent$/.test(field.key)) return `${value}%`
|
|
96
|
+
if (field.key === 'amount' || field.key.endsWith('_amount')) {
|
|
97
|
+
return formatMoney(value, currency)
|
|
98
|
+
}
|
|
99
|
+
return `${humanize(field.key)}: ${value}`
|
|
100
|
+
}
|
|
101
|
+
if (field.type === 'boolean') {
|
|
102
|
+
return value ? humanize(field.key) : null
|
|
103
|
+
}
|
|
104
|
+
return `${humanize(field.key)}: ${value}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatMoney(value: unknown, currency: string): string {
|
|
108
|
+
const n = typeof value === 'number' ? value : Number(value)
|
|
109
|
+
if (Number.isNaN(n)) return String(value)
|
|
110
|
+
try {
|
|
111
|
+
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(n)
|
|
112
|
+
} catch {
|
|
113
|
+
return `${n} ${currency}`
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function humanize(key: string): string {
|
|
118
|
+
const spaced = key.replace(/_/g, ' ').trim()
|
|
119
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
|
120
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { HexColorPicker } from 'react-colorful'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import { Input } from '../ui/input'
|
|
6
|
+
|
|
7
|
+
/** Matches the server-side validation in `Spree::OptionValue#color_code`. */
|
|
8
|
+
const HEX_RE = /^#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/
|
|
9
|
+
|
|
10
|
+
function normalizeHex(input: string | null | undefined): string | null {
|
|
11
|
+
if (!input) return null
|
|
12
|
+
const trimmed = input.trim()
|
|
13
|
+
if (!trimmed) return null
|
|
14
|
+
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
|
|
15
|
+
return HEX_RE.test(withHash) ? withHash.toUpperCase() : null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ColorPickerProps {
|
|
19
|
+
/** Hex value with `#` prefix (e.g. `#FF0000`). `null` / `undefined` / `''` for empty. */
|
|
20
|
+
value: string | null | undefined
|
|
21
|
+
/** Fires with a normalized `#RRGGBB` value, or `null` when cleared. */
|
|
22
|
+
onChange: (value: string | null) => void
|
|
23
|
+
/** Disables the trigger and locks the panel closed. */
|
|
24
|
+
disabled?: boolean
|
|
25
|
+
/** Forwarded to the text input for label association. */
|
|
26
|
+
id?: string
|
|
27
|
+
/** Forwarded to the text input. */
|
|
28
|
+
'aria-invalid'?: boolean
|
|
29
|
+
className?: string
|
|
30
|
+
placeholder?: string
|
|
31
|
+
/**
|
|
32
|
+
* Hides the inline hex input — the swatch button alone becomes the trigger
|
|
33
|
+
* and the hex input moves into the picker panel. Use in dense surfaces
|
|
34
|
+
* (table cells, repeated row editors) where the inline input would crowd
|
|
35
|
+
* the layout. Default `false`.
|
|
36
|
+
*/
|
|
37
|
+
compact?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* Which edge of the swatch the panel anchors to. `start` (default) grows
|
|
40
|
+
* the panel rightward from the swatch's left edge; `end` grows it leftward
|
|
41
|
+
* from the swatch's right edge — use when the swatch sits near the right
|
|
42
|
+
* edge of its container (e.g. the rightmost cells in a table).
|
|
43
|
+
*/
|
|
44
|
+
panelAlign?: 'start' | 'end'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hex color picker with a swatch trigger and a free-form text input.
|
|
49
|
+
*
|
|
50
|
+
* The picker panel is rendered **inline** (not via a portal) because Base UI's
|
|
51
|
+
* `<Popover>` Portal mechanism interacts poorly with deeply-nested portal trees
|
|
52
|
+
* (e.g. when the picker lives inside a `<Sheet>` inside a `<TableRow>` inside a
|
|
53
|
+
* `useSortable` context — the Popup component silently fails to mount). The
|
|
54
|
+
* inline approach uses an absolute-positioned panel with click-outside +
|
|
55
|
+
* Escape-to-close handling.
|
|
56
|
+
*/
|
|
57
|
+
export function ColorPicker({
|
|
58
|
+
value,
|
|
59
|
+
onChange,
|
|
60
|
+
disabled,
|
|
61
|
+
id,
|
|
62
|
+
'aria-invalid': ariaInvalid,
|
|
63
|
+
className,
|
|
64
|
+
placeholder = '#000000',
|
|
65
|
+
compact = false,
|
|
66
|
+
panelAlign = 'start',
|
|
67
|
+
}: ColorPickerProps) {
|
|
68
|
+
const { t } = useTranslation()
|
|
69
|
+
const normalizedValue = normalizeHex(value)
|
|
70
|
+
const [open, setOpen] = useState(false)
|
|
71
|
+
// Local text mirrors `value` but lets the user type freely (including
|
|
72
|
+
// invalid intermediate states like `#FF`) without bouncing through onChange.
|
|
73
|
+
const [text, setText] = useState(normalizedValue ?? '')
|
|
74
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
75
|
+
|
|
76
|
+
// Resync when the form-level value changes (e.g. when the picker writes to
|
|
77
|
+
// a different row, or when the form resets after a save).
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
setText(normalizedValue ?? '')
|
|
80
|
+
}, [normalizedValue])
|
|
81
|
+
|
|
82
|
+
// `pointerdown` (not `click`) fires before the trigger's own click, matching
|
|
83
|
+
// popover-library behavior so reopening doesn't double-toggle.
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!open) return
|
|
86
|
+
function handle(event: PointerEvent) {
|
|
87
|
+
const target = event.target as Node | null
|
|
88
|
+
if (!target || !containerRef.current) return
|
|
89
|
+
if (!containerRef.current.contains(target)) {
|
|
90
|
+
setOpen(false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function handleKey(event: KeyboardEvent) {
|
|
94
|
+
if (event.key === 'Escape') setOpen(false)
|
|
95
|
+
}
|
|
96
|
+
document.addEventListener('pointerdown', handle)
|
|
97
|
+
document.addEventListener('keydown', handleKey)
|
|
98
|
+
return () => {
|
|
99
|
+
document.removeEventListener('pointerdown', handle)
|
|
100
|
+
document.removeEventListener('keydown', handleKey)
|
|
101
|
+
}
|
|
102
|
+
}, [open])
|
|
103
|
+
|
|
104
|
+
function commitText(raw: string) {
|
|
105
|
+
const normalized = normalizeHex(raw)
|
|
106
|
+
if (normalized) {
|
|
107
|
+
onChange(normalized)
|
|
108
|
+
setText(normalized)
|
|
109
|
+
} else if (raw.trim() === '') {
|
|
110
|
+
onChange(null)
|
|
111
|
+
setText('')
|
|
112
|
+
}
|
|
113
|
+
// else: invalid input — keep the text, don't propagate
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hexInput = (
|
|
117
|
+
<Input
|
|
118
|
+
id={id}
|
|
119
|
+
type="text"
|
|
120
|
+
inputMode="text"
|
|
121
|
+
autoComplete="off"
|
|
122
|
+
spellCheck={false}
|
|
123
|
+
value={text}
|
|
124
|
+
placeholder={placeholder}
|
|
125
|
+
disabled={disabled}
|
|
126
|
+
aria-invalid={ariaInvalid}
|
|
127
|
+
className="font-mono uppercase"
|
|
128
|
+
onChange={(e) => setText(e.target.value)}
|
|
129
|
+
onBlur={(e) => commitText(e.target.value)}
|
|
130
|
+
onKeyDown={(e) => {
|
|
131
|
+
if (e.key === 'Enter') {
|
|
132
|
+
e.preventDefault()
|
|
133
|
+
commitText(e.currentTarget.value)
|
|
134
|
+
}
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div ref={containerRef} className={cn('relative flex items-center gap-2', className)}>
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
disabled={disabled}
|
|
144
|
+
aria-label={
|
|
145
|
+
normalizedValue
|
|
146
|
+
? t('admin.a11y.change_color', { value: normalizedValue })
|
|
147
|
+
: t('admin.a11y.pick_color')
|
|
148
|
+
}
|
|
149
|
+
aria-haspopup="dialog"
|
|
150
|
+
aria-expanded={open}
|
|
151
|
+
onClick={() => setOpen((v) => !v)}
|
|
152
|
+
className={cn(
|
|
153
|
+
'inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border shadow-xs transition-shadow',
|
|
154
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
155
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
156
|
+
!normalizedValue && 'color-picker-empty-swatch',
|
|
157
|
+
)}
|
|
158
|
+
style={normalizedValue ? { backgroundColor: normalizedValue } : undefined}
|
|
159
|
+
/>
|
|
160
|
+
{!compact && hexInput}
|
|
161
|
+
{open && (
|
|
162
|
+
<div
|
|
163
|
+
role="dialog"
|
|
164
|
+
aria-label={t('admin.a11y.pick_color')}
|
|
165
|
+
className={cn(
|
|
166
|
+
'absolute top-full z-50 mt-2 min-w-min rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-md',
|
|
167
|
+
panelAlign === 'end' ? 'right-0' : 'left-0',
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
<HexColorPicker
|
|
171
|
+
color={normalizedValue ?? '#000000'}
|
|
172
|
+
onChange={(next) => {
|
|
173
|
+
const normalized = normalizeHex(next)
|
|
174
|
+
if (normalized) {
|
|
175
|
+
setText(normalized)
|
|
176
|
+
onChange(normalized)
|
|
177
|
+
}
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
{compact && <div className="mt-3">{hexInput}</div>}
|
|
181
|
+
<div className="mt-3 flex items-center justify-between gap-2">
|
|
182
|
+
<span className="font-mono text-xs text-muted-foreground">
|
|
183
|
+
{normalizedValue ?? t('admin.color_picker.no_color')}
|
|
184
|
+
</span>
|
|
185
|
+
{normalizedValue && (
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={() => {
|
|
189
|
+
setText('')
|
|
190
|
+
onChange(null)
|
|
191
|
+
}}
|
|
192
|
+
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
|
193
|
+
>
|
|
194
|
+
{t('admin.actions.clear')}
|
|
195
|
+
</button>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useCallback, useContext, useRef, useState } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Button } from '../ui/button'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '../ui/dialog'
|
|
12
|
+
|
|
13
|
+
interface ConfirmOptions {
|
|
14
|
+
title?: string
|
|
15
|
+
message: string
|
|
16
|
+
confirmLabel?: string
|
|
17
|
+
cancelLabel?: string
|
|
18
|
+
variant?: 'default' | 'destructive'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>
|
|
22
|
+
|
|
23
|
+
const ConfirmContext = createContext<ConfirmFn | null>(null)
|
|
24
|
+
|
|
25
|
+
export function useConfirm(): ConfirmFn {
|
|
26
|
+
const ctx = useContext(ConfirmContext)
|
|
27
|
+
if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider')
|
|
28
|
+
return ctx
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
32
|
+
const { t } = useTranslation()
|
|
33
|
+
const [open, setOpen] = useState(false)
|
|
34
|
+
const [options, setOptions] = useState<ConfirmOptions>({
|
|
35
|
+
message: '',
|
|
36
|
+
})
|
|
37
|
+
const resolveRef = useRef<((value: boolean) => void) | null>(null)
|
|
38
|
+
|
|
39
|
+
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
|
|
40
|
+
setOptions(opts)
|
|
41
|
+
setOpen(true)
|
|
42
|
+
return new Promise<boolean>((resolve) => {
|
|
43
|
+
resolveRef.current = resolve
|
|
44
|
+
})
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
const handleClose = useCallback((confirmed: boolean) => {
|
|
48
|
+
setOpen(false)
|
|
49
|
+
resolveRef.current?.(confirmed)
|
|
50
|
+
resolveRef.current = null
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<ConfirmContext.Provider value={confirm}>
|
|
55
|
+
{children}
|
|
56
|
+
<Dialog
|
|
57
|
+
open={open}
|
|
58
|
+
onOpenChange={(o) => {
|
|
59
|
+
if (!o) handleClose(false)
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<DialogContent showCloseButton={false}>
|
|
63
|
+
<DialogHeader>
|
|
64
|
+
<DialogTitle>{options.title ?? t('admin.components.confirm_dialog.title')}</DialogTitle>
|
|
65
|
+
<DialogDescription>{options.message}</DialogDescription>
|
|
66
|
+
</DialogHeader>
|
|
67
|
+
<DialogFooter>
|
|
68
|
+
<Button variant="outline" onClick={() => handleClose(false)}>
|
|
69
|
+
{options.cancelLabel ?? t('admin.actions.cancel')}
|
|
70
|
+
</Button>
|
|
71
|
+
<Button
|
|
72
|
+
variant={options.variant === 'destructive' ? 'destructive' : 'default'}
|
|
73
|
+
onClick={() => handleClose(true)}
|
|
74
|
+
>
|
|
75
|
+
{options.confirmLabel ?? t('admin.actions.confirm')}
|
|
76
|
+
</Button>
|
|
77
|
+
</DialogFooter>
|
|
78
|
+
</DialogContent>
|
|
79
|
+
</Dialog>
|
|
80
|
+
</ConfirmContext.Provider>
|
|
81
|
+
)
|
|
82
|
+
}
|