@tuturuuu/ui 0.7.0 → 0.8.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/CHANGELOG.md +48 -0
- package/package.json +8 -8
- package/src/components/ui/currency-input.test.tsx +43 -0
- package/src/components/ui/currency-input.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
- package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
- package/src/components/ui/finance/transactions/form-types.ts +2 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
- package/src/components/ui/money-input.test.tsx +64 -0
- package/src/components/ui/money-input.tsx +63 -0
- package/src/components/ui/storefront/cart-summary.tsx +114 -29
- package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
- package/src/components/ui/storefront/hero-panel.tsx +2 -8
- package/src/components/ui/storefront/image-panel.tsx +6 -0
- package/src/components/ui/storefront/index.ts +11 -0
- package/src/components/ui/storefront/listing-card.tsx +84 -22
- package/src/components/ui/storefront/product-detail.tsx +289 -0
- package/src/components/ui/storefront/product-dialog.tsx +72 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
- package/src/components/ui/storefront/storefront-surface.tsx +333 -133
- package/src/components/ui/storefront/types.ts +23 -1
- package/src/components/ui/storefront/utils.ts +111 -27
- package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
- package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
- package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
- package/src/components/ui/text-editor/content-migration.ts +41 -18
- package/src/components/ui/text-editor/extensions.ts +1 -1
- package/src/components/ui/text-editor/image-extension.ts +40 -18
- package/src/components/ui/text-editor/video-extension.ts +11 -2
- package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
- package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
- package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
- package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
- package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
- package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
- package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
- package/src/hooks/useBoardRealtime.ts +6 -3
- package/src/hooks/useBoardRealtime.types.ts +11 -0
- package/src/hooks/useCursorTracking.ts +91 -27
- package/src/hooks/useTaskUserRealtime.ts +5 -3
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { getCurrencyLocale } from '@tuturuuu/utils/format';
|
|
4
|
+
import {
|
|
5
|
+
getCurrencyFractionDigits,
|
|
6
|
+
majorToMinor,
|
|
7
|
+
minorToMajor,
|
|
8
|
+
} from '@tuturuuu/utils/money';
|
|
9
|
+
import { useMemo } from 'react';
|
|
10
|
+
import { CurrencyInput, type CurrencyInputProps } from './currency-input';
|
|
11
|
+
|
|
12
|
+
export interface MoneyInputProps
|
|
13
|
+
extends Omit<
|
|
14
|
+
CurrencyInputProps,
|
|
15
|
+
'value' | 'onChange' | 'locale' | 'maximumFractionDigits' | 'currencySuffix'
|
|
16
|
+
> {
|
|
17
|
+
/** ISO currency code (e.g. 'USD', 'VND'). Drives precision + locale. */
|
|
18
|
+
currency: string;
|
|
19
|
+
/** Amount in integer minor units (cents for USD, whole units for JPY/VND). */
|
|
20
|
+
value: number | undefined;
|
|
21
|
+
/** Emits the amount in integer minor units. */
|
|
22
|
+
onChange: (minorValue: number) => void;
|
|
23
|
+
/** Show the currency code as a muted suffix (defaults to true). */
|
|
24
|
+
showCurrencySuffix?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Currency-aware money input. The `value`/`onChange` boundary is always in
|
|
29
|
+
* integer **minor units** (the canonical storage format); the field renders and
|
|
30
|
+
* edits in localized major units with the correct precision for the currency.
|
|
31
|
+
*
|
|
32
|
+
* Wraps the shared {@link CurrencyInput} so all money entry across the platform
|
|
33
|
+
* shares one polished input (cursor preservation, quick-action helpers) while
|
|
34
|
+
* keeping the minor-unit conversion centralized in one place.
|
|
35
|
+
*/
|
|
36
|
+
export function MoneyInput({
|
|
37
|
+
currency,
|
|
38
|
+
value,
|
|
39
|
+
onChange,
|
|
40
|
+
showCurrencySuffix = true,
|
|
41
|
+
...props
|
|
42
|
+
}: MoneyInputProps) {
|
|
43
|
+
const fractionDigits = useMemo(
|
|
44
|
+
() => getCurrencyFractionDigits(currency),
|
|
45
|
+
[currency]
|
|
46
|
+
);
|
|
47
|
+
const locale = useMemo(() => getCurrencyLocale(currency), [currency]);
|
|
48
|
+
const majorValue = useMemo(
|
|
49
|
+
() => (value === undefined ? undefined : minorToMajor(value, currency)),
|
|
50
|
+
[currency, value]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<CurrencyInput
|
|
55
|
+
{...props}
|
|
56
|
+
value={majorValue}
|
|
57
|
+
onChange={(major) => onChange(majorToMinor(major, currency))}
|
|
58
|
+
locale={locale}
|
|
59
|
+
maximumFractionDigits={fractionDigits}
|
|
60
|
+
currencySuffix={showCurrencySuffix ? currency.toUpperCase() : undefined}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { ArrowRight, Tag, TriangleAlert } from '@tuturuuu/icons';
|
|
3
|
+
import { ArrowRight, Tag, TriangleAlert, Zap } from '@tuturuuu/icons';
|
|
4
4
|
import type { InventoryStorefront } from '@tuturuuu/internal-api/inventory';
|
|
5
5
|
import { cn } from '@tuturuuu/utils/format';
|
|
6
6
|
import type { FormEvent } from 'react';
|
|
7
7
|
import { Badge } from '../badge';
|
|
8
8
|
import { Button } from '../button';
|
|
9
9
|
import { AccentButton } from './accent-button';
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
10
|
+
import { StorefrontImagePanel } from './image-panel';
|
|
11
|
+
import type {
|
|
12
|
+
StorefrontBuyerDefaults,
|
|
13
|
+
StorefrontCartEntry,
|
|
14
|
+
StorefrontSurfaceLabels,
|
|
15
|
+
} from './types';
|
|
16
|
+
import {
|
|
17
|
+
formatStorefrontPrice,
|
|
18
|
+
getStorefrontLinePrice,
|
|
19
|
+
getStorefrontVariantLabel,
|
|
20
|
+
storefrontCartLineKey,
|
|
21
|
+
storefrontSurfaceClasses,
|
|
22
|
+
} from './utils';
|
|
12
23
|
|
|
13
24
|
export function StorefrontCartSummary({
|
|
25
|
+
buyerDefaults,
|
|
14
26
|
cartEntries,
|
|
15
27
|
checkoutHref,
|
|
16
28
|
currency,
|
|
@@ -19,10 +31,12 @@ export function StorefrontCartSummary({
|
|
|
19
31
|
isSubmitting,
|
|
20
32
|
labels,
|
|
21
33
|
onCheckoutSubmit,
|
|
34
|
+
onInstantCheckout,
|
|
22
35
|
radius,
|
|
23
36
|
storefront,
|
|
24
37
|
total,
|
|
25
38
|
}: {
|
|
39
|
+
buyerDefaults?: StorefrontBuyerDefaults;
|
|
26
40
|
cartEntries: StorefrontCartEntry[];
|
|
27
41
|
checkoutHref?: string;
|
|
28
42
|
currency: string;
|
|
@@ -31,6 +45,7 @@ export function StorefrontCartSummary({
|
|
|
31
45
|
isSubmitting: boolean;
|
|
32
46
|
labels: StorefrontSurfaceLabels;
|
|
33
47
|
onCheckoutSubmit?: (formData: FormData) => void;
|
|
48
|
+
onInstantCheckout?: () => void;
|
|
34
49
|
radius: string;
|
|
35
50
|
storefront: InventoryStorefront;
|
|
36
51
|
total: number;
|
|
@@ -40,11 +55,17 @@ export function StorefrontCartSummary({
|
|
|
40
55
|
const submitDisabled =
|
|
41
56
|
!hasCart || isSubmitting || isCheckoutDisabled || !onCheckoutSubmit;
|
|
42
57
|
const canOpenCheckout = hasCart && Boolean(checkoutHref);
|
|
58
|
+
const buyerEmail = buyerDefaults?.email?.trim() || undefined;
|
|
59
|
+
const buyerName = buyerDefaults?.name?.trim() || undefined;
|
|
60
|
+
const inputClassName =
|
|
61
|
+
'h-11 rounded-md border border-input bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring/40';
|
|
62
|
+
const labelClassName = 'grid gap-1.5 text-sm';
|
|
43
63
|
|
|
44
64
|
return (
|
|
45
65
|
<aside
|
|
46
66
|
className={cn(
|
|
47
67
|
'h-fit p-4 lg:sticky lg:top-4',
|
|
68
|
+
isCheckout ? 'p-5 sm:p-6' : null,
|
|
48
69
|
storefrontSurfaceClasses[storefront.surfaceStyle],
|
|
49
70
|
radius
|
|
50
71
|
)}
|
|
@@ -58,24 +79,43 @@ export function StorefrontCartSummary({
|
|
|
58
79
|
<p className="mt-2 text-muted-foreground text-sm leading-6">
|
|
59
80
|
{labels.reservedCopy}
|
|
60
81
|
</p>
|
|
61
|
-
<div className="mt-4 grid gap-2">
|
|
62
|
-
{cartEntries.map(({ line, listing }) =>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
<div className="mt-4 -mr-1 grid max-h-72 gap-2.5 overflow-y-auto pr-1">
|
|
83
|
+
{cartEntries.map(({ line, listing, variant }) => {
|
|
84
|
+
const unitPrice = getStorefrontLinePrice(listing, variant);
|
|
85
|
+
const variantLabel = variant
|
|
86
|
+
? getStorefrontVariantLabel(variant)
|
|
87
|
+
: null;
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
className="flex items-center gap-3 text-sm"
|
|
91
|
+
key={storefrontCartLineKey(line.listingId, line.variantId)}
|
|
92
|
+
>
|
|
93
|
+
<StorefrontImagePanel
|
|
94
|
+
className={cn('size-10 shrink-0 rounded-md', radius)}
|
|
95
|
+
imageUrl={variant?.imageUrl ?? listing.imageUrl}
|
|
96
|
+
label={listing.title}
|
|
97
|
+
/>
|
|
98
|
+
<div className="min-w-0 flex-1">
|
|
99
|
+
<p className="truncate font-medium">{listing.title}</p>
|
|
100
|
+
{variantLabel ? (
|
|
101
|
+
<p className="truncate text-muted-foreground text-xs">
|
|
102
|
+
{variantLabel}
|
|
103
|
+
</p>
|
|
104
|
+
) : null}
|
|
105
|
+
<p className="truncate text-muted-foreground text-xs tabular-nums">
|
|
106
|
+
{line.quantity} × {formatStorefrontPrice(unitPrice, currency)}
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
<span className="shrink-0 whitespace-nowrap font-medium tabular-nums">
|
|
110
|
+
{formatStorefrontPrice(unitPrice * line.quantity, currency)}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
75
115
|
</div>
|
|
76
|
-
<div className="mt-4 flex items-center justify-between border-border border-t pt-4">
|
|
116
|
+
<div className="mt-4 flex items-center justify-between gap-2 border-border border-t pt-4">
|
|
77
117
|
<span className="text-muted-foreground text-sm">{labels.total}</span>
|
|
78
|
-
<span className="font-semibold">
|
|
118
|
+
<span className="shrink-0 whitespace-nowrap font-semibold tabular-nums">
|
|
79
119
|
{formatStorefrontPrice(total, currency)}
|
|
80
120
|
</span>
|
|
81
121
|
</div>
|
|
@@ -93,20 +133,53 @@ export function StorefrontCartSummary({
|
|
|
93
133
|
) : null}
|
|
94
134
|
{isCheckout ? (
|
|
95
135
|
<form
|
|
96
|
-
className="mt-
|
|
136
|
+
className="mt-5 grid gap-3"
|
|
97
137
|
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
|
98
138
|
event.preventDefault();
|
|
99
139
|
onCheckoutSubmit?.(new FormData(event.currentTarget));
|
|
100
140
|
}}
|
|
101
141
|
>
|
|
142
|
+
<label className={labelClassName}>
|
|
143
|
+
<span className="font-medium text-xs">{labels.form.name}</span>
|
|
144
|
+
<input
|
|
145
|
+
autoComplete="name"
|
|
146
|
+
className={inputClassName}
|
|
147
|
+
defaultValue={buyerName}
|
|
148
|
+
name="customerName"
|
|
149
|
+
placeholder={labels.form.name}
|
|
150
|
+
required
|
|
151
|
+
/>
|
|
152
|
+
</label>
|
|
153
|
+
<label className={labelClassName}>
|
|
154
|
+
<span className="font-medium text-xs">{labels.form.email}</span>
|
|
155
|
+
<input
|
|
156
|
+
autoComplete="email"
|
|
157
|
+
className={inputClassName}
|
|
158
|
+
defaultValue={buyerEmail}
|
|
159
|
+
name="customerEmail"
|
|
160
|
+
placeholder={labels.form.email}
|
|
161
|
+
required
|
|
162
|
+
type="email"
|
|
163
|
+
/>
|
|
164
|
+
</label>
|
|
165
|
+
<label className={labelClassName}>
|
|
166
|
+
<span className="font-medium text-xs">{labels.form.phone}</span>
|
|
167
|
+
<input
|
|
168
|
+
autoComplete="tel"
|
|
169
|
+
className={inputClassName}
|
|
170
|
+
name="customerPhone"
|
|
171
|
+
placeholder={labels.form.phone}
|
|
172
|
+
type="tel"
|
|
173
|
+
/>
|
|
174
|
+
</label>
|
|
102
175
|
<textarea
|
|
103
|
-
className="min-h-
|
|
176
|
+
className="min-h-24 rounded-md border border-input bg-background px-3 py-2.5 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
104
177
|
name="note"
|
|
105
178
|
placeholder={labels.form.note}
|
|
106
179
|
/>
|
|
107
180
|
<AccentButton disabled={submitDisabled} radius={radius}>
|
|
108
181
|
{isSubmitting ? labels.reserving : labels.reserve}
|
|
109
|
-
<ArrowRight className="
|
|
182
|
+
<ArrowRight className="size-4 shrink-0" />
|
|
110
183
|
</AccentButton>
|
|
111
184
|
</form>
|
|
112
185
|
) : isPreview || isCheckoutDisabled ? (
|
|
@@ -114,16 +187,28 @@ export function StorefrontCartSummary({
|
|
|
114
187
|
{labels.checkoutDisabled}
|
|
115
188
|
</Button>
|
|
116
189
|
) : canOpenCheckout ? (
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
190
|
+
<div className="mt-4 grid gap-2">
|
|
191
|
+
{onInstantCheckout ? (
|
|
192
|
+
<AccentButton
|
|
193
|
+
disabled={isSubmitting}
|
|
194
|
+
onClick={onInstantCheckout}
|
|
195
|
+
radius={radius}
|
|
196
|
+
>
|
|
197
|
+
<Zap className="size-4 shrink-0" />
|
|
198
|
+
{isSubmitting ? labels.reserving : labels.instantCheckout}
|
|
199
|
+
</AccentButton>
|
|
200
|
+
) : null}
|
|
201
|
+
<Button asChild className={cn('w-full', radius)} variant="outline">
|
|
202
|
+
<a href={checkoutHref}>
|
|
203
|
+
{labels.checkout}
|
|
204
|
+
<ArrowRight className="size-4 shrink-0" />
|
|
205
|
+
</a>
|
|
206
|
+
</Button>
|
|
207
|
+
</div>
|
|
123
208
|
) : (
|
|
124
209
|
<Button className={cn('mt-4 w-full', radius)} disabled type="button">
|
|
125
210
|
{labels.checkout}
|
|
126
|
-
<ArrowRight className="
|
|
211
|
+
<ArrowRight className="size-4 shrink-0" />
|
|
127
212
|
</Button>
|
|
128
213
|
)}
|
|
129
214
|
</aside>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Loader2 } from '@tuturuuu/icons';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Full-screen blocking overlay shown while a checkout session is being created
|
|
7
|
+
* and while the browser is redirecting to the Polar-hosted checkout. Kept
|
|
8
|
+
* visible across the redirect (the page is navigating away) so the buyer always
|
|
9
|
+
* sees progress instead of a frozen button.
|
|
10
|
+
*/
|
|
11
|
+
export function StorefrontCheckoutOverlay({ label }: { label: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
aria-busy="true"
|
|
15
|
+
aria-live="assertive"
|
|
16
|
+
className="fixed inset-0 z-[100] grid place-items-center bg-background/80 backdrop-blur-sm"
|
|
17
|
+
role="status"
|
|
18
|
+
>
|
|
19
|
+
<div className="flex flex-col items-center gap-4 px-6 text-center">
|
|
20
|
+
<span className="grid size-14 place-items-center rounded-full bg-[var(--storefront-accent,var(--primary))]/10 text-[var(--storefront-accent,var(--primary))]">
|
|
21
|
+
<Loader2 className="size-7 animate-spin" />
|
|
22
|
+
</span>
|
|
23
|
+
<p className="font-medium text-sm">{label}</p>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -36,6 +36,7 @@ export function StorefrontHeroPanel({
|
|
|
36
36
|
className="absolute inset-0 h-full w-full"
|
|
37
37
|
imageUrl={heroImage}
|
|
38
38
|
label={storefront.name}
|
|
39
|
+
priority
|
|
39
40
|
/>
|
|
40
41
|
) : (
|
|
41
42
|
<div className="absolute inset-0 bg-gradient-to-br from-muted via-muted/60 to-background" />
|
|
@@ -51,14 +52,7 @@ export function StorefrontHeroPanel({
|
|
|
51
52
|
<Store className="h-4 w-4" />
|
|
52
53
|
<span>{labels.browse}</span>
|
|
53
54
|
</div>
|
|
54
|
-
<h2
|
|
55
|
-
className={cn(
|
|
56
|
-
'mt-2 text-balance font-semibold tracking-normal',
|
|
57
|
-
storefront.themePreset === 'editorial'
|
|
58
|
-
? 'text-3xl md:text-4xl'
|
|
59
|
-
: 'text-2xl'
|
|
60
|
-
)}
|
|
61
|
-
>
|
|
55
|
+
<h2 className="mt-2 text-balance font-semibold text-3xl tracking-tight md:text-4xl">
|
|
62
56
|
{storefront.name}
|
|
63
57
|
</h2>
|
|
64
58
|
<p className="mt-2 max-w-2xl text-muted-foreground text-sm leading-6">
|
|
@@ -6,10 +6,13 @@ export function StorefrontImagePanel({
|
|
|
6
6
|
className,
|
|
7
7
|
imageUrl,
|
|
8
8
|
label,
|
|
9
|
+
priority = false,
|
|
9
10
|
}: {
|
|
10
11
|
className?: string;
|
|
11
12
|
imageUrl: string | null;
|
|
12
13
|
label: string;
|
|
14
|
+
/** Eager-load above-the-fold images (e.g. the hero) to protect LCP. */
|
|
15
|
+
priority?: boolean;
|
|
13
16
|
}) {
|
|
14
17
|
if (imageUrl) {
|
|
15
18
|
return (
|
|
@@ -17,6 +20,9 @@ export function StorefrontImagePanel({
|
|
|
17
20
|
<img
|
|
18
21
|
alt=""
|
|
19
22
|
className={cn('w-full object-cover', className)}
|
|
23
|
+
decoding="async"
|
|
24
|
+
fetchPriority={priority ? 'high' : 'auto'}
|
|
25
|
+
loading={priority ? 'eager' : 'lazy'}
|
|
20
26
|
src={imageUrl}
|
|
21
27
|
/>
|
|
22
28
|
);
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
export { StorefrontCheckoutOverlay } from './checkout-overlay';
|
|
2
|
+
export { StorefrontProductDetail } from './product-detail';
|
|
3
|
+
export { StorefrontProductDialog } from './product-dialog';
|
|
1
4
|
export { StorefrontSurface } from './storefront-surface';
|
|
2
5
|
export type {
|
|
6
|
+
StorefrontBuyerDefaults,
|
|
3
7
|
StorefrontCartEntry,
|
|
4
8
|
StorefrontCartLine,
|
|
5
9
|
StorefrontSurfaceLabels,
|
|
@@ -7,6 +11,13 @@ export type {
|
|
|
7
11
|
} from './types';
|
|
8
12
|
export {
|
|
9
13
|
formatStorefrontPrice,
|
|
14
|
+
getStorefrontLinePrice,
|
|
15
|
+
getStorefrontListingFromPrice,
|
|
10
16
|
getStorefrontListingLimit,
|
|
17
|
+
getStorefrontListingVariants,
|
|
18
|
+
getStorefrontVariantLabel,
|
|
19
|
+
getStorefrontVariantLimit,
|
|
20
|
+
listingHasVariants,
|
|
11
21
|
sanitizeStorefrontAccentColor,
|
|
22
|
+
storefrontCartLineKey,
|
|
12
23
|
} from './utils';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Minus, Plus } from '@tuturuuu/icons';
|
|
3
|
+
import { Minus, Plus, SlidersHorizontal } from '@tuturuuu/icons';
|
|
4
4
|
import type { InventoryStorefrontListing } from '@tuturuuu/internal-api/inventory';
|
|
5
5
|
import { cn } from '@tuturuuu/utils/format';
|
|
6
6
|
import { Badge } from '../badge';
|
|
@@ -8,7 +8,12 @@ import { Button } from '../button';
|
|
|
8
8
|
import { AccentButton } from './accent-button';
|
|
9
9
|
import { StorefrontImagePanel } from './image-panel';
|
|
10
10
|
import type { StorefrontSurfaceLabels } from './types';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
formatStorefrontPrice,
|
|
13
|
+
getStorefrontListingFromPrice,
|
|
14
|
+
getStorefrontListingLimit,
|
|
15
|
+
listingHasVariants,
|
|
16
|
+
} from './utils';
|
|
12
17
|
|
|
13
18
|
export function StorefrontListingCard({
|
|
14
19
|
currency,
|
|
@@ -17,6 +22,7 @@ export function StorefrontListingCard({
|
|
|
17
22
|
listing,
|
|
18
23
|
onDecrement,
|
|
19
24
|
onIncrement,
|
|
25
|
+
onOpenDetail,
|
|
20
26
|
quantity,
|
|
21
27
|
radius,
|
|
22
28
|
showInventoryBadges,
|
|
@@ -26,35 +32,73 @@ export function StorefrontListingCard({
|
|
|
26
32
|
isList: boolean;
|
|
27
33
|
labels: StorefrontSurfaceLabels;
|
|
28
34
|
listing: InventoryStorefrontListing;
|
|
29
|
-
onDecrement?: (listingId: string) => void;
|
|
30
|
-
onIncrement?: (
|
|
35
|
+
onDecrement?: (listingId: string, variantId?: string | null) => void;
|
|
36
|
+
onIncrement?: (
|
|
37
|
+
listingId: string,
|
|
38
|
+
maxQuantity: number,
|
|
39
|
+
variantId?: string | null
|
|
40
|
+
) => void;
|
|
41
|
+
onOpenDetail?: (listingId: string) => void;
|
|
31
42
|
quantity: number;
|
|
32
43
|
radius: string;
|
|
33
44
|
showInventoryBadges: boolean;
|
|
34
45
|
surfaceClassName: string;
|
|
35
46
|
}) {
|
|
47
|
+
const hasVariants = listingHasVariants(listing);
|
|
36
48
|
const limit = getStorefrontListingLimit(listing);
|
|
37
49
|
const disabled = limit === 0 || quantity >= limit;
|
|
38
50
|
const canChange = Boolean(onIncrement || onDecrement);
|
|
51
|
+
const fromPrice = getStorefrontListingFromPrice(listing);
|
|
52
|
+
const openDetail = onOpenDetail ? () => onOpenDetail(listing.id) : undefined;
|
|
39
53
|
|
|
40
54
|
return (
|
|
41
55
|
<article
|
|
42
56
|
className={cn(
|
|
43
57
|
surfaceClassName,
|
|
44
58
|
radius,
|
|
59
|
+
'group relative overflow-hidden transition duration-200 hover:-translate-y-0.5 hover:shadow-foreground/10 hover:shadow-md',
|
|
45
60
|
isList
|
|
46
|
-
? 'grid gap-
|
|
47
|
-
: '
|
|
61
|
+
? 'grid gap-4 p-3 sm:grid-cols-[112px_minmax(0,1fr)_auto] sm:items-center'
|
|
62
|
+
: 'flex min-h-full flex-col gap-4 p-3'
|
|
48
63
|
)}
|
|
49
64
|
>
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
<div className="relative">
|
|
66
|
+
<button
|
|
67
|
+
aria-label={listing.title}
|
|
68
|
+
className={cn(
|
|
69
|
+
'block w-full overflow-hidden text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
|
|
70
|
+
radius
|
|
71
|
+
)}
|
|
72
|
+
disabled={!openDetail}
|
|
73
|
+
onClick={openDetail}
|
|
74
|
+
type="button"
|
|
75
|
+
>
|
|
76
|
+
<StorefrontImagePanel
|
|
77
|
+
className={cn(
|
|
78
|
+
'overflow-hidden transition duration-300 group-hover:scale-[1.02]',
|
|
79
|
+
radius,
|
|
80
|
+
isList ? 'aspect-square' : 'aspect-[4/3]'
|
|
81
|
+
)}
|
|
82
|
+
imageUrl={listing.imageUrl}
|
|
83
|
+
label={listing.title}
|
|
84
|
+
/>
|
|
85
|
+
</button>
|
|
86
|
+
{limit === 0 ? (
|
|
87
|
+
<span className="absolute inset-x-2 top-2 inline-flex w-fit items-center rounded-full bg-foreground/85 px-2.5 py-0.5 font-medium text-background text-xs">
|
|
88
|
+
{labels.soldOut}
|
|
89
|
+
</span>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
55
92
|
<div className="min-w-0">
|
|
56
93
|
<div className="flex flex-wrap items-center gap-2">
|
|
57
|
-
<
|
|
94
|
+
<button
|
|
95
|
+
className="min-w-0 truncate text-left font-semibold transition hover:text-[var(--storefront-accent,var(--primary))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-default disabled:hover:text-foreground"
|
|
96
|
+
disabled={!openDetail}
|
|
97
|
+
onClick={openDetail}
|
|
98
|
+
type="button"
|
|
99
|
+
>
|
|
100
|
+
{listing.title}
|
|
101
|
+
</button>
|
|
58
102
|
<Badge className="border-border bg-background" variant="outline">
|
|
59
103
|
{listing.listingType === 'bundle' ? labels.bundle : labels.product}
|
|
60
104
|
</Badge>
|
|
@@ -76,23 +120,41 @@ export function StorefrontListingCard({
|
|
|
76
120
|
</div>
|
|
77
121
|
|
|
78
122
|
<div className="mt-auto flex flex-wrap items-center justify-between gap-3">
|
|
79
|
-
<div>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
123
|
+
<div className="min-w-0">
|
|
124
|
+
{hasVariants ? (
|
|
125
|
+
<p className="truncate font-semibold tabular-nums">
|
|
126
|
+
<span className="font-normal text-muted-foreground text-xs">
|
|
127
|
+
{labels.fromPrice}{' '}
|
|
128
|
+
</span>
|
|
129
|
+
{formatStorefrontPrice(fromPrice, currency)}
|
|
130
|
+
</p>
|
|
131
|
+
) : (
|
|
132
|
+
<p className="truncate font-semibold tabular-nums">
|
|
133
|
+
{formatStorefrontPrice(listing.price, currency)}
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
{!hasVariants && listing.compareAtPrice ? (
|
|
137
|
+
<p className="truncate text-muted-foreground text-xs line-through">
|
|
85
138
|
{formatStorefrontPrice(listing.compareAtPrice, currency)}
|
|
86
139
|
</p>
|
|
87
140
|
) : null}
|
|
88
141
|
</div>
|
|
89
|
-
{
|
|
142
|
+
{hasVariants ? (
|
|
143
|
+
<AccentButton
|
|
144
|
+
disabled={limit === 0 || !openDetail}
|
|
145
|
+
onClick={openDetail}
|
|
146
|
+
radius={radius}
|
|
147
|
+
>
|
|
148
|
+
<SlidersHorizontal className="h-4 w-4" />
|
|
149
|
+
{limit === 0 ? labels.soldOut : labels.selectOptions}
|
|
150
|
+
</AccentButton>
|
|
151
|
+
) : canChange ? (
|
|
90
152
|
quantity > 0 ? (
|
|
91
153
|
<div className="flex items-center gap-1">
|
|
92
154
|
<Button
|
|
93
155
|
aria-label={`${labels.quantity} -`}
|
|
94
156
|
className={cn('h-8 w-8 p-0', radius)}
|
|
95
|
-
onClick={() => onDecrement?.(listing.id)}
|
|
157
|
+
onClick={() => onDecrement?.(listing.id, null)}
|
|
96
158
|
type="button"
|
|
97
159
|
variant="outline"
|
|
98
160
|
>
|
|
@@ -105,7 +167,7 @@ export function StorefrontListingCard({
|
|
|
105
167
|
aria-label={`${labels.quantity} +`}
|
|
106
168
|
className={cn('h-8 w-8 p-0', radius)}
|
|
107
169
|
disabled={disabled}
|
|
108
|
-
onClick={() => onIncrement?.(listing.id, limit)}
|
|
170
|
+
onClick={() => onIncrement?.(listing.id, limit, null)}
|
|
109
171
|
type="button"
|
|
110
172
|
variant="outline"
|
|
111
173
|
>
|
|
@@ -115,7 +177,7 @@ export function StorefrontListingCard({
|
|
|
115
177
|
) : (
|
|
116
178
|
<AccentButton
|
|
117
179
|
disabled={disabled}
|
|
118
|
-
onClick={() => onIncrement?.(listing.id, limit)}
|
|
180
|
+
onClick={() => onIncrement?.(listing.id, limit, null)}
|
|
119
181
|
radius={radius}
|
|
120
182
|
>
|
|
121
183
|
<Plus className="h-4 w-4" />
|