@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/package.json +8 -8
  3. package/src/components/ui/currency-input.test.tsx +43 -0
  4. package/src/components/ui/currency-input.tsx +1 -1
  5. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  6. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  7. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  8. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  9. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  10. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  12. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  13. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  14. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  15. package/src/components/ui/money-input.test.tsx +64 -0
  16. package/src/components/ui/money-input.tsx +63 -0
  17. package/src/components/ui/storefront/cart-summary.tsx +114 -29
  18. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  19. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  20. package/src/components/ui/storefront/image-panel.tsx +6 -0
  21. package/src/components/ui/storefront/index.ts +11 -0
  22. package/src/components/ui/storefront/listing-card.tsx +84 -22
  23. package/src/components/ui/storefront/product-detail.tsx +289 -0
  24. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  25. package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
  26. package/src/components/ui/storefront/storefront-surface.tsx +333 -133
  27. package/src/components/ui/storefront/types.ts +23 -1
  28. package/src/components/ui/storefront/utils.ts +111 -27
  29. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  30. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  31. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  32. package/src/components/ui/text-editor/content-migration.ts +41 -18
  33. package/src/components/ui/text-editor/extensions.ts +1 -1
  34. package/src/components/ui/text-editor/image-extension.ts +40 -18
  35. package/src/components/ui/text-editor/video-extension.ts +11 -2
  36. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  37. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  38. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  40. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  42. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  43. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  44. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  46. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  50. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  51. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  52. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  53. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  54. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  55. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  56. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  57. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  58. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  59. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  60. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  61. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  62. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  63. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  64. package/src/hooks/useBoardRealtime.ts +6 -3
  65. package/src/hooks/useBoardRealtime.types.ts +11 -0
  66. package/src/hooks/useCursorTracking.ts +91 -27
  67. 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 { InventoryStorefront } from '@tuturuuu/internal-api/inventory';
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
  });