@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.
- package/CHANGELOG.md +62 -0
- package/biome.json +1 -1
- package/package.json +53 -51
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
- package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
- package/src/components/ui/chat/ai-message-parts.tsx +23 -16
- package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
- package/src/components/ui/commercial-comparison-style.ts +104 -0
- package/src/components/ui/commercial-comparison-table.tsx +159 -0
- package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
- package/src/components/ui/commercial-comparison.test.tsx +86 -0
- package/src/components/ui/commercial-comparison.tsx +224 -0
- package/src/components/ui/comparison-value.test.tsx +32 -0
- package/src/components/ui/comparison-value.tsx +72 -0
- package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
- package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
- package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
- package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
- package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
- package/src/components/ui/custom/report-preview-pagination.ts +5 -3
- package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
- package/src/components/ui/custom/report-preview.tsx +2 -3
- package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
- package/src/components/ui/finance/invoices/columns.tsx +2 -0
- package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
- package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
- package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
- package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
- package/src/components/ui/finance/invoices/export-data.ts +229 -0
- package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
- package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
- package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
- package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
- package/src/components/ui/finance/invoices/hooks.ts +5 -110
- package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
- package/src/components/ui/finance/invoices/internal-api.ts +19 -1
- package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
- package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
- package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
- package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
- package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
- package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
- package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
- package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
- package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
- package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
- package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
- package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
- package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
- package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
- package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
- package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
- package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
- package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
- package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
- package/src/components/ui/finance/invoices/utils.ts +48 -106
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
- package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
- package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
- package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
- package/src/components/ui/legacy/calendar/working-location.ts +35 -0
- package/src/components/ui/public-workspace-prices.tsx +13 -0
- package/src/components/ui/pwa-status.test.tsx +13 -2
- package/src/components/ui/pwa-status.tsx +15 -3
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
- package/src/components/ui/text-editor/editor.tsx +11 -3
- package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
- package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
- package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
- package/src/hooks/calendar-provider-sync.ts +33 -0
- package/src/hooks/notification-subscription.ts +74 -0
- package/src/hooks/use-calendar-sync.tsx +11 -14
- package/src/hooks/use-calendar.tsx +2 -2
- 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({
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|