@tuturuuu/ui 0.32.0 → 0.33.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 +49 -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/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/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/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.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-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.ts +1 -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-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 +44 -45
- 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/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
|
@@ -5,7 +5,25 @@ export function normalizeAiMessageParts(
|
|
|
5
5
|
textFallback?: string
|
|
6
6
|
) {
|
|
7
7
|
const fallbackText = textFallback?.trim();
|
|
8
|
-
const normalizedParts = Array.isArray(parts)
|
|
8
|
+
const normalizedParts = Array.isArray(parts)
|
|
9
|
+
? parts.map((part) => {
|
|
10
|
+
if (
|
|
11
|
+
part.type === 'text' &&
|
|
12
|
+
typeof part.textStart === 'number' &&
|
|
13
|
+
typeof part.textLength === 'number' &&
|
|
14
|
+
typeof textFallback === 'string'
|
|
15
|
+
) {
|
|
16
|
+
return {
|
|
17
|
+
type: 'text',
|
|
18
|
+
text: textFallback.slice(
|
|
19
|
+
part.textStart,
|
|
20
|
+
part.textStart + part.textLength
|
|
21
|
+
),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return part;
|
|
25
|
+
})
|
|
26
|
+
: [];
|
|
9
27
|
const hasTextPart = normalizedParts.some(
|
|
10
28
|
(part) => readString(part.type) === 'text' && readString(part.text)
|
|
11
29
|
);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BookOpen,
|
|
3
|
+
Boxes,
|
|
4
|
+
BrainCircuit,
|
|
5
|
+
BriefcaseBusiness,
|
|
6
|
+
Building2,
|
|
7
|
+
CalendarDays,
|
|
8
|
+
ChartNoAxesCombined,
|
|
9
|
+
CircleDollarSign,
|
|
10
|
+
Code2,
|
|
11
|
+
Crown,
|
|
12
|
+
Database,
|
|
13
|
+
FileText,
|
|
14
|
+
Folder,
|
|
15
|
+
Gauge,
|
|
16
|
+
GraduationCap,
|
|
17
|
+
LayoutGrid,
|
|
18
|
+
Link,
|
|
19
|
+
ListTodo,
|
|
20
|
+
Mail,
|
|
21
|
+
MessageSquare,
|
|
22
|
+
Network,
|
|
23
|
+
Rocket,
|
|
24
|
+
Server,
|
|
25
|
+
ShoppingBag,
|
|
26
|
+
Sparkles,
|
|
27
|
+
Timer,
|
|
28
|
+
Users,
|
|
29
|
+
Video,
|
|
30
|
+
Wrench,
|
|
31
|
+
} from '@tuturuuu/icons/lucide';
|
|
32
|
+
import type { ComparisonTier } from '@tuturuuu/utils/commercial-comparison';
|
|
33
|
+
|
|
34
|
+
interface ComparisonTierStyle {
|
|
35
|
+
Icon: typeof Rocket;
|
|
36
|
+
color: string;
|
|
37
|
+
surface: string;
|
|
38
|
+
column: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const comparisonTierStyle = {
|
|
42
|
+
free: {
|
|
43
|
+
Icon: Rocket,
|
|
44
|
+
color: 'text-dynamic-green',
|
|
45
|
+
surface: 'border-dynamic-green/25 bg-dynamic-green/10',
|
|
46
|
+
column: 'bg-dynamic-green/[0.025]',
|
|
47
|
+
},
|
|
48
|
+
plus: {
|
|
49
|
+
Icon: Sparkles,
|
|
50
|
+
color: 'text-dynamic-blue',
|
|
51
|
+
surface: 'border-dynamic-blue/25 bg-dynamic-blue/10',
|
|
52
|
+
column: 'bg-dynamic-blue/[0.035]',
|
|
53
|
+
},
|
|
54
|
+
pro: {
|
|
55
|
+
Icon: Crown,
|
|
56
|
+
color: 'text-dynamic-purple',
|
|
57
|
+
surface: 'border-dynamic-purple/25 bg-dynamic-purple/10',
|
|
58
|
+
column: 'bg-dynamic-purple/[0.035]',
|
|
59
|
+
},
|
|
60
|
+
enterprise: {
|
|
61
|
+
Icon: Building2,
|
|
62
|
+
color: 'text-dynamic-orange',
|
|
63
|
+
surface: 'border-dynamic-orange/25 bg-dynamic-orange/10',
|
|
64
|
+
column: 'bg-dynamic-orange/[0.025]',
|
|
65
|
+
},
|
|
66
|
+
} satisfies Record<ComparisonTier, ComparisonTierStyle>;
|
|
67
|
+
|
|
68
|
+
const appIcons: Record<string, typeof LayoutGrid> = {
|
|
69
|
+
capacity: Gauge,
|
|
70
|
+
platform: LayoutGrid,
|
|
71
|
+
tasks: ListTodo,
|
|
72
|
+
calendar: CalendarDays,
|
|
73
|
+
drive: Folder,
|
|
74
|
+
ai: Sparkles,
|
|
75
|
+
meet: Video,
|
|
76
|
+
finance: CircleDollarSign,
|
|
77
|
+
contacts: Users,
|
|
78
|
+
inventory: Boxes,
|
|
79
|
+
forms: FileText,
|
|
80
|
+
chat: MessageSquare,
|
|
81
|
+
track: Timer,
|
|
82
|
+
mail: Mail,
|
|
83
|
+
learn: BookOpen,
|
|
84
|
+
teach: GraduationCap,
|
|
85
|
+
rewise: GraduationCap,
|
|
86
|
+
mind: BrainCircuit,
|
|
87
|
+
hive: Network,
|
|
88
|
+
cms: FileText,
|
|
89
|
+
storefront: ShoppingBag,
|
|
90
|
+
pay: CircleDollarSign,
|
|
91
|
+
git: Code2,
|
|
92
|
+
tools: Wrench,
|
|
93
|
+
shortener: Link,
|
|
94
|
+
nova: ChartNoAxesCombined,
|
|
95
|
+
colab: Users,
|
|
96
|
+
infrastructure: Server,
|
|
97
|
+
database: Database,
|
|
98
|
+
external: BriefcaseBusiness,
|
|
99
|
+
};
|
|
100
|
+
export function comparisonAppIcon(app: string) {
|
|
101
|
+
return appIcons[app] ?? LayoutGrid;
|
|
102
|
+
}
|
|
103
|
+
export const comparisonFocus =
|
|
104
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Layers3 } from '@tuturuuu/icons/lucide';
|
|
4
|
+
import {
|
|
5
|
+
COMPARISON_TIERS,
|
|
6
|
+
type ComparisonFeature,
|
|
7
|
+
type ComparisonTier,
|
|
8
|
+
} from '@tuturuuu/utils/commercial-comparison';
|
|
9
|
+
import type { ComparisonCopy } from './commercial-comparison';
|
|
10
|
+
import {
|
|
11
|
+
comparisonAppIcon,
|
|
12
|
+
comparisonFocus,
|
|
13
|
+
comparisonTierStyle,
|
|
14
|
+
} from './commercial-comparison-style';
|
|
15
|
+
import { ComparisonValue } from './comparison-value';
|
|
16
|
+
|
|
17
|
+
export function ComparisonTable({
|
|
18
|
+
copy,
|
|
19
|
+
visible,
|
|
20
|
+
tiers,
|
|
21
|
+
appIds,
|
|
22
|
+
details,
|
|
23
|
+
valueLabel,
|
|
24
|
+
}: {
|
|
25
|
+
copy: ComparisonCopy;
|
|
26
|
+
visible: ComparisonFeature[];
|
|
27
|
+
tiers: ComparisonTier[];
|
|
28
|
+
appIds: string[];
|
|
29
|
+
details: boolean;
|
|
30
|
+
valueLabel: (value: string) => string;
|
|
31
|
+
}) {
|
|
32
|
+
return (
|
|
33
|
+
<section
|
|
34
|
+
className={`max-h-[72vh] overflow-auto overscroll-contain rounded-2xl border border-foreground/10 bg-background/20 ${comparisonFocus}`}
|
|
35
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must scroll the comparison region.
|
|
36
|
+
tabIndex={0}
|
|
37
|
+
aria-label={copy.title}
|
|
38
|
+
>
|
|
39
|
+
<table className="w-full border-separate border-spacing-0 text-left text-sm">
|
|
40
|
+
<caption className="sr-only">{copy.description}</caption>
|
|
41
|
+
<thead>
|
|
42
|
+
<tr>
|
|
43
|
+
<th
|
|
44
|
+
scope="col"
|
|
45
|
+
className="sticky top-0 left-0 z-30 w-40 min-w-40 border-foreground/10 border-b bg-background px-4 py-5 sm:w-[40%] sm:min-w-64 sm:px-6"
|
|
46
|
+
>
|
|
47
|
+
<span className="flex items-center gap-2 font-mono-ui text-[0.65rem] text-foreground/50 uppercase tracking-[0.12em]">
|
|
48
|
+
<Layers3 aria-hidden className="size-3.5 shrink-0" />
|
|
49
|
+
{copy.feature}
|
|
50
|
+
</span>
|
|
51
|
+
</th>
|
|
52
|
+
{tiers.map((tier) => {
|
|
53
|
+
const { Icon, color, surface } = comparisonTierStyle[tier];
|
|
54
|
+
return (
|
|
55
|
+
<th
|
|
56
|
+
key={tier}
|
|
57
|
+
scope="col"
|
|
58
|
+
className="sticky top-0 z-20 min-w-24 border-foreground/10 border-b bg-background px-3 py-4 text-center sm:min-w-32"
|
|
59
|
+
>
|
|
60
|
+
<span
|
|
61
|
+
className={`mx-auto mb-2 flex size-8 items-center justify-center rounded-lg border ${surface} ${color}`}
|
|
62
|
+
>
|
|
63
|
+
<Icon aria-hidden className="size-4" />
|
|
64
|
+
</span>
|
|
65
|
+
<span className="font-display font-semibold text-sm tracking-tight">
|
|
66
|
+
{copy.tierNames[tier]}
|
|
67
|
+
</span>
|
|
68
|
+
</th>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
</tr>
|
|
72
|
+
</thead>
|
|
73
|
+
{appIds.map((appId) => {
|
|
74
|
+
const rows = visible.filter((row) => row.app === appId);
|
|
75
|
+
if (!rows.length) return null;
|
|
76
|
+
const Icon = comparisonAppIcon(appId);
|
|
77
|
+
const tone =
|
|
78
|
+
comparisonTierStyle[
|
|
79
|
+
COMPARISON_TIERS[appIds.indexOf(appId) % 4] ?? 'plus'
|
|
80
|
+
];
|
|
81
|
+
return (
|
|
82
|
+
<tbody key={appId}>
|
|
83
|
+
<tr>
|
|
84
|
+
<th
|
|
85
|
+
scope="rowgroup"
|
|
86
|
+
colSpan={tiers.length + 1}
|
|
87
|
+
className="border-foreground/10 border-b bg-background px-4 py-4 sm:px-6"
|
|
88
|
+
>
|
|
89
|
+
<span className="sticky left-4 inline-flex items-center gap-3 sm:left-6">
|
|
90
|
+
<span
|
|
91
|
+
className={`flex size-8 items-center justify-center rounded-lg border ${tone.surface} ${tone.color}`}
|
|
92
|
+
>
|
|
93
|
+
<Icon aria-hidden className="size-4" />
|
|
94
|
+
</span>
|
|
95
|
+
<span className="font-display font-semibold tracking-tight">
|
|
96
|
+
{copy.apps[appId]}
|
|
97
|
+
</span>
|
|
98
|
+
<span
|
|
99
|
+
className="rounded-md bg-foreground/5 px-1.5 py-0.5 font-mono-ui text-[0.6rem] text-foreground/40 tabular-nums"
|
|
100
|
+
aria-hidden
|
|
101
|
+
>
|
|
102
|
+
{rows.length.toString().padStart(2, '0')}
|
|
103
|
+
</span>
|
|
104
|
+
</span>
|
|
105
|
+
</th>
|
|
106
|
+
</tr>
|
|
107
|
+
{rows.map((row) => (
|
|
108
|
+
<tr key={row.id} className="group">
|
|
109
|
+
<th
|
|
110
|
+
scope="row"
|
|
111
|
+
className="sticky left-0 z-10 max-w-80 border-foreground/[0.06] border-b bg-background px-4 py-3 font-normal transition-colors group-hover:bg-muted sm:px-6"
|
|
112
|
+
>
|
|
113
|
+
<div className="flex items-start justify-between gap-2">
|
|
114
|
+
<span className="py-1.5 text-foreground/75 text-xs leading-relaxed sm:text-sm">
|
|
115
|
+
{copy.features[row.id]}
|
|
116
|
+
</span>
|
|
117
|
+
<ComparisonValue
|
|
118
|
+
value={row.pending ? 'pending' : 'info'}
|
|
119
|
+
label={
|
|
120
|
+
row.pending
|
|
121
|
+
? copy.pending
|
|
122
|
+
: (copy.features[row.id] ?? row.id)
|
|
123
|
+
}
|
|
124
|
+
context={copy.details}
|
|
125
|
+
explanation={copy.explanations[row.detail]}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
{details && (
|
|
129
|
+
<p className="mt-1 max-w-lg text-foreground/50 text-xs leading-relaxed">
|
|
130
|
+
{copy.explanations[row.detail]}
|
|
131
|
+
</p>
|
|
132
|
+
)}
|
|
133
|
+
</th>
|
|
134
|
+
{tiers.map((tier) => {
|
|
135
|
+
const value =
|
|
136
|
+
row.values[COMPARISON_TIERS.indexOf(tier)] ?? 'excluded';
|
|
137
|
+
return (
|
|
138
|
+
<td
|
|
139
|
+
key={tier}
|
|
140
|
+
className={`border-foreground/[0.06] border-b px-3 py-3 text-center align-middle transition-colors group-hover:bg-foreground/[0.04] ${comparisonTierStyle[tier].column}`}
|
|
141
|
+
>
|
|
142
|
+
<ComparisonValue
|
|
143
|
+
value={value}
|
|
144
|
+
label={valueLabel(value)}
|
|
145
|
+
context={`${copy.tierNames[tier]}: ${copy.features[row.id]}`}
|
|
146
|
+
explanation={copy.explanations[row.detail]}
|
|
147
|
+
/>
|
|
148
|
+
</td>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</tr>
|
|
152
|
+
))}
|
|
153
|
+
</tbody>
|
|
154
|
+
);
|
|
155
|
+
})}
|
|
156
|
+
</table>
|
|
157
|
+
</section>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ArrowRightLeft,
|
|
5
|
+
Check,
|
|
6
|
+
ChevronDown,
|
|
7
|
+
CircleHelp,
|
|
8
|
+
Layers3,
|
|
9
|
+
LayoutGrid,
|
|
10
|
+
RotateCcw,
|
|
11
|
+
Search,
|
|
12
|
+
Server,
|
|
13
|
+
} from '@tuturuuu/icons/lucide';
|
|
14
|
+
import {
|
|
15
|
+
COMPARISON_TIERS,
|
|
16
|
+
type ComparisonTier,
|
|
17
|
+
} from '@tuturuuu/utils/commercial-comparison';
|
|
18
|
+
import type { ComponentType } from 'react';
|
|
19
|
+
import type { ComparisonCopy } from './commercial-comparison';
|
|
20
|
+
import {
|
|
21
|
+
comparisonFocus,
|
|
22
|
+
comparisonTierStyle,
|
|
23
|
+
} from './commercial-comparison-style';
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
copy: ComparisonCopy;
|
|
27
|
+
query: string;
|
|
28
|
+
app: string;
|
|
29
|
+
category: string;
|
|
30
|
+
differences: boolean;
|
|
31
|
+
internal: boolean;
|
|
32
|
+
details: boolean;
|
|
33
|
+
tiers: ComparisonTier[];
|
|
34
|
+
appIds: string[];
|
|
35
|
+
onQuery: (value: string) => void;
|
|
36
|
+
onApp: (value: string) => void;
|
|
37
|
+
onCategory: (value: string) => void;
|
|
38
|
+
onDifferences: (value: boolean) => void;
|
|
39
|
+
onInternal: (value: boolean) => void;
|
|
40
|
+
onDetails: (value: boolean) => void;
|
|
41
|
+
onTier: (tier: ComparisonTier) => void;
|
|
42
|
+
onReset: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function FilterToggle({
|
|
46
|
+
label,
|
|
47
|
+
checked,
|
|
48
|
+
onChange,
|
|
49
|
+
Icon,
|
|
50
|
+
}: {
|
|
51
|
+
label: string;
|
|
52
|
+
checked: boolean;
|
|
53
|
+
onChange: (value: boolean) => void;
|
|
54
|
+
Icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>;
|
|
55
|
+
}) {
|
|
56
|
+
return (
|
|
57
|
+
<label
|
|
58
|
+
className={`relative inline-flex cursor-pointer items-center gap-2 rounded-full border px-3 py-2 text-xs transition-colors has-focus-visible:ring-2 has-focus-visible:ring-ring ${checked ? 'border-dynamic-blue/25 bg-dynamic-blue/10 text-foreground' : 'border-transparent text-foreground/60 hover:bg-foreground/5 hover:text-foreground'}`}
|
|
59
|
+
>
|
|
60
|
+
<input
|
|
61
|
+
type="checkbox"
|
|
62
|
+
className="sr-only"
|
|
63
|
+
checked={checked}
|
|
64
|
+
onChange={(event) => onChange(event.target.checked)}
|
|
65
|
+
/>
|
|
66
|
+
<Icon aria-hidden className="size-3.5 shrink-0" />
|
|
67
|
+
{label}
|
|
68
|
+
{checked && <Check aria-hidden className="size-3" />}
|
|
69
|
+
</label>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function ComparisonToolbar(p: Props) {
|
|
74
|
+
const selectClass = `h-12 w-full appearance-none rounded-xl border border-foreground/10 bg-transparent pr-9 pl-10 text-sm text-foreground transition-colors hover:border-foreground/25 ${comparisonFocus}`;
|
|
75
|
+
return (
|
|
76
|
+
<div className="relative rounded-2xl border border-foreground/10 bg-foreground/[0.025] p-3 sm:p-5">
|
|
77
|
+
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-[1.7fr_1fr_1fr]">
|
|
78
|
+
<label className="relative block sm:col-span-2 lg:col-span-1">
|
|
79
|
+
<span className="sr-only">{p.copy.search}</span>
|
|
80
|
+
<Search
|
|
81
|
+
aria-hidden
|
|
82
|
+
className="pointer-events-none absolute top-4 left-4 size-4 text-foreground/45"
|
|
83
|
+
/>
|
|
84
|
+
<input
|
|
85
|
+
type="search"
|
|
86
|
+
className={`h-12 w-full rounded-xl border border-foreground/10 bg-background/40 pr-4 pl-11 text-sm placeholder:text-foreground/45 ${comparisonFocus}`}
|
|
87
|
+
placeholder={p.copy.search}
|
|
88
|
+
value={p.query}
|
|
89
|
+
onChange={(event) => p.onQuery(event.target.value)}
|
|
90
|
+
/>
|
|
91
|
+
</label>
|
|
92
|
+
<label className="relative block">
|
|
93
|
+
<span className="sr-only">{p.copy.app}</span>
|
|
94
|
+
<LayoutGrid
|
|
95
|
+
aria-hidden
|
|
96
|
+
className="pointer-events-none absolute top-4 left-3.5 size-4 text-dynamic-blue"
|
|
97
|
+
/>
|
|
98
|
+
<select
|
|
99
|
+
className={selectClass}
|
|
100
|
+
value={p.app}
|
|
101
|
+
onChange={(event) => p.onApp(event.target.value)}
|
|
102
|
+
>
|
|
103
|
+
<option value="all" className="bg-background">
|
|
104
|
+
{p.copy.allApps}
|
|
105
|
+
</option>
|
|
106
|
+
{p.appIds.map((key) => (
|
|
107
|
+
<option className="bg-background" key={key} value={key}>
|
|
108
|
+
{p.copy.apps[key]}
|
|
109
|
+
</option>
|
|
110
|
+
))}
|
|
111
|
+
</select>
|
|
112
|
+
<ChevronDown
|
|
113
|
+
aria-hidden
|
|
114
|
+
className="pointer-events-none absolute top-4 right-3 size-4 text-foreground/40"
|
|
115
|
+
/>
|
|
116
|
+
</label>
|
|
117
|
+
<label className="relative block">
|
|
118
|
+
<span className="sr-only">{p.copy.category}</span>
|
|
119
|
+
<Layers3
|
|
120
|
+
aria-hidden
|
|
121
|
+
className="pointer-events-none absolute top-4 left-3.5 size-4 text-dynamic-purple"
|
|
122
|
+
/>
|
|
123
|
+
<select
|
|
124
|
+
className={selectClass}
|
|
125
|
+
value={p.category}
|
|
126
|
+
onChange={(event) => p.onCategory(event.target.value)}
|
|
127
|
+
>
|
|
128
|
+
<option value="all" className="bg-background">
|
|
129
|
+
{p.copy.allCategories}
|
|
130
|
+
</option>
|
|
131
|
+
{Object.entries(p.copy.categories)
|
|
132
|
+
.filter(([key]) => p.internal || key !== 'internal')
|
|
133
|
+
.map(([key, label]) => (
|
|
134
|
+
<option className="bg-background" key={key} value={key}>
|
|
135
|
+
{label}
|
|
136
|
+
</option>
|
|
137
|
+
))}
|
|
138
|
+
</select>
|
|
139
|
+
<ChevronDown
|
|
140
|
+
aria-hidden
|
|
141
|
+
className="pointer-events-none absolute top-4 right-3 size-4 text-foreground/40"
|
|
142
|
+
/>
|
|
143
|
+
</label>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="mt-3 flex flex-wrap items-center gap-1">
|
|
146
|
+
<FilterToggle
|
|
147
|
+
label={p.copy.differences}
|
|
148
|
+
checked={p.differences}
|
|
149
|
+
onChange={p.onDifferences}
|
|
150
|
+
Icon={ArrowRightLeft}
|
|
151
|
+
/>
|
|
152
|
+
<FilterToggle
|
|
153
|
+
label={p.copy.details}
|
|
154
|
+
checked={p.details}
|
|
155
|
+
onChange={p.onDetails}
|
|
156
|
+
Icon={CircleHelp}
|
|
157
|
+
/>
|
|
158
|
+
<FilterToggle
|
|
159
|
+
label={p.copy.internal}
|
|
160
|
+
checked={p.internal}
|
|
161
|
+
onChange={p.onInternal}
|
|
162
|
+
Icon={Server}
|
|
163
|
+
/>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
className={`ml-auto inline-flex items-center gap-2 rounded-full px-3 py-2 text-foreground/50 text-xs transition-colors hover:bg-foreground/5 hover:text-foreground ${comparisonFocus}`}
|
|
167
|
+
onClick={p.onReset}
|
|
168
|
+
>
|
|
169
|
+
<RotateCcw aria-hidden className="size-3.5" />
|
|
170
|
+
{p.copy.reset}
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
<fieldset className="mt-5 grid grid-cols-2 gap-2 border-foreground/10 border-t pt-4 sm:grid-cols-4">
|
|
174
|
+
<legend className="pr-3 font-mono-ui text-[0.65rem] text-foreground/45 uppercase tracking-[0.15em]">
|
|
175
|
+
{p.copy.tiers}
|
|
176
|
+
</legend>
|
|
177
|
+
{COMPARISON_TIERS.map((tier) => {
|
|
178
|
+
const { Icon, color, surface } = comparisonTierStyle[tier];
|
|
179
|
+
const selected = p.tiers.includes(tier);
|
|
180
|
+
return (
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
key={tier}
|
|
184
|
+
aria-pressed={selected}
|
|
185
|
+
disabled={p.tiers.length === 1 && selected}
|
|
186
|
+
onClick={() => p.onTier(tier)}
|
|
187
|
+
className={`flex min-h-12 items-center gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-colors disabled:cursor-default sm:px-4 ${selected ? surface : 'border-foreground/10 bg-transparent opacity-50 hover:opacity-100'} ${comparisonFocus}`}
|
|
188
|
+
>
|
|
189
|
+
<Icon aria-hidden className={`size-4 shrink-0 ${color}`} />
|
|
190
|
+
<span className="min-w-0 flex-1 font-medium text-sm">
|
|
191
|
+
{p.copy.tierNames[tier]}
|
|
192
|
+
</span>
|
|
193
|
+
<span
|
|
194
|
+
aria-hidden
|
|
195
|
+
className={`flex size-4 shrink-0 items-center justify-center rounded-full border ${selected ? `${color} border-current` : 'border-foreground/20'}`}
|
|
196
|
+
>
|
|
197
|
+
{selected && <Check className="size-2.5" />}
|
|
198
|
+
</span>
|
|
199
|
+
</button>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</fieldset>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
|
+
import { COMPARISON_FEATURES } from '@tuturuuu/utils/commercial-comparison';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import en from '../../../../../apps/web/messages/en.json';
|
|
5
|
+
import vi from '../../../../../apps/web/messages/vi.json';
|
|
6
|
+
import { CommercialComparison } from './commercial-comparison';
|
|
7
|
+
|
|
8
|
+
describe('commercial comparison', () => {
|
|
9
|
+
it('filters by app and resets an empty search', () => {
|
|
10
|
+
render(<CommercialComparison copy={en.commercialComparison} />);
|
|
11
|
+
fireEvent.change(screen.getByLabelText('App'), {
|
|
12
|
+
target: { value: 'drive' },
|
|
13
|
+
});
|
|
14
|
+
expect(
|
|
15
|
+
screen.getByText(
|
|
16
|
+
`${COMPARISON_FEATURES.filter((row) => row.app === 'drive' && row.category !== 'internal').length} matching features`
|
|
17
|
+
)
|
|
18
|
+
).toBeTruthy();
|
|
19
|
+
fireEvent.change(
|
|
20
|
+
screen.getByPlaceholderText(en.commercialComparison.search),
|
|
21
|
+
{ target: { value: 'no-matching-feature' } }
|
|
22
|
+
);
|
|
23
|
+
expect(screen.getByText(en.commercialComparison.empty)).toBeTruthy();
|
|
24
|
+
fireEvent.click(
|
|
25
|
+
screen.getAllByRole('button', { name: 'Reset filters' })[0]!
|
|
26
|
+
);
|
|
27
|
+
expect(
|
|
28
|
+
screen.getByText(
|
|
29
|
+
`${COMPARISON_FEATURES.filter((row) => row.category !== 'internal').length} matching features`
|
|
30
|
+
)
|
|
31
|
+
).toBeTruthy();
|
|
32
|
+
});
|
|
33
|
+
it('resets the explanation toggle with the other filters', () => {
|
|
34
|
+
render(<CommercialComparison copy={en.commercialComparison} />);
|
|
35
|
+
const details = screen.getByRole('checkbox', {
|
|
36
|
+
name: en.commercialComparison.details,
|
|
37
|
+
});
|
|
38
|
+
fireEvent.click(details);
|
|
39
|
+
expect(details).toBeChecked();
|
|
40
|
+
fireEvent.click(
|
|
41
|
+
screen.getByRole('button', { name: en.commercialComparison.reset })
|
|
42
|
+
);
|
|
43
|
+
expect(details).not.toBeChecked();
|
|
44
|
+
});
|
|
45
|
+
it('searches displayed capacity values', () => {
|
|
46
|
+
render(<CommercialComparison copy={en.commercialComparison} />);
|
|
47
|
+
fireEvent.change(
|
|
48
|
+
screen.getByPlaceholderText(en.commercialComparison.search),
|
|
49
|
+
{ target: { value: '20 GiB' } }
|
|
50
|
+
);
|
|
51
|
+
expect(screen.queryByText(en.commercialComparison.empty)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
it('compares only selected tiers and keeps at least one visible', () => {
|
|
54
|
+
render(<CommercialComparison copy={en.commercialComparison} />);
|
|
55
|
+
const controls = screen.getByRole('group', { name: 'Tiers to compare' });
|
|
56
|
+
for (const name of ['Free', 'Plus', 'Enterprise'])
|
|
57
|
+
fireEvent.click(within(controls).getByRole('button', { name }));
|
|
58
|
+
expect(
|
|
59
|
+
within(controls).getByRole('button', { name: 'Pro' })
|
|
60
|
+
).toBeDisabled();
|
|
61
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(2);
|
|
62
|
+
});
|
|
63
|
+
it('exposes explanations on touch and searches Vietnamese without accents', () => {
|
|
64
|
+
render(<CommercialComparison copy={vi.commercialComparison} />);
|
|
65
|
+
fireEvent.change(
|
|
66
|
+
screen.getByPlaceholderText(vi.commercialComparison.search),
|
|
67
|
+
{ target: { value: 'tin dung AI' } }
|
|
68
|
+
);
|
|
69
|
+
expect(screen.getByText('Tín dụng AI hằng tháng')).toBeTruthy();
|
|
70
|
+
fireEvent.click(screen.getByLabelText(vi.commercialComparison.details));
|
|
71
|
+
expect(
|
|
72
|
+
screen.getAllByText(vi.commercialComparison.explanations.aiAllowance)
|
|
73
|
+
.length
|
|
74
|
+
).toBeGreaterThan(0);
|
|
75
|
+
});
|
|
76
|
+
it('matches Vietnamese crossed d in unaccented search', () => {
|
|
77
|
+
render(<CommercialComparison copy={vi.commercialComparison} />);
|
|
78
|
+
fireEvent.change(
|
|
79
|
+
screen.getByPlaceholderText(vi.commercialComparison.search),
|
|
80
|
+
{ target: { value: 'dong thoi' } }
|
|
81
|
+
);
|
|
82
|
+
expect(
|
|
83
|
+
screen.getByText(vi.commercialComparison.features.board)
|
|
84
|
+
).toBeTruthy();
|
|
85
|
+
});
|
|
86
|
+
});
|