@podoba/react 0.0.34 → 0.0.36
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 +3 -3
- package/src/components/asset-masonry-grid.examples.tsx +9 -0
- package/src/components/asset-masonry-grid.tsx +62 -0
- package/src/components/asset-selection-surface.examples.tsx +33 -0
- package/src/components/asset-selection-surface.tsx +184 -0
- package/src/components/brand-page-header.tsx +60 -29
- package/src/components/button.tsx +27 -4
- package/src/components/combobox.tsx +1 -1
- package/src/components/compact-action-button.examples.tsx +7 -0
- package/src/components/compact-action-button.tsx +21 -0
- package/src/components/compact-settings-dialog.examples.tsx +15 -0
- package/src/components/compact-settings-dialog.tsx +50 -0
- package/src/components/context-action-glyph.examples.tsx +9 -0
- package/src/components/context-action-glyph.tsx +61 -0
- package/src/components/context-menu.tsx +191 -107
- package/src/components/context-search-panel.examples.tsx +9 -0
- package/src/components/context-search-panel.tsx +50 -0
- package/src/components/csv-binding-presentation.examples.tsx +14 -0
- package/src/components/csv-binding-presentation.tsx +40 -0
- package/src/components/dashboard-grid.tsx +1 -1
- package/src/components/date-picker.tsx +5 -3
- package/src/components/date-selection-calendar.examples.tsx +8 -0
- package/src/components/date-selection-calendar.tsx +47 -0
- package/src/components/delivery/send-to-print-modal.tsx +351 -84
- package/src/components/dialog-action-button.examples.tsx +7 -0
- package/src/components/dialog-action-button.tsx +22 -0
- package/src/components/dialog.tsx +32 -12
- package/src/components/document-upload-panel.examples.tsx +12 -0
- package/src/components/document-upload-panel.tsx +48 -0
- package/src/components/dropdown-menu.tsx +5 -3
- package/src/components/empty-panel-action.examples.tsx +7 -0
- package/src/components/empty-panel-action.tsx +8 -0
- package/src/components/field-appearance.ts +14 -0
- package/src/components/focus-field.tsx +3 -3
- package/src/components/icons.tsx +30 -0
- package/src/components/input.examples.tsx +8 -0
- package/src/components/input.tsx +13 -5
- package/src/components/media-asset-panel.examples.tsx +11 -0
- package/src/components/media-asset-panel.tsx +58 -0
- package/src/components/media-gallery.examples.tsx +12 -0
- package/src/components/media-gallery.tsx +53 -0
- package/src/components/media-settings-dialog.examples.tsx +21 -0
- package/src/components/media-settings-dialog.tsx +83 -0
- package/src/components/preview-info-card.examples.tsx +18 -0
- package/src/components/preview-info-card.tsx +27 -0
- package/src/components/reload-icon.examples.tsx +7 -0
- package/src/components/reload-icon.tsx +6 -0
- package/src/components/request-changes-modal.examples.tsx +26 -0
- package/src/components/request-changes-modal.tsx +36 -19
- package/src/components/rich-text-editor.tsx +1 -1
- package/src/components/select.examples.tsx +17 -0
- package/src/components/select.tsx +78 -22
- package/src/components/settings-dialog-surface.examples.tsx +44 -0
- package/src/components/settings-dialog-surface.tsx +240 -0
- package/src/components/subtle.tsx +64 -8
- package/src/components/table.examples.tsx +9 -0
- package/src/components/table.tsx +29 -12
- package/src/components/template-catalog-card.examples.tsx +25 -0
- package/src/components/template-catalog-card.tsx +179 -0
- package/src/components/text.tsx +14 -1
- package/src/components/textarea.examples.tsx +8 -0
- package/src/components/textarea.tsx +16 -6
- package/src/components/tile.tsx +16 -17
- package/src/editor/block-editor.tsx +56 -14
- package/src/index.ts +19 -0
- package/src/layout/app-shell.tsx +13 -9
- package/src/layout/topbar.tsx +9 -12
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { CsvActionButton, CsvMappingSelect, CsvPreviewTable } from './csv-binding-presentation'
|
|
2
|
+
import { SelectItem } from './select'
|
|
3
|
+
|
|
4
|
+
const headers = ['Headline', 'City']
|
|
5
|
+
const rows = [{ Headline: 'Annual report', City: 'Prague' }]
|
|
6
|
+
const choice = <CsvMappingSelect label="Headline" placeholder="Not bound" defaultSelectedKey="headline">
|
|
7
|
+
<SelectItem id="headline">Headline (headline)</SelectItem>
|
|
8
|
+
</CsvMappingSelect>
|
|
9
|
+
export const examples = {
|
|
10
|
+
default: () => <CsvPreviewTable headers={headers} rows={rows} label="CSV preview" emptyMessage="No preview rows available." />,
|
|
11
|
+
mapping: () => <CsvPreviewTable headers={headers} rows={rows} mapping={[choice, 'Not bound']} label="CSV mapping" emptyMessage="No columns available for mapping." />,
|
|
12
|
+
states: () => <><CsvActionButton>Review Mapping</CsvActionButton><CsvActionButton isDisabled>Bind CSV</CsvActionButton></>,
|
|
13
|
+
}
|
|
14
|
+
export const meta = { category: 'Form', description: 'Source CSV worksheet, hidden-label mapping select and footer action typography; preview rows do not sort or hover.' }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
2
|
+
import { uic } from '../utils/uic'
|
|
3
|
+
import { Table } from './table'
|
|
4
|
+
import { Select, type SelectProps } from './select'
|
|
5
|
+
import { DialogActionButton } from './dialog-action-button'
|
|
6
|
+
|
|
7
|
+
const PreviewScroll = uic('div', { displayName: 'CsvPreviewScroll', baseClass: 'w-full min-w-0 overflow-x-auto' })
|
|
8
|
+
|
|
9
|
+
/** CSV preview has one header per original column; a mapping row precedes data. */
|
|
10
|
+
export function CsvPreviewTable({ headers, rows, mapping, label, emptyMessage, ...rest }: {
|
|
11
|
+
headers: string[]
|
|
12
|
+
rows: Readonly<Record<string, string>>[]
|
|
13
|
+
mapping?: ReactNode[]
|
|
14
|
+
label: string
|
|
15
|
+
emptyMessage: string
|
|
16
|
+
'data-testid'?: string
|
|
17
|
+
}) {
|
|
18
|
+
const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches)
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const media = window.matchMedia('(max-width: 900px)')
|
|
21
|
+
const update = () => setCompact(media.matches)
|
|
22
|
+
update()
|
|
23
|
+
media.addEventListener('change', update)
|
|
24
|
+
return () => media.removeEventListener('change', update)
|
|
25
|
+
}, [])
|
|
26
|
+
const data: ReactNode[][] = rows.map(row => headers.map(header => row[header] ?? ''))
|
|
27
|
+
if (mapping) data.unshift(mapping)
|
|
28
|
+
return <PreviewScroll {...rest}>
|
|
29
|
+
<Table<ReactNode[]> appearance="worksheet" className={compact ? 'min-w-168' : 'min-w-256'}
|
|
30
|
+
aria-label={label} emptyMessage={emptyMessage} data={data}
|
|
31
|
+
columns={headers.map((header, index) => ({ key: String(index), header, render: row => row[index] }))} />
|
|
32
|
+
</PreviewScroll>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function CsvMappingSelect<T extends object>(props: SelectProps<T>) {
|
|
36
|
+
return <Select {...props} appearance="filled" isLabelHidden rootClassName="w-full min-w-48" />
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Original UI Button md (16/20 medium, 12x24 padding), without global button changes. */
|
|
40
|
+
export const CsvActionButton = DialogActionButton
|
|
@@ -34,7 +34,7 @@ export type DashboardGridProps = {
|
|
|
34
34
|
|
|
35
35
|
export function DashboardGrid({ children, className }: DashboardGridProps) {
|
|
36
36
|
return (
|
|
37
|
-
<div className={['grid grid-cols-12 gap-
|
|
37
|
+
<div className={['grid grid-cols-12 gap-3', className].filter(Boolean).join(' ')}>{children}</div>
|
|
38
38
|
)
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -42,9 +42,11 @@ const Chevron = ({ dir }: { dir: 'left' | 'right' }) => (
|
|
|
42
42
|
|
|
43
43
|
const calendarCellClass =
|
|
44
44
|
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-small text-fg outline-none transition-colors ' +
|
|
45
|
-
|
|
45
|
+
// #25: out-of-month and unavailable days stay de-emphasised, but they are dates a
|
|
46
|
+
// user reads and (outside-month) can click — `fg-muted`, not the 2.10:1 decorative grey.
|
|
47
|
+
'data-[outside-month]:text-fg-muted data-[hovered]:bg-surface-muted ' +
|
|
46
48
|
'data-[selected]:bg-fg data-[selected]:text-fg-inverted data-[selected]:font-medium ' +
|
|
47
|
-
'data-[unavailable]:text-fg-
|
|
49
|
+
'data-[unavailable]:text-fg-muted data-[unavailable]:line-through ' +
|
|
48
50
|
'data-[disabled]:opacity-40 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
|
|
49
51
|
|
|
50
52
|
function CalendarBody() {
|
|
@@ -62,7 +64,7 @@ function CalendarBody() {
|
|
|
62
64
|
<CalendarGrid className="w-full border-collapse">
|
|
63
65
|
<CalendarGridHeader>
|
|
64
66
|
{(day) => (
|
|
65
|
-
<CalendarHeaderCell className="pb-1 text-micro font-medium uppercase tracking-wide text-fg-
|
|
67
|
+
<CalendarHeaderCell className="pb-1 text-micro font-medium uppercase tracking-wide text-fg-muted">
|
|
66
68
|
{day}
|
|
67
69
|
</CalendarHeaderCell>
|
|
68
70
|
)}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { DateSelectionCalendar } from './date-selection-calendar'
|
|
2
|
+
const props = { monthLabel: 'September 2026', weekdays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], previousLabel: 'Previous month', nextLabel: 'Next month', onPrevious: () => {}, onNext: () => {}, onSelect: () => {}, weeks: [[null, ...Array.from({ length: 6 }, (_, i) => ({ id: String(i + 1), label: String(i + 1), accessibleLabel: 'September ' + (i + 1) + ', 2026', selected: i === 2, today: i === 4 }))]] }
|
|
3
|
+
export const examples = {
|
|
4
|
+
default: () => <DateSelectionCalendar {...props} />,
|
|
5
|
+
emptySelection: () => <DateSelectionCalendar {...props} weeks={props.weeks.map(week => week.map(day => day ? { ...day, selected: false } : null))} />,
|
|
6
|
+
states: () => <DateSelectionCalendar {...props} isDisabled />,
|
|
7
|
+
}
|
|
8
|
+
export const meta = { category: 'Form', description: 'Month navigation and date cells with selected, today, disabled and keyboard focus states.' }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Button as AriaButton } from 'react-aria-components'
|
|
2
|
+
import { uic } from '../utils/uic'
|
|
3
|
+
|
|
4
|
+
export const CalendarDialogContent = uic('div', { displayName: 'CalendarDialogContent', baseClass: 'flex flex-col gap-8' })
|
|
5
|
+
export const CalendarDateHint = uic('p', { displayName: 'CalendarDateHint', baseClass: 'm-0 min-h-9 text-small font-normal text-fg-workflow-muted', style: { maxWidth: '48ch' } })
|
|
6
|
+
const Frame = uic('div', { displayName: 'DateSelectionCalendar', baseClass: 'mt-6 overflow-hidden border border-border bg-surface' })
|
|
7
|
+
const Header = uic('div', { displayName: 'DateSelectionCalendarHeader', baseClass: 'flex min-h-15 items-center border-b border-border px-5' })
|
|
8
|
+
const Navigation = uic(AriaButton, { displayName: 'DateSelectionCalendarNavigation', baseClass: 'mx-1.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-sm bg-transparent text-2xl font-normal leading-none text-fg outline-none data-[hovered]:bg-surface-card data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring' })
|
|
9
|
+
const Month = uic('div', { displayName: 'DateSelectionCalendarMonth', baseClass: 'flex-1 text-center text-small font-normal text-fg' })
|
|
10
|
+
const Week = uic('div', { displayName: 'DateSelectionCalendarWeek', baseClass: 'grid grid-cols-7 border-b border-border last:border-b-0' })
|
|
11
|
+
const Weekday = uic('div', { displayName: 'DateSelectionCalendarWeekday', baseClass: 'border-e border-border px-3 pb-2 pt-4 text-center font-mono text-small font-normal tracking-normal text-fg-workflow-muted last:border-e-0' })
|
|
12
|
+
const Day = uic(AriaButton, { displayName: 'DateSelectionCalendarDay', baseClass: 'inline-flex min-h-16 w-full items-center justify-center rounded-none border-e border-border bg-transparent text-small font-normal text-fg outline-none last:border-e-0 data-[hovered]:bg-surface-card data-[focus-visible]:ring-2 data-[focus-visible]:ring-inset data-[focus-visible]:ring-ring data-[disabled]:cursor-not-allowed' })
|
|
13
|
+
const Placeholder = uic('div', { displayName: 'DateSelectionCalendarPlaceholder', baseClass: 'min-h-16 border-e border-border last:border-e-0' })
|
|
14
|
+
|
|
15
|
+
export interface DateSelectionCell {
|
|
16
|
+
id: string
|
|
17
|
+
label: string
|
|
18
|
+
accessibleLabel: string
|
|
19
|
+
selected: boolean
|
|
20
|
+
today: boolean
|
|
21
|
+
}
|
|
22
|
+
export function DateSelectionCalendar({ monthLabel, weekdays, weeks, previousLabel, nextLabel, onPrevious, onNext, onSelect, isDisabled = false, autoFocus = false }: {
|
|
23
|
+
monthLabel: string
|
|
24
|
+
weekdays: string[]
|
|
25
|
+
weeks: Array<Array<DateSelectionCell | null>>
|
|
26
|
+
previousLabel: string
|
|
27
|
+
nextLabel: string
|
|
28
|
+
onPrevious: () => void
|
|
29
|
+
onNext: () => void
|
|
30
|
+
onSelect: (id: string) => void
|
|
31
|
+
isDisabled?: boolean
|
|
32
|
+
autoFocus?: boolean
|
|
33
|
+
}) {
|
|
34
|
+
return <Frame>
|
|
35
|
+
<Header>
|
|
36
|
+
<Navigation aria-label={previousLabel} onPress={onPrevious} isDisabled={isDisabled} autoFocus={autoFocus}>‹</Navigation>
|
|
37
|
+
<Month aria-live="polite">{monthLabel}</Month>
|
|
38
|
+
<Navigation aria-label={nextLabel} onPress={onNext} isDisabled={isDisabled}>›</Navigation>
|
|
39
|
+
</Header>
|
|
40
|
+
<Week>{weekdays.map((label, index) => <Weekday key={index}>{label}</Weekday>)}</Week>
|
|
41
|
+
{weeks.map((week, index) => <Week key={index}>{week.map((day, column) => day
|
|
42
|
+
? <Day key={day.id} aria-label={day.accessibleLabel} aria-pressed={day.selected} aria-current={day.today ? 'date' : undefined} isDisabled={isDisabled} onPress={() => onSelect(day.id)}
|
|
43
|
+
style={day.selected ? { boxShadow: 'inset 0 calc(var(--spacing) * -0.5) 0 var(--color-brand-green)' } : undefined}
|
|
44
|
+
className={day.selected ? 'bg-surface-card' : day.today ? 'bg-brand-green/14 ring-1 ring-inset ring-brand-green data-[hovered]:bg-brand-green/18' : undefined}>{day.label}</Day>
|
|
45
|
+
: <Placeholder key={`blank-${column}`} />)}</Week>)}
|
|
46
|
+
</Frame>
|
|
47
|
+
}
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
// field is aria-invalid when invalid; the server error is role="alert"; confirm
|
|
22
22
|
// shows a pending state and disables while in flight / invalid.
|
|
23
23
|
|
|
24
|
-
import { useMemo, useState } from 'react'
|
|
24
|
+
import { useId, useMemo, useState } from 'react'
|
|
25
25
|
import { Button } from '../button'
|
|
26
|
-
import {
|
|
26
|
+
import { ModalDialog, ModalOverlay, ModalSurface } from '../dialog'
|
|
27
27
|
import { Input } from '../input'
|
|
28
28
|
|
|
29
29
|
/** The validated print spec the modal yields on confirm. */
|
|
@@ -36,6 +36,20 @@ export interface PrintSpec {
|
|
|
36
36
|
quantity: number
|
|
37
37
|
/** Delivery address — non-blank. */
|
|
38
38
|
address: string
|
|
39
|
+
/** Company / recipient. */
|
|
40
|
+
company?: string
|
|
41
|
+
/** Street address. */
|
|
42
|
+
street?: string
|
|
43
|
+
/** City. */
|
|
44
|
+
city?: string
|
|
45
|
+
/** ZIP / postcode. */
|
|
46
|
+
zip?: string
|
|
47
|
+
/** Contact person. */
|
|
48
|
+
contactName?: string
|
|
49
|
+
/** Contact phone. */
|
|
50
|
+
phone?: string
|
|
51
|
+
/** Optional print-production note. */
|
|
52
|
+
note?: string
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
/** Strings the send-to-print modal renders — supplied by the app (i18n). */
|
|
@@ -50,6 +64,7 @@ export interface SendToPrintModalLabels {
|
|
|
50
64
|
/** Material field label + placeholder. */
|
|
51
65
|
materialLabel: string
|
|
52
66
|
materialPlaceholder: string
|
|
67
|
+
materialDefault?: string
|
|
53
68
|
/** Quantity field label + placeholder. */
|
|
54
69
|
quantityLabel: string
|
|
55
70
|
quantityPlaceholder: string
|
|
@@ -64,6 +79,34 @@ export interface SendToPrintModalLabels {
|
|
|
64
79
|
confirm: string
|
|
65
80
|
/** Confirm button — pending state. */
|
|
66
81
|
submitting: string
|
|
82
|
+
/** Optional old-Manager section label. */
|
|
83
|
+
sectionLabel?: string
|
|
84
|
+
outputName?: string
|
|
85
|
+
outputDescription?: string
|
|
86
|
+
/** Optional no-material message. */
|
|
87
|
+
noMaterialConfigured?: string
|
|
88
|
+
companyLabel?: string
|
|
89
|
+
companyPlaceholder?: string
|
|
90
|
+
streetLabel?: string
|
|
91
|
+
streetPlaceholder?: string
|
|
92
|
+
cityLabel?: string
|
|
93
|
+
cityPlaceholder?: string
|
|
94
|
+
zipLabel?: string
|
|
95
|
+
zipPlaceholder?: string
|
|
96
|
+
contactNameLabel?: string
|
|
97
|
+
contactNamePlaceholder?: string
|
|
98
|
+
phoneLabel?: string
|
|
99
|
+
phonePlaceholder?: string
|
|
100
|
+
noteLabel?: string
|
|
101
|
+
notePlaceholder?: string
|
|
102
|
+
priceEyebrow?: string
|
|
103
|
+
priceIdle?: string
|
|
104
|
+
closeLabel?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface PrintMaterialOption {
|
|
108
|
+
id: string
|
|
109
|
+
label: string
|
|
67
110
|
}
|
|
68
111
|
|
|
69
112
|
export interface SendToPrintModalProps {
|
|
@@ -79,6 +122,16 @@ export interface SendToPrintModalProps {
|
|
|
79
122
|
error?: string
|
|
80
123
|
/** App-supplied i18n strings. */
|
|
81
124
|
labels: SendToPrintModalLabels
|
|
125
|
+
/** Output title shown in the old Manager print-order summary card. */
|
|
126
|
+
outputName?: string
|
|
127
|
+
/** Output dimension / format line shown in the old Manager print-order summary card. */
|
|
128
|
+
outputDescription?: string
|
|
129
|
+
/** Available print materials. Empty means the old Manager disabled the order action. */
|
|
130
|
+
materialOptions?: PrintMaterialOption[]
|
|
131
|
+
/** Initial quantity. Old Manager opens print with a production-like quantity instead of blank. */
|
|
132
|
+
initialQuantity?: number
|
|
133
|
+
/** Modal family. Dashboard Workspace print orders use the old wider source panel. */
|
|
134
|
+
surfaceVariant?: 'standard' | 'dashboard'
|
|
82
135
|
/** Optional test id forwarded to the dialog. */
|
|
83
136
|
'data-testid'?: string
|
|
84
137
|
}
|
|
@@ -92,6 +145,12 @@ function parseQuantity(raw: string): number | null {
|
|
|
92
145
|
return value
|
|
93
146
|
}
|
|
94
147
|
|
|
148
|
+
function capitalizeFirstAddressLetter(value: string): string {
|
|
149
|
+
const index = value.search(/\p{L}/u)
|
|
150
|
+
if (index < 0) return value
|
|
151
|
+
return `${value.slice(0, index)}${value.charAt(index).toUpperCase()}${value.slice(index + 1)}`
|
|
152
|
+
}
|
|
153
|
+
|
|
95
154
|
/** Send-to-print modal with validated size / material / quantity / address. */
|
|
96
155
|
export function SendToPrintModal({
|
|
97
156
|
isOpen,
|
|
@@ -100,31 +159,69 @@ export function SendToPrintModal({
|
|
|
100
159
|
isPending = false,
|
|
101
160
|
error,
|
|
102
161
|
labels,
|
|
162
|
+
outputName,
|
|
163
|
+
outputDescription,
|
|
164
|
+
materialOptions = [],
|
|
165
|
+
initialQuantity = 500,
|
|
166
|
+
surfaceVariant = 'standard',
|
|
103
167
|
'data-testid': testId,
|
|
104
168
|
}: SendToPrintModalProps): React.ReactNode {
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
169
|
+
const titleId = useId()
|
|
170
|
+
const descriptionId = useId()
|
|
171
|
+
// `materialOptions` is normally fetched, so it is [] on mount. Seeding useState
|
|
172
|
+
// from it captures that empty first render forever; deriving the fallback each
|
|
173
|
+
// render selects the first option as soon as the list arrives. '' means
|
|
174
|
+
// "untouched", not "nothing selected".
|
|
175
|
+
const [materialId, setMaterialId] = useState('')
|
|
176
|
+
const [sizeRaw, setSizeRaw] = useState('')
|
|
177
|
+
const [quantityRaw, setQuantityRaw] = useState(String(initialQuantity))
|
|
178
|
+
const [company, setCompany] = useState('')
|
|
179
|
+
const [street, setStreet] = useState('')
|
|
180
|
+
const [city, setCity] = useState('')
|
|
181
|
+
const [zip, setZip] = useState('')
|
|
182
|
+
const [contactName, setContactName] = useState('')
|
|
183
|
+
const [phone, setPhone] = useState('')
|
|
184
|
+
const [note, setNote] = useState('')
|
|
109
185
|
|
|
110
186
|
const quantity = useMemo(() => parseQuantity(quantityRaw), [quantityRaw])
|
|
187
|
+
const effectiveMaterialId = materialId || (materialOptions[0]?.id ?? '')
|
|
188
|
+
const selectedMaterial = useMemo(
|
|
189
|
+
() => materialOptions.find((option) => option.id === effectiveMaterialId) ?? null,
|
|
190
|
+
[effectiveMaterialId, materialOptions],
|
|
191
|
+
)
|
|
192
|
+
// `size` is the caller's output description when it supplies one, and a collected
|
|
193
|
+
// field otherwise — the documented `print_specs` rule is that it is non-blank, and
|
|
194
|
+
// `outputDescription` is optional, so it cannot be the only source.
|
|
195
|
+
const derivedSize = outputDescription?.trim() ?? ''
|
|
196
|
+
const size = derivedSize || sizeRaw.trim()
|
|
111
197
|
// Surface the quantity error only once the user has typed something invalid
|
|
112
198
|
// (not on the initial blank state — that would scream before any input).
|
|
113
199
|
const quantityInvalid = quantityRaw.trim().length > 0 && quantity === null
|
|
114
200
|
|
|
115
201
|
const reset = (): void => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
setQuantityRaw(
|
|
119
|
-
|
|
202
|
+
setMaterialId('')
|
|
203
|
+
setSizeRaw('')
|
|
204
|
+
setQuantityRaw(String(initialQuantity))
|
|
205
|
+
setCompany('')
|
|
206
|
+
setStreet('')
|
|
207
|
+
setCity('')
|
|
208
|
+
setZip('')
|
|
209
|
+
setContactName('')
|
|
210
|
+
setPhone('')
|
|
211
|
+
setNote('')
|
|
120
212
|
}
|
|
121
213
|
|
|
122
214
|
const canConfirm =
|
|
123
215
|
!isPending &&
|
|
124
|
-
size.
|
|
125
|
-
material.trim().length > 0 &&
|
|
216
|
+
size.length > 0 &&
|
|
126
217
|
quantity !== null &&
|
|
127
|
-
|
|
218
|
+
selectedMaterial !== null &&
|
|
219
|
+
company.trim().length > 0 &&
|
|
220
|
+
street.trim().length > 0 &&
|
|
221
|
+
city.trim().length > 0 &&
|
|
222
|
+
zip.trim().length > 0 &&
|
|
223
|
+
contactName.trim().length > 0 &&
|
|
224
|
+
phone.trim().length > 0
|
|
128
225
|
|
|
129
226
|
const handleOpenChange = (open: boolean): void => {
|
|
130
227
|
if (!open) reset()
|
|
@@ -132,85 +229,255 @@ export function SendToPrintModal({
|
|
|
132
229
|
}
|
|
133
230
|
|
|
134
231
|
const handleConfirm = (): void => {
|
|
135
|
-
if (!canConfirm || quantity === null) return
|
|
232
|
+
if (!canConfirm || quantity === null || selectedMaterial === null) return
|
|
233
|
+
const address = [company, street, `${zip} ${city}`, contactName, phone]
|
|
234
|
+
.map((part) => part.trim())
|
|
235
|
+
.filter(Boolean)
|
|
236
|
+
.join(', ')
|
|
136
237
|
onConfirm({
|
|
137
|
-
size
|
|
138
|
-
material:
|
|
238
|
+
size,
|
|
239
|
+
material: selectedMaterial.label.trim(),
|
|
139
240
|
quantity,
|
|
140
|
-
address
|
|
241
|
+
address,
|
|
242
|
+
company: company.trim(),
|
|
243
|
+
street: street.trim(),
|
|
244
|
+
city: city.trim(),
|
|
245
|
+
zip: zip.trim(),
|
|
246
|
+
contactName: contactName.trim(),
|
|
247
|
+
phone: phone.trim(),
|
|
248
|
+
note: note.trim(),
|
|
141
249
|
})
|
|
142
250
|
}
|
|
143
251
|
|
|
252
|
+
const surfaceClassName = surfaceVariant === 'dashboard'
|
|
253
|
+
? 'box-border flex h-[min(90vh,72rem)] max-h-[min(90vh,72rem)] w-[min(50vw,72rem)] max-w-[min(50vw,72rem)] flex-col overflow-hidden p-0 max-[900px]:h-[90vh] max-[900px]:w-[92vw] max-[900px]:max-w-[92vw]'
|
|
254
|
+
: 'box-border flex h-[min(90vh,72rem)] max-h-[min(90vh,72rem)] w-[min(90vw,51rem)] max-w-[min(90vw,51rem)] flex-col overflow-hidden p-0 max-[900px]:h-[90vh] max-[900px]:w-[92vw] max-[900px]:max-w-[92vw]'
|
|
255
|
+
|
|
144
256
|
return (
|
|
145
|
-
<
|
|
146
|
-
title={labels.title}
|
|
147
|
-
data-testid={testId ?? 'delivery-print-modal'}
|
|
257
|
+
<ModalOverlay
|
|
148
258
|
isOpen={isOpen}
|
|
149
259
|
onOpenChange={handleOpenChange}
|
|
260
|
+
isDismissable
|
|
150
261
|
>
|
|
151
|
-
<
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
262
|
+
<ModalSurface className={surfaceClassName}>
|
|
263
|
+
<ModalDialog
|
|
264
|
+
aria-labelledby={titleId}
|
|
265
|
+
aria-describedby={descriptionId}
|
|
266
|
+
data-testid={testId ?? 'delivery-print-modal'}
|
|
267
|
+
className="flex min-h-0 flex-1 flex-col outline-none"
|
|
268
|
+
>
|
|
269
|
+
{({ close }) => (
|
|
270
|
+
<form
|
|
271
|
+
className="flex min-h-0 flex-1 flex-col gap-6 py-10"
|
|
272
|
+
onSubmit={(event) => {
|
|
273
|
+
event.preventDefault()
|
|
274
|
+
handleConfirm()
|
|
275
|
+
}}
|
|
276
|
+
noValidate
|
|
277
|
+
>
|
|
278
|
+
<div className="mx-auto box-border w-full max-w-[50rem] px-5">
|
|
279
|
+
<div className="flex items-start justify-between gap-4">
|
|
280
|
+
<h2 id={titleId} className="m-0 max-w-[19ch] text-heading1 font-medium leading-[1.02] tracking-tight text-fg">
|
|
281
|
+
{labels.title}
|
|
282
|
+
</h2>
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
onClick={close}
|
|
286
|
+
aria-label={labels.closeLabel ?? 'Close'}
|
|
287
|
+
className="-mr-1 -mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none transition-colors hover:bg-surface-muted hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
|
|
288
|
+
>
|
|
289
|
+
<svg
|
|
290
|
+
width="18"
|
|
291
|
+
height="18"
|
|
292
|
+
viewBox="0 0 24 24"
|
|
293
|
+
fill="none"
|
|
294
|
+
stroke="currentColor"
|
|
295
|
+
strokeWidth="2"
|
|
296
|
+
strokeLinecap="round"
|
|
297
|
+
aria-hidden="true"
|
|
298
|
+
>
|
|
299
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
300
|
+
</svg>
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
|
306
|
+
<div className="mx-auto box-border flex w-full max-w-[50rem] flex-col gap-6 px-5">
|
|
307
|
+
<div className="flex flex-col gap-1">
|
|
308
|
+
<p className="m-0 text-body font-medium text-fg">
|
|
309
|
+
{labels.sectionLabel ?? labels.title}
|
|
310
|
+
</p>
|
|
311
|
+
<p id={descriptionId} className="m-0 max-w-[48ch] text-body font-normal text-fg">
|
|
312
|
+
{labels.body}
|
|
313
|
+
</p>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<div className="flex flex-col gap-1 rounded-md bg-surface-card p-3 text-caption leading-4 text-fg-muted">
|
|
317
|
+
<span>{outputName?.trim() || labels.outputName || labels.sizeLabel}</span>
|
|
318
|
+
{outputDescription?.trim() || labels.outputDescription || labels.sizePlaceholder ? (
|
|
319
|
+
<strong className="font-medium text-fg">
|
|
320
|
+
{outputDescription?.trim() || labels.outputDescription || labels.sizePlaceholder}
|
|
321
|
+
</strong>
|
|
322
|
+
) : null}
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<div className="grid gap-3">
|
|
326
|
+
{derivedSize ? null : (
|
|
327
|
+
<Input
|
|
328
|
+
label={labels.sizeLabel}
|
|
329
|
+
placeholder={labels.sizePlaceholder}
|
|
330
|
+
value={sizeRaw}
|
|
331
|
+
onChange={setSizeRaw}
|
|
332
|
+
data-testid="delivery-print-size"
|
|
333
|
+
/>
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
<Input
|
|
337
|
+
label={labels.quantityLabel}
|
|
338
|
+
placeholder={labels.quantityPlaceholder}
|
|
339
|
+
value={quantityRaw}
|
|
340
|
+
onChange={setQuantityRaw}
|
|
341
|
+
type="number"
|
|
342
|
+
inputMode="numeric"
|
|
343
|
+
isInvalid={quantityInvalid}
|
|
344
|
+
errorMessage={quantityInvalid ? labels.quantityError : undefined}
|
|
345
|
+
data-testid="delivery-print-quantity"
|
|
346
|
+
/>
|
|
347
|
+
|
|
348
|
+
{materialOptions.length > 1 ? (
|
|
349
|
+
<label className="grid gap-1.5 text-small font-medium text-fg">
|
|
350
|
+
<span>{labels.materialLabel}</span>
|
|
351
|
+
<select
|
|
352
|
+
value={effectiveMaterialId}
|
|
353
|
+
onChange={(event) => setMaterialId(event.currentTarget.value)}
|
|
354
|
+
data-testid="delivery-print-material"
|
|
355
|
+
className="h-12 rounded-md border border-border bg-surface px-3 text-body font-normal text-fg outline-none transition-colors hover:border-border-muted focus:border-fg focus:ring-2 focus:ring-ring"
|
|
356
|
+
>
|
|
357
|
+
{materialOptions.map((option) => (
|
|
358
|
+
<option key={option.id} value={option.id}>
|
|
359
|
+
{option.label}
|
|
360
|
+
</option>
|
|
361
|
+
))}
|
|
362
|
+
</select>
|
|
363
|
+
</label>
|
|
364
|
+
) : selectedMaterial ? (
|
|
365
|
+
<div
|
|
366
|
+
data-testid="delivery-print-material"
|
|
367
|
+
className="flex flex-col gap-1 rounded-md bg-surface-card p-3 text-caption leading-4 text-fg-muted"
|
|
368
|
+
>
|
|
369
|
+
<span>{labels.materialLabel}</span>
|
|
370
|
+
<strong className="font-medium text-fg">{selectedMaterial.label}</strong>
|
|
371
|
+
</div>
|
|
372
|
+
) : (
|
|
373
|
+
<p
|
|
374
|
+
role="note"
|
|
375
|
+
data-testid="delivery-print-material"
|
|
376
|
+
className="m-0 rounded-md bg-surface-card p-3 text-caption leading-4 text-fg-muted"
|
|
377
|
+
>
|
|
378
|
+
{labels.noMaterialConfigured ?? labels.materialPlaceholder}
|
|
379
|
+
</p>
|
|
380
|
+
)}
|
|
381
|
+
|
|
382
|
+
<Input
|
|
383
|
+
label={labels.companyLabel ?? labels.addressLabel}
|
|
384
|
+
placeholder={labels.companyPlaceholder ?? labels.addressPlaceholder}
|
|
385
|
+
value={company}
|
|
386
|
+
onChange={setCompany}
|
|
387
|
+
data-testid="delivery-print-company"
|
|
388
|
+
/>
|
|
389
|
+
<Input
|
|
390
|
+
label={labels.streetLabel ?? labels.addressLabel}
|
|
391
|
+
placeholder={labels.streetPlaceholder ?? labels.addressPlaceholder}
|
|
392
|
+
value={street}
|
|
393
|
+
onChange={(value) => setStreet(capitalizeFirstAddressLetter(value))}
|
|
394
|
+
data-testid="delivery-print-street"
|
|
395
|
+
/>
|
|
396
|
+
<div className="grid gap-4 sm:grid-cols-[1fr_10rem]">
|
|
397
|
+
<Input
|
|
398
|
+
label={labels.cityLabel ?? labels.addressLabel}
|
|
399
|
+
placeholder={labels.cityPlaceholder ?? labels.addressPlaceholder}
|
|
400
|
+
value={city}
|
|
401
|
+
onChange={setCity}
|
|
402
|
+
data-testid="delivery-print-city"
|
|
403
|
+
/>
|
|
404
|
+
<Input
|
|
405
|
+
label={labels.zipLabel ?? labels.addressLabel}
|
|
406
|
+
placeholder={labels.zipPlaceholder ?? labels.addressPlaceholder}
|
|
407
|
+
value={zip}
|
|
408
|
+
onChange={setZip}
|
|
409
|
+
data-testid="delivery-print-zip"
|
|
410
|
+
/>
|
|
411
|
+
</div>
|
|
412
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
413
|
+
<Input
|
|
414
|
+
label={labels.contactNameLabel ?? labels.addressLabel}
|
|
415
|
+
placeholder={labels.contactNamePlaceholder ?? labels.addressPlaceholder}
|
|
416
|
+
value={contactName}
|
|
417
|
+
onChange={setContactName}
|
|
418
|
+
data-testid="delivery-print-contact"
|
|
419
|
+
/>
|
|
420
|
+
<Input
|
|
421
|
+
label={labels.phoneLabel ?? labels.addressLabel}
|
|
422
|
+
placeholder={labels.phonePlaceholder ?? labels.addressPlaceholder}
|
|
423
|
+
value={phone}
|
|
424
|
+
onChange={setPhone}
|
|
425
|
+
type="tel"
|
|
426
|
+
data-testid="delivery-print-phone"
|
|
427
|
+
/>
|
|
428
|
+
</div>
|
|
429
|
+
<Input
|
|
430
|
+
label={labels.noteLabel ?? labels.addressLabel}
|
|
431
|
+
placeholder={labels.notePlaceholder ?? labels.addressPlaceholder}
|
|
432
|
+
value={note}
|
|
433
|
+
onChange={setNote}
|
|
434
|
+
data-testid="delivery-print-note"
|
|
435
|
+
/>
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
{error ? (
|
|
439
|
+
<p data-testid="delivery-print-error" role="alert" className="m-0 text-small text-danger">
|
|
440
|
+
{error}
|
|
441
|
+
</p>
|
|
442
|
+
) : null}
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<div className="sticky bottom-0 mt-0 w-full border-border border-t bg-surface pt-4">
|
|
447
|
+
<div className="mx-auto box-border flex w-full max-w-[50rem] items-center justify-between gap-4 px-5">
|
|
448
|
+
<div className="flex min-w-0 flex-col gap-1 text-small text-fg-muted">
|
|
449
|
+
{/* No fallback: borrowing `sizeLabel` / `materialPlaceholder` put
|
|
450
|
+
"Size" and "e.g. Matte 250g" in a money slot for every consumer
|
|
451
|
+
that had not adopted the new label set. */}
|
|
452
|
+
{labels.priceEyebrow ? <span>{labels.priceEyebrow}</span> : null}
|
|
453
|
+
{labels.priceIdle ? <span className="truncate">{labels.priceIdle}</span> : null}
|
|
454
|
+
</div>
|
|
455
|
+
<div className="flex shrink-0 justify-end gap-2">
|
|
456
|
+
<Button
|
|
457
|
+
variant="secondary"
|
|
458
|
+
size="sm"
|
|
459
|
+
isDisabled={isPending}
|
|
460
|
+
onPress={() => handleOpenChange(false)}
|
|
461
|
+
>
|
|
462
|
+
{labels.cancel}
|
|
463
|
+
</Button>
|
|
464
|
+
<Button
|
|
465
|
+
data-testid="delivery-print-confirm"
|
|
466
|
+
variant="primary"
|
|
467
|
+
size="sm"
|
|
468
|
+
isDisabled={!canConfirm}
|
|
469
|
+
isPending={isPending}
|
|
470
|
+
onPress={handleConfirm}
|
|
471
|
+
>
|
|
472
|
+
{isPending ? labels.submitting : labels.confirm}
|
|
473
|
+
</Button>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
</form>
|
|
478
|
+
)}
|
|
479
|
+
</ModalDialog>
|
|
480
|
+
</ModalSurface>
|
|
481
|
+
</ModalOverlay>
|
|
215
482
|
)
|
|
216
483
|
}
|