@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,289 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ArrowRight, Minus, Plus, ShoppingCart, Zap } from '@tuturuuu/icons';
|
|
4
|
+
import type {
|
|
5
|
+
InventoryListingVariant,
|
|
6
|
+
InventoryStorefrontListing,
|
|
7
|
+
} from '@tuturuuu/internal-api/inventory';
|
|
8
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
9
|
+
import { useMemo, useState } from 'react';
|
|
10
|
+
import { Badge } from '../badge';
|
|
11
|
+
import { Button } from '../button';
|
|
12
|
+
import { AccentButton } from './accent-button';
|
|
13
|
+
import { StorefrontImagePanel } from './image-panel';
|
|
14
|
+
import type { StorefrontCartLine, StorefrontSurfaceLabels } from './types';
|
|
15
|
+
import {
|
|
16
|
+
formatStorefrontPrice,
|
|
17
|
+
getStorefrontLinePrice,
|
|
18
|
+
getStorefrontListingLimit,
|
|
19
|
+
getStorefrontListingVariants,
|
|
20
|
+
getStorefrontVariantLimit,
|
|
21
|
+
storefrontCartLineKey,
|
|
22
|
+
} from './utils';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Full-bleed product page: a large image alongside a details rail with price,
|
|
26
|
+
* savings, availability, description, option/variant selectors, and the
|
|
27
|
+
* quantity / add-to-cart / buy-now controls. Shared by both the dedicated
|
|
28
|
+
* product route and the product detail dialog.
|
|
29
|
+
*/
|
|
30
|
+
export function StorefrontProductDetail({
|
|
31
|
+
cartHref,
|
|
32
|
+
cartLines,
|
|
33
|
+
currency,
|
|
34
|
+
isSubmitting = false,
|
|
35
|
+
labels,
|
|
36
|
+
listing,
|
|
37
|
+
onBuyNow,
|
|
38
|
+
onDecrement,
|
|
39
|
+
onIncrement,
|
|
40
|
+
quantity,
|
|
41
|
+
radius,
|
|
42
|
+
showInventoryBadges,
|
|
43
|
+
surfaceClassName,
|
|
44
|
+
}: {
|
|
45
|
+
cartHref?: string;
|
|
46
|
+
cartLines?: StorefrontCartLine[];
|
|
47
|
+
currency: string;
|
|
48
|
+
isSubmitting?: boolean;
|
|
49
|
+
labels: StorefrontSurfaceLabels;
|
|
50
|
+
listing: InventoryStorefrontListing;
|
|
51
|
+
onBuyNow?: (listingId: string, variantId?: string | null) => void;
|
|
52
|
+
onDecrement?: (listingId: string, variantId?: string | null) => void;
|
|
53
|
+
onIncrement?: (
|
|
54
|
+
listingId: string,
|
|
55
|
+
maxQuantity: number,
|
|
56
|
+
variantId?: string | null
|
|
57
|
+
) => void;
|
|
58
|
+
quantity: number;
|
|
59
|
+
radius: string;
|
|
60
|
+
showInventoryBadges: boolean;
|
|
61
|
+
surfaceClassName: string;
|
|
62
|
+
}) {
|
|
63
|
+
const options = listing.options ?? [];
|
|
64
|
+
const variants = useMemo(
|
|
65
|
+
() => getStorefrontListingVariants(listing),
|
|
66
|
+
[listing]
|
|
67
|
+
);
|
|
68
|
+
const hasVariants = variants.length > 0;
|
|
69
|
+
|
|
70
|
+
// Track the selected value per option group (groupId -> valueId).
|
|
71
|
+
const [selected, setSelected] = useState<Record<string, string>>({});
|
|
72
|
+
|
|
73
|
+
const selectedVariant: InventoryListingVariant | undefined = useMemo(() => {
|
|
74
|
+
if (!hasVariants || options.length === 0) return undefined;
|
|
75
|
+
return variants.find((variant) =>
|
|
76
|
+
options.every((group) => {
|
|
77
|
+
const optionValue = variant.optionValues.find(
|
|
78
|
+
(value) => value.groupId === group.id
|
|
79
|
+
);
|
|
80
|
+
return optionValue && selected[group.id] === optionValue.valueId;
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
}, [hasVariants, options, selected, variants]);
|
|
84
|
+
|
|
85
|
+
const needsSelection = hasVariants && !selectedVariant;
|
|
86
|
+
const limit = selectedVariant
|
|
87
|
+
? getStorefrontVariantLimit(listing, selectedVariant)
|
|
88
|
+
: getStorefrontListingLimit(listing);
|
|
89
|
+
const displayPrice = getStorefrontLinePrice(listing, selectedVariant);
|
|
90
|
+
const compareAtPrice = selectedVariant
|
|
91
|
+
? selectedVariant.compareAtPrice
|
|
92
|
+
: listing.compareAtPrice;
|
|
93
|
+
const imageUrl = selectedVariant?.imageUrl ?? listing.imageUrl;
|
|
94
|
+
const availableQuantity = selectedVariant
|
|
95
|
+
? selectedVariant.availableQuantity
|
|
96
|
+
: listing.availableQuantity;
|
|
97
|
+
|
|
98
|
+
const cartQuantity = useMemo(() => {
|
|
99
|
+
if (!cartLines) return quantity;
|
|
100
|
+
const key = storefrontCartLineKey(listing.id, selectedVariant?.id);
|
|
101
|
+
return (
|
|
102
|
+
cartLines.find(
|
|
103
|
+
(line) => storefrontCartLineKey(line.listingId, line.variantId) === key
|
|
104
|
+
)?.quantity ?? 0
|
|
105
|
+
);
|
|
106
|
+
}, [cartLines, listing.id, quantity, selectedVariant?.id]);
|
|
107
|
+
|
|
108
|
+
const canChange = Boolean(onIncrement || onDecrement);
|
|
109
|
+
const variantId = selectedVariant?.id ?? null;
|
|
110
|
+
const soldOut = limit === 0;
|
|
111
|
+
const addDisabled = needsSelection || soldOut || cartQuantity >= limit;
|
|
112
|
+
const buyNowDisabled = needsSelection || soldOut || isSubmitting;
|
|
113
|
+
const savingsPercent =
|
|
114
|
+
compareAtPrice && compareAtPrice > displayPrice
|
|
115
|
+
? Math.round((1 - displayPrice / compareAtPrice) * 100)
|
|
116
|
+
: 0;
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="grid gap-6 lg:grid-cols-2">
|
|
120
|
+
<div className={cn('overflow-hidden', surfaceClassName, radius)}>
|
|
121
|
+
<StorefrontImagePanel
|
|
122
|
+
className="aspect-square"
|
|
123
|
+
imageUrl={imageUrl}
|
|
124
|
+
label={listing.title}
|
|
125
|
+
priority
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className="flex flex-col gap-5">
|
|
130
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
131
|
+
<Badge className="border-border bg-background" variant="outline">
|
|
132
|
+
{listing.listingType === 'bundle' ? labels.bundle : labels.product}
|
|
133
|
+
</Badge>
|
|
134
|
+
{savingsPercent > 0 ? (
|
|
135
|
+
<Badge
|
|
136
|
+
className="border-transparent bg-[var(--storefront-accent,var(--primary))] text-[var(--storefront-accent-foreground,var(--primary-foreground))]"
|
|
137
|
+
variant="outline"
|
|
138
|
+
>
|
|
139
|
+
-{savingsPercent}%
|
|
140
|
+
</Badge>
|
|
141
|
+
) : null}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<h2 className="text-balance font-semibold text-3xl tracking-tight md:text-4xl">
|
|
145
|
+
{listing.title}
|
|
146
|
+
</h2>
|
|
147
|
+
|
|
148
|
+
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
149
|
+
{needsSelection ? (
|
|
150
|
+
<span className="text-muted-foreground text-sm">
|
|
151
|
+
{labels.fromPrice}{' '}
|
|
152
|
+
<span className="font-semibold text-foreground text-xl tabular-nums">
|
|
153
|
+
{formatStorefrontPrice(displayPrice, currency)}
|
|
154
|
+
</span>
|
|
155
|
+
</span>
|
|
156
|
+
) : (
|
|
157
|
+
<>
|
|
158
|
+
<span className="font-semibold text-2xl tabular-nums">
|
|
159
|
+
{formatStorefrontPrice(displayPrice, currency)}
|
|
160
|
+
</span>
|
|
161
|
+
{compareAtPrice ? (
|
|
162
|
+
<span className="text-lg text-muted-foreground tabular-nums line-through">
|
|
163
|
+
{formatStorefrontPrice(compareAtPrice, currency)}
|
|
164
|
+
</span>
|
|
165
|
+
) : null}
|
|
166
|
+
</>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{options.length > 0 ? (
|
|
171
|
+
<div className="grid gap-4">
|
|
172
|
+
{options.map((group) => (
|
|
173
|
+
<div className="grid gap-2" key={group.id}>
|
|
174
|
+
<span className="font-medium text-sm">{group.name}</span>
|
|
175
|
+
<div className="flex flex-wrap gap-2">
|
|
176
|
+
{group.values.map((value) => {
|
|
177
|
+
const isActive = selected[group.id] === value.id;
|
|
178
|
+
return (
|
|
179
|
+
<button
|
|
180
|
+
aria-pressed={isActive}
|
|
181
|
+
className={cn(
|
|
182
|
+
'inline-flex h-10 items-center justify-center border px-3 font-medium text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
|
|
183
|
+
radius,
|
|
184
|
+
isActive
|
|
185
|
+
? 'border-[var(--storefront-accent,var(--primary))] bg-[var(--storefront-accent,var(--primary))]/10 text-[var(--storefront-accent,var(--primary))]'
|
|
186
|
+
: 'border-border bg-card hover:bg-muted/45'
|
|
187
|
+
)}
|
|
188
|
+
key={value.id}
|
|
189
|
+
onClick={() =>
|
|
190
|
+
setSelected((current) => ({
|
|
191
|
+
...current,
|
|
192
|
+
[group.id]: value.id,
|
|
193
|
+
}))
|
|
194
|
+
}
|
|
195
|
+
type="button"
|
|
196
|
+
>
|
|
197
|
+
{value.label}
|
|
198
|
+
</button>
|
|
199
|
+
);
|
|
200
|
+
})}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
) : null}
|
|
206
|
+
|
|
207
|
+
{showInventoryBadges && !needsSelection ? (
|
|
208
|
+
<p className="text-muted-foreground text-sm">
|
|
209
|
+
{soldOut
|
|
210
|
+
? labels.soldOut
|
|
211
|
+
: typeof availableQuantity === 'number'
|
|
212
|
+
? `${availableQuantity} ${labels.available}`
|
|
213
|
+
: labels.available}
|
|
214
|
+
</p>
|
|
215
|
+
) : null}
|
|
216
|
+
|
|
217
|
+
<p className="text-pretty text-muted-foreground leading-7">
|
|
218
|
+
{listing.description ?? labels.fallbackDescription}
|
|
219
|
+
</p>
|
|
220
|
+
|
|
221
|
+
<div className="mt-auto flex flex-wrap items-center gap-3 border-border border-t pt-5">
|
|
222
|
+
{canChange ? (
|
|
223
|
+
needsSelection ? (
|
|
224
|
+
<Button className={cn('h-11', radius)} disabled type="button">
|
|
225
|
+
{labels.selectOptions}
|
|
226
|
+
</Button>
|
|
227
|
+
) : cartQuantity > 0 ? (
|
|
228
|
+
<div className="flex items-center gap-1">
|
|
229
|
+
<Button
|
|
230
|
+
aria-label={`${labels.quantity} -`}
|
|
231
|
+
className={cn('h-11 w-11 p-0', radius)}
|
|
232
|
+
onClick={() => onDecrement?.(listing.id, variantId)}
|
|
233
|
+
type="button"
|
|
234
|
+
variant="outline"
|
|
235
|
+
>
|
|
236
|
+
<Minus className="h-4 w-4" />
|
|
237
|
+
</Button>
|
|
238
|
+
<span className="min-w-10 text-center font-semibold tabular-nums">
|
|
239
|
+
{cartQuantity}
|
|
240
|
+
</span>
|
|
241
|
+
<Button
|
|
242
|
+
aria-label={`${labels.quantity} +`}
|
|
243
|
+
className={cn('h-11 w-11 p-0', radius)}
|
|
244
|
+
disabled={addDisabled}
|
|
245
|
+
onClick={() => onIncrement?.(listing.id, limit, variantId)}
|
|
246
|
+
type="button"
|
|
247
|
+
variant="outline"
|
|
248
|
+
>
|
|
249
|
+
<Plus className="h-4 w-4" />
|
|
250
|
+
</Button>
|
|
251
|
+
</div>
|
|
252
|
+
) : (
|
|
253
|
+
<AccentButton
|
|
254
|
+
disabled={addDisabled}
|
|
255
|
+
onClick={() => onIncrement?.(listing.id, limit, variantId)}
|
|
256
|
+
radius={radius}
|
|
257
|
+
>
|
|
258
|
+
<Plus className="h-4 w-4" />
|
|
259
|
+
{soldOut ? labels.soldOut : labels.add}
|
|
260
|
+
</AccentButton>
|
|
261
|
+
)
|
|
262
|
+
) : null}
|
|
263
|
+
|
|
264
|
+
{onBuyNow ? (
|
|
265
|
+
<Button
|
|
266
|
+
className={cn('h-11', radius)}
|
|
267
|
+
disabled={buyNowDisabled}
|
|
268
|
+
onClick={() => onBuyNow(listing.id, variantId)}
|
|
269
|
+
type="button"
|
|
270
|
+
>
|
|
271
|
+
<Zap className="size-4 shrink-0" />
|
|
272
|
+
{labels.buyNow}
|
|
273
|
+
</Button>
|
|
274
|
+
) : null}
|
|
275
|
+
|
|
276
|
+
{cartHref && cartQuantity > 0 ? (
|
|
277
|
+
<Button asChild className={cn('h-11', radius)} variant="outline">
|
|
278
|
+
<a href={cartHref}>
|
|
279
|
+
<ShoppingCart className="size-4 shrink-0" />
|
|
280
|
+
{labels.cart}
|
|
281
|
+
<ArrowRight className="size-4 shrink-0" />
|
|
282
|
+
</a>
|
|
283
|
+
</Button>
|
|
284
|
+
) : null}
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { InventoryStorefrontListing } from '@tuturuuu/internal-api/inventory';
|
|
4
|
+
import { Dialog, DialogContent, DialogTitle } from '../dialog';
|
|
5
|
+
import { StorefrontProductDetail } from './product-detail';
|
|
6
|
+
import type { StorefrontCartLine, StorefrontSurfaceLabels } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Large product detail dialog opened from a listing card. Reuses the same
|
|
10
|
+
* StorefrontProductDetail body as the dedicated product route, so the dialog
|
|
11
|
+
* and the deep-link page stay visually identical.
|
|
12
|
+
*/
|
|
13
|
+
export function StorefrontProductDialog({
|
|
14
|
+
cartHref,
|
|
15
|
+
cartLines,
|
|
16
|
+
currency,
|
|
17
|
+
isSubmitting,
|
|
18
|
+
labels,
|
|
19
|
+
listing,
|
|
20
|
+
onBuyNow,
|
|
21
|
+
onDecrement,
|
|
22
|
+
onIncrement,
|
|
23
|
+
onOpenChange,
|
|
24
|
+
radius,
|
|
25
|
+
showInventoryBadges,
|
|
26
|
+
surfaceClassName,
|
|
27
|
+
}: {
|
|
28
|
+
cartHref?: string;
|
|
29
|
+
cartLines?: StorefrontCartLine[];
|
|
30
|
+
currency: string;
|
|
31
|
+
isSubmitting?: boolean;
|
|
32
|
+
labels: StorefrontSurfaceLabels;
|
|
33
|
+
listing: InventoryStorefrontListing | null;
|
|
34
|
+
onBuyNow?: (listingId: string, variantId?: string | null) => void;
|
|
35
|
+
onDecrement?: (listingId: string, variantId?: string | null) => void;
|
|
36
|
+
onIncrement?: (
|
|
37
|
+
listingId: string,
|
|
38
|
+
maxQuantity: number,
|
|
39
|
+
variantId?: string | null
|
|
40
|
+
) => void;
|
|
41
|
+
onOpenChange: (open: boolean) => void;
|
|
42
|
+
radius: string;
|
|
43
|
+
showInventoryBadges: boolean;
|
|
44
|
+
surfaceClassName: string;
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<Dialog onOpenChange={onOpenChange} open={Boolean(listing)}>
|
|
48
|
+
<DialogContent className="max-h-[90vh] w-[calc(100%-1.5rem)] max-w-5xl overflow-y-auto sm:w-[calc(100%-2rem)]">
|
|
49
|
+
{listing ? (
|
|
50
|
+
<>
|
|
51
|
+
<DialogTitle className="sr-only">{listing.title}</DialogTitle>
|
|
52
|
+
<StorefrontProductDetail
|
|
53
|
+
cartHref={cartHref}
|
|
54
|
+
cartLines={cartLines}
|
|
55
|
+
currency={currency}
|
|
56
|
+
isSubmitting={isSubmitting}
|
|
57
|
+
labels={labels}
|
|
58
|
+
listing={listing}
|
|
59
|
+
onBuyNow={onBuyNow}
|
|
60
|
+
onDecrement={onDecrement}
|
|
61
|
+
onIncrement={onIncrement}
|
|
62
|
+
quantity={0}
|
|
63
|
+
radius={radius}
|
|
64
|
+
showInventoryBadges={showInventoryBadges}
|
|
65
|
+
surfaceClassName={surfaceClassName}
|
|
66
|
+
/>
|
|
67
|
+
</>
|
|
68
|
+
) : null}
|
|
69
|
+
</DialogContent>
|
|
70
|
+
</Dialog>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
InventoryStorefront,
|
|
4
|
+
InventoryStorefrontListing,
|
|
5
|
+
} from '@tuturuuu/internal-api/inventory';
|
|
3
6
|
import { describe, expect, it } from 'vitest';
|
|
4
7
|
import { StorefrontSurface } from './storefront-surface';
|
|
5
8
|
import { sanitizeStorefrontAccentColor } from './utils';
|
|
@@ -29,6 +32,30 @@ const storefront: InventoryStorefront = {
|
|
|
29
32
|
wsId: 'ws-1',
|
|
30
33
|
};
|
|
31
34
|
|
|
35
|
+
const listing: InventoryStorefrontListing = {
|
|
36
|
+
availableQuantity: 8,
|
|
37
|
+
bundleId: null,
|
|
38
|
+
compareAtPrice: null,
|
|
39
|
+
createdAt: '2026-06-12T00:00:00.000Z',
|
|
40
|
+
description: 'A mentoring session for checkout tests.',
|
|
41
|
+
id: 'listing-1',
|
|
42
|
+
imageUrl: null,
|
|
43
|
+
listingType: 'product',
|
|
44
|
+
maxPerOrder: 5,
|
|
45
|
+
price: 100,
|
|
46
|
+
productId: 'product-1',
|
|
47
|
+
sortOrder: 1,
|
|
48
|
+
status: 'published',
|
|
49
|
+
storefrontId: storefront.id,
|
|
50
|
+
title: '1:1 Mentoring',
|
|
51
|
+
unitId: 'unit-1',
|
|
52
|
+
unitName: 'Session',
|
|
53
|
+
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
54
|
+
warehouseId: 'warehouse-1',
|
|
55
|
+
warehouseName: 'Main',
|
|
56
|
+
wsId: storefront.wsId,
|
|
57
|
+
};
|
|
58
|
+
|
|
32
59
|
describe('StorefrontSurface', () => {
|
|
33
60
|
it('sanitizes hex accent colors only', () => {
|
|
34
61
|
expect(sanitizeStorefrontAccentColor('#abc')).toBe('#aabbcc');
|
|
@@ -56,6 +83,50 @@ describe('StorefrontSurface', () => {
|
|
|
56
83
|
expect(screen.getByText('Preview checkout disabled')).toBeDisabled();
|
|
57
84
|
});
|
|
58
85
|
|
|
86
|
+
it('links storefront chrome back to the store and keeps the cart icon stable', () => {
|
|
87
|
+
render(
|
|
88
|
+
<StorefrontSurface
|
|
89
|
+
cartHref="/preview-store/cart"
|
|
90
|
+
cartLines={[{ listingId: listing.id, quantity: 2 }]}
|
|
91
|
+
listings={[listing]}
|
|
92
|
+
mode="store"
|
|
93
|
+
storefront={storefront}
|
|
94
|
+
storefrontHref="/preview-store"
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(screen.getByRole('link', { name: 'Preview Store' })).toHaveAttribute(
|
|
99
|
+
'href',
|
|
100
|
+
'/preview-store'
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const cartLink = screen.getByRole('link', { name: 'Cart: 2' });
|
|
104
|
+
expect(cartLink).toHaveAttribute('href', '/preview-store/cart');
|
|
105
|
+
expect(cartLink).toHaveClass('h-11', 'min-w-14', 'shrink-0');
|
|
106
|
+
expect(cartLink.querySelector('svg')).toHaveClass('size-5', 'shrink-0');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('prefills checkout buyer details while keeping editable form fields', () => {
|
|
110
|
+
render(
|
|
111
|
+
<StorefrontSurface
|
|
112
|
+
buyerDefaults={{
|
|
113
|
+
email: 'buyer@example.com',
|
|
114
|
+
name: 'Sokora Buyer',
|
|
115
|
+
}}
|
|
116
|
+
cartLines={[{ listingId: listing.id, quantity: 1 }]}
|
|
117
|
+
listings={[listing]}
|
|
118
|
+
mode="checkout"
|
|
119
|
+
onCheckoutSubmit={() => undefined}
|
|
120
|
+
storefront={storefront}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(screen.getByLabelText('Name')).toHaveValue('Sokora Buyer');
|
|
125
|
+
expect(screen.getByLabelText('Email')).toHaveValue('buyer@example.com');
|
|
126
|
+
expect(screen.getByLabelText('Name')).toBeEnabled();
|
|
127
|
+
expect(screen.getByLabelText('Email')).toBeEnabled();
|
|
128
|
+
});
|
|
129
|
+
|
|
59
130
|
it('keeps simulated storefront chrome customer-facing', () => {
|
|
60
131
|
render(
|
|
61
132
|
<StorefrontSurface
|
|
@@ -86,4 +157,56 @@ describe('StorefrontSurface', () => {
|
|
|
86
157
|
expect(screen.queryByText('Checkout disabled')).not.toBeInTheDocument();
|
|
87
158
|
expect(screen.getByText('Checkout unavailable')).toBeDisabled();
|
|
88
159
|
});
|
|
160
|
+
|
|
161
|
+
it('renders only http storefront section links', () => {
|
|
162
|
+
render(
|
|
163
|
+
<StorefrontSurface
|
|
164
|
+
listings={[]}
|
|
165
|
+
mode="store"
|
|
166
|
+
storefront={{
|
|
167
|
+
...storefront,
|
|
168
|
+
sections: [
|
|
169
|
+
{
|
|
170
|
+
createdAt: null,
|
|
171
|
+
description: null,
|
|
172
|
+
href: 'javascript:alert(document.domain)',
|
|
173
|
+
id: 'section-unsafe',
|
|
174
|
+
imageUrl: null,
|
|
175
|
+
items: [],
|
|
176
|
+
metadata: {},
|
|
177
|
+
sectionType: 'promo',
|
|
178
|
+
sortOrder: 0,
|
|
179
|
+
status: 'published',
|
|
180
|
+
storefrontId: storefront.id,
|
|
181
|
+
title: 'Unsafe section',
|
|
182
|
+
updatedAt: null,
|
|
183
|
+
wsId: storefront.wsId,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
createdAt: null,
|
|
187
|
+
description: null,
|
|
188
|
+
href: 'https://example.com/promo',
|
|
189
|
+
id: 'section-safe',
|
|
190
|
+
imageUrl: null,
|
|
191
|
+
items: [],
|
|
192
|
+
metadata: {},
|
|
193
|
+
sectionType: 'promo',
|
|
194
|
+
sortOrder: 1,
|
|
195
|
+
status: 'published',
|
|
196
|
+
storefrontId: storefront.id,
|
|
197
|
+
title: 'Safe section',
|
|
198
|
+
updatedAt: null,
|
|
199
|
+
wsId: storefront.wsId,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(screen.getByText('Safe section')).toBeInTheDocument();
|
|
207
|
+
expect(
|
|
208
|
+
screen.getByRole('link', { name: 'example.com/promo' })
|
|
209
|
+
).toHaveAttribute('href', 'https://example.com/promo');
|
|
210
|
+
expect(screen.queryByText('javascript:alert(document.domain)')).toBeNull();
|
|
211
|
+
});
|
|
89
212
|
});
|