@tuturuuu/ui 0.30.0 → 0.30.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.2](https://github.com/tutur3u/platform/compare/ui-v0.30.1...ui-v0.30.2) (2026-08-31)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **finance:** allow advance subscription invoices ([d307068](https://github.com/tutur3u/platform/commit/d3070683a7c74b9f18f65dabdf482132fa02cf13))
9
+ * **tasks:** restore description checklist controls ([7976f78](https://github.com/tutur3u/platform/commit/7976f78292237e8047509e4243dafa14acea1b54))
10
+
11
+ ## [0.30.1](https://github.com/tutur3u/platform/compare/ui-v0.30.0...ui-v0.30.1) (2026-08-27)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **forms:** repair the landing demo, the brand lockup and two analytics labels ([3f16968](https://github.com/tutur3u/platform/commit/3f16968f5802ebbd518ee27025c61a759839fd4e))
17
+ * **forms:** repair the landing demo, the brand lockup and two analytics labels ([#5164](https://github.com/tutur3u/platform/issues/5164)) ([56d7f62](https://github.com/tutur3u/platform/commit/56d7f62325a8ec0caa5bb3ccd76b050dd66b1e53))
18
+
3
19
  ## [0.30.0](https://github.com/tutur3u/platform/compare/ui-v0.29.2...ui-v0.30.0) (2026-08-26)
4
20
 
5
21
 
package/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "root": false,
3
- "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
3
+ "$schema": "https://biomejs.dev/schemas/2.5.11/schema.json",
4
4
  "extends": "//"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.30.0",
3
+ "version": "0.30.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -57,43 +57,43 @@
57
57
  "@streamdown/mermaid": "^1.0.2",
58
58
  "@tanstack/react-hotkeys": "^0.10.0",
59
59
  "@tanstack/react-pacer": "^0.23.0",
60
- "@tanstack/react-query": "^5.101.2",
61
- "@tanstack/react-table": "^9.0.0",
60
+ "@tanstack/react-query": "^5.102.8",
61
+ "@tanstack/react-table": "^9.2.3",
62
62
  "@tanstack/react-virtual": "^3.14.10",
63
- "@tiptap/core": "3.30.2",
64
- "@tiptap/extension-collaboration": "3.30.2",
65
- "@tiptap/extension-collaboration-caret": "3.30.2",
66
- "@tiptap/extension-color": "3.30.2",
67
- "@tiptap/extension-details": "3.30.2",
68
- "@tiptap/extension-drag-handle": "3.30.2",
69
- "@tiptap/extension-drag-handle-react": "3.30.2",
70
- "@tiptap/extension-highlight": "3.30.2",
71
- "@tiptap/extension-horizontal-rule": "3.30.2",
72
- "@tiptap/extension-image": "3.30.2",
73
- "@tiptap/extension-link": "3.30.2",
74
- "@tiptap/extension-list": "3.30.2",
75
- "@tiptap/extension-node-range": "3.30.2",
76
- "@tiptap/extension-placeholder": "3.30.2",
77
- "@tiptap/extension-strike": "3.30.2",
78
- "@tiptap/extension-subscript": "3.30.2",
79
- "@tiptap/extension-superscript": "3.30.2",
80
- "@tiptap/extension-table": "3.30.2",
81
- "@tiptap/extension-table-cell": "3.30.2",
82
- "@tiptap/extension-table-header": "3.30.2",
83
- "@tiptap/extension-table-row": "3.30.2",
84
- "@tiptap/extension-text-align": "3.30.2",
85
- "@tiptap/extension-text-style": "3.30.2",
86
- "@tiptap/extension-youtube": "3.30.2",
87
- "@tiptap/pm": "3.30.2",
88
- "@tiptap/react": "3.30.2",
89
- "@tiptap/starter-kit": "3.30.2",
63
+ "@tiptap/core": "3.30.5",
64
+ "@tiptap/extension-collaboration": "3.30.5",
65
+ "@tiptap/extension-collaboration-caret": "3.30.5",
66
+ "@tiptap/extension-color": "3.30.5",
67
+ "@tiptap/extension-details": "3.30.5",
68
+ "@tiptap/extension-drag-handle": "3.30.5",
69
+ "@tiptap/extension-drag-handle-react": "3.30.5",
70
+ "@tiptap/extension-highlight": "3.30.5",
71
+ "@tiptap/extension-horizontal-rule": "3.30.5",
72
+ "@tiptap/extension-image": "3.30.5",
73
+ "@tiptap/extension-link": "3.30.5",
74
+ "@tiptap/extension-list": "3.30.5",
75
+ "@tiptap/extension-node-range": "3.30.5",
76
+ "@tiptap/extension-placeholder": "3.30.5",
77
+ "@tiptap/extension-strike": "3.30.5",
78
+ "@tiptap/extension-subscript": "3.30.5",
79
+ "@tiptap/extension-superscript": "3.30.5",
80
+ "@tiptap/extension-table": "3.30.5",
81
+ "@tiptap/extension-table-cell": "3.30.5",
82
+ "@tiptap/extension-table-header": "3.30.5",
83
+ "@tiptap/extension-table-row": "3.30.5",
84
+ "@tiptap/extension-text-align": "3.30.5",
85
+ "@tiptap/extension-text-style": "3.30.5",
86
+ "@tiptap/extension-youtube": "3.30.5",
87
+ "@tiptap/pm": "3.30.5",
88
+ "@tiptap/react": "3.30.5",
89
+ "@tiptap/starter-kit": "3.30.5",
90
90
  "@tuturuuu/ai": "0.8.3",
91
91
  "@tuturuuu/apis": "0.11.3",
92
92
  "@tuturuuu/hooks": "0.1.1",
93
93
  "@tuturuuu/icons": "0.1.0",
94
94
  "@tuturuuu/internal-api": "0.33.0",
95
95
  "@tuturuuu/supabase": "0.5.1",
96
- "@tuturuuu/utils": "0.26.0",
96
+ "@tuturuuu/utils": "0.27.0",
97
97
  "@types/debug": "^4.1.13",
98
98
  "browser-image-compression": "^2.0.2",
99
99
  "class-variance-authority": "^0.7.1",
@@ -108,18 +108,18 @@
108
108
  "framer-motion": "^13.1.1",
109
109
  "gsap": "^3.15.0",
110
110
  "html-entities": "^2.6.0",
111
- "html2canvas-pro": "^2.3.9",
111
+ "html2canvas-pro": "^2.4.0",
112
112
  "input-otp": "^1.5.0",
113
113
  "katex": "^0.18.4",
114
114
  "lodash": "4.18.1",
115
115
  "moment": "^2.30.1",
116
116
  "motion": "^13.1.1",
117
- "next": "^16.3.2",
118
- "next-intl": "^4.13.7",
117
+ "next": "^16.3.3",
118
+ "next-intl": "^4.14.1",
119
119
  "next-themes": "^0.4.6",
120
- "nuqs": "^2.10.0",
120
+ "nuqs": "^2.10.1",
121
121
  "prosemirror-state": "^1.4.4",
122
- "prosemirror-view": "1.42.2",
122
+ "prosemirror-view": "1.42.3",
123
123
  "qrcode.react": "^4.2.0",
124
124
  "react": "^19.2.8",
125
125
  "react-colorful": "^5.8.0",
@@ -136,7 +136,7 @@
136
136
  "recharts": "3.10.1",
137
137
  "slot-text": "^0.3.4",
138
138
  "sonner": "^2.0.8",
139
- "streamdown": "^2.5.0",
139
+ "streamdown": "^2.6.0",
140
140
  "tailwind-scrollbar": "^4.0.2",
141
141
  "tailwind-scrollbar-hide": "^4.0.0",
142
142
  "tailwindcss-animate": "^1.0.7",
@@ -150,21 +150,21 @@
150
150
  "devDependencies": {
151
151
  "@tailwindcss/postcss": "^4.3.3",
152
152
  "@tailwindcss/typography": "^0.5.20",
153
- "@tanstack/react-query": "^5.102.1",
154
- "@tanstack/react-table": "^9.1.2",
153
+ "@tanstack/react-query": "^5.102.8",
154
+ "@tanstack/react-table": "^9.2.3",
155
155
  "@testing-library/jest-dom": "^7.0.1",
156
- "@testing-library/react": "^16.3.2",
156
+ "@testing-library/react": "^16.3.3",
157
157
  "@tuturuuu/types": "0.28.0",
158
158
  "@tuturuuu/typescript-config": "0.1.2",
159
159
  "@types/html2canvas": "^1.0.0",
160
160
  "@types/lodash": "^4.17.25",
161
- "@types/node": "^26.2.0",
161
+ "@types/node": "^26.4.0",
162
162
  "@types/react": "^19.2.18",
163
- "@types/react-dom": "^19.2.4",
163
+ "@types/react-dom": "^19.2.5",
164
164
  "@types/react-resizable": "^4.0.0",
165
165
  "@types/react-syntax-highlighter": "^15.5.13",
166
166
  "@types/uuid": "^11.0.0",
167
- "@vitejs/plugin-react": "^6.1.0",
167
+ "@vitejs/plugin-react": "^6.1.1",
168
168
  "autoprefixer": "^10.5.4",
169
169
  "jsdom": "^30.0.1",
170
170
  "postcss": "^8.5.26",
@@ -313,6 +313,10 @@
313
313
  "types": "./src/components/ui/custom/system-language-wrapper.tsx",
314
314
  "import": "./src/components/ui/custom/system-language-wrapper.tsx"
315
315
  },
316
+ "./custom/tuturuuu-wordmark": {
317
+ "types": "./src/components/ui/custom/tuturuuu-wordmark.tsx",
318
+ "import": "./src/components/ui/custom/tuturuuu-wordmark.tsx"
319
+ },
316
320
  "./custom/tuturuuu-logo": {
317
321
  "types": "./src/components/ui/custom/tuturuuu-logo.tsx",
318
322
  "import": "./src/components/ui/custom/tuturuuu-logo.tsx"
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { toProductName } from '../tuturuuu-wordmark';
3
+
4
+ describe('toProductName', () => {
5
+ it('strips the company name from a full app name', () => {
6
+ expect(toProductName('Tuturuuu Forms')).toBe('Forms');
7
+ expect(toProductName('Tuturuuu Calendar')).toBe('Calendar');
8
+ });
9
+
10
+ it('is case-insensitive and tolerates extra spacing', () => {
11
+ expect(toProductName('TUTURUUU Forms')).toBe('Forms');
12
+ });
13
+
14
+ it('leaves a bare product name alone', () => {
15
+ expect(toProductName('Forms')).toBe('Forms');
16
+ });
17
+
18
+ it('returns the name unchanged when stripping would empty it', () => {
19
+ // Otherwise the lockup renders with a blank second line.
20
+ expect(toProductName('Tuturuuu')).toBe('Tuturuuu');
21
+ });
22
+ });
@@ -0,0 +1,72 @@
1
+ import { cn } from '@tuturuuu/utils/format';
2
+ import { TuturuuLogo } from './tuturuuu-logo';
3
+
4
+ /**
5
+ * The Tuturuuu product lockup: mark, then "Tuturuuu" over the product name.
6
+ *
7
+ * Two lines rather than one string. "Tuturuuu Forms" set on a single line reads
8
+ * as one long product name; stacking it says what it is — a Tuturuuu product
9
+ * called Forms — and keeps the company name consistent across products while
10
+ * only the second line changes.
11
+ *
12
+ * The logo deliberately does not take a local `src`. Satellites are served from
13
+ * their own domains and do not carry `/media`, so a local path 404s there; the
14
+ * component's default is the absolute URL for exactly that reason.
15
+ */
16
+ export function TuturuuuWordmark({
17
+ className,
18
+ product,
19
+ size = 'md',
20
+ }: {
21
+ className?: string;
22
+ /** The second line — the product name, without "Tuturuuu" in front of it. */
23
+ product: string;
24
+ size?: 'sm' | 'md';
25
+ }) {
26
+ const dimension = size === 'sm' ? 28 : 32;
27
+
28
+ return (
29
+ <span className={cn('flex items-center gap-2.5', className)}>
30
+ <TuturuuLogo
31
+ alt=""
32
+ aria-hidden
33
+ className={cn('object-contain', size === 'sm' ? 'h-7 w-7' : 'h-8 w-8')}
34
+ height={dimension}
35
+ width={dimension}
36
+ />
37
+ <span className="flex flex-col text-left leading-none">
38
+ <span
39
+ className={cn(
40
+ 'uppercase tracking-[0.24em] opacity-70',
41
+ size === 'sm' ? 'text-[9px]' : 'text-[10px]'
42
+ )}
43
+ >
44
+ Tuturuuu
45
+ </span>
46
+ <span
47
+ className={cn(
48
+ 'font-display font-semibold uppercase tracking-[0.08em]',
49
+ size === 'sm' ? 'text-sm' : 'text-lg'
50
+ )}
51
+ >
52
+ {product}
53
+ </span>
54
+ </span>
55
+ </span>
56
+ );
57
+ }
58
+
59
+ /**
60
+ * The product line of a full app name: "Tuturuuu Forms" -> "Forms".
61
+ *
62
+ * Exists so the nav and the footer cannot disagree. They derive the same label
63
+ * from the same prop, and two copies of one regex is exactly the kind of
64
+ * duplication that drifts when only one of them is updated.
65
+ *
66
+ * A name that is only "Tuturuuu" is returned unchanged rather than reduced to
67
+ * an empty string, which would render a lockup with a blank second line.
68
+ */
69
+ export function toProductName(appName: string): string {
70
+ const stripped = appName.replace(/^Tuturuuu\s+/i, '').trim();
71
+ return stripped || appName;
72
+ }
@@ -30,15 +30,28 @@ const testState = vi.hoisted(() => {
30
30
 
31
31
  return {
32
32
  InvoicePaymentSettings: vi.fn(),
33
+ SubscriptionAttendanceSummary: vi.fn(),
33
34
  categories: [] as Array<{ id: string; name: string }>,
34
35
  groupIds: ['group-1'],
35
36
  month: '2026-07',
37
+ setMonth: vi.fn(),
36
38
  productSelectionInjected: false,
37
39
  products: [product],
38
40
  selectedProducts: [{ inventory, product, quantity: 1 }],
39
41
  useCategories: vi.fn(),
40
42
  useWallets: vi.fn(),
41
43
  userId: 'user-1',
44
+ userGroups: [
45
+ {
46
+ workspace_user_groups: {
47
+ ending_date: null as string | null,
48
+ id: 'group-1',
49
+ name: 'Group 1',
50
+ sessions: ['2026-07-05'],
51
+ starting_date: '2026-07-01',
52
+ },
53
+ },
54
+ ],
42
55
  wallets: [] as Array<{
43
56
  currency: string;
44
57
  id: string;
@@ -67,7 +80,7 @@ vi.mock('nuqs', () => ({
67
80
  useQueryState: (key: string, options?: { defaultValue?: unknown }) => {
68
81
  if (key === 'user_id') return [testState.userId, vi.fn()];
69
82
  if (key === 'group_ids') return [testState.groupIds, vi.fn()];
70
- if (key === 'month') return [testState.month, vi.fn()];
83
+ if (key === 'month') return [testState.month, testState.setMonth];
71
84
  return [options?.defaultValue ?? '', vi.fn()];
72
85
  },
73
86
  }));
@@ -113,7 +126,10 @@ vi.mock('./components/invoice-products-permission-warning', () => ({
113
126
  }));
114
127
 
115
128
  vi.mock('./components/subscription-attendance-summary', () => ({
116
- SubscriptionAttendanceSummary: () => null,
129
+ SubscriptionAttendanceSummary: (props: unknown) => {
130
+ testState.SubscriptionAttendanceSummary(props);
131
+ return null;
132
+ },
117
133
  }));
118
134
 
119
135
  vi.mock('./components/subscription-group-selector', () => ({
@@ -178,17 +194,7 @@ vi.mock('./hooks', () => ({
178
194
  isLoading: false,
179
195
  }),
180
196
  useUserGroups: () => ({
181
- data: [
182
- {
183
- workspace_user_groups: {
184
- ending_date: null,
185
- id: 'group-1',
186
- name: 'Group 1',
187
- sessions: ['2026-07-05'],
188
- starting_date: '2026-07-01',
189
- },
190
- },
191
- ],
197
+ data: testState.userGroups,
192
198
  isLoading: false,
193
199
  }),
194
200
  useUserLinkedPromotions: () => ({ data: [] }),
@@ -262,9 +268,14 @@ function renderSubscriptionInvoice(
262
268
  describe('SubscriptionInvoice checkout defaults', () => {
263
269
  beforeEach(() => {
264
270
  testState.InvoicePaymentSettings.mockClear();
271
+ testState.SubscriptionAttendanceSummary.mockClear();
265
272
  testState.useCategories.mockClear();
266
273
  testState.useWallets.mockClear();
267
274
  testState.productSelectionInjected = false;
275
+ testState.month = '2026-07';
276
+ testState.setMonth.mockClear();
277
+ testState.userGroups[0]!.workspace_user_groups.ending_date = null;
278
+ testState.userGroups[0]!.workspace_user_groups.sessions = ['2026-07-05'];
268
279
  testState.products[0]!.finance_category_id = null;
269
280
  testState.wallets = [];
270
281
  testState.categories = [];
@@ -328,4 +339,29 @@ describe('SubscriptionInvoice checkout defaults', () => {
328
339
  )
329
340
  );
330
341
  });
342
+
343
+ it('keeps an advance month selected after the group schedule end', async () => {
344
+ testState.month = '2026-09';
345
+ testState.userGroups[0]!.workspace_user_groups.ending_date = '2026-08-31';
346
+ testState.userGroups[0]!.workspace_user_groups.sessions = [
347
+ '2026-07-05',
348
+ '2026-08-05',
349
+ '2026-09-05',
350
+ ];
351
+
352
+ renderSubscriptionInvoice();
353
+
354
+ await waitFor(() =>
355
+ expect(testState.SubscriptionAttendanceSummary).toHaveBeenCalledWith(
356
+ expect.objectContaining({
357
+ availableMonths: expect.arrayContaining([
358
+ expect.objectContaining({ value: '2026-09' }),
359
+ ]),
360
+ selectedMonth: '2026-09',
361
+ })
362
+ )
363
+ );
364
+ expect(testState.setMonth).not.toHaveBeenCalledWith('2026-07');
365
+ expect(testState.setMonth).not.toHaveBeenCalledWith('2026-08');
366
+ });
331
367
  });
@@ -728,30 +728,22 @@ export function SubscriptionInvoice({
728
728
  }
729
729
  lastMonthSyncRef.current = null;
730
730
 
731
- const { earliestStart, latestEnd } = getGroupsDateRange(
732
- userGroups,
733
- selectedGroupIds
734
- );
731
+ const { earliestStart } = getGroupsDateRange(userGroups, selectedGroupIds);
735
732
 
736
- if (!earliestStart || !latestEnd) return;
733
+ if (!earliestStart) return;
737
734
 
738
735
  const currentMonth = getMonthStartDate(selectedMonth);
739
736
  if (Number.isNaN(currentMonth.getTime())) return;
740
737
 
741
738
  const earliestMonthStart = getMonthStartDate(earliestStart);
742
- const latestMonthStart = getMonthStartDate(latestEnd);
743
739
 
744
- if (currentMonth < earliestMonthStart || currentMonth > latestMonthStart) {
740
+ if (currentMonth < earliestMonthStart) {
745
741
  // Only correct once per groups-selection to avoid update loops
746
742
  if (lastValidatedKeyRef.current === monthValidationKey) return;
747
743
  lastValidatedKeyRef.current = monthValidationKey;
748
744
 
749
745
  const now = getCurrentBillingDate(workspaceTimezone);
750
- let defaultMonth: Date;
751
-
752
- if (now >= earliestStart && now <= latestEnd) defaultMonth = now;
753
- else if (now > latestEnd) defaultMonth = latestEnd;
754
- else defaultMonth = earliestStart;
746
+ const defaultMonth = now >= earliestStart ? now : earliestStart;
755
747
 
756
748
  const nextMonth = formatMonthValue(defaultMonth);
757
749
  if (nextMonth !== selectedMonth) {
@@ -233,7 +233,7 @@ describe('subscription invoice coverage', () => {
233
233
  ).toBe(true);
234
234
  });
235
235
 
236
- it('generates prepaid future month options capped by group ending dates', () => {
236
+ it('keeps future billing months available after a group ending date', () => {
237
237
  const groups = [
238
238
  {
239
239
  workspace_user_groups: {
@@ -247,10 +247,11 @@ describe('subscription invoice coverage', () => {
247
247
  ] satisfies UserGroup[];
248
248
 
249
249
  expect(
250
- getAvailableMonths(groups, [groupId], [], 'en-US', '2026-06', 12).map(
251
- (month) => month.value
252
- )
253
- ).toEqual(['2026-06', '2026-07', '2026-08']);
250
+ getAvailableMonths(groups, [groupId], [], 'en-US', '2026-06', 3, {
251
+ now: new Date(2026, 5, 15),
252
+ workspaceTimezone: 'UTC',
253
+ }).map((month) => month.value)
254
+ ).toEqual(['2026-06', '2026-07', '2026-08', '2026-09']);
254
255
  });
255
256
 
256
257
  it('skips paid months and uses scheduled sessions for future prepaid quantities', () => {
@@ -524,7 +524,9 @@ export const getAvailableMonths = (
524
524
  ? getMonthStartDate(selectedMonthFallback)
525
525
  : currentMonthStart;
526
526
  const resolvedRangeEnd =
527
- futureMonthHorizon > 0 && !latestEnd ? futureHorizonEnd : resolvedLatestEnd;
527
+ futureMonthHorizon > 0 && futureHorizonEnd > resolvedLatestEnd
528
+ ? futureHorizonEnd
529
+ : resolvedLatestEnd;
528
530
 
529
531
  const months: AvailableMonthOption[] = [];
530
532
  const currentDate = new Date(earliestStart);
@@ -1,6 +1,7 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
2
  import type { ColumnDef } from '@tuturuuu/ui/custom/tables/data-table';
3
- import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { renderToString } from 'react-dom/server';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import { transactionColumns } from './columns';
5
6
 
6
7
  vi.mock('next-intl', () => ({
@@ -82,6 +83,10 @@ describe('transactionColumns', () => {
82
83
  'finance-confidential-mode=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;Secure';
83
84
  });
84
85
 
86
+ afterEach(() => {
87
+ vi.useRealTimers();
88
+ });
89
+
85
90
  it('masks the amount column when finance numbers are globally hidden', () => {
86
91
  render(<TransactionAmountCell amount={123} />);
87
92
 
@@ -116,4 +121,23 @@ describe('transactionColumns', () => {
116
121
  expect(screen.getByText('Cash')).toBeVisible();
117
122
  expect(screen.getByText('Food & Beverage')).toBeVisible();
118
123
  });
124
+
125
+ it('keeps server-rendered transaction dates stable as time advances', () => {
126
+ vi.useFakeTimers();
127
+ vi.setSystemTime('2026-08-31T00:59:00.000Z');
128
+
129
+ const transaction = {
130
+ taken_at: '2026-08-31T00:00:00.000Z',
131
+ };
132
+ const firstRender = renderToString(
133
+ <TransactionColumnCell accessorKey="taken_at" original={transaction} />
134
+ );
135
+
136
+ vi.setSystemTime('2026-08-31T02:01:00.000Z');
137
+ const laterRender = renderToString(
138
+ <TransactionColumnCell accessorKey="taken_at" original={transaction} />
139
+ );
140
+
141
+ expect(laterRender).toBe(firstRender);
142
+ });
119
143
  });
@@ -12,6 +12,7 @@ import { formatCurrency } from '@tuturuuu/utils/format';
12
12
  import moment from 'moment';
13
13
  import 'moment/locale/vi';
14
14
  import { useLocale } from 'next-intl';
15
+ import { useSyncExternalStore } from 'react';
15
16
  import { DataTableColumnHeader } from '../../custom/tables/data-table-column-header';
16
17
  import {
17
18
  FINANCE_HIDDEN_AMOUNT,
@@ -27,6 +28,44 @@ interface TransactionExtraData {
27
28
  isPersonalWorkspace?: boolean;
28
29
  }
29
30
 
31
+ const subscribeToHydration = () => () => undefined;
32
+ const getHydratedSnapshot = () => true;
33
+ const getServerSnapshot = () => false;
34
+
35
+ function TransactionDateCell({
36
+ includeTime = false,
37
+ locale,
38
+ value,
39
+ }: {
40
+ includeTime?: boolean;
41
+ locale: string;
42
+ value: unknown;
43
+ }) {
44
+ const isHydrated = useSyncExternalStore(
45
+ subscribeToHydration,
46
+ getHydratedSnapshot,
47
+ getServerSnapshot
48
+ );
49
+
50
+ if (!value) return <div className="min-w-32">-</div>;
51
+
52
+ const localDate = moment(value);
53
+ if (!localDate.isValid()) return <div className="min-w-32">-</div>;
54
+
55
+ const format = includeTime ? 'DD/MM/YYYY, HH:mm:ss' : 'DD/MM/YYYY';
56
+ const absoluteDate = isHydrated
57
+ ? localDate.locale(locale).format(format)
58
+ : moment.utc(value).locale(locale).format(format);
59
+
60
+ return (
61
+ <div className="min-w-32">
62
+ {isHydrated && !includeTime
63
+ ? `${localDate.locale(locale).fromNow()}, ${absoluteDate}`
64
+ : absoluteDate}
65
+ </div>
66
+ );
67
+ }
68
+
30
69
  function TransactionAmountCell({
31
70
  amount,
32
71
  currency,
@@ -265,15 +304,7 @@ export const transactionColumns = ({
265
304
  />
266
305
  ),
267
306
  cell: ({ row }) => (
268
- <div className="min-w-32">
269
- {row.getValue('taken_at')
270
- ? `${moment(row.getValue('taken_at')).locale(locale).fromNow()}, ${moment(
271
- row.getValue('taken_at')
272
- )
273
- .locale(locale)
274
- .format('DD/MM/YYYY')}`
275
- : '-'}
276
- </div>
307
+ <TransactionDateCell locale={locale} value={row.getValue('taken_at')} />
277
308
  ),
278
309
  },
279
310
  {
@@ -286,13 +317,11 @@ export const transactionColumns = ({
286
317
  />
287
318
  ),
288
319
  cell: ({ row }) => (
289
- <div className="min-w-32">
290
- {row.getValue('created_at')
291
- ? moment(row.getValue('created_at'))
292
- .locale(locale)
293
- .format('DD/MM/YYYY, HH:mm:ss')
294
- : '-'}
295
- </div>
320
+ <TransactionDateCell
321
+ includeTime
322
+ locale={locale}
323
+ value={row.getValue('created_at')}
324
+ />
296
325
  ),
297
326
  },
298
327
  {
@@ -1,6 +1,6 @@
1
1
  import { cn } from '@tuturuuu/utils/format';
2
2
  import type { ReactNode } from 'react';
3
- import { TuturuuLogo } from '../custom/tuturuuu-logo';
3
+ import { TuturuuuWordmark, toProductName } from '../custom/tuturuuu-wordmark';
4
4
 
5
5
  export interface MarketingFooterLink {
6
6
  href: string;
@@ -16,6 +16,8 @@ export interface MarketingFooterColumn {
16
16
 
17
17
  interface MarketingFooterProps {
18
18
  appName: string;
19
+ /** Product line of the lockup; defaults to `appName` minus "Tuturuuu ". */
20
+ productName?: string;
19
21
  /** One-line positioning statement under the wordmark. */
20
22
  tagline: string;
21
23
  columns: MarketingFooterColumn[];
@@ -29,6 +31,7 @@ interface MarketingFooterProps {
29
31
  /** Shared satellite marketing footer: wordmark column plus link columns. */
30
32
  export function MarketingFooter({
31
33
  appName,
34
+ productName,
32
35
  tagline,
33
36
  columns,
34
37
  legal,
@@ -44,12 +47,10 @@ export function MarketingFooter({
44
47
  >
45
48
  <div className="mx-auto grid w-full max-w-6xl gap-12 lg:grid-cols-[1.5fr_repeat(3,1fr)]">
46
49
  <div className="max-w-sm">
47
- <div className="flex items-center gap-2.5">
48
- <TuturuuLogo className="h-7 w-7" height={28} width={28} />
49
- <span className="font-display font-semibold text-[0.95rem] tracking-[-0.01em]">
50
- {appName}
51
- </span>
52
- </div>
50
+ <TuturuuuWordmark
51
+ product={productName ?? toProductName(appName)}
52
+ size="sm"
53
+ />
53
54
  <p className="mt-4 text-foreground/50 text-sm leading-relaxed">
54
55
  {tagline}
55
56
  </p>
@@ -3,7 +3,7 @@
3
3
  import { Menu, X } from '@tuturuuu/icons';
4
4
  import { cn } from '@tuturuuu/utils/format';
5
5
  import { type ReactNode, useEffect, useState } from 'react';
6
- import { TuturuuLogo } from '../custom/tuturuuu-logo';
6
+ import { TuturuuuWordmark, toProductName } from '../custom/tuturuuu-wordmark';
7
7
 
8
8
  export interface MarketingNavLink {
9
9
  /** In-page anchor (`#pricing`) or absolute URL. */
@@ -12,8 +12,17 @@ export interface MarketingNavLink {
12
12
  }
13
13
 
14
14
  interface MarketingNavProps {
15
- /** App name shown next to the logo, e.g. "Forms". */
15
+ /**
16
+ * The full app name, e.g. "Tuturuuu Forms". Used for the accessible landmark
17
+ * label, and as the fallback source for `productName`.
18
+ */
16
19
  appName: string;
20
+ /**
21
+ * The product line of the lockup — "Forms", not "Tuturuuu Forms". Defaults
22
+ * to `appName` with a leading "Tuturuuu " stripped, so callers that already
23
+ * pass the full name keep working.
24
+ */
25
+ productName?: string;
17
26
  /** Where the wordmark links back to. Defaults to the landing root. */
18
27
  homeHref?: string;
19
28
  links: MarketingNavLink[];
@@ -38,6 +47,7 @@ interface MarketingNavProps {
38
47
  */
39
48
  export function MarketingNav({
40
49
  appName,
50
+ productName,
41
51
  homeHref = '/',
42
52
  links,
43
53
  action,
@@ -79,13 +89,13 @@ export function MarketingNav({
79
89
  className="mx-auto flex h-16 w-full max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8"
80
90
  >
81
91
  <a
82
- className="flex shrink-0 items-center gap-2.5 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
92
+ className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
83
93
  href={homeHref}
84
94
  >
85
- <TuturuuLogo className="h-7 w-7" height={28} width={28} />
86
- <span className="font-display font-semibold text-[0.95rem] tracking-[-0.01em]">
87
- {appName}
88
- </span>
95
+ <TuturuuuWordmark
96
+ product={productName ?? toProductName(appName)}
97
+ size="sm"
98
+ />
89
99
  </a>
90
100
 
91
101
  <div className="hidden items-center gap-1 md:flex">
@@ -0,0 +1,85 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import type { NodeViewProps } from '@tiptap/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { TaskItemCheckboxContent } from './task-item-checkbox-view';
5
+
6
+ vi.mock('@tuturuuu/supabase/next/client', () => ({
7
+ createClient: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('./task-item-checkbox', async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import('./task-item-checkbox')>();
12
+
13
+ return {
14
+ ...actual,
15
+ useMentionedTaskStatuses: () => ({ data: [] }),
16
+ };
17
+ });
18
+
19
+ function createNodeViewProps() {
20
+ const setNodeMarkup = vi.fn();
21
+ const command = vi.fn(
22
+ (
23
+ callback: (props: {
24
+ tr: { setNodeMarkup: typeof setNodeMarkup };
25
+ }) => boolean
26
+ ) => callback({ tr: { setNodeMarkup } })
27
+ );
28
+
29
+ const props = {
30
+ node: {
31
+ attrs: { checked: false },
32
+ descendants: vi.fn(),
33
+ },
34
+ getPos: () => 4,
35
+ editor: {
36
+ isEditable: true,
37
+ commands: { command },
38
+ },
39
+ } as unknown as NodeViewProps;
40
+
41
+ return { command, props, setNodeMarkup };
42
+ }
43
+
44
+ describe('TaskItemCheckboxContent', () => {
45
+ it('cycles pointer interactions before the editor can replace the node view', () => {
46
+ const { props, setNodeMarkup } = createNodeViewProps();
47
+ render(<TaskItemCheckboxContent {...props} />);
48
+
49
+ const checkbox = screen.getByRole('checkbox', {
50
+ name: 'Task item status',
51
+ });
52
+ expect(checkbox).toHaveAttribute('aria-checked', 'false');
53
+
54
+ fireEvent.pointerDown(checkbox, { button: 0, isPrimary: true });
55
+
56
+ expect(setNodeMarkup).toHaveBeenLastCalledWith(4, undefined, {
57
+ checked: 'indeterminate',
58
+ });
59
+ expect(checkbox).toHaveAttribute('aria-checked', 'mixed');
60
+ });
61
+
62
+ it('cycles all three states from the keyboard', () => {
63
+ const { props, setNodeMarkup } = createNodeViewProps();
64
+ render(<TaskItemCheckboxContent {...props} />);
65
+
66
+ const checkbox = screen.getByRole('checkbox', {
67
+ name: 'Task item status',
68
+ });
69
+
70
+ fireEvent.keyDown(checkbox, { key: ' ' });
71
+ fireEvent.keyDown(checkbox, { key: 'Enter' });
72
+ fireEvent.keyDown(checkbox, { key: ' ' });
73
+
74
+ expect(setNodeMarkup).toHaveBeenNthCalledWith(1, 4, undefined, {
75
+ checked: 'indeterminate',
76
+ });
77
+ expect(setNodeMarkup).toHaveBeenNthCalledWith(2, 4, undefined, {
78
+ checked: true,
79
+ });
80
+ expect(setNodeMarkup).toHaveBeenNthCalledWith(3, 4, undefined, {
81
+ checked: false,
82
+ });
83
+ expect(checkbox).toHaveAttribute('aria-checked', 'false');
84
+ });
85
+ });
@@ -68,14 +68,24 @@ export function TaskItemCheckboxContent({
68
68
  });
69
69
  }, [editor, getPos, node.attrs, checkboxState]);
70
70
 
71
- const handleCheckboxClick = useCallback(
72
- (event: React.MouseEvent<HTMLButtonElement>) => {
71
+ const handleCheckboxPointerDown = useCallback(
72
+ (event: React.PointerEvent<HTMLButtonElement>) => {
73
+ if (!event.isPrimary || event.button !== 0) return;
73
74
  event.preventDefault();
74
75
  event.stopPropagation();
75
- if (!editor.isEditable) return;
76
76
  handleCheckboxCycle();
77
77
  },
78
- [editor, handleCheckboxCycle]
78
+ [handleCheckboxCycle]
79
+ );
80
+
81
+ const handleCheckboxKeyDown = useCallback(
82
+ (event: React.KeyboardEvent<HTMLButtonElement>) => {
83
+ if (event.key !== 'Enter' && event.key !== ' ') return;
84
+ event.preventDefault();
85
+ event.stopPropagation();
86
+ handleCheckboxCycle();
87
+ },
88
+ [handleCheckboxCycle]
79
89
  );
80
90
 
81
91
  return (
@@ -84,29 +94,29 @@ export function TaskItemCheckboxContent({
84
94
  className="task-list-checkbox-label flex shrink-0 select-none pt-2.5"
85
95
  contentEditable={false}
86
96
  >
97
+ {/* biome-ignore lint/a11y/useSemanticElements: a button avoids native checkbox state changes while ProseMirror owns the tri-state document update */}
87
98
  <button
88
99
  type="button"
89
- aria-label={
90
- isChecked
91
- ? 'Checked'
92
- : isIndeterminate
93
- ? 'Indeterminate'
94
- : 'Unchecked'
95
- }
100
+ role="checkbox"
101
+ aria-label="Task item status"
102
+ aria-checked={isIndeterminate ? 'mixed' : isChecked}
96
103
  aria-disabled={!editor.isEditable}
97
- onClick={handleCheckboxClick}
104
+ onPointerDown={handleCheckboxPointerDown}
105
+ onKeyDown={handleCheckboxKeyDown}
98
106
  className={cn(
99
- 'task-list-checkbox flex h-4.5 w-4.5 items-center justify-center',
100
- 'cursor-pointer rounded-lg border-2 bg-background',
101
- 'transition-all duration-150',
107
+ 'task-list-checkbox flex size-5 items-center justify-center',
108
+ 'cursor-pointer rounded-md border-2 bg-background',
109
+ 'transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:scale-95',
110
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2',
111
+ !editor.isEditable && 'cursor-default opacity-70',
102
112
  !completedTaskColor && [
103
113
  'border-input',
104
114
  'hover:scale-105 hover:border-dynamic-gray hover:bg-dynamic-gray/10',
105
- 'focus:border-dynamic-gray focus:outline-none focus:ring-2 focus:ring-dynamic-gray/30 focus:ring-offset-2',
115
+ 'focus-visible:border-dynamic-gray',
106
116
  isChecked &&
107
- 'border-dynamic-green bg-dynamic-green/20 hover:border-dynamic-green hover:bg-dynamic-green/10 focus:border-dynamic-green focus:ring-dynamic-green/30',
117
+ 'border-dynamic-green bg-dynamic-green/20 hover:border-dynamic-green hover:bg-dynamic-green/10 focus-visible:border-dynamic-green focus-visible:ring-dynamic-green/30',
108
118
  isIndeterminate &&
109
- 'border-dynamic-orange bg-dynamic-orange/20 hover:border-dynamic-orange hover:bg-dynamic-orange/10 focus:border-dynamic-orange focus:ring-dynamic-orange/30',
119
+ 'border-dynamic-orange bg-dynamic-orange/20 hover:border-dynamic-orange hover:bg-dynamic-orange/10 focus-visible:border-dynamic-orange focus-visible:ring-dynamic-orange/30',
110
120
  ],
111
121
  completedTaskColor === 'red' && [
112
122
  'border-dynamic-red/50',