@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,52 +1,58 @@
1
1
  import type {
2
+ InventoryListingVariant,
2
3
  InventoryStorefront,
3
4
  InventoryStorefrontListing,
4
5
  } from '@tuturuuu/internal-api/inventory';
6
+ import { formatMoneyFromMinor } from '@tuturuuu/utils/money';
5
7
  import type { CSSProperties } from 'react';
6
8
 
9
+ // The storefront now ships a single, unified design language. The merchant
10
+ // preset fields (cornerStyle/surfaceStyle/themePreset/layoutStyle) are retained
11
+ // in the data model for backwards compatibility, but every value resolves to the
12
+ // same refined look so the experience is consistent across all storefronts.
13
+
14
+ /** One soft, modern corner radius for every surface. */
15
+ export const STOREFRONT_RADIUS = 'rounded-2xl';
16
+
7
17
  export const storefrontRadiusClasses: Record<
8
18
  InventoryStorefront['cornerStyle'],
9
19
  string
10
20
  > = {
11
- compact: 'rounded-md',
12
- rounded: 'rounded-lg',
13
- soft: 'rounded-2xl',
21
+ compact: STOREFRONT_RADIUS,
22
+ rounded: STOREFRONT_RADIUS,
23
+ soft: STOREFRONT_RADIUS,
14
24
  };
15
25
 
26
+ /** One elevated card surface for every storefront. */
27
+ export const STOREFRONT_SURFACE =
28
+ 'border border-border/60 bg-card shadow-sm shadow-foreground/5';
29
+
16
30
  export const storefrontSurfaceClasses: Record<
17
31
  InventoryStorefront['surfaceStyle'],
18
32
  string
19
33
  > = {
20
- glass:
21
- 'border border-border/70 bg-card/75 shadow-sm shadow-foreground/5 backdrop-blur',
22
- soft: 'border border-border/70 bg-muted/35 shadow-sm shadow-foreground/5',
23
- solid: 'border border-border bg-card shadow-sm shadow-foreground/5',
34
+ glass: STOREFRONT_SURFACE,
35
+ soft: STOREFRONT_SURFACE,
36
+ solid: STOREFRONT_SURFACE,
24
37
  };
25
38
 
26
- /**
27
- * Theme presets change the storefront's typographic personality so the choice
28
- * is actually visible to shoppers. Applied at the surface root and inherited by
29
- * headings/body inside.
30
- */
39
+ /** Unified typography for the whole storefront. */
31
40
  export const storefrontThemeClasses: Record<
32
41
  InventoryStorefront['themePreset'],
33
42
  string
34
43
  > = {
35
- // Spacious, headline-led magazine feel with serif headings.
36
- editorial:
37
- 'font-sans [&_h1]:font-serif [&_h1]:tracking-tight [&_h2]:font-serif [&_h2]:tracking-tight',
38
- // Refined boutique look: airy, wide-tracked uppercase headings.
39
- boutique:
40
- 'font-sans [&_h1]:uppercase [&_h1]:tracking-[0.12em] [&_h2]:tracking-wide',
41
- // Dense, scannable product-catalog density.
42
- catalog: 'font-sans text-[0.95rem] [&_h1]:tracking-tight',
43
- // Clean default.
44
+ editorial: 'font-sans',
45
+ boutique: 'font-sans',
46
+ catalog: 'font-sans',
44
47
  minimal: 'font-sans',
45
48
  };
46
49
 
47
50
  export type StorefrontAccentStyle = CSSProperties & {
48
51
  '--storefront-accent'?: string;
52
+ '--storefront-accent-border'?: string;
49
53
  '--storefront-accent-foreground'?: string;
54
+ '--storefront-accent-soft'?: string;
55
+ '--storefront-accent-text'?: string;
50
56
  };
51
57
 
52
58
  export function sanitizeStorefrontAccentColor(value?: string | null) {
@@ -65,6 +71,19 @@ export function sanitizeStorefrontAccentColor(value?: string | null) {
65
71
  return null;
66
72
  }
67
73
 
74
+ export function getSafeStorefrontHttpUrl(value?: string | null) {
75
+ const normalized = value?.trim();
76
+ if (!normalized) return null;
77
+
78
+ try {
79
+ const url = new URL(normalized);
80
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
81
+ return url.toString();
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
68
87
  export function getStorefrontListingLimit(listing: InventoryStorefrontListing) {
69
88
  const available =
70
89
  typeof listing.availableQuantity === 'number'
@@ -74,12 +93,80 @@ export function getStorefrontListingLimit(listing: InventoryStorefrontListing) {
74
93
  return Math.max(0, Math.min(listing.maxPerOrder, available));
75
94
  }
76
95
 
77
- export function formatStorefrontPrice(value: number, currency: string) {
78
- return new Intl.NumberFormat(undefined, {
79
- currency,
80
- maximumFractionDigits: 0,
81
- style: 'currency',
82
- }).format(value);
96
+ /** Active, selectable variants for a listing, in display order. */
97
+ export function getStorefrontListingVariants(
98
+ listing: InventoryStorefrontListing
99
+ ): InventoryListingVariant[] {
100
+ return (listing.variants ?? []).filter(
101
+ (variant) => variant.status === 'active'
102
+ );
103
+ }
104
+
105
+ export function listingHasVariants(listing: InventoryStorefrontListing) {
106
+ return getStorefrontListingVariants(listing).length > 0;
107
+ }
108
+
109
+ /** Per-order limit for a specific variant, capped by the listing's maxPerOrder. */
110
+ export function getStorefrontVariantLimit(
111
+ listing: InventoryStorefrontListing,
112
+ variant: InventoryListingVariant
113
+ ) {
114
+ const available =
115
+ typeof variant.availableQuantity === 'number'
116
+ ? variant.availableQuantity
117
+ : Number.POSITIVE_INFINITY;
118
+
119
+ return Math.max(0, Math.min(listing.maxPerOrder, available));
120
+ }
121
+
122
+ /**
123
+ * Resolves the price to charge for a cart line: the variant's resolved price
124
+ * when a variant is selected, otherwise the listing price. Both are minor units.
125
+ */
126
+ export function getStorefrontLinePrice(
127
+ listing: InventoryStorefrontListing,
128
+ variant?: InventoryListingVariant | null
129
+ ) {
130
+ return variant ? variant.price : listing.price;
131
+ }
132
+
133
+ /** Lowest active-variant price, for a "from {price}" label on variant listings. */
134
+ export function getStorefrontListingFromPrice(
135
+ listing: InventoryStorefrontListing
136
+ ) {
137
+ const variants = getStorefrontListingVariants(listing);
138
+ if (variants.length === 0) return listing.price;
139
+ return variants.reduce(
140
+ (min, variant) => Math.min(min, variant.price),
141
+ Number.POSITIVE_INFINITY
142
+ );
143
+ }
144
+
145
+ /** Stable identity for a cart line so listing+variant combos stay distinct. */
146
+ export function storefrontCartLineKey(
147
+ listingId: string,
148
+ variantId?: string | null
149
+ ) {
150
+ return `${listingId}::${variantId ?? ''}`;
151
+ }
152
+
153
+ /** Composes a human label for the selected variant from its option values. */
154
+ export function getStorefrontVariantLabel(
155
+ variant: InventoryListingVariant
156
+ ): string | null {
157
+ if (variant.title) return variant.title;
158
+ const labels = variant.optionValues.map((value) => value.label);
159
+ if (labels.length > 0) return labels.join(' / ');
160
+ return variant.sku ?? null;
161
+ }
162
+
163
+ /**
164
+ * Format a storefront price. Listing/bundle prices and cart totals are stored
165
+ * in integer minor units (cents for USD), so convert through the shared money
166
+ * helper, which also applies the currency's correct decimal precision.
167
+ */
168
+ export function formatStorefrontPrice(minorValue: number, currency: string) {
169
+ return formatMoneyFromMinor(minorValue, currency);
83
170
  }
84
171
 
85
172
  export function getListingInitials(title: string) {
@@ -98,7 +185,10 @@ export function getAccentStyle(
98
185
 
99
186
  return {
100
187
  '--storefront-accent': accentColor,
188
+ '--storefront-accent-border': `color-mix(in oklab, ${accentColor} 42%, var(--border))`,
101
189
  '--storefront-accent-foreground': getAccentForeground(accentColor),
190
+ '--storefront-accent-soft': `color-mix(in oklab, ${accentColor} 14%, var(--background))`,
191
+ '--storefront-accent-text': `color-mix(in oklab, ${accentColor} 68%, var(--foreground))`,
102
192
  };
103
193
  }
104
194
 
@@ -16,6 +16,29 @@ describe('content-migration', () => {
16
16
  expect(migrateInlineImagesToBlock(content)).toEqual(content);
17
17
  });
18
18
 
19
+ it('should return malformed non-array content unchanged', () => {
20
+ const content = {
21
+ type: 'doc',
22
+ content: {},
23
+ } as unknown as JSONContent;
24
+
25
+ expect(migrateInlineImagesToBlock(content)).toEqual(content);
26
+ });
27
+
28
+ it('should ignore malformed nested non-array content', () => {
29
+ const content = {
30
+ type: 'doc',
31
+ content: [
32
+ {
33
+ type: 'paragraph',
34
+ content: {},
35
+ },
36
+ ],
37
+ } as unknown as JSONContent;
38
+
39
+ expect(migrateInlineImagesToBlock(content)).toEqual(content);
40
+ });
41
+
19
42
  it('should pass through content with no images', () => {
20
43
  const content: JSONContent = {
21
44
  type: 'doc',
@@ -662,6 +685,15 @@ describe('content-migration', () => {
662
685
  expect(needsMigration(content)).toBe(false);
663
686
  });
664
687
 
688
+ it('should return false for malformed non-array content', () => {
689
+ const content = {
690
+ type: 'doc',
691
+ content: {},
692
+ } as unknown as JSONContent;
693
+
694
+ expect(needsMigration(content)).toBe(false);
695
+ });
696
+
665
697
  it('should return false for content with no images', () => {
666
698
  const content: JSONContent = {
667
699
  type: 'doc',
@@ -1,3 +1,4 @@
1
+ import { generateHTML, generateJSON } from '@tiptap/core';
1
2
  import type SupabaseProvider from '@tuturuuu/ui/hooks/supabase-provider';
2
3
  import { describe, expect, it } from 'vitest';
3
4
  import * as Y from 'yjs';
@@ -34,6 +35,128 @@ describe('text editor extensions', () => {
34
35
  expect(names).toContain('collaborationCaret');
35
36
  });
36
37
 
38
+ it('registers theme-aware highlight, text color, and background color extensions', () => {
39
+ const names = extensionNames({});
40
+
41
+ expect(names).toContain('highlight');
42
+ expect(names).toContain('textStyle');
43
+ expect(names).toContain('color');
44
+ expect(names).toContain('backgroundColor');
45
+ });
46
+
47
+ it('round-trips theme-aware highlight, text color, and background color marks', () => {
48
+ const extensions = getEditorExtensions();
49
+ const content = {
50
+ type: 'doc',
51
+ content: [
52
+ {
53
+ type: 'paragraph',
54
+ attrs: { textAlign: null },
55
+ content: [
56
+ {
57
+ type: 'text',
58
+ marks: [
59
+ {
60
+ type: 'highlight',
61
+ attrs: {
62
+ color: 'var(--calendar-bg-yellow)',
63
+ textColor: 'var(--yellow)',
64
+ },
65
+ },
66
+ ],
67
+ text: 'Highlighted',
68
+ },
69
+ { type: 'text', text: ' ' },
70
+ {
71
+ type: 'text',
72
+ marks: [
73
+ {
74
+ type: 'textStyle',
75
+ attrs: {
76
+ color: 'var(--blue)',
77
+ backgroundColor: 'var(--calendar-bg-blue)',
78
+ },
79
+ },
80
+ ],
81
+ text: 'Colored',
82
+ },
83
+ ],
84
+ },
85
+ ],
86
+ };
87
+
88
+ const html = generateHTML(content, extensions);
89
+
90
+ expect(html).toContain('var(--calendar-bg-yellow)');
91
+ expect(html).toContain('var(--yellow)');
92
+ expect(html).toContain('var(--calendar-bg-blue)');
93
+ expect(html).toContain('var(--blue)');
94
+
95
+ const parsed = generateJSON(html, extensions);
96
+ const paragraph = parsed.content?.[0];
97
+ const highlightedText = paragraph?.content?.[0];
98
+ const coloredText = paragraph?.content?.[2];
99
+
100
+ expect(highlightedText?.marks).toEqual(
101
+ expect.arrayContaining([
102
+ expect.objectContaining({
103
+ type: 'highlight',
104
+ attrs: expect.objectContaining({
105
+ color: 'var(--calendar-bg-yellow)',
106
+ textColor: 'var(--yellow)',
107
+ }),
108
+ }),
109
+ ])
110
+ );
111
+ expect(coloredText?.marks).toEqual(
112
+ expect.arrayContaining([
113
+ expect.objectContaining({
114
+ type: 'textStyle',
115
+ attrs: expect.objectContaining({
116
+ color: 'var(--blue)',
117
+ backgroundColor: 'var(--calendar-bg-blue)',
118
+ }),
119
+ }),
120
+ ])
121
+ );
122
+ });
123
+
124
+ it('keeps legacy and raw hex highlight content valid', () => {
125
+ const html = generateHTML(
126
+ {
127
+ type: 'doc',
128
+ content: [
129
+ {
130
+ type: 'paragraph',
131
+ attrs: { textAlign: null },
132
+ content: [
133
+ {
134
+ type: 'text',
135
+ marks: [{ type: 'highlight' }],
136
+ text: 'Legacy',
137
+ },
138
+ { type: 'text', text: ' ' },
139
+ {
140
+ type: 'text',
141
+ marks: [
142
+ {
143
+ type: 'highlight',
144
+ attrs: { color: '#FFF59D' },
145
+ },
146
+ ],
147
+ text: 'Hex',
148
+ },
149
+ ],
150
+ },
151
+ ],
152
+ },
153
+ getEditorExtensions()
154
+ );
155
+
156
+ expect(html).toContain('<mark');
157
+ expect(html).toContain('#FFF59D');
158
+ });
159
+
37
160
  it('round-trips task mention workspace metadata through HTML attrs', () => {
38
161
  const renderOutput = (Mention.config as any).renderHTML({
39
162
  HTMLAttributes: {
@@ -1,4 +1,5 @@
1
1
  import { type EditorState, NodeSelection, type Plugin } from '@tiptap/pm/state';
2
+ import { toast } from '@tuturuuu/ui/sonner';
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
  import { __imageExtensionPrivate, CustomImage } from '../image-extension';
4
5
  import { MAX_IMAGE_SIZE, MAX_VIDEO_SIZE } from '../media-utils';
@@ -16,7 +17,7 @@ vi.mock('../media-utils', async () => {
16
17
  });
17
18
 
18
19
  // Mock the sonner toast
19
- vi.mock('../../sonner', () => ({
20
+ vi.mock('@tuturuuu/ui/sonner', () => ({
20
21
  toast: {
21
22
  error: vi.fn(),
22
23
  success: vi.fn(),
@@ -113,6 +114,27 @@ describe('ImageExtension', () => {
113
114
 
114
115
  expect(extension).toBeDefined();
115
116
  });
117
+
118
+ it('should let a delegated getter clear the configured image upload handler', () => {
119
+ const staleUpload = vi.fn().mockResolvedValue('stale-url');
120
+
121
+ expect(
122
+ __imageExtensionPrivate.resolveUploadHandler({
123
+ configuredHandler: staleUpload,
124
+ delegatedGetter: () => undefined,
125
+ })
126
+ ).toBeUndefined();
127
+ });
128
+
129
+ it('should fall back to the configured upload handler only without a delegated getter', () => {
130
+ const upload = vi.fn().mockResolvedValue('url');
131
+
132
+ expect(
133
+ __imageExtensionPrivate.resolveUploadHandler({
134
+ configuredHandler: upload,
135
+ })
136
+ ).toBe(upload);
137
+ });
116
138
  });
117
139
 
118
140
  describe('size presets', () => {
@@ -300,6 +322,52 @@ describe('ImageExtension', () => {
300
322
  );
301
323
  expect(result).toBe(false);
302
324
  });
325
+
326
+ it('should block pasted images when delegated upload permission is cleared', () => {
327
+ const staleUpload = vi.fn().mockResolvedValue('stale-url');
328
+ const extension = CustomImage({
329
+ onImageUpload: staleUpload,
330
+ getOnImageUpload: () => undefined,
331
+ });
332
+
333
+ const plugins = extension.config.addProseMirrorPlugins();
334
+ const pastePlugin = plugins.find(
335
+ (p: Plugin) => p.props?.handleDOMEvents?.paste !== undefined
336
+ );
337
+
338
+ const imageFile = new File(['image'], 'image.png', {
339
+ type: 'image/png',
340
+ });
341
+ const mockView = {
342
+ state: { selection: { from: 0, to: 0 } },
343
+ dispatch: vi.fn(),
344
+ dom: document.createElement('div'),
345
+ };
346
+ const mockEvent = {
347
+ clipboardData: {
348
+ items: [
349
+ {
350
+ type: 'image/png',
351
+ getAsFile: () => imageFile,
352
+ },
353
+ ],
354
+ },
355
+ preventDefault: vi.fn(),
356
+ } as unknown as ClipboardEvent;
357
+
358
+ const result = pastePlugin?.props?.handleDOMEvents?.paste(
359
+ mockView as any,
360
+ mockEvent
361
+ );
362
+
363
+ expect(result).toBe(true);
364
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
365
+ expect(staleUpload).not.toHaveBeenCalled();
366
+ expect(toast.error).toHaveBeenCalledWith('Insufficient permissions', {
367
+ description:
368
+ 'You do not have permission to upload images in this editor.',
369
+ });
370
+ });
303
371
  });
304
372
 
305
373
  describe('drop handler behavior', () => {
@@ -53,6 +53,16 @@ describe('VideoExtension', () => {
53
53
  const extension = Video();
54
54
  expect(extension).toBeDefined();
55
55
  });
56
+
57
+ it('should accept a delegated upload getter', () => {
58
+ const mockUpload = vi.fn().mockResolvedValue('url');
59
+ const extension = Video({
60
+ onVideoUpload: vi.fn().mockResolvedValue('stale-url'),
61
+ getOnVideoUpload: () => mockUpload,
62
+ });
63
+
64
+ expect(extension).toBeDefined();
65
+ });
56
66
  });
57
67
 
58
68
  describe('node configuration', () => {
@@ -251,6 +261,43 @@ describe('VideoExtension', () => {
251
261
  expect(result).toBe(false);
252
262
  });
253
263
 
264
+ it('should return false when delegated video upload permission is cleared', () => {
265
+ const staleUpload = vi.fn().mockResolvedValue('stale-url');
266
+ const extension = Video({
267
+ onVideoUpload: staleUpload,
268
+ getOnVideoUpload: () => undefined,
269
+ });
270
+ const plugins = (
271
+ extension.config as any
272
+ ).addProseMirrorPlugins() as any[];
273
+ const pastePlugin = plugins[1];
274
+
275
+ const mockView = {
276
+ state: { selection: { from: 0, to: 0 }, tr: {} },
277
+ dispatch: vi.fn(),
278
+ };
279
+ const mockEvent = {
280
+ clipboardData: {
281
+ items: [
282
+ {
283
+ type: 'video/mp4',
284
+ getAsFile: () => new File(['video'], 'video.mp4'),
285
+ },
286
+ ],
287
+ },
288
+ preventDefault: vi.fn(),
289
+ } as unknown as ClipboardEvent;
290
+
291
+ const result = pastePlugin.props?.handleDOMEvents?.paste(
292
+ mockView as any,
293
+ mockEvent
294
+ );
295
+
296
+ expect(result).toBe(false);
297
+ expect(mockEvent.preventDefault).not.toHaveBeenCalled();
298
+ expect(staleUpload).not.toHaveBeenCalled();
299
+ });
300
+
254
301
  it('should return false when clipboard has no items', () => {
255
302
  const mockUpload = vi.fn().mockResolvedValue('url');
256
303
  const extension = Video({ onVideoUpload: mockUpload });
@@ -0,0 +1,62 @@
1
+ import { Extension } from '@tiptap/core';
2
+
3
+ export interface BackgroundColorOptions {
4
+ types: string[];
5
+ }
6
+
7
+ declare module '@tiptap/core' {
8
+ interface Commands<ReturnType> {
9
+ backgroundColor: {
10
+ setBackgroundColor: (color: string) => ReturnType;
11
+ unsetBackgroundColor: () => ReturnType;
12
+ };
13
+ }
14
+ }
15
+
16
+ export const BackgroundColor = Extension.create<BackgroundColorOptions>({
17
+ name: 'backgroundColor',
18
+
19
+ addOptions() {
20
+ return {
21
+ types: ['textStyle'],
22
+ };
23
+ },
24
+
25
+ addGlobalAttributes() {
26
+ return [
27
+ {
28
+ types: this.options.types,
29
+ attributes: {
30
+ backgroundColor: {
31
+ default: null,
32
+ parseHTML: (element) =>
33
+ element.style.getPropertyValue('background-color') || null,
34
+ renderHTML: (attributes) => {
35
+ if (!attributes.backgroundColor) return {};
36
+
37
+ return {
38
+ style: `background-color: ${attributes.backgroundColor}`,
39
+ };
40
+ },
41
+ },
42
+ },
43
+ },
44
+ ];
45
+ },
46
+
47
+ addCommands() {
48
+ return {
49
+ setBackgroundColor:
50
+ (color) =>
51
+ ({ chain }) =>
52
+ chain().setMark('textStyle', { backgroundColor: color }).run(),
53
+ unsetBackgroundColor:
54
+ () =>
55
+ ({ chain }) =>
56
+ chain()
57
+ .setMark('textStyle', { backgroundColor: null })
58
+ .removeEmptyTextStyle()
59
+ .run(),
60
+ };
61
+ },
62
+ });