@spree/dashboard-ui 0.13.1 → 1.0.0-beta.2

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.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +38 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/back-button.tsx +14 -9
  11. package/src/spree/bulk-dialog.tsx +2 -2
  12. package/src/spree/bulk-price-table.tsx +145 -14
  13. package/src/spree/bulk-variants-table.tsx +434 -0
  14. package/src/spree/calculator-summary.tsx +5 -1
  15. package/src/spree/claim-resolve-dialog.tsx +219 -0
  16. package/src/spree/confirm-dialog.tsx +1 -1
  17. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  18. package/src/spree/data-grid/cells.tsx +199 -25
  19. package/src/spree/data-grid/context.ts +6 -0
  20. package/src/spree/data-grid/data-grid.tsx +149 -7
  21. package/src/spree/data-grid/helpers.ts +6 -2
  22. package/src/spree/data-grid/index.ts +9 -1
  23. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  24. package/src/spree/delivery-form-dialog.tsx +166 -0
  25. package/src/spree/drag-handle.tsx +2 -2
  26. package/src/spree/editable-note-card.tsx +103 -0
  27. package/src/spree/external-link.tsx +56 -0
  28. package/src/spree/file-type-icon.tsx +68 -0
  29. package/src/spree/form-actions.tsx +2 -3
  30. package/src/spree/form-section.tsx +46 -0
  31. package/src/spree/fulfill-items-form.tsx +282 -0
  32. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  33. package/src/spree/fulfillment-item-list.tsx +61 -0
  34. package/src/spree/fulfillment-panel.tsx +53 -0
  35. package/src/spree/icons.ts +167 -0
  36. package/src/spree/json-preview-drawer.tsx +4 -4
  37. package/src/spree/label-upload-dialog.tsx +195 -0
  38. package/src/spree/language-menu-items.tsx +37 -30
  39. package/src/spree/metadata/metadata-card.tsx +1 -1
  40. package/src/spree/order-cancel-dialog.tsx +149 -0
  41. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  42. package/src/spree/post-sale-fields.tsx +133 -0
  43. package/src/spree/product-membership-list.tsx +453 -0
  44. package/src/spree/products/status-card.tsx +79 -0
  45. package/src/spree/quantity-tier-editor.tsx +162 -0
  46. package/src/spree/resource-combobox.tsx +12 -1
  47. package/src/spree/resource-name-cell.tsx +21 -4
  48. package/src/spree/return-dialogs.tsx +238 -0
  49. package/src/spree/route-error-boundary.tsx +1 -1
  50. package/src/spree/row-actions.tsx +2 -2
  51. package/src/spree/search-input.tsx +76 -0
  52. package/src/spree/secret-input.tsx +26 -40
  53. package/src/spree/tax-identifiers-card.tsx +418 -0
  54. package/src/spree/theme-toggle.tsx +36 -23
  55. package/src/spree/wizard.tsx +254 -0
  56. package/src/styles.css +750 -75
  57. package/src/ui/accordion.tsx +83 -0
  58. package/src/ui/alert.tsx +79 -0
  59. package/src/ui/attachment.tsx +1 -1
  60. package/src/ui/badge.tsx +198 -56
  61. package/src/ui/breadcrumb.tsx +1 -1
  62. package/src/ui/button.tsx +22 -10
  63. package/src/ui/calendar.tsx +1 -1
  64. package/src/ui/card.tsx +52 -6
  65. package/src/ui/checkbox.tsx +13 -5
  66. package/src/ui/combobox.tsx +125 -6
  67. package/src/ui/command.tsx +149 -23
  68. package/src/ui/context-menu.tsx +99 -0
  69. package/src/ui/data-table.tsx +448 -9
  70. package/src/ui/date-picker.tsx +18 -3
  71. package/src/ui/date-range-picker.tsx +47 -60
  72. package/src/ui/dialog.tsx +6 -6
  73. package/src/ui/dropdown-menu.tsx +18 -11
  74. package/src/ui/field.tsx +10 -2
  75. package/src/ui/input-group.tsx +1 -1
  76. package/src/ui/input.tsx +1 -1
  77. package/src/ui/media-preview.tsx +105 -0
  78. package/src/ui/pagination.tsx +19 -11
  79. package/src/ui/popover.tsx +4 -2
  80. package/src/ui/progress.tsx +5 -2
  81. package/src/ui/radio-group.tsx +4 -4
  82. package/src/ui/rich-text-editor.tsx +214 -88
  83. package/src/ui/scroll-area.tsx +65 -0
  84. package/src/ui/select.tsx +17 -10
  85. package/src/ui/separator.tsx +1 -1
  86. package/src/ui/sheet.tsx +49 -7
  87. package/src/ui/sidebar.tsx +138 -37
  88. package/src/ui/skeleton.tsx +3 -1
  89. package/src/ui/tabs.tsx +84 -0
  90. package/src/ui/textarea.tsx +1 -1
  91. package/src/ui/thumbnail.tsx +80 -0
  92. package/src/ui/toast.tsx +112 -0
  93. package/src/ui/tooltip.tsx +3 -1
  94. package/src/ui/sonner.tsx +0 -47
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The dashboard's icon set.
3
+ *
4
+ * Every icon the dashboard renders is re-exported from here rather than
5
+ * imported from `lucide-react` directly, so the icon set is swappable in one
6
+ * file instead of across hundreds. Adding an icon means adding a line here.
7
+ *
8
+ * `LucideIcon` is the icon *type* the nav registries and the plugin API are
9
+ * written against; it still comes straight from `lucide-react`.
10
+ */
11
+ export {
12
+ AlertTriangleIcon,
13
+ ArchiveIcon,
14
+ ArrowLeftIcon,
15
+ ArrowLeftRightIcon,
16
+ ArrowUpDownIcon,
17
+ BadgeCheckIcon,
18
+ BanIcon,
19
+ BanknoteIcon,
20
+ BarChart3Icon as AnalyticsIcon,
21
+ BoldIcon,
22
+ BookOpenIcon,
23
+ BoxesIcon,
24
+ BracesIcon,
25
+ Building2Icon,
26
+ CalendarIcon,
27
+ CalendarOffIcon,
28
+ ChartColumnIcon,
29
+ CheckCircle2Icon,
30
+ CheckCircleIcon,
31
+ CheckIcon,
32
+ ChevronDownIcon,
33
+ ChevronLeftIcon,
34
+ ChevronRightIcon,
35
+ ChevronsLeftIcon,
36
+ ChevronsRightIcon,
37
+ ChevronsUpDownIcon,
38
+ CircleCheckIcon,
39
+ CircleDotIcon,
40
+ CircleIcon,
41
+ ClipboardCheckIcon,
42
+ ClockIcon,
43
+ Columns3Icon,
44
+ CommandIcon,
45
+ CopyIcon,
46
+ CornerDownLeftIcon,
47
+ CreditCardIcon,
48
+ DownloadIcon,
49
+ EllipsisVerticalIcon,
50
+ ExternalLinkIcon,
51
+ EyeIcon,
52
+ EyeOffIcon,
53
+ FileArchiveIcon,
54
+ FileAudioIcon,
55
+ FileCodeIcon,
56
+ FileIcon,
57
+ FileImageIcon,
58
+ FileSpreadsheetIcon,
59
+ FileTextIcon,
60
+ FileVideoIcon,
61
+ FilmIcon,
62
+ FilterIcon,
63
+ FolderMinusIcon,
64
+ FolderPlusIcon,
65
+ FolderTreeIcon,
66
+ GiftIcon,
67
+ GlobeIcon,
68
+ GlobeLockIcon,
69
+ GripVerticalIcon,
70
+ HandCoinsIcon,
71
+ HomeIcon,
72
+ ImageIcon,
73
+ ImagePlusIcon,
74
+ InboxIcon,
75
+ InfoIcon,
76
+ ItalicIcon,
77
+ KeyRoundIcon,
78
+ LanguagesIcon,
79
+ LayersIcon,
80
+ LibraryIcon,
81
+ LinkIcon,
82
+ ListChecksIcon,
83
+ ListFilter,
84
+ ListIcon,
85
+ ListOrderedIcon,
86
+ Loader2,
87
+ Loader2Icon,
88
+ Locate,
89
+ LockIcon,
90
+ LogOutIcon,
91
+ MailIcon,
92
+ MapIcon,
93
+ MapPinIcon,
94
+ Maximize,
95
+ MenuIcon,
96
+ MessageCircleIcon,
97
+ Minus,
98
+ MinusIcon,
99
+ MonitorIcon,
100
+ MoonStarIcon,
101
+ MoreHorizontalIcon,
102
+ MoveDownIcon,
103
+ MoveUpIcon,
104
+ OctagonXIcon,
105
+ PackageCheckIcon,
106
+ PackageIcon,
107
+ PackageXIcon,
108
+ PanelLeftIcon,
109
+ PaperclipIcon,
110
+ PauseIcon,
111
+ PencilIcon,
112
+ PercentIcon,
113
+ PhoneIcon,
114
+ PlayIcon,
115
+ PlugIcon,
116
+ Plus,
117
+ PlusIcon,
118
+ PrinterIcon,
119
+ QuoteIcon,
120
+ RadioTowerIcon,
121
+ ReceiptTextIcon,
122
+ RedoIcon,
123
+ RepeatIcon,
124
+ RotateCcwIcon,
125
+ RotateCcwIcon as ReturnRequestIcon,
126
+ ScrollTextIcon,
127
+ SearchIcon,
128
+ SendIcon,
129
+ SettingsIcon,
130
+ ShapesIcon,
131
+ ShieldAlertIcon,
132
+ ShieldCheckIcon,
133
+ ShieldIcon,
134
+ ShoppingCartIcon,
135
+ SlidersHorizontalIcon,
136
+ SparklesIcon,
137
+ SplitIcon,
138
+ StarIcon,
139
+ StoreIcon,
140
+ StrikethroughIcon,
141
+ SunIcon,
142
+ TableIcon,
143
+ TagIcon,
144
+ TagsIcon,
145
+ TicketPercentIcon,
146
+ Trash2Icon,
147
+ TrashIcon,
148
+ TrendingDownIcon,
149
+ TrendingUpIcon,
150
+ TriangleAlertIcon,
151
+ TruckIcon,
152
+ Undo2Icon,
153
+ UndoIcon,
154
+ UploadCloudIcon,
155
+ UploadIcon,
156
+ UserIcon,
157
+ UserMinusIcon,
158
+ UserPlusIcon,
159
+ UsersIcon,
160
+ UsersRoundIcon,
161
+ WarehouseIcon,
162
+ WebhookIcon,
163
+ X,
164
+ XCircleIcon,
165
+ XIcon,
166
+ ZapIcon,
167
+ } from 'lucide-react'
@@ -8,6 +8,9 @@ import {
8
8
  SheetTitle,
9
9
  useCopyToClipboard,
10
10
  } from '@spree/dashboard-ui'
11
+ import type { ComponentProps } from 'react'
12
+ import { useEffect, useMemo, useState } from 'react'
13
+ import { useTranslation } from 'react-i18next'
11
14
  import {
12
15
  CheckIcon,
13
16
  ChevronDownIcon,
@@ -17,10 +20,7 @@ import {
17
20
  Loader2Icon,
18
21
  RotateCcwIcon,
19
22
  XIcon,
20
- } from 'lucide-react'
21
- import type { ComponentProps } from 'react'
22
- import { useEffect, useMemo, useState } from 'react'
23
- import { useTranslation } from 'react-i18next'
23
+ } from './icons'
24
24
  import { type JsonValueLinkResolver, JsonValueView } from './json-value-view'
25
25
 
26
26
  // Re-export so existing imports of `JsonValueLinkResolver` from this module
@@ -0,0 +1,195 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Controller, type UseFormReturn } from 'react-hook-form'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { Button } from '../ui/button'
5
+ import {
6
+ Combobox,
7
+ ComboboxButtonTrigger,
8
+ ComboboxContent,
9
+ ComboboxEmpty,
10
+ ComboboxItem,
11
+ ComboboxList,
12
+ ComboboxSearch,
13
+ ComboboxTriggerPlaceholder,
14
+ ComboboxValue,
15
+ } from '../ui/combobox'
16
+ import {
17
+ Dialog,
18
+ DialogBody,
19
+ DialogContent,
20
+ DialogDescription,
21
+ DialogFooter,
22
+ DialogHeader,
23
+ DialogTitle,
24
+ } from '../ui/dialog'
25
+ import { Field, FieldError, FieldLabel } from '../ui/field'
26
+ import { Input } from '../ui/input'
27
+ import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group'
28
+
29
+ /** The form shape both panels bind for a label bought elsewhere. */
30
+ export type LabelUploadFields = {
31
+ tracking_number: string
32
+ carrier: string
33
+ cost: string
34
+ }
35
+
36
+ /**
37
+ * Records a label the merchant bought elsewhere — postage from a carrier's
38
+ * own site, a 3PL's PDF — so the parcel still has its file, its tracking
39
+ * number and what it cost, with no carrier account connected.
40
+ *
41
+ * Shared by the operator's order page and the seller's; on the seller branch
42
+ * it is the only way a label arrives, since buying one runs through the
43
+ * marketplace's carrier account.
44
+ *
45
+ * The file field comes in as a slot: uploading needs an API client, which is
46
+ * each panel's own.
47
+ */
48
+ export function LabelUploadDialog({
49
+ form,
50
+ open,
51
+ onOpenChange,
52
+ fileField,
53
+ carrierKeys,
54
+ carrierLabel,
55
+ currency,
56
+ onSubmit,
57
+ pending = false,
58
+ }: {
59
+ form: UseFormReturn<LabelUploadFields>
60
+ open: boolean
61
+ onOpenChange: (open: boolean) => void
62
+ fileField: ReactNode
63
+ carrierKeys: string[]
64
+ carrierLabel: (key: string) => string
65
+ /** Shown beside the cost, when the caller knows which currency it is in. */
66
+ currency?: string
67
+ onSubmit: (values: LabelUploadFields) => void | Promise<void>
68
+ pending?: boolean
69
+ }) {
70
+ const { t } = useTranslation()
71
+ const { errors } = form.formState
72
+
73
+ return (
74
+ <Dialog open={open} onOpenChange={onOpenChange}>
75
+ <DialogContent>
76
+ <form
77
+ onSubmit={(event) => {
78
+ // The order page renders its cards inside their own forms — without
79
+ // stopping the bubble the browser submits the outer one.
80
+ form.handleSubmit(onSubmit)(event)
81
+ event.stopPropagation()
82
+ }}
83
+ >
84
+ <DialogHeader>
85
+ <DialogTitle>{t('admin.orders.detail.fulfillments.upload_label_title')}</DialogTitle>
86
+ <DialogDescription>
87
+ {t('admin.orders.detail.fulfillments.upload_label_description')}
88
+ </DialogDescription>
89
+ </DialogHeader>
90
+
91
+ <DialogBody className="flex flex-col gap-4">
92
+ {errors.root?.message && (
93
+ <p className="text-destructive text-sm" role="alert">
94
+ {errors.root.message}
95
+ </p>
96
+ )}
97
+
98
+ <Field>
99
+ <FieldLabel>{t('admin.orders.detail.fulfillments.upload_label_file')}</FieldLabel>
100
+ {fileField}
101
+ </Field>
102
+
103
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
104
+ <Field>
105
+ <FieldLabel htmlFor="label-tracking-number">
106
+ {t('admin.orders.fulfill.tracking_label')}
107
+ </FieldLabel>
108
+ <Input
109
+ id="label-tracking-number"
110
+ placeholder={t('admin.orders.fulfill.tracking_placeholder')}
111
+ aria-invalid={!!errors.tracking_number}
112
+ {...form.register('tracking_number')}
113
+ />
114
+ <FieldError errors={[errors.tracking_number]} />
115
+ </Field>
116
+
117
+ <Field>
118
+ <FieldLabel htmlFor="label-cost">
119
+ {t('admin.orders.detail.fulfillments.upload_label_cost')}
120
+ </FieldLabel>
121
+ <InputGroup>
122
+ {currency && (
123
+ <InputGroupAddon>
124
+ <InputGroupText>{currency}</InputGroupText>
125
+ </InputGroupAddon>
126
+ )}
127
+ <InputGroupInput
128
+ id="label-cost"
129
+ type="number"
130
+ step="0.01"
131
+ min="0"
132
+ {...form.register('cost')}
133
+ />
134
+ </InputGroup>
135
+ <FieldError errors={[errors.cost]} />
136
+ </Field>
137
+ </div>
138
+
139
+ <Field>
140
+ <FieldLabel htmlFor="label-carrier">
141
+ {t('admin.orders.detail.fulfillments.carrier_label')}
142
+ </FieldLabel>
143
+ <Controller
144
+ control={form.control}
145
+ name="carrier"
146
+ render={({ field }) => (
147
+ <Combobox
148
+ items={carrierKeys}
149
+ value={field.value}
150
+ onValueChange={(value: string | null) => field.onChange(value ?? '')}
151
+ itemToStringLabel={carrierLabel}
152
+ >
153
+ <ComboboxButtonTrigger id="label-carrier" onBlur={field.onBlur}>
154
+ {field.value ? (
155
+ <ComboboxValue />
156
+ ) : (
157
+ <ComboboxTriggerPlaceholder>
158
+ {t('admin.orders.detail.fulfillments.carrier_auto')}
159
+ </ComboboxTriggerPlaceholder>
160
+ )}
161
+ </ComboboxButtonTrigger>
162
+ <ComboboxContent>
163
+ <ComboboxSearch
164
+ placeholder={t('admin.orders.detail.fulfillments.carrier_search')}
165
+ />
166
+ <ComboboxEmpty>{t('admin.common.no_results')}</ComboboxEmpty>
167
+ <ComboboxList>
168
+ {(value: string) => (
169
+ <ComboboxItem key={value} value={value}>
170
+ {carrierLabel(value)}
171
+ </ComboboxItem>
172
+ )}
173
+ </ComboboxList>
174
+ </ComboboxContent>
175
+ </Combobox>
176
+ )}
177
+ />
178
+ </Field>
179
+ </DialogBody>
180
+
181
+ <DialogFooter>
182
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
183
+ {t('admin.actions.cancel')}
184
+ </Button>
185
+ <Button type="submit" disabled={pending}>
186
+ {pending
187
+ ? t('admin.actions.saving')
188
+ : t('admin.orders.detail.fulfillments.upload_label')}
189
+ </Button>
190
+ </DialogFooter>
191
+ </form>
192
+ </DialogContent>
193
+ </Dialog>
194
+ )
195
+ }
@@ -1,13 +1,7 @@
1
- import { CheckIcon, LanguagesIcon } from 'lucide-react'
2
- import {
3
- DropdownMenuItem,
4
- DropdownMenuSub,
5
- DropdownMenuSubContent,
6
- DropdownMenuSubTrigger,
7
- } from '../ui/dropdown-menu'
1
+ import { ChevronsUpDownIcon } from './icons'
8
2
 
9
3
  interface LanguageMenuItemsProps {
10
- /** Translated "Language" label for the submenu trigger (passed by the app). */
4
+ /** Translated "Language" label for the row (passed by the app). */
11
5
  label: string
12
6
  /** Admin UI languages available to switch to ({ code, name } pairs). */
13
7
  locales: ReadonlyArray<{ code: string; name: string }>
@@ -17,9 +11,15 @@ interface LanguageMenuItemsProps {
17
11
  }
18
12
 
19
13
  /**
20
- * Admin UI language picker rendered inside the user dropdown menu as a nested
21
- * submenu (a single "Language ▸" row that expands to the list), so the menu
22
- * stays compact no matter how many languages are installed.
14
+ * Admin UI language picker rendered inside the user dropdown's Preferences
15
+ * section, as a native `<select>`.
16
+ *
17
+ * Native rather than the design system's `Select`: a menu nested inside a menu
18
+ * is awkward to drive with a pointer and stacks two portal layers, while the
19
+ * platform control opens as its own popup outside the dropdown entirely — and
20
+ * gives long locale lists native scrolling and type-ahead for free. The
21
+ * `<select>` is transparent and overlaid on the styled pill, so the row still
22
+ * matches the Theme row above it.
23
23
  *
24
24
  * Headless: the label, locale list, and change handler are passed in, so this
25
25
  * component imports no i18n/data runtime. Renders nothing when fewer than two
@@ -31,24 +31,31 @@ export function LanguageMenuItems({ label, locales, value, onSelect }: LanguageM
31
31
  const current = locales.find((l) => l.code === value)
32
32
 
33
33
  return (
34
- <DropdownMenuSub>
35
- <DropdownMenuSubTrigger>
36
- <LanguagesIcon className="size-4" />
37
- {label}
38
- {current && <span className="ml-auto text-xs text-muted-foreground">{current.name}</span>}
39
- </DropdownMenuSubTrigger>
40
- <DropdownMenuSubContent>
41
- {locales.map((locale) => (
42
- <DropdownMenuItem
43
- key={locale.code}
44
- closeOnClick={false}
45
- onClick={() => onSelect(locale.code)}
46
- >
47
- {locale.name}
48
- {value === locale.code && <CheckIcon className="ml-auto size-3.5" />}
49
- </DropdownMenuItem>
50
- ))}
51
- </DropdownMenuSubContent>
52
- </DropdownMenuSub>
34
+ // Not a DropdownMenuItem: the row is a container for the select, and menu
35
+ // items steal the pointer and close the menu on click.
36
+ <div className="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5">
37
+ <span className="text-sm text-foreground">{label}</span>
38
+ {/* The select is transparent and stretched over the pill, so the hit area
39
+ tracks the pill's width whatever the locale name is. `focus-within`
40
+ carries the focus ring, since `opacity-0` hides the native one. */}
41
+ <span className="relative inline-flex rounded-lg focus-within:ring-2 focus-within:ring-ring">
42
+ <span className="pointer-events-none inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-xs font-normal text-foreground">
43
+ {current?.name ?? value}
44
+ <ChevronsUpDownIcon className="size-3 text-muted-foreground" />
45
+ </span>
46
+ <select
47
+ aria-label={label}
48
+ value={value}
49
+ onChange={(event) => onSelect(event.target.value)}
50
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
51
+ >
52
+ {locales.map((locale) => (
53
+ <option key={locale.code} value={locale.code}>
54
+ {locale.name}
55
+ </option>
56
+ ))}
57
+ </select>
58
+ </span>
59
+ </div>
53
60
  )
54
61
  }
@@ -1,7 +1,7 @@
1
- import { BracesIcon } from 'lucide-react'
2
1
  import { Fragment } from 'react'
3
2
  import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'
4
3
  import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../../ui/empty'
4
+ import { BracesIcon } from '../icons'
5
5
 
6
6
  interface MetadataCardProps {
7
7
  /**
@@ -0,0 +1,149 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Controller, type UseFormReturn } from 'react-hook-form'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { Button } from '../ui/button'
5
+ import { Checkbox } from '../ui/checkbox'
6
+ import {
7
+ Dialog,
8
+ DialogBody,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from '../ui/dialog'
15
+ import { Field, FieldLabel } from '../ui/field'
16
+ import { Textarea } from '../ui/textarea'
17
+
18
+ /** The form shape both panels bind for a cancellation. */
19
+ export type CancelOrderFields = {
20
+ cancel_reason_id: string
21
+ cancel_note: string
22
+ refund_payments: boolean
23
+ notify_customer: boolean
24
+ }
25
+
26
+ /**
27
+ * Cancels an order, asking why first.
28
+ *
29
+ * A dialog rather than a bare confirm, because a cancellation carries a
30
+ * reason the merchant wants recorded — this doubles as the confirmation, so
31
+ * there is no second dialog in front of it.
32
+ *
33
+ * `showRefund` is off by default: releasing an unclaimed authorization happens
34
+ * either way, but handing back money already taken is not every surface's
35
+ * decision. On the seller branch the endpoint accepts no refund argument at
36
+ * all, so showing the switch would offer something that would be refused.
37
+ */
38
+ export function OrderCancelDialog({
39
+ form,
40
+ reasonField,
41
+ showRefund = false,
42
+ showNotify = true,
43
+ onSubmit,
44
+ onClose,
45
+ pending = false,
46
+ }: {
47
+ form: UseFormReturn<CancelOrderFields>
48
+ /** The reason picker, wired to whichever vocabulary the caller reads. */
49
+ reasonField?: ReactNode
50
+ showRefund?: boolean
51
+ showNotify?: boolean
52
+ onSubmit: (values: CancelOrderFields) => void | Promise<void>
53
+ onClose: () => void
54
+ pending?: boolean
55
+ }) {
56
+ const { t } = useTranslation()
57
+ const { errors } = form.formState
58
+
59
+ return (
60
+ <Dialog open onOpenChange={(next) => !next && onClose()}>
61
+ <DialogContent>
62
+ <form onSubmit={form.handleSubmit(onSubmit)}>
63
+ <DialogHeader>
64
+ <DialogTitle>{t('admin.pages.orders.detail.dialogs.cancel_title')}</DialogTitle>
65
+ <DialogDescription>{t('admin.orders.detail.cancel.description')}</DialogDescription>
66
+ </DialogHeader>
67
+
68
+ <DialogBody>
69
+ {errors.root?.message && (
70
+ <p className="text-destructive text-sm" role="alert">
71
+ {errors.root.message}
72
+ </p>
73
+ )}
74
+
75
+ <div className="flex flex-col gap-4">
76
+ {reasonField}
77
+
78
+ <Field>
79
+ <FieldLabel htmlFor="order-cancel-note">
80
+ {t('admin.orders.detail.cancel.note_label')}
81
+ </FieldLabel>
82
+ <Textarea
83
+ id="order-cancel-note"
84
+ rows={3}
85
+ placeholder={t('admin.orders.detail.cancel.note_placeholder')}
86
+ {...form.register('cancel_note')}
87
+ />
88
+ </Field>
89
+
90
+ {showRefund && (
91
+ <Controller
92
+ control={form.control}
93
+ name="refund_payments"
94
+ render={({ field }) => (
95
+ <label
96
+ htmlFor="order-cancel-refund"
97
+ className="flex cursor-pointer items-start gap-2 text-sm"
98
+ >
99
+ <Checkbox
100
+ id="order-cancel-refund"
101
+ checked={field.value}
102
+ onCheckedChange={(checked) => field.onChange(checked === true)}
103
+ />
104
+ <span>
105
+ {t('admin.orders.detail.cancel.refund_payments')}
106
+ <span className="block text-muted-foreground">
107
+ {t('admin.orders.detail.cancel.refund_payments_help')}
108
+ </span>
109
+ </span>
110
+ </label>
111
+ )}
112
+ />
113
+ )}
114
+
115
+ {showNotify && (
116
+ <Controller
117
+ control={form.control}
118
+ name="notify_customer"
119
+ render={({ field }) => (
120
+ <label
121
+ htmlFor="order-cancel-notify"
122
+ className="flex cursor-pointer items-center gap-2 text-sm"
123
+ >
124
+ <Checkbox
125
+ id="order-cancel-notify"
126
+ checked={field.value}
127
+ onCheckedChange={(checked) => field.onChange(checked === true)}
128
+ />
129
+ {t('admin.orders.detail.cancel.notify_customer')}
130
+ </label>
131
+ )}
132
+ />
133
+ )}
134
+ </div>
135
+ </DialogBody>
136
+
137
+ <DialogFooter>
138
+ <Button type="button" variant="outline" onClick={onClose} disabled={pending}>
139
+ {t('admin.orders.detail.cancel.keep_order')}
140
+ </Button>
141
+ <Button type="submit" variant="destructive" disabled={pending}>
142
+ {t('admin.pages.orders.detail.actions.cancel')}
143
+ </Button>
144
+ </DialogFooter>
145
+ </form>
146
+ </DialogContent>
147
+ </Dialog>
148
+ )
149
+ }