@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
@@ -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) ? 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
+ });