@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,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 type { StorefrontCartEntry, StorefrontSurfaceLabels } from './types';
11
- import { formatStorefrontPrice, storefrontSurfaceClasses } from './utils';
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
- <div
64
- className="flex items-center justify-between gap-3 text-sm"
65
- key={line.listingId}
66
- >
67
- <span className="min-w-0 truncate">
68
- {line.quantity} x {listing.title}
69
- </span>
70
- <span className="font-medium">
71
- {formatStorefrontPrice(listing.price * line.quantity, currency)}
72
- </span>
73
- </div>
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-4 grid gap-2"
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-20 rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
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="h-4 w-4" />
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
- <Button asChild className={cn('mt-4 w-full', radius)}>
118
- <a href={checkoutHref}>
119
- {labels.checkout}
120
- <ArrowRight className="h-4 w-4" />
121
- </a>
122
- </Button>
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="h-4 w-4" />
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 { formatStorefrontPrice, getStorefrontListingLimit } from './utils';
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?: (listingId: string, maxQuantity: number) => void;
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-3 p-3 sm:grid-cols-[112px_minmax(0,1fr)_auto] sm:items-center'
47
- : 'grid min-h-full gap-4 p-3'
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
- <StorefrontImagePanel
51
- className={cn(isList ? 'aspect-square' : 'aspect-[4/3]')}
52
- imageUrl={listing.imageUrl}
53
- label={listing.title}
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
- <p className="min-w-0 truncate font-semibold">{listing.title}</p>
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
- <p className="font-semibold">
81
- {formatStorefrontPrice(listing.price, currency)}
82
- </p>
83
- {listing.compareAtPrice ? (
84
- <p className="text-muted-foreground text-xs line-through">
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
- {canChange ? (
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" />