@tuturuuu/ui 0.32.0 → 0.34.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 (104) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  23. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  24. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  25. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  26. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  27. package/src/components/ui/custom/report-preview.tsx +2 -3
  28. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  29. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  30. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  31. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  32. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  33. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  34. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  35. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  36. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  37. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  38. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  39. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  40. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  41. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  42. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  43. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  44. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  45. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  46. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  47. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  48. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  49. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  50. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  51. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  52. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  53. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  54. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  55. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  56. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  57. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  58. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  59. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  60. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  61. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  62. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  63. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  64. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  65. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  66. package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
  67. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  68. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  69. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  70. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  71. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  72. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  73. package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
  74. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  75. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  76. package/src/components/ui/finance/invoices/utils.ts +48 -106
  77. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  78. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  79. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  80. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  81. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  82. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  83. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  84. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  85. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  86. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  87. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  88. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  89. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
  90. package/src/components/ui/public-workspace-prices.tsx +13 -0
  91. package/src/components/ui/pwa-status.test.tsx +13 -2
  92. package/src/components/ui/pwa-status.tsx +15 -3
  93. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  94. package/src/components/ui/text-editor/editor.tsx +11 -3
  95. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  96. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  97. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  98. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  99. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  100. package/src/hooks/calendar-provider-sync.ts +33 -0
  101. package/src/hooks/notification-subscription.ts +74 -0
  102. package/src/hooks/use-calendar-sync.tsx +11 -14
  103. package/src/hooks/use-calendar.tsx +2 -2
  104. package/src/hooks/use-notifications.ts +5 -141
@@ -0,0 +1,224 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Check,
5
+ FlaskConical,
6
+ ListFilter,
7
+ SearchX,
8
+ SlidersHorizontal,
9
+ } from '@tuturuuu/icons/lucide';
10
+ import {
11
+ COMPARISON_FEATURES,
12
+ COMPARISON_TIERS,
13
+ type ComparisonTier,
14
+ } from '@tuturuuu/utils/commercial-comparison';
15
+ import { useId, useState } from 'react';
16
+ import { Button } from './button';
17
+ import { ComparisonTable } from './commercial-comparison-table';
18
+ import { ComparisonToolbar } from './commercial-comparison-toolbar';
19
+
20
+ export interface ComparisonCopy {
21
+ title: string;
22
+ description: string;
23
+ search: string;
24
+ allApps: string;
25
+ allCategories: string;
26
+ category: string;
27
+ app: string;
28
+ differences: string;
29
+ internal: string;
30
+ details: string;
31
+ reset: string;
32
+ empty: string;
33
+ results: string;
34
+ feature: string;
35
+ tiers: string;
36
+ pending: string;
37
+ included: string;
38
+ excluded: string;
39
+ preview: string;
40
+ contract: string;
41
+ seats: string;
42
+ internalValue: string;
43
+ features: Record<string, string>;
44
+ apps: Record<string, string>;
45
+ explanations: Record<string, string>;
46
+ categories: Record<string, string>;
47
+ tierNames: Record<ComparisonTier, string>;
48
+ }
49
+
50
+ export function CommercialComparison({ copy }: { copy: ComparisonCopy }) {
51
+ const id = useId();
52
+ const [query, setQuery] = useState('');
53
+ const [app, setApp] = useState('all');
54
+ const [category, setCategory] = useState('all');
55
+ const [differences, setDifferences] = useState(false);
56
+ const [internal, setInternal] = useState(false);
57
+ const [details, setDetails] = useState(false);
58
+ const [tiers, setTiers] = useState<ComparisonTier[]>(COMPARISON_TIERS);
59
+ const normalized = query
60
+ .normalize('NFD')
61
+ .replace(/\p{M}/gu, '')
62
+ .toLowerCase()
63
+ .replace(/đ/g, 'd')
64
+ .trim();
65
+ const visible = COMPARISON_FEATURES.filter((row) => {
66
+ if (!internal && row.category === 'internal') return false;
67
+ if (app !== 'all' && row.app !== app) return false;
68
+ if (category !== 'all' && row.category !== category) return false;
69
+ if (
70
+ differences &&
71
+ new Set(tiers.map((tier) => row.values[COMPARISON_TIERS.indexOf(tier)]))
72
+ .size < 2
73
+ )
74
+ return false;
75
+ const text = [
76
+ copy.features[row.id],
77
+ copy.apps[row.app],
78
+ copy.categories[row.category],
79
+ copy.explanations[row.detail],
80
+ ...row.values.map(valueLabel),
81
+ ]
82
+ .join(' ')
83
+ .normalize('NFD')
84
+ .replace(/\p{M}/gu, '')
85
+ .toLowerCase()
86
+ .replace(/đ/g, 'd');
87
+ return normalized.split(/\s+/).every((word) => text.includes(word));
88
+ });
89
+ const appIds = [
90
+ ...new Set(
91
+ COMPARISON_FEATURES.filter(
92
+ (row) => internal || row.category !== 'internal'
93
+ ).map((row) => row.app)
94
+ ),
95
+ ];
96
+ function reset() {
97
+ setQuery('');
98
+ setApp('all');
99
+ setCategory('all');
100
+ setDifferences(false);
101
+ setDetails(false);
102
+ setInternal(false);
103
+ setTiers(COMPARISON_TIERS);
104
+ }
105
+ function toggleTier(tier: ComparisonTier) {
106
+ setTiers((current) =>
107
+ current.includes(tier)
108
+ ? current.length > 1
109
+ ? current.filter((item) => item !== tier)
110
+ : current
111
+ : COMPARISON_TIERS.filter(
112
+ (item) => item === tier || current.includes(item)
113
+ )
114
+ );
115
+ }
116
+ function valueLabel(value: string) {
117
+ if (value === 'internal') return copy.internalValue;
118
+ if (
119
+ value === 'included' ||
120
+ value === 'excluded' ||
121
+ value === 'preview' ||
122
+ value === 'contract' ||
123
+ value === 'seats'
124
+ )
125
+ return copy[value];
126
+ return value;
127
+ }
128
+ return (
129
+ <section
130
+ aria-labelledby={`${id}-title`}
131
+ className="relative mt-16 min-w-0 scroll-mt-28 space-y-6 sm:mt-24"
132
+ id="compare-plans"
133
+ >
134
+ <div
135
+ className="pointer-events-none absolute -top-8 right-0 h-64 w-2/3 rounded-full bg-dynamic-blue/[0.04] blur-3xl"
136
+ aria-hidden
137
+ />
138
+ <div className="relative flex flex-wrap items-end justify-between gap-6 pb-3">
139
+ <div className="max-w-3xl space-y-4">
140
+ <span className="inline-flex items-center gap-2 font-mono-ui text-[0.65rem] text-dynamic-blue uppercase tracking-[0.18em]">
141
+ <SlidersHorizontal aria-hidden className="size-3.5" />
142
+ {copy.tiers}
143
+ </span>
144
+ <h3
145
+ className="text-balance font-display font-semibold text-3xl leading-[1.1] tracking-[-0.035em] sm:text-4xl lg:text-5xl"
146
+ id={`${id}-title`}
147
+ >
148
+ {copy.title}
149
+ </h3>
150
+ <p className="max-w-xl text-foreground/55 text-sm leading-relaxed sm:text-base">
151
+ {copy.description}
152
+ </p>
153
+ </div>
154
+ </div>
155
+ <ComparisonToolbar
156
+ copy={copy}
157
+ query={query}
158
+ app={app}
159
+ category={category}
160
+ differences={differences}
161
+ internal={internal}
162
+ details={details}
163
+ tiers={tiers}
164
+ appIds={appIds}
165
+ onQuery={setQuery}
166
+ onApp={setApp}
167
+ onCategory={setCategory}
168
+ onDifferences={setDifferences}
169
+ onDetails={setDetails}
170
+ onTier={toggleTier}
171
+ onReset={reset}
172
+ onInternal={(value) => {
173
+ setInternal(value);
174
+ setApp('all');
175
+ setCategory('all');
176
+ }}
177
+ />
178
+ <div className="flex flex-wrap items-center justify-between gap-3 px-1">
179
+ <p
180
+ aria-live="polite"
181
+ className="inline-flex items-center gap-2 text-foreground/55 text-xs"
182
+ >
183
+ <ListFilter aria-hidden className="size-3.5 text-dynamic-blue" />
184
+ {visible.length} {copy.results}
185
+ </p>
186
+ <div className="flex flex-wrap items-center gap-4 text-[0.65rem] text-foreground/50">
187
+ <span className="inline-flex items-center gap-1.5">
188
+ <Check aria-hidden className="size-3.5 text-dynamic-green" />
189
+ {copy.included}
190
+ </span>
191
+ <span className="inline-flex items-center gap-1.5">
192
+ <FlaskConical
193
+ aria-hidden
194
+ className="size-3.5 text-dynamic-orange"
195
+ />
196
+ {copy.preview}
197
+ </span>
198
+ </div>
199
+ </div>
200
+ {visible.length === 0 ? (
201
+ <div className="flex flex-col items-center rounded-2xl border border-foreground/10 border-dashed bg-foreground/[0.02] px-6 py-16 text-center">
202
+ <SearchX aria-hidden className="mb-4 size-8 text-foreground/30" />
203
+ <p className="text-foreground/65 text-sm">{copy.empty}</p>
204
+ <Button
205
+ variant="outline"
206
+ className="mt-5 rounded-full"
207
+ onClick={reset}
208
+ >
209
+ {copy.reset}
210
+ </Button>
211
+ </div>
212
+ ) : (
213
+ <ComparisonTable
214
+ copy={copy}
215
+ visible={visible}
216
+ tiers={tiers}
217
+ appIds={appIds}
218
+ details={details}
219
+ valueLabel={valueLabel}
220
+ />
221
+ )}
222
+ </section>
223
+ );
224
+ }
@@ -0,0 +1,32 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { expect, it } from 'vitest';
3
+ import { ComparisonValue } from './comparison-value';
4
+
5
+ it('keeps a status icon compact and reveals its wording and scope on tap', async () => {
6
+ render(
7
+ <ComparisonValue
8
+ value="included"
9
+ label="Included"
10
+ context="Plus: Tasks"
11
+ explanation="Within your workspace limits"
12
+ />
13
+ );
14
+ const button = screen.getByRole('button', { name: 'Plus: Tasks: Included' });
15
+ expect(button.textContent).toBe('');
16
+ fireEvent.click(button);
17
+ await waitFor(() =>
18
+ expect(screen.getByRole('tooltip').textContent).toContain(
19
+ 'Within your workspace limits'
20
+ )
21
+ );
22
+ fireEvent.keyDown(button, { key: 'Escape' });
23
+ await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull());
24
+ });
25
+ it('keeps numeric quotas visible instead of hiding the limit in an icon', () => {
26
+ render(
27
+ <ComparisonValue value="20 GiB" label="20 GiB" context="Plus: Drive" />
28
+ );
29
+ expect(
30
+ screen.getByRole('button', { name: 'Plus: Drive: 20 GiB' }).textContent
31
+ ).toBe('20 GiB');
32
+ });
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Check,
5
+ Clock3,
6
+ FileText,
7
+ FlaskConical,
8
+ Gauge,
9
+ Info,
10
+ Minus,
11
+ Server,
12
+ Users,
13
+ } from '@tuturuuu/icons/lucide';
14
+ import { useState } from 'react';
15
+ import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
16
+
17
+ const statusIcons = {
18
+ info: Info,
19
+ included: Check,
20
+ excluded: Minus,
21
+ preview: FlaskConical,
22
+ contract: FileText,
23
+ seats: Users,
24
+ internal: Server,
25
+ pending: Clock3,
26
+ limited: Gauge,
27
+ };
28
+
29
+ /** Keep quantities visible; status wording and scope stay accessible on focus or tap. */
30
+ export function ComparisonValue({
31
+ value,
32
+ label,
33
+ context,
34
+ explanation,
35
+ }: {
36
+ value: string;
37
+ label: string;
38
+ context: string;
39
+ explanation?: string;
40
+ }) {
41
+ const [open, setOpen] = useState(false);
42
+ const Icon = statusIcons[value as keyof typeof statusIcons];
43
+ return (
44
+ <Tooltip open={open} onOpenChange={setOpen}>
45
+ <TooltipTrigger asChild>
46
+ <button
47
+ type="button"
48
+ aria-label={`${context}: ${label}`}
49
+ className={`inline-flex min-h-8 min-w-8 items-center justify-center rounded-md px-1.5 align-middle transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring ${value === 'excluded' ? 'text-foreground/25' : value === 'included' ? 'bg-dynamic-green/[0.08] text-dynamic-green' : value === 'preview' || value === 'pending' ? 'bg-dynamic-orange/[0.08] text-dynamic-orange' : value === 'info' ? 'text-foreground/35' : 'font-mono-ui text-foreground/80 text-xs tabular-nums'}`}
50
+ onClick={(event) => {
51
+ event.preventDefault();
52
+ setOpen(!open);
53
+ }}
54
+ >
55
+ {Icon ? (
56
+ <Icon
57
+ aria-hidden="true"
58
+ className="size-4"
59
+ strokeWidth={value === 'included' ? 2.5 : 1.75}
60
+ />
61
+ ) : (
62
+ label
63
+ )}
64
+ </button>
65
+ </TooltipTrigger>
66
+ <TooltipContent className="max-w-72 space-y-1 text-left">
67
+ <p className="font-semibold">{label}</p>
68
+ {explanation && <p className="leading-relaxed">{explanation}</p>}
69
+ </TooltipContent>
70
+ </Tooltip>
71
+ );
72
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ paginateReportBlocks,
4
+ parseReportSegments,
5
+ } from '../report-preview-pagination';
6
+
7
+ describe.each([20, 200])(
8
+ 'report pagination progress with later page height %i',
9
+ (laterPageHeight) => {
10
+ it.each([
11
+ 'Short feedback',
12
+ 'Long feedback with spaces. '.repeat(12),
13
+ 'x'.repeat(150),
14
+ '- First bullet\n- Second bullet that also cannot fit',
15
+ ])('finishes and preserves every character: %s', (text) => {
16
+ const segments = parseReportSegments(text, 'feedback');
17
+ let measurements = 0;
18
+ const pages = paginateReportBlocks({
19
+ blocks: [
20
+ {
21
+ key: 'feedback',
22
+ kind: 'rich-text',
23
+ splittable: true,
24
+ title: 'Feedback',
25
+ tone: 'neutral',
26
+ segments,
27
+ },
28
+ ],
29
+ firstPageHeight: 1,
30
+ laterPageHeight,
31
+ pageGap: 16,
32
+ measureBlock: () => {
33
+ // Fail deterministically instead of hanging the test runner if the
34
+ // fallback retries the same unconsumed segment forever.
35
+ if (++measurements > 500)
36
+ throw new Error('Pagination made no progress');
37
+ return 100;
38
+ },
39
+ });
40
+
41
+ const rendered = pages
42
+ .flatMap((page) => page.blocks)
43
+ .flatMap((block) => block.segments);
44
+ for (const source of segments) {
45
+ expect(
46
+ rendered
47
+ .filter((segment) => segment.id === source.id)
48
+ .map((segment) => segment.text)
49
+ .join('')
50
+ ).toBe(source.text);
51
+ }
52
+ expect(pages.length).toBeLessThanOrEqual(text.length);
53
+ });
54
+ }
55
+ );
56
+
57
+ it.each([0, 1, 20, 60, 200])(
58
+ 'preserves mixed report sections with a %i-pixel page budget',
59
+ (pageHeight) => {
60
+ const content = parseReportSegments('Tiến bộ tốt. '.repeat(20), 'content');
61
+ const feedback = parseReportSegments('- Review\n\n- Practice', 'feedback');
62
+ let measurements = 0;
63
+ const pages = paginateReportBlocks({
64
+ blocks: [
65
+ {
66
+ key: 'empty',
67
+ kind: 'rich-text',
68
+ splittable: true,
69
+ title: 'Empty',
70
+ tone: 'neutral',
71
+ segments: [],
72
+ },
73
+ {
74
+ key: 'content',
75
+ kind: 'rich-text',
76
+ splittable: true,
77
+ title: 'Content',
78
+ tone: 'primary',
79
+ segments: content,
80
+ },
81
+ {
82
+ key: 'feedback',
83
+ kind: 'rich-text',
84
+ splittable: true,
85
+ title: 'Feedback',
86
+ tone: 'neutral',
87
+ segments: feedback,
88
+ },
89
+ {
90
+ key: 'closing',
91
+ kind: 'atomic',
92
+ splittable: false,
93
+ title: 'Closing',
94
+ tone: 'accent',
95
+ segments: parseReportSegments('Thank you', 'closing'),
96
+ },
97
+ ],
98
+ firstPageHeight: pageHeight,
99
+ laterPageHeight: pageHeight,
100
+ pageGap: 16,
101
+ measureBlock: ({ segments }) => {
102
+ if (++measurements > 1000)
103
+ throw new Error('Pagination made no progress');
104
+ return (
105
+ 40 +
106
+ segments.reduce((height, segment) => height + segment.text.length, 0)
107
+ );
108
+ },
109
+ });
110
+ const output = pages
111
+ .flatMap((page) => page.blocks)
112
+ .flatMap((block) => block.segments);
113
+ for (const segment of [...content, ...feedback].filter(
114
+ (entry) => entry.kind !== 'spacer'
115
+ )) {
116
+ expect(
117
+ output
118
+ .filter((entry) => entry.id === segment.id)
119
+ .map((entry) => entry.text)
120
+ .join('')
121
+ ).toBe(segment.text);
122
+ }
123
+ expect(
124
+ output
125
+ .filter((segment) => segment.id === 'closing-paragraph-0')
126
+ .map((segment) => segment.text)
127
+ ).toEqual(['Thank you']);
128
+ }
129
+ );
@@ -0,0 +1,56 @@
1
+ import { act, render } from '@testing-library/react';
2
+ import { afterEach, expect, it, vi } from 'vitest';
3
+ import { ReportPreviewViewport } from '../report-preview-viewport';
4
+
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ vi.unstubAllGlobals();
8
+ });
9
+ it('fits the visible sheet when the panel shrinks and never enlarges A4', () => {
10
+ let width = 400;
11
+ let resize = () => {};
12
+ const disconnect = vi.fn();
13
+ vi.stubGlobal(
14
+ 'ResizeObserver',
15
+ class {
16
+ constructor(callback: () => void) {
17
+ resize = callback;
18
+ }
19
+ observe() {}
20
+ disconnect = disconnect;
21
+ }
22
+ );
23
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(
24
+ () => width
25
+ );
26
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800);
27
+ const { container, unmount } = render(
28
+ <ReportPreviewViewport>
29
+ <div>Report</div>
30
+ </ReportPreviewViewport>
31
+ );
32
+ const sheet = container.querySelector('[style]') as HTMLElement;
33
+ expect(sheet.style.zoom).toBe('0.5');
34
+ width = 320;
35
+ act(() => resize());
36
+ expect(sheet.style.zoom).toBe('0.4');
37
+ width = 1000;
38
+ act(() => resize());
39
+ expect(sheet.style.zoom).toBe('1');
40
+ unmount();
41
+ expect(disconnect).toHaveBeenCalledOnce();
42
+ });
43
+
44
+ it('renders and performs the initial fit without ResizeObserver', () => {
45
+ vi.stubGlobal('ResizeObserver', undefined);
46
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(400);
47
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800);
48
+ const { container } = render(
49
+ <ReportPreviewViewport>
50
+ <div>Report</div>
51
+ </ReportPreviewViewport>
52
+ );
53
+ expect((container.querySelector('[style]') as HTMLElement).style.zoom).toBe(
54
+ '0.5'
55
+ );
56
+ });
@@ -0,0 +1,42 @@
1
+ import {
2
+ focusManager,
3
+ QueryClient,
4
+ QueryClientProvider,
5
+ } from '@tanstack/react-query';
6
+ import { render, screen, waitFor } from '@testing-library/react';
7
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
8
+ import { AccountAssuranceRefresh } from './account-assurance-refresh';
9
+
10
+ const getProfile = vi.hoisted(() => vi.fn());
11
+ vi.mock('@tuturuuu/internal-api/users', () => ({
12
+ getCurrentUserProfile: getProfile,
13
+ }));
14
+ vi.mock('next/navigation', () => ({ usePathname: () => '/personal' }));
15
+ beforeEach(() => {
16
+ getProfile.mockReset();
17
+ focusManager.setFocused(true);
18
+ });
19
+ afterEach(() => focusManager.setFocused(undefined));
20
+ function mount() {
21
+ render(
22
+ <QueryClientProvider client={new QueryClient()}>
23
+ <AccountAssuranceRefresh />
24
+ <p>cached workspace</p>
25
+ </QueryClientProvider>
26
+ );
27
+ }
28
+ it('keeps cached content visible and revalidates on focus', async () => {
29
+ getProfile.mockResolvedValue({ id: 'actor' });
30
+ mount();
31
+ expect(screen.getByText('cached workspace')).toBeTruthy();
32
+ await waitFor(() => expect(getProfile).toHaveBeenCalledTimes(1));
33
+ focusManager.setFocused(false);
34
+ focusManager.setFocused(true);
35
+ await waitFor(() => expect(getProfile).toHaveBeenCalledTimes(2));
36
+ });
37
+ it('does not block optional cached content when offline', async () => {
38
+ getProfile.mockRejectedValue(new Error('Offline'));
39
+ mount();
40
+ await waitFor(() => expect(getProfile).toHaveBeenCalledOnce());
41
+ expect(screen.getByText('cached workspace')).toBeTruthy();
42
+ });
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
5
+ import { usePathname } from 'next/navigation';
6
+
7
+ /** Recheck on reconnect/resume without blocking optional accounts' cached UI.
8
+ * The internal API routes MFA_REQUIRED through the server auth proxy. Offline
9
+ * clients cannot discover a policy/recovery change until they reconnect.
10
+ */
11
+ export function AccountAssuranceRefresh() {
12
+ const pathname = usePathname();
13
+ const protectedPage = Boolean(
14
+ pathname &&
15
+ !/^\/(?:en|vi)?\/?$/.test(pathname) &&
16
+ !/(?:^|\/)(?:login|verify-token|auth)(?:\/|$)/.test(pathname)
17
+ );
18
+ useQuery({
19
+ queryKey: ['account-assurance-refresh'],
20
+ queryFn: () => getCurrentUserProfile(),
21
+ enabled: protectedPage,
22
+ staleTime: 0,
23
+ gcTime: 0,
24
+ retry: false,
25
+ refetchOnWindowFocus: 'always',
26
+ refetchOnReconnect: 'always',
27
+ throwOnError: false,
28
+ });
29
+ return null;
30
+ }
@@ -12,7 +12,13 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
12
12
  `https://unpkg.com/pdfjs-dist@${pdfjs.version}/legacy/build/pdf.worker.min.mjs`
13
13
  ).toString();
14
14
 
15
- export function PDFViewer({ url }: { url: string }) {
15
+ export function PDFViewer({
16
+ onLoadError,
17
+ url,
18
+ }: {
19
+ onLoadError?: () => void;
20
+ url: string;
21
+ }) {
16
22
  const t = useTranslations();
17
23
  const containerRef = useRef<HTMLDivElement>(null);
18
24
 
@@ -47,7 +53,12 @@ export function PDFViewer({ url }: { url: string }) {
47
53
 
48
54
  return (
49
55
  <div ref={containerRef} className="relative">
50
- <Document file={url} onLoadSuccess={onDocumentLoadSuccess}>
56
+ <Document
57
+ suspense={false}
58
+ file={url}
59
+ onLoadError={onLoadError}
60
+ onLoadSuccess={onDocumentLoadSuccess}
61
+ >
51
62
  <AnimatePresence mode="wait">
52
63
  <motion.div
53
64
  key={pageNumber}
@@ -478,14 +478,16 @@ export function paginateReportBlocks({
478
478
  Math.max(1, refineSplitBoundary(forcedSegment.text, 48))
479
479
  )
480
480
  );
481
+ // Consume the head we actually emitted. When nothing fits, the split
482
+ // result retains the entire source as its tail; reusing that tail here
483
+ // would repeat the same segment forever and freeze the report editor.
481
484
  const tailSegment =
482
- forcedSplit.tailSegment ??
483
- (headSegment.text.length < forcedSegment.text.length
485
+ headSegment.text.length < forcedSegment.text.length
484
486
  ? cloneSegment(
485
487
  forcedSegment,
486
488
  forcedSegment.text.slice(headSegment.text.length)
487
489
  )
488
- : null);
490
+ : null;
489
491
  const forcedHeight =
490
492
  forcedSplit.height ||
491
493
  measureBlock({
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, useEffect, useRef, useState } from 'react';
4
+
5
+ /** Scale only the on-screen sheet; export and pagination measurement stay at A4. */
6
+ export function ReportPreviewViewport({ children }: { children: ReactNode }) {
7
+ const container = useRef<HTMLDivElement>(null);
8
+ const sheet = useRef<HTMLDivElement>(null);
9
+ const [scale, setScale] = useState(1);
10
+ useEffect(() => {
11
+ const parent = container.current;
12
+ const page = sheet.current;
13
+ if (!parent || !page) return;
14
+ const resize = () => {
15
+ const width = page.offsetWidth;
16
+ if (width > 0 && parent.clientWidth > 0) {
17
+ setScale(Math.min(1, parent.clientWidth / width));
18
+ }
19
+ };
20
+ resize();
21
+ if (typeof ResizeObserver === 'undefined') return;
22
+ const observer = new ResizeObserver(resize);
23
+ observer.observe(parent);
24
+ return () => observer.disconnect();
25
+ }, []);
26
+ return (
27
+ <div
28
+ ref={container}
29
+ className="w-full min-w-0 overflow-hidden print:hidden"
30
+ >
31
+ <div
32
+ ref={sheet}
33
+ className="mx-auto flex w-[210mm] flex-col gap-6"
34
+ style={{ zoom: scale }}
35
+ >
36
+ {children}
37
+ </div>
38
+ </div>
39
+ );
40
+ }