@tuturuuu/ui 0.18.0 → 0.19.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 (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/package.json +70 -69
  3. package/src/components/ui/calendar-app/calendar-client-page.tsx +19 -1
  4. package/src/components/ui/calendar-app/calendar-page-shell.tsx +37 -16
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager-helpers.ts +70 -0
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +516 -578
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +1 -0
  8. package/src/components/ui/calendar-app/components/calendar-sync-settings-panel.tsx +155 -0
  9. package/src/components/ui/calendar-app/components/merge-provider-calendars.test.ts +46 -0
  10. package/src/components/ui/calendar-app/components/merge-provider-calendars.ts +81 -0
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +22 -129
  12. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +17 -3
  13. package/src/components/ui/chat/chat-agent-details-utils.tsx +2 -0
  14. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +17 -6
  15. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +35 -0
  16. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +17 -0
  17. package/src/components/ui/custom/nav-link.test.tsx +1 -0
  18. package/src/components/ui/custom/nav-link.tsx +4 -1
  19. package/src/components/ui/custom/settings-dialog-shell.tsx +23 -14
  20. package/src/components/ui/custom/statistic-card.tsx +3 -5
  21. package/src/components/ui/custom/structure.test.tsx +4 -0
  22. package/src/components/ui/custom/user-filters.tsx +4 -7
  23. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +1 -1
  24. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +1 -1
  25. package/src/components/ui/custom/workspace-select.tsx +38 -53
  26. package/src/components/ui/finance/analytics/category-spending-chart.test.tsx +3 -3
  27. package/src/components/ui/finance/budgets/budget-card.test.tsx +3 -3
  28. package/src/components/ui/finance/debts/debt-loan-card.test.tsx +3 -3
  29. package/src/components/ui/finance/debts/debt-loan-detail-cards.test.tsx +3 -3
  30. package/src/components/ui/finance/invoices/charts/invoice-totals-chart.test.tsx +3 -3
  31. package/src/components/ui/finance/invoices/columns.test.tsx +3 -3
  32. package/src/components/ui/finance/invoices/components/invoice-customer-select-card.tsx +1 -1
  33. package/src/components/ui/finance/shared/numbers-visibility-toggle.test.tsx +1 -1
  34. package/src/components/ui/finance/tags/tag-manager.test.tsx +2 -2
  35. package/src/components/ui/finance/transactions/categories/columns.test.tsx +5 -5
  36. package/src/components/ui/finance/transactions/columns.test.tsx +2 -2
  37. package/src/components/ui/finance/transactions/confidential-field.test.tsx +2 -2
  38. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +2 -2
  39. package/src/components/ui/finance/transactions/transaction-statistics.test.tsx +3 -3
  40. package/src/components/ui/finance/wallets/form.test.tsx +3 -3
  41. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +10 -0
  42. package/src/components/ui/legacy/calendar/calendar-cell.tsx +1 -1
  43. package/src/components/ui/storefront/accent-button.tsx +4 -1
  44. package/src/components/ui/storefront/browse-panel.tsx +6 -1
  45. package/src/components/ui/storefront/cart-summary-parts.tsx +18 -4
  46. package/src/components/ui/storefront/cart-summary.tsx +22 -6
  47. package/src/components/ui/storefront/hero-panel.tsx +13 -17
  48. package/src/components/ui/storefront/index.ts +2 -0
  49. package/src/components/ui/storefront/listing-card.tsx +6 -4
  50. package/src/components/ui/storefront/product-detail.tsx +51 -21
  51. package/src/components/ui/storefront/product-dialog.tsx +8 -1
  52. package/src/components/ui/storefront/storefront-surface.test.tsx +79 -3
  53. package/src/components/ui/storefront/storefront-surface.tsx +151 -100
  54. package/src/components/ui/storefront/types.ts +3 -0
  55. package/src/components/ui/storefront/utils.ts +5 -1
  56. package/src/components/ui/text-editor/__tests__/task-mention-checkbox.test.ts +23 -6
  57. package/src/components/ui/text-editor/__tests__/upload-placeholder.test.ts +1 -10
  58. package/src/components/ui/text-editor/color-controls.tsx +1 -1
  59. package/src/globals.css +20 -0
  60. package/src/globals.test.ts +75 -0
  61. package/src/hooks/__tests__/use-calendar-sync.test.tsx +103 -1
  62. package/src/hooks/use-calendar-sync.tsx +51 -14
  63. package/src/lib/task-personal-external.ts +1 -1
@@ -105,6 +105,16 @@ describe('wallets data table infinite loading', () => {
105
105
  mocks.searchParams = new URLSearchParams();
106
106
 
107
107
  globalThis.IntersectionObserver = class IntersectionObserver {
108
+ readonly callback: IntersectionObserverCallback;
109
+ readonly options?: IntersectionObserverInit;
110
+
111
+ constructor(
112
+ callback: IntersectionObserverCallback,
113
+ options?: IntersectionObserverInit
114
+ ) {
115
+ this.callback = callback;
116
+ this.options = options;
117
+ }
108
118
  disconnect() {}
109
119
  observe() {}
110
120
  takeRecords() {
@@ -804,7 +804,7 @@ export const CalendarCell = ({ date, hour }: CalendarCellProps) => {
804
804
  maxWidth: 200,
805
805
  zIndex: 10000,
806
806
  whiteSpace: 'nowrap',
807
- opacity: showTooltip ? 1 : 0,
807
+ opacity: 1,
808
808
  transition: 'opacity 0.15s',
809
809
  }}
810
810
  >
@@ -5,11 +5,13 @@ import type { ReactNode } from 'react';
5
5
 
6
6
  export function AccentButton({
7
7
  children,
8
+ className,
8
9
  disabled,
9
10
  onClick,
10
11
  radius,
11
12
  }: {
12
13
  children: ReactNode;
14
+ className?: string;
13
15
  disabled?: boolean;
14
16
  onClick?: () => void;
15
17
  radius: string;
@@ -21,7 +23,8 @@ export function AccentButton({
21
23
  'bg-primary text-primary-foreground hover:bg-primary/90',
22
24
  '[--accent-bg:var(--storefront-accent,var(--primary))] [--accent-fg:var(--storefront-accent-foreground,var(--primary-foreground))]',
23
25
  'bg-[var(--accent-bg)] text-[var(--accent-fg)] hover:opacity-90',
24
- radius
26
+ radius,
27
+ className
25
28
  )}
26
29
  disabled={disabled}
27
30
  onClick={onClick}
@@ -16,12 +16,14 @@ type BrowseFilter = 'all' | 'bundle' | 'product';
16
16
  export function StorefrontBrowsePanel({
17
17
  compactLayout,
18
18
  emptyListings,
19
+ flushTop = false,
19
20
  labels,
20
21
  listings,
21
22
  renderListing,
22
23
  }: {
23
24
  compactLayout: boolean;
24
25
  emptyListings: ReactNode;
26
+ flushTop?: boolean;
25
27
  labels: StorefrontSurfaceLabels;
26
28
  listings: InventoryStorefrontListing[];
27
29
  renderListing: (listing: InventoryStorefrontListing) => ReactNode;
@@ -47,7 +49,10 @@ export function StorefrontBrowsePanel({
47
49
  const hasActiveFilters = filter !== 'all' || query.trim().length > 0;
48
50
 
49
51
  return (
50
- <section aria-labelledby="storefront-shop-heading" className="mt-10">
52
+ <section
53
+ aria-labelledby="storefront-shop-heading"
54
+ className={flushTop ? undefined : 'mt-10'}
55
+ >
51
56
  <div className="flex flex-col gap-5 border-border border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
52
57
  <div>
53
58
  <p className="font-mono text-muted-foreground text-xs uppercase tracking-[0.16em]">
@@ -14,6 +14,7 @@ import { AccentButton } from './accent-button';
14
14
  import type {
15
15
  StorefrontBuyerDefaults,
16
16
  StorefrontCartEntry,
17
+ StorefrontLinkComponent,
17
18
  StorefrontSurfaceLabels,
18
19
  } from './types';
19
20
  import {
@@ -68,6 +69,7 @@ export function CartActions({
68
69
  isPreview,
69
70
  isSubmitting,
70
71
  labels,
72
+ linkComponent,
71
73
  onCheckoutOpen,
72
74
  onInstantCheckout,
73
75
  radius,
@@ -78,13 +80,20 @@ export function CartActions({
78
80
  isPreview: boolean;
79
81
  isSubmitting: boolean;
80
82
  labels: StorefrontSurfaceLabels;
83
+ linkComponent?: StorefrontLinkComponent;
81
84
  onCheckoutOpen?: () => void;
82
85
  onInstantCheckout?: () => void;
83
86
  radius: string;
84
87
  }) {
88
+ const NavigationLink = linkComponent ?? 'a';
85
89
  if (isPreview || isCheckoutDisabled) {
86
90
  return (
87
- <Button className={cn('w-full', radius)} disabled type="button">
91
+ <Button
92
+ className={cn('w-full', radius)}
93
+ disabled
94
+ type="button"
95
+ variant="secondary"
96
+ >
88
97
  {labels.checkoutDisabled}
89
98
  </Button>
90
99
  );
@@ -92,7 +101,12 @@ export function CartActions({
92
101
 
93
102
  if (!canOpenCheckout) {
94
103
  return (
95
- <Button className={cn('w-full', radius)} disabled type="button">
104
+ <Button
105
+ className={cn('w-full', radius)}
106
+ disabled
107
+ type="button"
108
+ variant="secondary"
109
+ >
96
110
  {labels.checkout}
97
111
  <ArrowRight className="size-4 shrink-0" />
98
112
  </Button>
@@ -122,10 +136,10 @@ export function CartActions({
122
136
  </AccentButton>
123
137
  ) : (
124
138
  <Button asChild className={cn('w-full', radius)} variant="outline">
125
- <a href={checkoutHref}>
139
+ <NavigationLink href={checkoutHref ?? '#'}>
126
140
  {labels.checkout}
127
141
  <ArrowRight className="size-4 shrink-0" />
128
- </a>
142
+ </NavigationLink>
129
143
  </Button>
130
144
  )}
131
145
  </div>
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { ArrowRight } from '@tuturuuu/icons';
3
+ import { ArrowRight, ShoppingCart } 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';
@@ -15,6 +15,7 @@ import {
15
15
  import type {
16
16
  StorefrontBuyerDefaults,
17
17
  StorefrontCartEntry,
18
+ StorefrontLinkComponent,
18
19
  StorefrontSurfaceLabels,
19
20
  } from './types';
20
21
  import { formatStorefrontPrice, storefrontSurfaceClasses } from './utils';
@@ -31,6 +32,7 @@ export function StorefrontCartSummary({
31
32
  isPreview,
32
33
  isSubmitting,
33
34
  labels,
35
+ linkComponent,
34
36
  onCheckoutOpen,
35
37
  onCheckoutSubmit,
36
38
  onInstantCheckout,
@@ -48,6 +50,7 @@ export function StorefrontCartSummary({
48
50
  isPreview: boolean;
49
51
  isSubmitting: boolean;
50
52
  labels: StorefrontSurfaceLabels;
53
+ linkComponent?: StorefrontLinkComponent;
51
54
  onCheckoutOpen?: () => void;
52
55
  onCheckoutSubmit?: (formData: FormData) => void;
53
56
  onInstantCheckout?: () => void;
@@ -113,19 +116,31 @@ export function StorefrontCartSummary({
113
116
  className={cn(
114
117
  'h-fit',
115
118
  presentation === 'panel'
116
- ? cn('p-4', storefrontSurfaceClasses[storefront.surfaceStyle], radius)
119
+ ? cn(
120
+ 'grid gap-5 p-5 sm:p-6',
121
+ storefrontSurfaceClasses[storefront.surfaceStyle],
122
+ radius
123
+ )
117
124
  : 'grid gap-4',
118
125
  className
119
126
  )}
120
127
  >
121
- <div>
122
- <div className="flex items-center justify-between gap-3">
123
- <p className="font-semibold">{labels.cart}</p>
128
+ <div className="grid gap-2">
129
+ <div className="flex items-center gap-3">
130
+ <span
131
+ className={cn(
132
+ 'grid size-9 shrink-0 place-items-center border border-border bg-background',
133
+ radius
134
+ )}
135
+ >
136
+ <ShoppingCart className="size-4" />
137
+ </span>
138
+ <p className="min-w-0 flex-1 font-semibold">{labels.cart}</p>
124
139
  <Badge className="border-border bg-background" variant="outline">
125
140
  {cartEntries.length}
126
141
  </Badge>
127
142
  </div>
128
- <p className="mt-2 text-muted-foreground text-sm leading-6">
143
+ <p className="text-pretty text-muted-foreground text-sm leading-6">
129
144
  {labels.reservedCopy}
130
145
  </p>
131
146
  </div>
@@ -146,6 +161,7 @@ export function StorefrontCartSummary({
146
161
  isPreview={isPreview}
147
162
  isSubmitting={isSubmitting}
148
163
  labels={labels}
164
+ linkComponent={linkComponent}
149
165
  onCheckoutOpen={onCheckoutOpen}
150
166
  onInstantCheckout={onInstantCheckout}
151
167
  radius={radius}
@@ -21,6 +21,8 @@ export function StorefrontHeroPanel({
21
21
  }) {
22
22
  const heroImage = storefront.coverImageUrl ?? storefront.heroImageUrl;
23
23
 
24
+ if (!heroImage) return null;
25
+
24
26
  return (
25
27
  <section
26
28
  className={cn(
@@ -38,9 +40,11 @@ export function StorefrontHeroPanel({
38
40
  <h2 className="text-balance font-semibold text-4xl tracking-[-0.035em] sm:text-5xl lg:text-6xl">
39
41
  {storefront.name}
40
42
  </h2>
41
- <p className="mt-4 max-w-xl text-pretty text-muted-foreground leading-7">
42
- {storefront.description ?? labels.fallbackDescription}
43
- </p>
43
+ {storefront.description?.trim() ? (
44
+ <p className="mt-4 max-w-xl text-pretty text-muted-foreground leading-7">
45
+ {storefront.description.trim()}
46
+ </p>
47
+ ) : null}
44
48
  <div className="mt-6 flex flex-wrap gap-2">
45
49
  <Badge className="border-border bg-background" variant="outline">
46
50
  {listingsCount} {labels.product}
@@ -58,20 +62,12 @@ export function StorefrontHeroPanel({
58
62
  </div>
59
63
 
60
64
  <div className="relative min-h-64 border-border border-t md:min-h-full md:border-t-0 md:border-l">
61
- {heroImage ? (
62
- <StorefrontImagePanel
63
- className="absolute inset-0 h-full w-full"
64
- imageUrl={heroImage}
65
- label={storefront.name}
66
- priority
67
- />
68
- ) : (
69
- <div className="absolute inset-0 grid place-items-center bg-muted/35">
70
- <div className="grid size-24 place-items-center rounded-xl border border-border bg-background/80">
71
- <Store className="size-8 text-muted-foreground" />
72
- </div>
73
- </div>
74
- )}
65
+ <StorefrontImagePanel
66
+ className="absolute inset-0 h-full w-full"
67
+ imageUrl={heroImage}
68
+ label={storefront.name}
69
+ priority
70
+ />
75
71
  </div>
76
72
  </section>
77
73
  );
@@ -6,11 +6,13 @@ export type {
6
6
  StorefrontBuyerDefaults,
7
7
  StorefrontCartEntry,
8
8
  StorefrontCartLine,
9
+ StorefrontLinkComponent,
9
10
  StorefrontSurfaceLabels,
10
11
  StorefrontSurfaceMode,
11
12
  } from './types';
12
13
  export {
13
14
  formatStorefrontPrice,
15
+ getAccentStyle,
14
16
  getStorefrontLinePrice,
15
17
  getStorefrontListingFromPrice,
16
18
  getStorefrontListingLimit,
@@ -59,7 +59,7 @@ export function StorefrontListingCard({
59
59
  className={cn(
60
60
  surfaceClassName,
61
61
  radius,
62
- 'group relative overflow-hidden transition duration-200 hover:border-foreground/20 hover:shadow-foreground/5 hover:shadow-sm',
62
+ 'group relative overflow-hidden transition duration-200 [contain-intrinsic-size:auto_420px] [content-visibility:auto] hover:border-foreground/20 hover:shadow-foreground/5 hover:shadow-sm',
63
63
  isList
64
64
  ? 'grid gap-4 p-3 sm:grid-cols-[112px_minmax(0,1fr)_auto] sm:items-center'
65
65
  : 'flex min-h-full flex-col'
@@ -106,9 +106,11 @@ export function StorefrontListingCard({
106
106
  {listing.listingType === 'bundle' ? labels.bundle : labels.product}
107
107
  </Badge>
108
108
  </div>
109
- <p className="mt-1 line-clamp-2 text-muted-foreground text-sm leading-6">
110
- {listing.description ?? labels.fallbackDescription}
111
- </p>
109
+ {listing.description?.trim() ? (
110
+ <p className="mt-1 line-clamp-2 text-muted-foreground text-sm leading-6">
111
+ {listing.description.trim()}
112
+ </p>
113
+ ) : null}
112
114
  {showInventoryBadges ? (
113
115
  <p className="mt-2 text-muted-foreground text-xs">
114
116
  {limit === 0
@@ -20,7 +20,11 @@ import { Badge } from '../badge';
20
20
  import { Button } from '../button';
21
21
  import { AccentButton } from './accent-button';
22
22
  import { StorefrontImagePanel } from './image-panel';
23
- import type { StorefrontCartLine, StorefrontSurfaceLabels } from './types';
23
+ import type {
24
+ StorefrontCartLine,
25
+ StorefrontLinkComponent,
26
+ StorefrontSurfaceLabels,
27
+ } from './types';
24
28
  import {
25
29
  formatStorefrontPrice,
26
30
  getStorefrontLinePrice,
@@ -43,6 +47,7 @@ export function StorefrontProductDetail({
43
47
  currency,
44
48
  isSubmitting = false,
45
49
  labels,
50
+ linkComponent,
46
51
  listing,
47
52
  onBuyNow,
48
53
  onDecrement,
@@ -59,6 +64,7 @@ export function StorefrontProductDetail({
59
64
  currency: string;
60
65
  isSubmitting?: boolean;
61
66
  labels: StorefrontSurfaceLabels;
67
+ linkComponent?: StorefrontLinkComponent;
62
68
  listing: InventoryStorefrontListing;
63
69
  onBuyNow?: (listingId: string, variantId?: string | null) => void;
64
70
  onDecrement?: (listingId: string, variantId?: string | null) => void;
@@ -73,6 +79,7 @@ export function StorefrontProductDetail({
73
79
  showInventoryBadges: boolean;
74
80
  surfaceClassName: string;
75
81
  }) {
82
+ const NavigationLink = linkComponent ?? 'a';
76
83
  const options = listing.options ?? [];
77
84
  const variants = useMemo(
78
85
  () => getStorefrontListingVariants(listing),
@@ -130,13 +137,15 @@ export function StorefrontProductDetail({
130
137
  const isDialog = presentation === 'dialog';
131
138
  const isSquareTerminal = checkoutMode === 'square_terminal';
132
139
  const isPolar = checkoutMode === 'polar';
140
+ const description = listing.description?.trim();
141
+ const hasMatchedActions = canChange && Boolean(onBuyNow);
133
142
 
134
143
  return (
135
144
  <div
136
145
  className={cn(
137
146
  'grid min-w-0',
138
147
  isDialog
139
- ? 'lg:grid-cols-[minmax(0,1.08fr)_minmax(22rem,0.92fr)]'
148
+ ? 'lg:h-[min(44rem,88dvh)] lg:grid-cols-[minmax(0,1.08fr)_minmax(22rem,0.92fr)]'
140
149
  : 'gap-6 lg:grid-cols-2'
141
150
  )}
142
151
  >
@@ -150,7 +159,7 @@ export function StorefrontProductDetail({
150
159
  <StorefrontImagePanel
151
160
  className={cn(
152
161
  isDialog
153
- ? 'aspect-[4/3] min-h-72 lg:aspect-auto lg:h-full lg:min-h-[36rem]'
162
+ ? 'aspect-[4/3] min-h-72 lg:aspect-auto lg:h-full lg:min-h-0'
154
163
  : 'aspect-square'
155
164
  )}
156
165
  imageUrl={imageUrl}
@@ -162,8 +171,7 @@ export function StorefrontProductDetail({
162
171
  <div
163
172
  className={cn(
164
173
  'flex min-w-0 flex-col gap-5',
165
- isDialog &&
166
- 'p-5 sm:p-7 lg:max-h-[min(44rem,90dvh)] lg:overflow-y-auto lg:p-9'
174
+ isDialog && 'p-5 sm:p-7 lg:h-full lg:overflow-y-auto lg:p-9'
167
175
  )}
168
176
  >
169
177
  <div className="flex flex-wrap items-center gap-2">
@@ -253,9 +261,11 @@ export function StorefrontProductDetail({
253
261
  </p>
254
262
  ) : null}
255
263
 
256
- <p className="text-pretty text-muted-foreground leading-7">
257
- {listing.description ?? labels.fallbackDescription}
258
- </p>
264
+ {description ? (
265
+ <p className="text-pretty text-muted-foreground leading-7">
266
+ {description}
267
+ </p>
268
+ ) : null}
259
269
 
260
270
  {isSquareTerminal || isPolar ? (
261
271
  <div
@@ -291,14 +301,24 @@ export function StorefrontProductDetail({
291
301
  </div>
292
302
  ) : null}
293
303
 
294
- <div className="mt-auto flex flex-wrap items-center gap-3 border-border border-t pt-5">
304
+ <div
305
+ className={cn(
306
+ 'mt-auto grid w-full grid-cols-1 gap-2 border-border border-t pt-5',
307
+ hasMatchedActions && 'grid-cols-2'
308
+ )}
309
+ >
295
310
  {canChange ? (
296
311
  needsSelection ? (
297
- <Button className={cn('h-11', radius)} disabled type="button">
312
+ <Button
313
+ className={cn('h-11 w-full', radius)}
314
+ disabled
315
+ type="button"
316
+ variant="secondary"
317
+ >
298
318
  {labels.selectOptions}
299
319
  </Button>
300
320
  ) : cartQuantity > 0 ? (
301
- <div className="flex items-center gap-1">
321
+ <div className="grid h-11 w-full grid-cols-[2.75rem_minmax(2.5rem,1fr)_2.75rem] items-center gap-1">
302
322
  <Button
303
323
  aria-label={`${labels.quantity} -`}
304
324
  className={cn('h-11 w-11 p-0', radius)}
@@ -323,36 +343,46 @@ export function StorefrontProductDetail({
323
343
  </Button>
324
344
  </div>
325
345
  ) : (
326
- <AccentButton
346
+ <Button
347
+ className={cn('h-11 w-full', radius)}
327
348
  disabled={addDisabled}
328
349
  onClick={() => onIncrement?.(listing.id, limit, variantId)}
329
- radius={radius}
350
+ type="button"
351
+ variant="secondary"
330
352
  >
331
353
  <Plus className="h-4 w-4" />
332
354
  {soldOut ? labels.soldOut : labels.add}
333
- </AccentButton>
355
+ </Button>
334
356
  )
335
357
  ) : null}
336
358
 
337
359
  {onBuyNow ? (
338
- <Button
339
- className={cn('h-11', radius)}
360
+ <AccentButton
361
+ className="h-11 w-full"
340
362
  disabled={buyNowDisabled}
341
363
  onClick={() => onBuyNow(listing.id, variantId)}
342
- type="button"
364
+ radius={radius}
343
365
  >
344
366
  <Zap className="size-4 shrink-0" />
345
367
  {labels.buyNow}
346
- </Button>
368
+ </AccentButton>
347
369
  ) : null}
348
370
 
349
371
  {cartHref && cartQuantity > 0 ? (
350
- <Button asChild className={cn('h-11', radius)} variant="outline">
351
- <a href={cartHref}>
372
+ <Button
373
+ asChild
374
+ className={cn(
375
+ 'h-11 w-full',
376
+ hasMatchedActions && 'col-span-2',
377
+ radius
378
+ )}
379
+ variant="outline"
380
+ >
381
+ <NavigationLink href={cartHref}>
352
382
  <ShoppingCart className="size-4 shrink-0" />
353
383
  {labels.cart}
354
384
  <ArrowRight className="size-4 shrink-0" />
355
- </a>
385
+ </NavigationLink>
356
386
  </Button>
357
387
  ) : null}
358
388
  </div>
@@ -3,7 +3,11 @@
3
3
  import type { InventoryStorefrontListing } from '@tuturuuu/internal-api/inventory';
4
4
  import { Dialog, DialogContent, DialogTitle } from '../dialog';
5
5
  import { StorefrontProductDetail } from './product-detail';
6
- import type { StorefrontCartLine, StorefrontSurfaceLabels } from './types';
6
+ import type {
7
+ StorefrontCartLine,
8
+ StorefrontLinkComponent,
9
+ StorefrontSurfaceLabels,
10
+ } from './types';
7
11
 
8
12
  /**
9
13
  * Large product detail dialog opened from a listing card. Reuses the same
@@ -17,6 +21,7 @@ export function StorefrontProductDialog({
17
21
  currency,
18
22
  isSubmitting,
19
23
  labels,
24
+ linkComponent,
20
25
  listing,
21
26
  onBuyNow,
22
27
  onDecrement,
@@ -32,6 +37,7 @@ export function StorefrontProductDialog({
32
37
  currency: string;
33
38
  isSubmitting?: boolean;
34
39
  labels: StorefrontSurfaceLabels;
40
+ linkComponent?: StorefrontLinkComponent;
35
41
  listing: InventoryStorefrontListing | null;
36
42
  onBuyNow?: (listingId: string, variantId?: string | null) => void;
37
43
  onDecrement?: (listingId: string, variantId?: string | null) => void;
@@ -58,6 +64,7 @@ export function StorefrontProductDialog({
58
64
  currency={currency}
59
65
  isSubmitting={isSubmitting}
60
66
  labels={labels}
67
+ linkComponent={linkComponent}
61
68
  listing={listing}
62
69
  onBuyNow={onBuyNow}
63
70
  onDecrement={onDecrement}
@@ -162,6 +162,22 @@ describe('StorefrontSurface', () => {
162
162
  expect(sanitizeStorefrontAccentColor('red')).toBeNull();
163
163
  });
164
164
 
165
+ it('omits the duplicate hero card when no cover media is configured', () => {
166
+ render(
167
+ <StorefrontSurface
168
+ listings={[listing]}
169
+ mode="store"
170
+ storefront={storefront}
171
+ />
172
+ );
173
+
174
+ expect(screen.getAllByText('Preview Store')).toHaveLength(1);
175
+ expect(screen.queryByText('Buyer-facing copy')).not.toBeInTheDocument();
176
+ expect(
177
+ screen.getByRole('heading', { name: 'Shop' }).closest('section')
178
+ ).not.toHaveClass('mt-10');
179
+ });
180
+
165
181
  it('renders a preview empty state without enabling checkout', () => {
166
182
  render(
167
183
  <StorefrontSurface
@@ -176,7 +192,7 @@ describe('StorefrontSurface', () => {
176
192
  />
177
193
  );
178
194
 
179
- expect(screen.getAllByText('Preview Store')).toHaveLength(2);
195
+ expect(screen.getAllByText('Preview Store')).toHaveLength(1);
180
196
  expect(screen.getByText('No buyer listings')).toBeInTheDocument();
181
197
  expect(screen.getByText('Create a listing next.')).toBeInTheDocument();
182
198
  fireEvent.click(screen.getByRole('button', { name: 'Cart: 0' }));
@@ -244,6 +260,7 @@ describe('StorefrontSurface', () => {
244
260
  it('opens a wide, readable Square Terminal product dialog', () => {
245
261
  const longListing = {
246
262
  ...listing,
263
+ description: null,
247
264
  title: 'Shikishi Board (8.8 x 10 inches, 253 mm x 225 mm)',
248
265
  };
249
266
 
@@ -252,6 +269,7 @@ describe('StorefrontSurface', () => {
252
269
  detailListingId={longListing.id}
253
270
  labels={{
254
271
  buyNow: 'Pay at terminal',
272
+ fallbackDescription: 'Available from this workspace storefront.',
255
273
  squareTerminal: 'Square Terminal',
256
274
  squareTerminalDescription: 'Finish payment on the counter device.',
257
275
  }}
@@ -259,6 +277,7 @@ describe('StorefrontSurface', () => {
259
277
  mode="store"
260
278
  onBuyNow={() => undefined}
261
279
  onDetailListingChange={() => undefined}
280
+ onIncrement={() => undefined}
262
281
  storefront={{ ...storefront, checkoutMode: 'square_terminal' }}
263
282
  />
264
283
  );
@@ -283,6 +302,16 @@ describe('StorefrontSurface', () => {
283
302
  expect(
284
303
  screen.getByRole('button', { name: 'Pay at terminal' })
285
304
  ).toBeEnabled();
305
+ expect(
306
+ screen.queryByText('Available from this workspace storefront.')
307
+ ).not.toBeInTheDocument();
308
+
309
+ const addButton = screen.getByRole('button', { name: 'Add' });
310
+ const payButton = screen.getByRole('button', { name: 'Pay at terminal' });
311
+ expect(addButton).toHaveClass('h-11', 'w-full', 'bg-secondary');
312
+ expect(payButton).toHaveClass('h-11', 'w-full');
313
+ expect(addButton.parentElement).toBe(payButton.parentElement);
314
+ expect(addButton.parentElement).toHaveClass('grid-cols-2');
286
315
  });
287
316
 
288
317
  it('keeps the compatibility cart page as a full cart review', () => {
@@ -299,7 +328,12 @@ describe('StorefrontSurface', () => {
299
328
  />
300
329
  );
301
330
 
302
- expect(screen.getByRole('region', { name: 'Cart' })).toBeInTheDocument();
331
+ expect(screen.getByRole('region', { name: 'Cart' })).toHaveClass(
332
+ 'grid',
333
+ 'gap-5',
334
+ 'p-5',
335
+ 'sm:p-6'
336
+ );
303
337
  expect(screen.getByText('1:1 Mentoring')).toBeInTheDocument();
304
338
  expect(screen.getAllByText('$2.00')).toHaveLength(2);
305
339
  expect(screen.getByRole('button', { name: /Checkout/ })).toBeEnabled();
@@ -379,6 +413,48 @@ describe('StorefrontSurface', () => {
379
413
  );
380
414
  });
381
415
 
416
+ it('keeps a configured bundle fixed price independent of component count', () => {
417
+ const fixedBundle: InventoryBundle = {
418
+ ...categoryBundle,
419
+ id: 'fixed-bundle',
420
+ name: 'Three for two dollars',
421
+ price: 200,
422
+ pricingMode: 'fixed_price',
423
+ };
424
+ const fixedListing: InventoryStorefrontListing = {
425
+ ...bundleListing,
426
+ bundleId: fixedBundle.id,
427
+ id: 'fixed-bundle-listing',
428
+ price: 200,
429
+ title: fixedBundle.name,
430
+ };
431
+
432
+ render(
433
+ <StorefrontSurface
434
+ bundles={[fixedBundle]}
435
+ cartLines={[
436
+ {
437
+ bundleSelections: {
438
+ 'component-keychains': [
439
+ { listingId: 'listing-keychain-a', quantity: 1 },
440
+ { listingId: 'listing-keychain-b', quantity: 1 },
441
+ { listingId: 'listing-keychain-c', quantity: 1 },
442
+ ],
443
+ },
444
+ listingId: fixedListing.id,
445
+ quantity: 1,
446
+ },
447
+ ]}
448
+ listings={[fixedListing]}
449
+ mode="cart"
450
+ storefront={storefront}
451
+ />
452
+ );
453
+
454
+ expect(screen.getAllByText('$2.00').length).toBeGreaterThan(0);
455
+ expect(screen.queryByText('$27.00')).not.toBeInTheDocument();
456
+ });
457
+
382
458
  it('prefills checkout buyer details while keeping editable form fields', () => {
383
459
  render(
384
460
  <StorefrontSurface
@@ -420,7 +496,7 @@ describe('StorefrontSurface', () => {
420
496
  );
421
497
 
422
498
  expect(screen.queryByText('Simulated checkout')).not.toBeInTheDocument();
423
- expect(screen.getAllByText('Preview Store')).toHaveLength(2);
499
+ expect(screen.getAllByText('Preview Store')).toHaveLength(1);
424
500
  });
425
501
 
426
502
  it('blocks disabled checkout without showing checkout mode badges', () => {