@tuturuuu/ui 0.7.0 → 0.9.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 (226) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/biome.json +1 -1
  3. package/package.json +75 -73
  4. package/src/components/ui/accordion.tsx +1 -1
  5. package/src/components/ui/breadcrumb.tsx +1 -1
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +4 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +239 -33
  8. package/src/components/ui/calendar-app/components/load-smart-scheduling-tasks.tsx +143 -0
  9. package/src/components/ui/calendar-app/components/priority-view.tsx +10 -3
  10. package/src/components/ui/calendar-app/components/tasks-sidebar.tsx +4 -116
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +67 -2
  12. package/src/components/ui/calendar.tsx +1 -1
  13. package/src/components/ui/carousel.tsx +1 -1
  14. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +1 -1
  15. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +1 -1
  16. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +1 -1
  17. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +1 -1
  18. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +1 -1
  19. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +1 -1
  20. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +2 -2
  21. package/src/components/ui/chat/chat-agent-details-utils.test.ts +1 -1
  22. package/src/components/ui/chat/chat-agent-details-utils.tsx +1 -1
  23. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +2 -2
  24. package/src/components/ui/checkbox.tsx +1 -1
  25. package/src/components/ui/color-picker.tsx +1 -1
  26. package/src/components/ui/command.tsx +1 -1
  27. package/src/components/ui/context-menu.tsx +5 -1
  28. package/src/components/ui/currency-input.test.tsx +43 -0
  29. package/src/components/ui/currency-input.tsx +1 -1
  30. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +3 -0
  31. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +19 -0
  32. package/src/components/ui/custom/combobox.test.tsx +195 -0
  33. package/src/components/ui/custom/combobox.tsx +273 -156
  34. package/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx +5 -13
  35. package/src/components/ui/custom/facebook-mockup/facebook-mockup.tsx +7 -1
  36. package/src/components/ui/custom/facebook-mockup/form.tsx +1 -1
  37. package/src/components/ui/custom/facebook-mockup/image-upload-field.tsx +1 -1
  38. package/src/components/ui/custom/facebook-mockup/preview.tsx +1 -1
  39. package/src/components/ui/custom/settings-dialog-shell.tsx +2 -1
  40. package/src/components/ui/custom/theme-toggle.tsx +1 -1
  41. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  42. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  43. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  44. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  45. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  46. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  47. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  48. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  49. package/src/components/ui/custom/workspace-select.tsx +8 -3
  50. package/src/components/ui/dialog.test.tsx +52 -0
  51. package/src/components/ui/dialog.tsx +6 -2
  52. package/src/components/ui/dropdown-menu.tsx +5 -1
  53. package/src/components/ui/finance/debts/debt-loan-form.tsx +12 -5
  54. package/src/components/ui/finance/debts/debt-loan-summary.tsx +3 -2
  55. package/src/components/ui/finance/debts/debts-page.test.tsx +54 -5
  56. package/src/components/ui/finance/debts/debts-page.tsx +15 -2
  57. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +3 -5
  58. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +25 -5
  59. package/src/components/ui/finance/invoices/new-invoice-page.tsx +7 -2
  60. package/src/components/ui/finance/invoices/standard-invoice.tsx +4 -2
  61. package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -2
  62. package/src/components/ui/finance/invoices/utils.ts +3 -1
  63. package/src/components/ui/finance/transactions/form-content-dialog.tsx +3 -0
  64. package/src/components/ui/finance/transactions/form-types.ts +3 -0
  65. package/src/components/ui/finance/transactions/form.tsx +2 -0
  66. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +2 -0
  67. package/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx +1 -1
  68. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  69. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +1 -4
  70. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +3 -0
  71. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -1
  72. package/src/components/ui/finance/wallets/form.test.tsx +51 -3
  73. package/src/components/ui/finance/wallets/form.tsx +15 -4
  74. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  75. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +4 -2
  76. package/src/components/ui/finance/wallets/wallets-data-table.tsx +1 -0
  77. package/src/components/ui/finance/wallets/wallets-page.tsx +5 -2
  78. package/src/components/ui/input-otp.tsx +1 -1
  79. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +28 -39
  80. package/src/components/ui/legacy/calendar/calendar-cell.tsx +2 -0
  81. package/src/components/ui/legacy/calendar/calendar-content.tsx +10 -6
  82. package/src/components/ui/legacy/calendar/calendar-header.tsx +23 -3
  83. package/src/components/ui/legacy/calendar/calendar-loading-skeleton.tsx +135 -0
  84. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +175 -237
  85. package/src/components/ui/legacy/calendar/event-card.test.tsx +177 -0
  86. package/src/components/ui/legacy/calendar/event-card.tsx +220 -131
  87. package/src/components/ui/legacy/calendar/event-modal.tsx +17 -17
  88. package/src/components/ui/legacy/calendar/event-provider-display.tsx +69 -0
  89. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +86 -4
  90. package/src/components/ui/legacy/calendar/smart-calendar.tsx +32 -2
  91. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +19 -10
  92. package/src/components/ui/money-input.test.tsx +64 -0
  93. package/src/components/ui/money-input.tsx +63 -0
  94. package/src/components/ui/navigation-menu.tsx +1 -1
  95. package/src/components/ui/pagination.tsx +1 -1
  96. package/src/components/ui/radio-group.tsx +1 -1
  97. package/src/components/ui/select.tsx +5 -1
  98. package/src/components/ui/sheet.tsx +1 -1
  99. package/src/components/ui/sidebar.tsx +1 -1
  100. package/src/components/ui/storefront/cart-popover.tsx +61 -0
  101. package/src/components/ui/storefront/cart-summary-parts.tsx +290 -0
  102. package/src/components/ui/storefront/cart-summary.tsx +104 -80
  103. package/src/components/ui/storefront/checkout-overlay.tsx +26 -0
  104. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  105. package/src/components/ui/storefront/image-panel.tsx +6 -0
  106. package/src/components/ui/storefront/index.ts +11 -0
  107. package/src/components/ui/storefront/listing-card.tsx +84 -22
  108. package/src/components/ui/storefront/merch-sections.tsx +70 -0
  109. package/src/components/ui/storefront/product-detail.tsx +289 -0
  110. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  111. package/src/components/ui/storefront/storefront-surface.test.tsx +221 -3
  112. package/src/components/ui/storefront/storefront-surface.tsx +288 -153
  113. package/src/components/ui/storefront/types.ts +27 -1
  114. package/src/components/ui/storefront/utils.ts +117 -27
  115. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  116. package/src/components/ui/text-editor/__tests__/extensions.test.ts +123 -0
  117. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  118. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  119. package/src/components/ui/text-editor/background-color-extension.ts +62 -0
  120. package/src/components/ui/text-editor/color-controls.tsx +284 -0
  121. package/src/components/ui/text-editor/content-migration.ts +41 -18
  122. package/src/components/ui/text-editor/editor.tsx +69 -14
  123. package/src/components/ui/text-editor/extensions.ts +9 -3
  124. package/src/components/ui/text-editor/highlight-extension.ts +22 -0
  125. package/src/components/ui/text-editor/image-extension.ts +40 -18
  126. package/src/components/ui/text-editor/tool-bar.tsx +9 -16
  127. package/src/components/ui/text-editor/video-extension.ts +11 -2
  128. package/src/components/ui/toast.tsx +1 -1
  129. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +270 -0
  130. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  131. package/src/components/ui/tu-do/boards/board-public-link-section.tsx +231 -0
  132. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +222 -109
  133. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  134. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +113 -46
  135. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +2 -0
  136. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-move.ts +5 -0
  137. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +3 -0
  138. package/src/components/ui/tu-do/boards/boardId/kanban/data/kanban-deadline-query.ts +50 -2
  139. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/__tests__/column-reorder.test.ts +17 -0
  140. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/column-reorder.ts +4 -1
  141. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +51 -9
  142. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-order.ts +2 -8
  143. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-sort-key.ts +47 -0
  144. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  145. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +127 -38
  146. package/src/components/ui/tu-do/boards/boardId/kanban/planner/__tests__/kanban-planner-island.test.tsx +380 -0
  147. package/src/components/ui/tu-do/boards/boardId/kanban/planner/kanban-planner-dialog.tsx +204 -0
  148. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-digest-panel.tsx +61 -0
  149. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-item-strip.tsx +54 -0
  150. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-plan-toolbar.tsx +251 -0
  151. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-scope-badge.tsx +27 -0
  152. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-section.tsx +58 -0
  153. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-share-dialog.tsx +238 -0
  154. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-target-controls.tsx +143 -0
  155. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-utils.ts +65 -0
  156. package/src/components/ui/tu-do/boards/boardId/kanban/planner/use-kanban-planner-state.ts +234 -0
  157. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +410 -4
  158. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +106 -14
  159. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +443 -19
  160. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +94 -32
  161. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +213 -106
  162. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +186 -0
  163. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +59 -2
  164. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +3 -0
  165. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +3 -0
  166. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +191 -28
  167. package/src/components/ui/tu-do/boards/boardId/task-filter.test.tsx +152 -0
  168. package/src/components/ui/tu-do/boards/boardId/task-filter.tsx +555 -545
  169. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +7 -0
  170. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  171. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  172. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  173. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  174. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  175. package/src/components/ui/tu-do/boards/share-section.tsx +100 -0
  176. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  177. package/src/components/ui/tu-do/drafts/draft-convert-dialog.tsx +10 -12
  178. package/src/components/ui/tu-do/drafts/drafts-page.tsx +33 -16
  179. package/src/components/ui/tu-do/initiatives/task-initiatives-client.tsx +56 -88
  180. package/src/components/ui/tu-do/my-tasks/my-tasks-content.tsx +26 -2
  181. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +55 -8
  182. package/src/components/ui/tu-do/notes/note-edit-dialog.tsx +1 -4
  183. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +25 -0
  184. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +341 -38
  185. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +253 -0
  186. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +237 -3
  187. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +17 -0
  188. package/src/components/ui/tu-do/shared/__tests__/task-legacy-route-recovery.test.tsx +16 -0
  189. package/src/components/ui/tu-do/shared/board-client.tsx +2 -7
  190. package/src/components/ui/tu-do/shared/board-config-storage.ts +7 -1
  191. package/src/components/ui/tu-do/shared/board-header.tsx +465 -937
  192. package/src/components/ui/tu-do/shared/board-layout-settings.tsx +165 -136
  193. package/src/components/ui/tu-do/shared/board-switcher.tsx +209 -217
  194. package/src/components/ui/tu-do/shared/board-views.tsx +596 -82
  195. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  196. package/src/components/ui/tu-do/shared/list-view.tsx +227 -1
  197. package/src/components/ui/tu-do/shared/recycle-bin-panel.tsx +142 -94
  198. package/src/components/ui/tu-do/shared/special-task-list-pins.ts +51 -0
  199. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +28 -0
  200. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  201. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  202. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  203. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  204. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  205. package/src/components/ui/tu-do/shared/task-edit-dialog/field-diff-viewer.tsx +3 -2
  206. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.test.tsx +91 -0
  207. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.tsx +123 -78
  208. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +7 -1
  209. package/src/components/ui/tu-do/shared/task-edit-dialog/task-snapshot-dialog.tsx +8 -3
  210. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +44 -15
  211. package/src/components/ui/tu-do/shared/task-legacy-route-recovery.tsx +2 -9
  212. package/src/declarations.d.ts +1 -0
  213. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +322 -2
  214. package/src/hooks/__tests__/use-calendar-sync.test.tsx +446 -0
  215. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  216. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  217. package/src/hooks/use-calendar-sync.tsx +247 -243
  218. package/src/hooks/use-calendar.tsx +323 -138
  219. package/src/hooks/use-task-actions.ts +24 -0
  220. package/src/hooks/use-user-workspace-config.ts +75 -0
  221. package/src/hooks/use-workspace-currency.ts +8 -3
  222. package/src/hooks/useBoardRealtime.ts +6 -3
  223. package/src/hooks/useBoardRealtime.types.ts +11 -0
  224. package/src/hooks/useBoardRealtimeEventHandler.ts +11 -0
  225. package/src/hooks/useCursorTracking.ts +91 -27
  226. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -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-text,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" />
@@ -0,0 +1,70 @@
1
+ import type { InventoryStorefrontSection } from '@tuturuuu/internal-api/inventory';
2
+ import { cn } from '@tuturuuu/utils/format';
3
+ import { StorefrontImagePanel } from './image-panel';
4
+ import { getSafeStorefrontHttpUrl } from './utils';
5
+
6
+ export function StorefrontMerchSections({
7
+ radius,
8
+ sections,
9
+ }: {
10
+ radius: string;
11
+ sections: InventoryStorefrontSection[];
12
+ }) {
13
+ const visibleSections = sections
14
+ .filter((section) => section.status === 'published')
15
+ .filter((section) => section.sectionType !== 'cover')
16
+ .filter((section) => {
17
+ const sectionHref = getSafeStorefrontHttpUrl(section.href);
18
+ return Boolean(
19
+ section.title?.trim() ||
20
+ section.description?.trim() ||
21
+ section.imageUrl?.trim() ||
22
+ sectionHref
23
+ );
24
+ })
25
+ .sort((a, b) => a.sortOrder - b.sortOrder);
26
+
27
+ if (visibleSections.length === 0) return null;
28
+
29
+ return (
30
+ <div className="mt-4 grid gap-3">
31
+ {visibleSections.map((section) => {
32
+ const sectionHref = getSafeStorefrontHttpUrl(section.href);
33
+
34
+ return (
35
+ <section
36
+ className={cn(
37
+ 'grid overflow-hidden border border-border bg-card md:grid-cols-[minmax(0,1fr)_280px]',
38
+ radius
39
+ )}
40
+ key={section.id}
41
+ >
42
+ <div className="flex min-w-0 flex-col justify-center gap-2 p-4">
43
+ {section.title ? (
44
+ <h2 className="font-semibold text-lg">{section.title}</h2>
45
+ ) : null}
46
+ {section.description ? (
47
+ <p className="text-muted-foreground text-sm leading-6">
48
+ {section.description}
49
+ </p>
50
+ ) : null}
51
+ {sectionHref ? (
52
+ <a
53
+ className="mt-1 w-fit font-medium text-sm underline-offset-4 hover:underline"
54
+ href={sectionHref}
55
+ >
56
+ {sectionHref.replace(/^https?:\/\//u, '')}
57
+ </a>
58
+ ) : null}
59
+ </div>
60
+ <StorefrontImagePanel
61
+ className="min-h-36 md:min-h-full"
62
+ imageUrl={section.imageUrl}
63
+ label={section.title ?? 'Storefront section'}
64
+ />
65
+ </section>
66
+ );
67
+ })}
68
+ </div>
69
+ );
70
+ }
@@ -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-border,var(--border))] bg-[var(--storefront-accent-soft,var(--muted))] text-[var(--storefront-accent-text,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
+ }