@weft-js/warp 0.0.0-stage → 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 +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- package/tokens/color.mjs +66 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { FileTextIcon, LockIcon, TrashIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { money } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, type State } from '@weft-js/core'
|
|
4
|
+
import { Badge } from '../Badge'
|
|
5
|
+
import { Button, type Tone } from '../Button'
|
|
6
|
+
import { Banner } from '../parts'
|
|
7
|
+
import { Table, type Column } from '../Table'
|
|
8
|
+
import { toast } from '../Toast'
|
|
9
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
10
|
+
import { read } from './atoms'
|
|
11
|
+
import '../styles'
|
|
12
|
+
import '@weft-js/warp-core/styles/flows/tokens.css'
|
|
13
|
+
import '@weft-js/warp-core/styles/flows/base.css'
|
|
14
|
+
import '@weft-js/warp-core/styles/rich/helpers.css'
|
|
15
|
+
import '@weft-js/warp-core/styles/layout/tokens.css'
|
|
16
|
+
import '@weft-js/warp-core/styles/layout/row.css'
|
|
17
|
+
import '@weft-js/warp-core/styles/layout/stack.css'
|
|
18
|
+
import '@weft-js/warp-core/styles/data/list.css'
|
|
19
|
+
import '@weft-js/warp-core/styles/flows/pay-frame.css'
|
|
20
|
+
import type { ProviderAppearance } from '../PaymentCard'
|
|
21
|
+
|
|
22
|
+
/** Where an invoice stands. */
|
|
23
|
+
export type InvoiceStatus = 'paid' | 'retrying' | 'failed' | 'open'
|
|
24
|
+
/** One invoice in the billing history. */
|
|
25
|
+
export type Invoice = { id: string; date: string; number: string; status: InvoiceStatus; amount: number; pdf?: string }
|
|
26
|
+
const STATUS: Record<InvoiceStatus, [string, Tone]> = { paid: ['Paid', 'positive'], retrying: ['Retrying', 'caution'], failed: ['Failed', 'critical'], open: ['Open', 'neutral'] }
|
|
27
|
+
|
|
28
|
+
/** Invoices (09 §4.3): a DataTable; a failed payment pins a Banner above it with "Update payment method". */
|
|
29
|
+
export function Invoices({ invoices, currency, locale, onUpdatePayment, onExport }: { invoices: readonly Invoice[] | State<readonly Invoice[]>; currency?: string | undefined; locale?: string | undefined; onUpdatePayment?: () => void; onExport?: () => void }) {
|
|
30
|
+
const list = derived(() => read(invoices))
|
|
31
|
+
const failed = derived(() => list.value.some(i => i.status === 'failed'))
|
|
32
|
+
const columns: Column<Invoice>[] = [
|
|
33
|
+
{ id: 'date', header: 'Date', rowHeader: true, card: 'title' },
|
|
34
|
+
{ id: 'number', header: 'Number' },
|
|
35
|
+
{ id: 'status', header: 'Status', card: 'badge', cell: i => <Badge tone={STATUS[i.status][1]} dot>{STATUS[i.status][0]}</Badge> },
|
|
36
|
+
{ id: 'amount', header: 'Amount', numeric: true, value: i => money(i.amount, currency, locale) },
|
|
37
|
+
{ id: 'pdf', header: 'Receipt', value: () => '', cell: i => (i.pdf ? <a data-scope="button" data-emphasis="ghost" data-tone="neutral" data-size="sm" data-icon-only="" href={i.pdf} aria-label={`PDF for ${i.number}`}><FileTextIcon /></a> : '') },
|
|
38
|
+
]
|
|
39
|
+
const fix = derived(() => <Button size="sm" tone="critical" onClick={() => onUpdatePayment?.()}>Update payment method</Button>) // one build: Banner reads the prop twice
|
|
40
|
+
const tools = derived(() => (onExport ? <Button emphasis="ghost" tone="neutral" size="sm" onClick={onExport}>Export CSV</Button> : undefined)) // one build: Table reads the prop twice
|
|
41
|
+
return (
|
|
42
|
+
<div data-scope="stack">
|
|
43
|
+
{failed.value && <Banner tone="critical" live="polite" actions={fix.value}>Your last payment failed. Update your card to keep this plan.</Banner>}
|
|
44
|
+
<Table caption="Invoices" captionHidden rows={list} columns={columns} picker={false} heading={3} actions={tools.value} empty={{ title: 'No invoices yet', text: 'Your first one comes at the end of this period.' }} />
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A card on file. */
|
|
50
|
+
export type PaymentMethod = { id: string; brand: string; last4: string; expires: string; default?: boolean }
|
|
51
|
+
|
|
52
|
+
/** Saved cards (09 §4.3): the last four only. Make default applies at once; remove gives Undo. Details live with the provider. */
|
|
53
|
+
export function PaymentMethods({ methods, onAdd, onDefault, onRemove, onRestore }: { methods: readonly PaymentMethod[] | State<readonly PaymentMethod[]>; onAdd?: () => void; onDefault: (m: PaymentMethod) => void; onRemove: (m: PaymentMethod) => void; onRestore?: (m: PaymentMethod) => void }) {
|
|
54
|
+
const list = derived(() => read(methods))
|
|
55
|
+
const name = (m: PaymentMethod) => `${m.brand} ending ${m.last4}`
|
|
56
|
+
return (
|
|
57
|
+
<div data-scope="stack">
|
|
58
|
+
<ul data-scope="list" aria-label="Payment methods">
|
|
59
|
+
{list.value.map(m => (
|
|
60
|
+
<li key={m.id}>
|
|
61
|
+
<div data-part="item">
|
|
62
|
+
<span></span>
|
|
63
|
+
<span><b>{name(m)}</b><br /><span class="muted">Expires {m.expires}</span></span>
|
|
64
|
+
<span class="row">
|
|
65
|
+
{m.default
|
|
66
|
+
? <Badge tone="neutral">Default</Badge>
|
|
67
|
+
: <>
|
|
68
|
+
<Button emphasis="link" aria-label={`Make default: ${name(m)}`} onClick={() => { onDefault(m); toast.show({ title: 'Default payment method changed', tone: 'positive' }) }}>Make default</Button>
|
|
69
|
+
<Button emphasis="ghost" tone="critical" size="sm" data-icon-only="" label={`Remove ${name(m)}`} onClick={() => { onRemove(m); toast.show({ title: `${name(m)} removed`, action: onRestore && { label: 'Undo', onClick: () => onRestore(m) } }) }}><TrashIcon /></Button>
|
|
70
|
+
</>}
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
</li>
|
|
74
|
+
))}
|
|
75
|
+
</ul>
|
|
76
|
+
{onAdd && <div><Button emphasis="frame" size="sm" onClick={onAdd}>Add method</Button></div>}
|
|
77
|
+
<span class="muted">Card details live with the payment provider; warp only shows the last four digits.</span>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Props for PayFrame. */
|
|
83
|
+
export interface PayFrameProps {
|
|
84
|
+
/** The iframe's title: what's inside it, for a screen reader (09 §1.5). */
|
|
85
|
+
title: string
|
|
86
|
+
/** The provider's hosted page. */
|
|
87
|
+
src?: string
|
|
88
|
+
/** Or mount the provider's fields here, with warp's tokens for its appearance API. */
|
|
89
|
+
mount?: (el: HTMLElement, appearance: ProviderAppearance) => { destroy(): void }
|
|
90
|
+
testMode?: boolean
|
|
91
|
+
/** Ready once the provider says so; a `src` frame also flips on its load event. */
|
|
92
|
+
ready?: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The payment slot (09 §4.3): warp styles it (border, radius, shimmer, test-mode badge) and passes tokens to the
|
|
97
|
+
* provider. It's an iframe slot only: warp never renders a card field.
|
|
98
|
+
*/
|
|
99
|
+
export function PayFrame({ title, src, mount, testMode, ready }: PayFrameProps) {
|
|
100
|
+
const loaded = state(false)
|
|
101
|
+
const done = derived(() => !!ready || loaded.value)
|
|
102
|
+
const hosted = derived(() => !!src)
|
|
103
|
+
let host: HTMLDivElement | undefined
|
|
104
|
+
use((el: HTMLDivElement) => {
|
|
105
|
+
if (!mount) return { update() {}, destroy() {} }
|
|
106
|
+
const css = getComputedStyle(el)
|
|
107
|
+
const token = (t: string) => css.getPropertyValue(`--warp-${t}`).trim()
|
|
108
|
+
const made = mount(el, { fontFamily: css.fontFamily, fontSize: token('text-control-size'), color: token('color-text'), errorColor: token('color-critical-text'), borderRadius: token('radius-control'), borderColor: token('color-border-control') })
|
|
109
|
+
return { update() {}, destroy: () => made.destroy() }
|
|
110
|
+
}, () => host)
|
|
111
|
+
return (
|
|
112
|
+
<div data-scope="pay-frame" data-state={done.value ? undefined : 'loading'} aria-busy={done.value ? undefined : 'true'}>
|
|
113
|
+
<div data-part="head">
|
|
114
|
+
<span class="row"><LockIcon />Card fields are hosted by your payment provider</span>
|
|
115
|
+
{testMode && <Badge tone="caution">Test mode</Badge>}
|
|
116
|
+
</div>
|
|
117
|
+
<div ref={n => (host = n)} data-part="slot" role="group" aria-label={title}>
|
|
118
|
+
{!done.value && <><i data-loop=""></i><div><i data-loop=""></i><i data-loop=""></i></div></>}
|
|
119
|
+
{hosted.value && <iframe src={src} title={title} width="100%" height="148" frameborder="0" onLoad={() => (loaded.value = true)}></iframe>}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { BagIcon, MinusIcon, PlusIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { cartCount, cartTotals, money, type CartLine } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, type State } from '@weft-js/core'
|
|
4
|
+
import { Badge } from '../Badge'
|
|
5
|
+
import { Button } from '../Button'
|
|
6
|
+
import { Drawer } from '../Drawer'
|
|
7
|
+
import { toast } from '../Toast'
|
|
8
|
+
import { afterFlush } from './atoms'
|
|
9
|
+
import '../styles'
|
|
10
|
+
import '@weft-js/warp-core/styles/flows/tokens.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/flows/base.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/rich/helpers.css'
|
|
13
|
+
import '@weft-js/warp-core/styles/layout/tokens.css'
|
|
14
|
+
import '@weft-js/warp-core/styles/layout/row.css'
|
|
15
|
+
import '@weft-js/warp-core/styles/layout/stack.css'
|
|
16
|
+
import '@weft-js/warp-core/styles/app/badge.css' // the count's bump
|
|
17
|
+
import '@weft-js/warp-core/styles/flows/line-item.css'
|
|
18
|
+
import '@weft-js/warp-core/styles/flows/lines.css'
|
|
19
|
+
import '@weft-js/warp-core/styles/flows/qty.css'
|
|
20
|
+
|
|
21
|
+
export const setQuantity = (cart: State<CartLine[]>, id: string, quantity: number) => (cart.value = cart.value.map(l => (l.id === id ? { ...l, quantity } : l)))
|
|
22
|
+
|
|
23
|
+
/** Takes a line out, with Undo back to where it was. */
|
|
24
|
+
export function removeLine(cart: State<CartLine[]>, line: CartLine) {
|
|
25
|
+
const at = cart.value.findIndex(l => l.id === line.id)
|
|
26
|
+
cart.value = cart.value.filter(l => l.id !== line.id)
|
|
27
|
+
toast.show({ title: 'Removed from cart', description: line.name, action: { label: 'Undo', onClick: () => { const next = [...cart.value]; next.splice(at, 0, line); cart.value = next } } })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One line: thumb, name, variant, stepper, price (09 §6.1). The stepper's minus at 1 removes, asks nothing, gives Undo. */
|
|
31
|
+
export function LineItem({ line, cart, currency, locale, problem }: { line: CartLine; cart: State<CartLine[]>; currency?: string | undefined; locale?: string | undefined; problem?: unknown }) {
|
|
32
|
+
const down = () => (line.quantity <= 1 ? removeLine(cart, line) : setQuantity(cart, line.id, line.quantity - 1))
|
|
33
|
+
return (
|
|
34
|
+
<div data-scope="line-item" data-id={line.id}>
|
|
35
|
+
<span data-part="thumb">{line.art && <img src={line.art} alt="" />}</span>
|
|
36
|
+
<span>
|
|
37
|
+
<b id={`li-${line.id}`}>{line.name}</b>
|
|
38
|
+
<small>{line.variant ? `${line.variant} · ` : ''}{money(line.price, currency, locale)}</small>
|
|
39
|
+
{problem && <><br /><Badge tone="critical">{problem}</Badge></>}
|
|
40
|
+
</span>
|
|
41
|
+
<span data-part="price">
|
|
42
|
+
{money(line.price * line.quantity, currency, locale)}
|
|
43
|
+
<span data-scope="qty">
|
|
44
|
+
<button type="button" aria-label={line.quantity <= 1 ? `Remove ${line.name}` : `Fewer ${line.name}`} onClick={down}><MinusIcon /></button>
|
|
45
|
+
<output aria-live="polite" aria-label={`Quantity of ${line.name}`}>{line.quantity}</output>
|
|
46
|
+
<button type="button" aria-label={`More ${line.name}`} onClick={() => setQuantity(cart, line.id, line.quantity + 1)}><PlusIcon /></button>
|
|
47
|
+
</span>
|
|
48
|
+
</span>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The cart button: a count badge, and the count said in words. */
|
|
54
|
+
export function CartButton({ count, onClick }: { count: number; onClick: () => void }) {
|
|
55
|
+
return (
|
|
56
|
+
<Button emphasis="ghost" tone="neutral" data-icon-only="" label={`Cart, ${count} items`} style="position:relative" onClick={onClick}>
|
|
57
|
+
<BagIcon />
|
|
58
|
+
{/* keyed by the count, so a new number is a new node and the bump replays once */}
|
|
59
|
+
{[count].filter(n => n > 0).map(n => <Badge key={n} count emphasis="solid" aria-hidden="true" data-bump="" style="position:absolute;inset:-2px -4px auto auto">{n}</Badge>)}
|
|
60
|
+
</Button>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Props for CartDrawer. */
|
|
65
|
+
export interface CartDrawerProps {
|
|
66
|
+
open: State<boolean>
|
|
67
|
+
cart: State<CartLine[]>
|
|
68
|
+
currency?: string | undefined
|
|
69
|
+
locale?: string | undefined
|
|
70
|
+
onCheckout: () => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The cart drawer (09 §6.1): a Sheet, right on wide screens and from the bottom on a phone, with the subtotal and Checkout. */
|
|
74
|
+
export function CartDrawer({ open, cart, currency, locale, onCheckout }: CartDrawerProps) {
|
|
75
|
+
const lines = derived(() => cart.value)
|
|
76
|
+
const n = derived(() => cartCount(lines.value))
|
|
77
|
+
const subtotal = derived(() => cartTotals(lines.value).subtotal)
|
|
78
|
+
const go = () => { open.value = false; afterFlush(onCheckout) }
|
|
79
|
+
return (
|
|
80
|
+
<Drawer
|
|
81
|
+
open={open}
|
|
82
|
+
title={`Cart · ${n.value} ${n.value === 1 ? 'item' : 'items'}`}
|
|
83
|
+
footer={
|
|
84
|
+
<div data-scope="stack">
|
|
85
|
+
<dl data-scope="lines"><div data-part="total"><dt>Subtotal</dt><dd>{money(subtotal.value, currency, locale)}</dd></div></dl>
|
|
86
|
+
<span class="muted">Shipping and tax at checkout.</span>
|
|
87
|
+
<Button size="lg" full disabled={!n.value} autofocus onClick={go}>Checkout</Button>
|
|
88
|
+
</div>
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
{lines.value.length
|
|
92
|
+
? lines.value.map(l => <LineItem key={l.id} line={l} cart={cart} currency={currency} locale={locale} />)
|
|
93
|
+
: <span class="muted">Your cart is empty.</span>}
|
|
94
|
+
</Drawer>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { BagIcon, DownIcon, LeftIcon, LockIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { addressProblems, announce, cartCount, cartTotals, errorStep, money, newOrderKey, openWhenWide, uid, type Address, type AddressPart, type CartLine, type CheckoutError } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, type State, untrack } from '@weft-js/core'
|
|
4
|
+
import { Alert } from '../Alert'
|
|
5
|
+
import { Button } from '../Button'
|
|
6
|
+
import { Checkbox } from '../Choice'
|
|
7
|
+
import { Input } from '../Field'
|
|
8
|
+
import { AddressField, type AddressProvider } from '../PlaceField'
|
|
9
|
+
import { Tag } from '../Tag'
|
|
10
|
+
import { toast } from '../Toast'
|
|
11
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
12
|
+
import { LineItem } from './Cart'
|
|
13
|
+
import { OrderConfirmation, type OrderStage } from './Order'
|
|
14
|
+
import { StepBar } from './StepBar'
|
|
15
|
+
import { Sweep, afterFlush } from './atoms'
|
|
16
|
+
import '../styles'
|
|
17
|
+
import '@weft-js/warp-core/styles/flows/tokens.css'
|
|
18
|
+
import '@weft-js/warp-core/styles/flows/base.css'
|
|
19
|
+
import '@weft-js/warp-core/styles/rich/helpers.css'
|
|
20
|
+
import '@weft-js/warp-core/styles/layout/tokens.css'
|
|
21
|
+
import '@weft-js/warp-core/styles/layout/row.css'
|
|
22
|
+
import '@weft-js/warp-core/styles/layout/stack.css'
|
|
23
|
+
import '@weft-js/warp-core/styles/flows/flow.css'
|
|
24
|
+
import '@weft-js/warp-core/styles/flows/checkout.css'
|
|
25
|
+
import '@weft-js/warp-core/styles/flows/choice.css'
|
|
26
|
+
import '@weft-js/warp-core/styles/flows/plan-card.css'
|
|
27
|
+
import '@weft-js/warp-core/styles/flows/line-item.css'
|
|
28
|
+
import '@weft-js/warp-core/styles/flows/lines.css'
|
|
29
|
+
import '@weft-js/warp-core/styles/flows/auth.css' // the "or with a card" divider
|
|
30
|
+
|
|
31
|
+
/** A way to ship an order. */
|
|
32
|
+
export type ShippingOption = { id: string; name: string; /** "Arrives Thu 6 Nov" */ eta: string; price: number }
|
|
33
|
+
/** A valid coupon and what it takes off. */
|
|
34
|
+
export type Coupon = { code: string; percentOff: number }
|
|
35
|
+
/** Where a checkout stands. */
|
|
36
|
+
export type CheckoutStep = 'cart' | 'address' | 'shipping' | 'payment' | 'review' | 'done'
|
|
37
|
+
/** Everything the buyer entered, handed over to place the order. */
|
|
38
|
+
export type OrderDraft = { lines: CartLine[]; name: string; email: string; phone: string; address: Address; billing: Address; shipping: string; coupon?: string | undefined; total: number; idempotencyKey: string }
|
|
39
|
+
/** What placing an order came back with: the order, or why not. */
|
|
40
|
+
export type PlaceOrderResult = { order: { id: string; stage?: OrderStage | undefined } } | { error: CheckoutError }
|
|
41
|
+
|
|
42
|
+
/** Props for Checkout. */
|
|
43
|
+
export interface CheckoutProps {
|
|
44
|
+
cart: State<CartLine[]>
|
|
45
|
+
shipping: ShippingOption[]
|
|
46
|
+
countries?: string[] | undefined
|
|
47
|
+
defaultCountry?: string
|
|
48
|
+
addressProvider?: AddressProvider
|
|
49
|
+
taxRate?: number
|
|
50
|
+
/** The ISO 4217 code prices are shown in, such as `EUR`. */
|
|
51
|
+
currency?: string
|
|
52
|
+
locale?: string
|
|
53
|
+
/** The Payment step's body: a PayFrame, in a provider's slot. warp never renders card fields. */
|
|
54
|
+
payment: unknown
|
|
55
|
+
/** The saved wallet's frame button, above the divider. */
|
|
56
|
+
wallet?: { label: string; onClick: () => void }
|
|
57
|
+
/** Checks a code; resolve undefined when it isn't valid. */
|
|
58
|
+
coupon?: (code: string) => Promise<Coupon | undefined> | Coupon | undefined
|
|
59
|
+
/** The client's key for this attempt. Without one a key is made; a retry after a timeout resends it. */
|
|
60
|
+
idempotencyKey?: string | undefined
|
|
61
|
+
/** Throw on a transport failure: values stay, a Toast offers Retry with the same key. */
|
|
62
|
+
onPlaceOrder: (order: OrderDraft) => Promise<PlaceOrderResult>
|
|
63
|
+
onStepChange?: ((step: CheckoutStep) => void) | undefined
|
|
64
|
+
onContinueShopping?: () => void
|
|
65
|
+
signInHref?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const STEPS: { id: CheckoutStep; title: string }[] = [{ id: 'cart', title: 'Cart' }, { id: 'address', title: 'Address' }, { id: 'shipping', title: 'Shipping' }, { id: 'payment', title: 'Payment' }, { id: 'review', title: 'Review' }]
|
|
69
|
+
const WIDE = 860
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Checkout (09 §6.2): Cart → Address → Shipping → Payment → Review → Confirmation, guest first. Each step's title takes
|
|
73
|
+
* focus and the step is announced; back goes to a step without losing a value. Place order is idempotent by a client
|
|
74
|
+
* key; CARD_DECLINED returns to Payment, OUT_OF_STOCK to Cart with the line marked, PRICE_CHANGED stays on Review.
|
|
75
|
+
*/
|
|
76
|
+
export function Checkout({ cart, shipping, countries, defaultCountry = 'US', addressProvider, taxRate = 0, currency, locale, payment, wallet, coupon, idempotencyKey, onPlaceOrder, onStepChange, onContinueShopping, signInHref }: CheckoutProps) {
|
|
77
|
+
const id = uid('co')
|
|
78
|
+
const step = state<CheckoutStep>('cart')
|
|
79
|
+
const dir = state<'back' | undefined>(undefined)
|
|
80
|
+
const name = state('')
|
|
81
|
+
const email = state('')
|
|
82
|
+
const phone = state('')
|
|
83
|
+
const address = state<Address | null>(null)
|
|
84
|
+
const billing = state<Address | null>(null)
|
|
85
|
+
const sameBilling = state(true)
|
|
86
|
+
const nameError = state('')
|
|
87
|
+
const emailError = state('')
|
|
88
|
+
const addressErrors = state<Partial<Record<AddressPart, string>>>({})
|
|
89
|
+
const billingErrors = state<Partial<Record<AddressPart, string>>>({})
|
|
90
|
+
const addressAlert = state('')
|
|
91
|
+
const ship = state(untrack(() => shipping[0]?.id ?? ''))
|
|
92
|
+
const code = state('')
|
|
93
|
+
const codeError = state('')
|
|
94
|
+
const applied = state<Coupon | undefined>(undefined)
|
|
95
|
+
const checking = state(false)
|
|
96
|
+
const placing = state(false)
|
|
97
|
+
const declined = state('')
|
|
98
|
+
const outOfStock = state('')
|
|
99
|
+
const newTotal = state<number | undefined>(undefined)
|
|
100
|
+
const placed = state<{ id: string; stage?: OrderStage | undefined } | undefined>(undefined)
|
|
101
|
+
const paySlot = derived(() => payment)
|
|
102
|
+
let root: HTMLElement | undefined
|
|
103
|
+
let summary: HTMLDetailsElement | undefined
|
|
104
|
+
let key: string | undefined
|
|
105
|
+
use(openWhenWide, () => summary, () => WIDE)
|
|
106
|
+
|
|
107
|
+
const lines = derived(() => cart.value)
|
|
108
|
+
const option = derived(() => shipping.find(o => o.id === ship.value))
|
|
109
|
+
const totals = derived(() => cartTotals(lines.value, { shipping: option.value?.price ?? 0, percentOff: applied.value?.percentOff, taxRate }))
|
|
110
|
+
const total = derived(() => newTotal.value ?? totals.value.total)
|
|
111
|
+
const cost = (n: number) => money(n, currency, locale)
|
|
112
|
+
const at = derived(() => STEPS.findIndex(s => s.id === step.value))
|
|
113
|
+
const stepTitle = (to: CheckoutStep) => (to === 'done' ? 'Order confirmed' : STEPS.find(s => s.id === to)!.title)
|
|
114
|
+
const section = (to: CheckoutStep) => root?.querySelector<HTMLElement>(`[data-step="${to}"]`)
|
|
115
|
+
|
|
116
|
+
const go = (to: CheckoutStep, focus?: 'field' , back?: boolean) => {
|
|
117
|
+
dir.value = back ? 'back' : undefined
|
|
118
|
+
step.value = to
|
|
119
|
+
onStepChange?.(to)
|
|
120
|
+
if (root) announce(root.ownerDocument, to === 'done' ? stepTitle(to) : `Step ${STEPS.findIndex(s => s.id === to) + 1} of ${STEPS.length}, ${stepTitle(to)}`)
|
|
121
|
+
afterFlush(() => {
|
|
122
|
+
const s = section(to)
|
|
123
|
+
const field = focus && s?.querySelector<HTMLElement>('input:not([type="hidden"]):checked, input:not([type="hidden"]), button[data-part="trigger"]')
|
|
124
|
+
;(field || s?.querySelector<HTMLElement>('[data-part="step-title"]'))?.focus()
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
const prev = (to: CheckoutStep) => go(to, undefined, true)
|
|
128
|
+
const submitAddress = (e: Event) => {
|
|
129
|
+
e.preventDefault()
|
|
130
|
+
nameError.value = name.value.trim() ? '' : 'Enter the name for delivery.'
|
|
131
|
+
emailError.value = !email.value ? 'We send the receipt here.' : /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value) ? '' : 'That doesn’t look like an email.'
|
|
132
|
+
addressErrors.value = addressProblems(address.value, defaultCountry)
|
|
133
|
+
billingErrors.value = sameBilling.value ? {} : addressProblems(billing.value, defaultCountry)
|
|
134
|
+
const n = [nameError.value, emailError.value].filter(Boolean).length + Object.keys(addressErrors.value).length + Object.keys(billingErrors.value).length
|
|
135
|
+
addressAlert.value = n ? `${n === 1 ? '1 field needs' : `${n} fields need`} attention` : ''
|
|
136
|
+
if (!n) return go('shipping')
|
|
137
|
+
if (root) announce(root.ownerDocument, addressAlert.value)
|
|
138
|
+
afterFlush(() => section('address')?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus())
|
|
139
|
+
}
|
|
140
|
+
const apply = async (e: Event) => {
|
|
141
|
+
e.preventDefault()
|
|
142
|
+
const v = code.value.trim().toUpperCase()
|
|
143
|
+
if (!v) return void (codeError.value = 'Enter a code first.')
|
|
144
|
+
checking.value = true
|
|
145
|
+
const c = await coupon?.(v)
|
|
146
|
+
checking.value = false
|
|
147
|
+
if (!c) return void (codeError.value = 'That code isn’t valid or has expired.')
|
|
148
|
+
applied.value = c
|
|
149
|
+
code.value = ''
|
|
150
|
+
codeError.value = ''
|
|
151
|
+
toast.show({ title: `${c.code} applied`, description: `${c.percentOff}% off your items.`, tone: 'positive' })
|
|
152
|
+
}
|
|
153
|
+
const unapply = () => {
|
|
154
|
+
const c = applied.value!
|
|
155
|
+
applied.value = undefined
|
|
156
|
+
toast.show({ title: `${c.code} removed`, action: { label: 'Undo', onClick: () => (applied.value = c) } })
|
|
157
|
+
}
|
|
158
|
+
const pay = async () => {
|
|
159
|
+
if (placing.value) return // a double tap: the first press owns the key
|
|
160
|
+
placing.value = true
|
|
161
|
+
const useKey = idempotencyKey ?? (key ??= newOrderKey())
|
|
162
|
+
const a = address.value!
|
|
163
|
+
try {
|
|
164
|
+
const r = await onPlaceOrder({ lines: lines.value, name: name.value, email: email.value, phone: phone.value, address: a, billing: sameBilling.value ? a : billing.value!, shipping: ship.value, coupon: applied.value?.code, total: total.value, idempotencyKey: useKey })
|
|
165
|
+
if ('order' in r) { placed.value = r.order; key = undefined; return go('done') }
|
|
166
|
+
key = undefined // a definite answer: the next attempt is a new one
|
|
167
|
+
const err = r.error
|
|
168
|
+
declined.value = err.code === 'CARD_DECLINED' ? err.reason : ''
|
|
169
|
+
outOfStock.value = err.code === 'OUT_OF_STOCK' ? err.sku : ''
|
|
170
|
+
newTotal.value = err.code === 'PRICE_CHANGED' ? err.total : undefined
|
|
171
|
+
if (err.code === 'PRICE_CHANGED') {
|
|
172
|
+
if (root) announce(root.ownerDocument, `The total changed to ${cost(err.total)}`, true)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
go(errorStep(err), undefined, true)
|
|
176
|
+
} catch {
|
|
177
|
+
toast.show({ title: 'We couldn’t reach the store', description: 'You weren’t charged twice. Try again.', tone: 'critical', action: { label: 'Retry', onClick: pay } })
|
|
178
|
+
} finally { placing.value = false }
|
|
179
|
+
}
|
|
180
|
+
const edits = derived(() => [
|
|
181
|
+
{ step: 'address', label: 'Ship to', text: `${name.value}, ${[address.value?.lines[0], address.value?.locality, address.value?.postalCode].filter(Boolean).join(', ')}` },
|
|
182
|
+
{ step: 'shipping', label: 'Delivery', text: `${option.value?.name ?? ''} · ${option.value?.eta ?? ''}` },
|
|
183
|
+
{ step: 'payment', label: 'Payment', text: 'Card from your provider' },
|
|
184
|
+
])
|
|
185
|
+
const count = derived(() => cartCount(lines.value))
|
|
186
|
+
const Nav = ({ back, children }: { back: CheckoutStep; children: unknown }) => (
|
|
187
|
+
<div data-part="nav"><Button emphasis="ghost" tone="neutral" icon={LeftIcon} onClick={() => prev(back)}>Back</Button>{children}</div>
|
|
188
|
+
)
|
|
189
|
+
return (
|
|
190
|
+
<div ref={n => (root = n)} data-scope="checkout">
|
|
191
|
+
<div data-part="layout">
|
|
192
|
+
<div data-scope="stack">
|
|
193
|
+
{step.value !== 'done' && <StepBar label="Checkout steps" steps={STEPS} current={at.value} />}
|
|
194
|
+
<div data-scope="flow" data-dir={dir.value}>
|
|
195
|
+
<section data-part="step" data-step="cart" data-state={step.value === 'cart' ? 'active' : undefined} aria-labelledby={`${id}-cart`}>
|
|
196
|
+
<div data-part="step-body">
|
|
197
|
+
<h2 data-part="step-title" id={`${id}-cart`} tabindex="-1">Your cart</h2>
|
|
198
|
+
{signInHref && <span class="muted">Checking out as a guest. <a href={signInHref}>Sign in</a></span>}
|
|
199
|
+
{outOfStock.value && <Alert tone="critical" live title="Part of your order just sold out">Remove the marked line to continue.</Alert>}
|
|
200
|
+
{lines.value.length
|
|
201
|
+
? lines.value.map(l => <LineItem key={l.id} line={l} cart={cart} currency={currency} locale={locale} problem={l.id === outOfStock.value ? 'Out of stock' : undefined} />)
|
|
202
|
+
: <span class="muted">Your cart is empty.</span>}
|
|
203
|
+
<div data-part="nav">
|
|
204
|
+
{onContinueShopping ? <Button emphasis="link" onClick={onContinueShopping}>Continue shopping</Button> : <span></span>}
|
|
205
|
+
<Button disabled={!count.value || lines.value.some(l => l.id === outOfStock.value)} onClick={() => go('address')}>Continue to address</Button>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</section>
|
|
209
|
+
<section data-part="step" data-step="address" data-state={step.value === 'address' ? 'active' : undefined} aria-labelledby={`${id}-address`}>
|
|
210
|
+
<form data-part="step-body" novalidate onSubmit={submitAddress}>
|
|
211
|
+
<h2 data-part="step-title" id={`${id}-address`} tabindex="-1">Where should it go?</h2>
|
|
212
|
+
{addressAlert.value && <Alert tone="critical">{addressAlert.value}</Alert>}
|
|
213
|
+
<div data-part="cols">
|
|
214
|
+
<Input label="Full name" autocomplete="name" value={name.value} error={nameError.value} onInput={e => { name.value = e.currentTarget.value; nameError.value = '' }} />
|
|
215
|
+
<Input label="Email for receipt" type="email" autocomplete="email" inputmode="email" value={email.value} error={emailError.value} onInput={e => { email.value = e.currentTarget.value; emailError.value = '' }} />
|
|
216
|
+
</div>
|
|
217
|
+
<AddressField label="Shipping address" value={address} countries={countries} defaultCountry={defaultCountry} provider={addressProvider} locale={locale} errors={addressErrors.value} />
|
|
218
|
+
<Input label="Phone (optional)" type="tel" autocomplete="tel" inputmode="tel" hint="Only for the courier, if they can’t find you." value={phone.value} onInput={e => (phone.value = e.currentTarget.value)} />
|
|
219
|
+
<Checkbox checked={sameBilling.value} onChange={(e: Event) => (sameBilling.value = (e.currentTarget as HTMLInputElement).checked)}>Use as billing address</Checkbox>
|
|
220
|
+
{!sameBilling.value && <AddressField label="Billing address" value={billing} countries={countries} defaultCountry={defaultCountry} locale={locale} errors={billingErrors.value} />}
|
|
221
|
+
<Nav back="cart"><Button type="submit">Continue to shipping</Button></Nav>
|
|
222
|
+
</form>
|
|
223
|
+
</section>
|
|
224
|
+
<section data-part="step" data-step="shipping" data-state={step.value === 'shipping' ? 'active' : undefined} aria-labelledby={`${id}-shipping`}>
|
|
225
|
+
<div data-part="step-body">
|
|
226
|
+
<h2 data-part="step-title" id={`${id}-shipping`} tabindex="-1">How fast?</h2>
|
|
227
|
+
<fieldset data-scope="choice">
|
|
228
|
+
<legend data-part="sr-only">Shipping speed</legend>
|
|
229
|
+
{shipping.map(o => (
|
|
230
|
+
<label key={o.id} data-scope="plan-card" data-sweep="">
|
|
231
|
+
<Sweep />
|
|
232
|
+
<input type="radio" name={`${id}-ship`} value={o.id} checked={o.id === ship.value} onChange={() => (ship.value = o.id)} />
|
|
233
|
+
<span data-part="radio" aria-hidden="true"></span>
|
|
234
|
+
<span><b>{o.name}</b><small>{o.eta}</small></span>
|
|
235
|
+
<b>{o.price ? cost(o.price) : 'Free'}</b>
|
|
236
|
+
</label>
|
|
237
|
+
))}
|
|
238
|
+
</fieldset>
|
|
239
|
+
<Nav back="address"><Button onClick={() => go('payment')}>Continue to payment</Button></Nav>
|
|
240
|
+
</div>
|
|
241
|
+
</section>
|
|
242
|
+
<section data-part="step" data-step="payment" data-state={step.value === 'payment' ? 'active' : undefined} aria-labelledby={`${id}-payment`}>
|
|
243
|
+
<div data-part="step-body">
|
|
244
|
+
<h2 data-part="step-title" id={`${id}-payment`} tabindex="-1">Payment</h2>
|
|
245
|
+
{declined.value && <Alert tone="critical" live title="Your card was declined">{declined.value} Try another card.</Alert>}
|
|
246
|
+
{wallet && <Button emphasis="frame" full onClick={wallet.onClick}>{wallet.label}</Button>}
|
|
247
|
+
{wallet && <div data-scope="auth" data-embedded=""><div data-part="divider">or with a card</div></div>}
|
|
248
|
+
{paySlot.value}
|
|
249
|
+
<Nav back="shipping"><Button onClick={() => { declined.value = ''; go('review') }}>Review order</Button></Nav>
|
|
250
|
+
</div>
|
|
251
|
+
</section>
|
|
252
|
+
<section data-part="step" data-step="review" data-state={step.value === 'review' ? 'active' : undefined} aria-labelledby={`${id}-review`}>
|
|
253
|
+
<div data-part="step-body">
|
|
254
|
+
<h2 data-part="step-title" id={`${id}-review`} tabindex="-1">Review and pay</h2>
|
|
255
|
+
{newTotal.value !== undefined && <Alert tone="caution" title={`The total is now ${cost(newTotal.value)}`}>The price changed while you were checking out. Look it over, then pay again.</Alert>}
|
|
256
|
+
<dl data-scope="lines">
|
|
257
|
+
{edits.value.map(r => (
|
|
258
|
+
<div key={r.step}>
|
|
259
|
+
<dt>{r.label}<br /><span>{r.text}</span></dt>
|
|
260
|
+
<dd><Button emphasis="link" aria-label={`Edit ${r.label.toLowerCase()}`} onClick={() => go(r.step as CheckoutStep, 'field', true)}>Edit</Button></dd>
|
|
261
|
+
</div>
|
|
262
|
+
))}
|
|
263
|
+
</dl>
|
|
264
|
+
<span class="muted">By paying you agree to the terms of sale. You can cancel within 1 hour.</span>
|
|
265
|
+
<Nav back="payment"><Button size="lg" loading={placing.value} onClick={pay}><LockIcon />{`Pay ${cost(total.value)}`}</Button></Nav>
|
|
266
|
+
</div>
|
|
267
|
+
</section>
|
|
268
|
+
<section data-part="step" data-step="done" data-state={step.value === 'done' ? 'active' : undefined} aria-labelledby={`${id}-done`}>
|
|
269
|
+
{placed.value && <OrderConfirmation titleId={`${id}-done`} orderId={placed.value.id} email={email.value} stage={placed.value.stage} note={`${option.value?.name ?? ''} delivery. ${option.value?.eta ?? ''}.`} onContinue={onContinueShopping} />}
|
|
270
|
+
</section>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
<details ref={n => (summary = n)} data-part="summary">
|
|
274
|
+
<summary><span class="row"><BagIcon />Order summary</span><span class="row"><b>{cost(total.value)}</b><DownIcon /></span></summary>
|
|
275
|
+
{lines.value.map(l => (
|
|
276
|
+
<div key={l.id} data-scope="line-item">
|
|
277
|
+
<span data-part="thumb">{l.art && <img src={l.art} alt="" />}</span>
|
|
278
|
+
<span><b>{l.name}</b><small>Qty {l.quantity}</small></span>
|
|
279
|
+
<span data-part="price">{cost(l.price * l.quantity)}</span>
|
|
280
|
+
</div>
|
|
281
|
+
))}
|
|
282
|
+
{coupon && (
|
|
283
|
+
<form data-scope="row" data-wrap="none" data-align="start" novalidate onSubmit={(e: Event) => void apply(e)}>
|
|
284
|
+
<div data-grow=""><Input label="Discount code" value={code.value} error={codeError.value} onInput={e => { code.value = e.currentTarget.value; codeError.value = '' }} /></div>
|
|
285
|
+
<Button emphasis="frame" type="submit" loading={checking.value}>Apply</Button>
|
|
286
|
+
</form>
|
|
287
|
+
)}
|
|
288
|
+
{applied.value && <div class="chips"><Tag label={applied.value.code} onRemove={unapply}>{applied.value.code}</Tag></div>}
|
|
289
|
+
<dl data-scope="lines">
|
|
290
|
+
<div><dt>Subtotal</dt><dd>{cost(totals.value.subtotal)}</dd></div>
|
|
291
|
+
{applied.value && <div><dt>Discount · {applied.value.code}</dt><dd>−{cost(totals.value.discount)}</dd></div>}
|
|
292
|
+
<div><dt>Shipping · {option.value?.name}</dt><dd>{totals.value.shipping ? cost(totals.value.shipping) : 'Free'}</dd></div>
|
|
293
|
+
<div><dt>Estimated tax</dt><dd>{cost(totals.value.tax)}</dd></div>
|
|
294
|
+
<div data-part="total"><dt>Total</dt><dd data-changed={newTotal.value !== undefined ? '' : undefined}>{cost(total.value)}</dd></div>
|
|
295
|
+
</dl>
|
|
296
|
+
</details>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
)
|
|
300
|
+
}
|