@godxjp/ui 28.12.0 → 29.0.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 (178) hide show
  1. package/agent/START-HERE.md +29 -10
  2. package/agent/components/Anchor.json +6 -1
  3. package/agent/components/AppLauncher.json +10 -0
  4. package/agent/components/AppShell.json +1 -1
  5. package/agent/components/AreaChart.json +19 -1
  6. package/agent/components/Attachments.json +26 -1
  7. package/agent/components/BarChart.json +11 -1
  8. package/agent/components/BranchScopePicker.json +10 -0
  9. package/agent/components/Cascader.json +6 -1
  10. package/agent/components/Checkbox.json +6 -0
  11. package/agent/components/CompactBarTrend.json +1 -1
  12. package/agent/components/CredentialReveal.json +21 -0
  13. package/agent/components/DataState.json +1 -1
  14. package/agent/components/DataTable.json +4 -4
  15. package/agent/components/FormField.json +1 -1
  16. package/agent/components/InfiniteQueryState.json +1 -1
  17. package/agent/components/Input.json +1 -1
  18. package/agent/components/InputOTP.json +35 -0
  19. package/agent/components/LineChart.json +20 -2
  20. package/agent/components/ListRow.json +1 -1
  21. package/agent/components/Masonry.json +1 -1
  22. package/agent/components/MasterDetail.json +1 -1
  23. package/agent/components/PasswordStrength.json +1 -1
  24. package/agent/components/PermissionMatrix.json +6 -1
  25. package/agent/components/SearchInput.json +5 -0
  26. package/agent/components/Select.json +1 -0
  27. package/agent/components/ServiceRolePanel.json +5 -0
  28. package/agent/components/Sidebar.json +1 -1
  29. package/agent/components/Switch.json +6 -0
  30. package/agent/components/Table.json +8 -3
  31. package/agent/components/Tabs.json +10 -0
  32. package/agent/components/ThemeScope.json +49 -0
  33. package/agent/components/TimeRangePicker.json +5 -0
  34. package/agent/components/Topbar.json +1 -0
  35. package/agent/components/TopbarItem.json +2 -1
  36. package/agent/components/Transfer.json +6 -1
  37. package/agent/components/TreeSelect.json +1 -1
  38. package/agent/components/Upload.json +5 -0
  39. package/agent/components/UploadCropDialog.json +1 -1
  40. package/agent/components/formatDate.json +1 -1
  41. package/agent/components-index.json +5 -0
  42. package/agent/components.json +297 -31
  43. package/agent/index.json +19 -9
  44. package/agent/llms.txt +10 -10
  45. package/agent/patterns/tenant-brand-color.json +28 -0
  46. package/agent/patterns-index.json +27 -0
  47. package/agent/patterns.json +28 -0
  48. package/agent/rules.json +15 -0
  49. package/agent/tokens.json +4965 -970
  50. package/dist/app/index.d.ts +3 -0
  51. package/dist/app/index.js +3 -0
  52. package/dist/app/tenant-theme.d.ts +80 -0
  53. package/dist/app/tenant-theme.js +154 -0
  54. package/dist/app/theme-axes.d.ts +14 -1
  55. package/dist/app/theme-axes.js +24 -31
  56. package/dist/components/charts/chart-cartesian.d.ts +5 -1
  57. package/dist/components/charts/chart-cartesian.js +15 -8
  58. package/dist/components/data-display/badge.d.ts +1 -1
  59. package/dist/components/data-display/badge.js +20 -2
  60. package/dist/components/data-display/carousel.js +4 -4
  61. package/dist/components/data-display/data-table.js +13 -2
  62. package/dist/components/data-display/permission-matrix.js +1 -1
  63. package/dist/components/data-display/table.d.ts +11 -2
  64. package/dist/components/data-display/table.js +18 -2
  65. package/dist/components/data-entry/control-appearance.d.ts +12 -6
  66. package/dist/components/data-entry/control-appearance.js +1 -1
  67. package/dist/components/data-entry/select.js +4 -3
  68. package/dist/components/feedback/dialog.js +6 -3
  69. package/dist/components/feedback/overlay-header-tone.d.ts +7 -0
  70. package/dist/components/feedback/overlay-header-tone.js +4 -4
  71. package/dist/components/feedback/sheet.d.ts +1 -1
  72. package/dist/components/feedback/sheet.js +6 -9
  73. package/dist/components/feedback/sonner.js +16 -3
  74. package/dist/components/general/button.js +22 -5
  75. package/dist/components/layout/affix.js +15 -1
  76. package/dist/components/layout/sidebar.js +7 -1
  77. package/dist/components/navigation/anchor.d.ts +1 -1
  78. package/dist/components/navigation/anchor.js +5 -4
  79. package/dist/components/navigation/app-setting-picker.js +1 -1
  80. package/dist/components/navigation/pagination.js +1 -1
  81. package/dist/components/navigation/tabs.js +15 -2
  82. package/dist/components/query/infinite-query-state.d.ts +22 -6
  83. package/dist/contracts/measurement.json +1 -1
  84. package/dist/i18n/messages/en.json +0 -697
  85. package/dist/i18n/messages/ja.json +0 -691
  86. package/dist/i18n/messages/vi.json +0 -691
  87. package/dist/lib/control-styles.d.ts +31 -11
  88. package/dist/lib/control-styles.js +6 -6
  89. package/dist/lib/overlay-portal.d.ts +20 -0
  90. package/dist/lib/overlay-portal.js +93 -0
  91. package/dist/props/components/app.prop.d.ts +12 -0
  92. package/dist/props/components/charts.prop.d.ts +30 -0
  93. package/dist/props/components/index.d.ts +1 -1
  94. package/dist/props/components/navigation.prop.d.ts +21 -2
  95. package/dist/props/components/query.prop.d.ts +36 -2
  96. package/dist/props/registry.d.ts +46 -1
  97. package/dist/props/registry.js +38 -3
  98. package/dist/styles/alert-layout.css +34 -14
  99. package/dist/styles/badge-layout.css +10 -6
  100. package/dist/styles/base.css +14 -5
  101. package/dist/styles/card-layout.css +19 -8
  102. package/dist/styles/chart-layout.css +22 -3
  103. package/dist/styles/control.css +165 -59
  104. package/dist/styles/data-display-layout.css +129 -36
  105. package/dist/styles/data-entry-layout.css +23 -87
  106. package/dist/styles/dialog-layout.css +49 -19
  107. package/dist/styles/float-button-layout.css +5 -5
  108. package/dist/styles/focus-ring.css +9 -5
  109. package/dist/styles/layout.css +42 -15
  110. package/dist/styles/logo-layout.css +1 -1
  111. package/dist/styles/motion.css +1 -1
  112. package/dist/styles/navigation-layout.css +90 -29
  113. package/dist/styles/shell-layout.css +63 -40
  114. package/dist/styles/table-layout.css +56 -17
  115. package/dist/styles/text-layout.css +13 -4
  116. package/dist/styles/toggle.css +8 -2
  117. package/dist/tokens/components/actions.css +1 -1
  118. package/dist/tokens/components/attachments.css +4 -4
  119. package/dist/tokens/components/badge.css +4 -4
  120. package/dist/tokens/components/callout.css +1 -1
  121. package/dist/tokens/components/card.css +9 -4
  122. package/dist/tokens/components/chart.css +10 -1
  123. package/dist/tokens/components/chat-bubble.css +1 -1
  124. package/dist/tokens/components/control.css +28 -10
  125. package/dist/tokens/components/conversations.css +2 -1
  126. package/dist/tokens/components/data-display.css +12 -7
  127. package/dist/tokens/components/descriptions.css +1 -1
  128. package/dist/tokens/components/draggable-panel.css +1 -1
  129. package/dist/tokens/components/feedback.css +28 -8
  130. package/dist/tokens/components/float-button.css +1 -1
  131. package/dist/tokens/components/legal-document.css +1 -1
  132. package/dist/tokens/components/logo.css +1 -1
  133. package/dist/tokens/components/mega-menu.css +5 -3
  134. package/dist/tokens/components/navigation.css +21 -7
  135. package/dist/tokens/components/segmented.css +8 -3
  136. package/dist/tokens/components/shell.css +19 -5
  137. package/dist/tokens/components/table.css +9 -1
  138. package/dist/tokens/components/thought-chain.css +1 -1
  139. package/dist/tokens/components/toggle.css +2 -0
  140. package/dist/tokens/components/tree.css +3 -1
  141. package/dist/tokens/components/upload.css +6 -6
  142. package/dist/tokens/components/welcome.css +1 -1
  143. package/dist/tokens/foundation.css +28 -1
  144. package/docs/COMPOSITION-VS-COMPONENT.md +31 -0
  145. package/docs/CUSTOMER-THEMING.md +637 -1
  146. package/docs/DESIGN-AUTHORITY.md +13 -0
  147. package/docs/FRAME-COVERAGE-REPORT.md +3 -2
  148. package/docs/GLASSMORPHISM-STANDARD.md +196 -0
  149. package/docs/THEME-API-COVERAGE.md +538 -0
  150. package/docs/TOKEN-RESOLUTION.md +195 -0
  151. package/docs/TOKENS.md +63 -24
  152. package/docs/asset-modules.d.ts +7 -0
  153. package/docs/data-display/charts.tsx +80 -0
  154. package/docs/data-display/data-table/index.tsx +30 -0
  155. package/docs/data-display/popover.tsx +1 -1
  156. package/docs/data-display/table.tsx +52 -0
  157. package/docs/feedback/sheet.tsx +10 -10
  158. package/docs/foundation/density.tsx +4 -4
  159. package/docs/i18n/messages/en.json +1201 -0
  160. package/docs/i18n/messages/ja.json +1195 -0
  161. package/docs/i18n/messages/vi.json +1195 -0
  162. package/docs/layout/account-chip.tsx +2 -2
  163. package/docs/layout/responsive-grid.tsx +1 -1
  164. package/docs/navigation/toolbar.tsx +20 -12
  165. package/docs/providers/theme-scope.tsx +186 -0
  166. package/docs/showcase/caimono-price-comparison.tsx +911 -0
  167. package/docs/showcase/case4-login.tsx +2 -2
  168. package/docs/showcase/marketing-page.tsx +3 -2
  169. package/docs/showcase/permission-matrix.tsx +13 -5
  170. package/docs/showcase/table-pagination.tsx +2 -1
  171. package/docs/showcase/tenant-brand-color.tsx +338 -0
  172. package/docs/showcase/theme-customization.tsx +2 -1
  173. package/docs/showcase/theme-lab.tsx +2125 -0
  174. package/docs/themes/flat.css +462 -0
  175. package/docs/themes/glassmorphism.css +958 -0
  176. package/docs/themes/index.ts +200 -0
  177. package/package.json +4 -3
  178. package/scripts/explain-token.mjs +382 -0
@@ -0,0 +1,911 @@
1
+ /** Caimono comparison composition. Public components and scoped token overrides only.
2
+ * Fixed source snapshot, not live merchant data. See /tmp/caimono-gaps.md for fidelity limits.
3
+ */
4
+ import * as React from "react";
5
+ import { Button, Heading, Text } from "@godxjp/ui/general";
6
+ import { Flex, PageContainer, ResponsiveGrid } from "@godxjp/ui/layout";
7
+ import {
8
+ Badge,
9
+ Card,
10
+ CardContent,
11
+ CardHeader,
12
+ CardTitle,
13
+ CardDescription,
14
+ DataTable,
15
+ Descriptions,
16
+ EmptyState,
17
+ type ColumnDef,
18
+ } from "@godxjp/ui/data-display";
19
+ import {
20
+ Checkbox,
21
+ FormField,
22
+ NumberInput,
23
+ SearchInput,
24
+ Segmented,
25
+ Select,
26
+ } from "@godxjp/ui/data-entry";
27
+ import {
28
+ Alert,
29
+ AlertDescription,
30
+ Dialog,
31
+ DialogBody,
32
+ DialogContent,
33
+ DialogDescription,
34
+ DialogFooter,
35
+ DialogHeader,
36
+ DialogTitle,
37
+ } from "@godxjp/ui/feedback";
38
+ import { AppSettingPicker } from "@godxjp/ui/navigation";
39
+ import { AreaChart } from "@godxjp/ui/charts";
40
+ import { useTranslation } from "@godxjp/ui/i18n";
41
+
42
+ // CUSTOMER-THEMING.md permits per-subtree custom-property overrides.
43
+ // No ordinary CSS declarations; reapply to portalled content because it leaves the page scope.
44
+ const theme = {
45
+ "--primary": "20.86956522 100.00000000% 36.07843137%", // #B84000, accessible action, not decorative orange
46
+ "--primary-hover": "20.92105263 100.00000000% 29.80392157%",
47
+ "--primary-foreground": "0 0% 100%",
48
+ "--ring": "218.00000000 75.63025210% 46.66666667%",
49
+ "--focus-ring-color": "218.00000000 75.63025210% 46.66666667%",
50
+ "--background": "210.00000000 22.22222222% 96.47058824%",
51
+ "--foreground": "220.27397260 76.84210526% 18.62745098%",
52
+ "--card": "0 0% 100%",
53
+ "--card-foreground": "220.27397260 76.84210526% 18.62745098%",
54
+ "--popover": "0 0% 100%",
55
+ "--popover-foreground": "220.27397260 76.84210526% 18.62745098%",
56
+ "--muted-foreground": "215.67567568 18.40796020% 39.41176471%",
57
+ "--border": "214.28571429 26.92307692% 89.80392157%",
58
+ "--input": "216.36363636 14.79820628% 56.27450980%",
59
+ "--accent": "32.72727273 100.00000000% 95.68627451%",
60
+ "--accent-foreground": "220.27397260 76.84210526% 18.62745098%",
61
+ "--text-primary": "20.86956522 100.00000000% 36.07843137%",
62
+ "--success": "155.55555556 69.23076923% 22.94117647%",
63
+ "--text-success": "155.55555556 69.23076923% 22.94117647%",
64
+ "--text-warning": "37.20930233 100.00000000% 25.29411765%",
65
+ "--text-error": "353.19148936 64.97695853% 42.54901961%",
66
+ "--text-info": "218.10810811 74.74747475% 38.82352941%",
67
+ "--radius-md": "10px",
68
+ "--radius-xl": "16px",
69
+ "--control-radius": "var(--radius-md)",
70
+ "--button-radius": "var(--radius-md)",
71
+ "--card-radius": "var(--radius-xl)",
72
+ "--radius-2xl": "24px",
73
+ "--card-shadow": "0 12px 36px rgb(11 35 84 / .06)",
74
+ "--font-sans-base":
75
+ 'Inter, "Noto Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
76
+ "--font-sans-ja":
77
+ '"Noto Sans JP", "Noto Sans CJK JP", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif',
78
+ "--chart-1": "#B84000",
79
+ } as React.CSSProperties;
80
+
81
+ const snapshot = Date.parse("2026-09-13T09:00:00Z");
82
+ type Offer = {
83
+ id: string;
84
+ merchant: string;
85
+ model: string;
86
+ variant: string;
87
+ condition: string;
88
+ price: number;
89
+ shipping: { Tokyo: number | null; Osaka: number | null };
90
+ points: number;
91
+ observedAt: string;
92
+ eta: number | null;
93
+ inStock: boolean;
94
+ coupon?: number;
95
+ };
96
+ const common = { model: "AERO-H1", variant: "black", condition: "new", inStock: true };
97
+ const offers: Offer[] = [
98
+ {
99
+ ...common,
100
+ id: "mori",
101
+ merchant: "mori",
102
+ price: 24800,
103
+ shipping: { Tokyo: 600, Osaka: 1000 },
104
+ points: 248,
105
+ observedAt: "2026-09-13T08:48:00Z",
106
+ eta: 1,
107
+ },
108
+ {
109
+ ...common,
110
+ id: "aoi",
111
+ merchant: "aoi",
112
+ price: 25100,
113
+ shipping: { Tokyo: 0, Osaka: 0 },
114
+ points: 251,
115
+ observedAt: "2026-09-13T08:42:00Z",
116
+ eta: 2,
117
+ },
118
+ {
119
+ ...common,
120
+ id: "hako",
121
+ merchant: "hako",
122
+ price: 25500,
123
+ shipping: { Tokyo: 0, Osaka: 0 },
124
+ points: 255,
125
+ observedAt: "2026-09-13T08:38:00Z",
126
+ eta: 3,
127
+ coupon: 1000,
128
+ },
129
+ {
130
+ ...common,
131
+ id: "kumo",
132
+ merchant: "kumo",
133
+ price: 23900,
134
+ shipping: { Tokyo: null, Osaka: null },
135
+ points: 239,
136
+ observedAt: "2026-09-13T08:20:00Z",
137
+ eta: 2,
138
+ },
139
+ {
140
+ ...common,
141
+ id: "haru",
142
+ merchant: "haru",
143
+ price: 22900,
144
+ shipping: { Tokyo: 0, Osaka: 0 },
145
+ points: 229,
146
+ observedAt: "2026-09-11T08:00:00Z",
147
+ eta: 4,
148
+ },
149
+ {
150
+ ...common,
151
+ id: "mika",
152
+ merchant: "mika",
153
+ price: 21800,
154
+ shipping: { Tokyo: 0, Osaka: 0 },
155
+ points: 218,
156
+ observedAt: "2026-09-13T08:30:00Z",
157
+ eta: null,
158
+ inStock: false,
159
+ },
160
+ {
161
+ ...common,
162
+ id: "neko-used",
163
+ merchant: "neko",
164
+ condition: "used",
165
+ price: 18800,
166
+ shipping: { Tokyo: 500, Osaka: 800 },
167
+ points: 0,
168
+ observedAt: "2026-09-13T08:40:00Z",
169
+ eta: 3,
170
+ },
171
+ {
172
+ ...common,
173
+ id: "aoi-cream",
174
+ merchant: "aoi",
175
+ variant: "cream",
176
+ price: 26100,
177
+ shipping: { Tokyo: 0, Osaka: 0 },
178
+ points: 261,
179
+ observedAt: "2026-09-13T08:42:00Z",
180
+ eta: 2,
181
+ },
182
+ {
183
+ ...common,
184
+ id: "mori-cream",
185
+ merchant: "mori",
186
+ variant: "cream",
187
+ price: 25700,
188
+ shipping: { Tokyo: 600, Osaka: 1000 },
189
+ points: 257,
190
+ observedAt: "2026-09-13T08:48:00Z",
191
+ eta: 1,
192
+ },
193
+ {
194
+ ...common,
195
+ id: "wrong-model",
196
+ merchant: "other",
197
+ model: "AERO-H2",
198
+ price: 9000,
199
+ shipping: { Tokyo: 0, Osaka: 0 },
200
+ points: 0,
201
+ observedAt: "2026-09-13T08:48:00Z",
202
+ eta: 1,
203
+ },
204
+ ];
205
+ const history = {
206
+ "30": [
207
+ ["2026-08-15", 27500],
208
+ ["2026-08-19", 26800],
209
+ ["2026-08-23", 26800],
210
+ ["2026-08-27", 27200],
211
+ ["2026-08-31", 25900],
212
+ ["2026-09-04", 26100],
213
+ ["2026-09-09", 25500],
214
+ ["2026-09-13", 25100],
215
+ ],
216
+ "90": [
217
+ ["2026-06-16", 29900],
218
+ ["2026-06-29", 28900],
219
+ ["2026-07-12", 29100],
220
+ ["2026-07-25", 27900],
221
+ ["2026-08-07", 28100],
222
+ ["2026-08-20", 26900],
223
+ ["2026-09-02", 25700],
224
+ ["2026-09-13", 25100],
225
+ ],
226
+ } satisfies Record<string, [string, number][]>;
227
+
228
+ export default function CaimonoPriceComparison() {
229
+ const { t, locale } = useTranslation();
230
+ // Re-declare derived font aliases in this nested scope; root aliases are already resolved.
231
+ const font =
232
+ locale === "ja"
233
+ ? theme["--font-sans-ja" as keyof React.CSSProperties]
234
+ : theme["--font-sans-base" as keyof React.CSSProperties];
235
+ const scopedTheme = {
236
+ ...theme,
237
+ "--font-family-sans": font,
238
+ "--font-family-body": font,
239
+ "--font-family-display": font,
240
+ } as React.CSSProperties;
241
+ const [variant, setVariant] = React.useState("black");
242
+ const [condition, setCondition] = React.useState("new");
243
+ const [destination, setDestination] = React.useState<"Tokyo" | "Osaka">("Tokyo");
244
+ const [query, setQuery] = React.useState("");
245
+ const [sort, setSort] = React.useState("total");
246
+ const [free, setFree] = React.useState(false);
247
+ const [stock, setStock] = React.useState(false);
248
+ const [fast, setFast] = React.useState(false);
249
+ const [coupon, setCoupon] = React.useState(false);
250
+ const [period, setPeriod] = React.useState<"30" | "90">("30");
251
+ const [dialog, setDialog] = React.useState<"offer" | "alert" | "quote" | null>(null);
252
+ const [selectedId, setSelectedId] = React.useState<string | null>(null);
253
+ const [threshold, setThreshold] = React.useState<number | null>(25000);
254
+ const [notice, setNotice] = React.useState("");
255
+ const [favorite, setFavorite] = React.useState(false);
256
+ const favoriteKey = `caimono:favorite:JP:AERO-H1:${variant}:${condition}`;
257
+ React.useEffect(() => {
258
+ try {
259
+ setFavorite(localStorage.getItem(favoriteKey) === "1");
260
+ } catch {
261
+ setFavorite(false);
262
+ }
263
+ }, [favoriteKey]);
264
+ /*
265
+ * THE MARKET, not the viewer. A Japanese price-comparison board quotes JPY and stamps Tokyo time
266
+ * whichever language it is read in — translating the page does not move the shop. Both live here
267
+ * as market data so `Intl` derives the formatting from a value rather than from a literal at the
268
+ * call site, which is what `check:no-consumer-coupling` asks for and why it flagged the literals.
269
+ */
270
+ const MARKET = { currency: "JPY", timeZone: "Asia/Tokyo" } as const;
271
+
272
+ const money = (value: number | null) =>
273
+ value === null
274
+ ? t("caimono.unknownTotal")
275
+ : new Intl.NumberFormat(locale, { style: "currency", currency: MARKET.currency }).format(
276
+ value,
277
+ );
278
+ const number = (value: number) => new Intl.NumberFormat(locale).format(value);
279
+ const date = (value: string | number) =>
280
+ new Intl.DateTimeFormat(locale, {
281
+ dateStyle: "medium",
282
+ timeStyle: "short",
283
+ timeZone: MARKET.timeZone,
284
+ }).format(new Date(value));
285
+ const evaluate = (offer: Offer, applyCoupon = coupon) => {
286
+ const shipping = offer.shipping[destination];
287
+ const discount =
288
+ applyCoupon && snapshot < Date.parse("2026-09-14T09:00:00Z") ? (offer.coupon ?? 0) : 0;
289
+ const age = snapshot - Date.parse(offer.observedAt);
290
+ const reason =
291
+ !Number.isFinite(age) || age < 0 || age > 86400000
292
+ ? "stale"
293
+ : !offer.inStock
294
+ ? "outStock"
295
+ : shipping === null
296
+ ? "needsShipping"
297
+ : null;
298
+ return {
299
+ ...offer,
300
+ shippingCost: shipping,
301
+ discount,
302
+ total: shipping === null ? null : offer.price + shipping - discount,
303
+ reason,
304
+ };
305
+ };
306
+ const scoped = offers.filter(
307
+ (o) => o.model === "AERO-H1" && o.variant === variant && o.condition === condition,
308
+ );
309
+ const rows = scoped
310
+ .map((o) => evaluate(o))
311
+ .filter(
312
+ (o) =>
313
+ t(`caimono.merchants.${o.merchant}`)
314
+ .toLocaleLowerCase(locale)
315
+ .includes(query.toLocaleLowerCase(locale)) &&
316
+ (!free || o.shippingCost === 0) &&
317
+ (!stock || o.inStock) &&
318
+ (!fast || o.eta === 1),
319
+ )
320
+ .sort(
321
+ (a, b) =>
322
+ Number(!!a.reason) - Number(!!b.reason) ||
323
+ (sort === "delivery"
324
+ ? (a.eta ?? Infinity) - (b.eta ?? Infinity)
325
+ : (a.total ?? Infinity) - (b.total ?? Infinity)) ||
326
+ a.id.localeCompare(b.id),
327
+ );
328
+ const eligible = rows.filter((o) => !o.reason && o.total !== null);
329
+ const minimum = eligible.length ? Math.min(...eligible.map((o) => o.total!)) : null;
330
+ const best = eligible.find((o) => o.total === minimum);
331
+ const selected = scoped.find((o) => o.id === selectedId);
332
+ const detail = selected ? evaluate(selected) : undefined;
333
+ const reset = () => {
334
+ setQuery("");
335
+ setFree(false);
336
+ setStock(false);
337
+ setFast(false);
338
+ };
339
+ const openOffer = (id: string) => {
340
+ setSelectedId(id);
341
+ setNotice("");
342
+ setDialog("offer");
343
+ };
344
+ const columns: ColumnDef<(typeof rows)[number]>[] = [
345
+ {
346
+ key: "merchant",
347
+ header: t("caimono.shop"),
348
+ render: (o) => (
349
+ <Flex direction="col" gap="xs">
350
+ <Text weight="semibold">{t(`caimono.merchants.${o.merchant}`)}</Text>
351
+ <Text size="sm" tone="muted">
352
+ {t(`caimono.${o.condition}`)}
353
+ </Text>
354
+ <Text size="xs" tone="muted">
355
+ {t("caimono.observed", { date: date(o.observedAt) })}
356
+ </Text>
357
+ </Flex>
358
+ ),
359
+ },
360
+ {
361
+ key: "total",
362
+ header: t("caimono.cash"),
363
+ render: (o) => (
364
+ <Flex direction="col" gap="xs">
365
+ <Text size="xl" weight="bold" tabular>
366
+ {money(o.total)}
367
+ </Text>
368
+ {!o.reason && o.total === minimum ? (
369
+ <Badge tone="success">{t("caimono.best")}</Badge>
370
+ ) : o.reason ? (
371
+ <Text size="sm" tone="warning">
372
+ {t(`caimono.${o.reason}`)}
373
+ </Text>
374
+ ) : null}
375
+ <Text size="sm" tone="muted">
376
+ {t("caimono.itemValue", { amount: money(o.price) })}
377
+ </Text>
378
+ <Text size="sm" tone="muted">
379
+ {t("caimono.shippingValue", {
380
+ amount: o.shippingCost === null ? t("caimono.unknown") : money(o.shippingCost),
381
+ })}
382
+ </Text>
383
+ {o.discount > 0 && (
384
+ <Text size="sm" tone="success">
385
+ {t("caimono.discountValue", { amount: money(o.discount) })}
386
+ </Text>
387
+ )}
388
+ </Flex>
389
+ ),
390
+ },
391
+ {
392
+ key: "eta",
393
+ header: t("caimono.delivery"),
394
+ render: (o) => (
395
+ <Flex direction="col" gap="xs">
396
+ <Text>
397
+ {o.eta === null
398
+ ? t("caimono.unknown")
399
+ : t("caimono.eta", {
400
+ duration: new Intl.NumberFormat(locale, {
401
+ style: "unit",
402
+ unit: "day",
403
+ unitDisplay: "long",
404
+ }).format(o.eta),
405
+ })}
406
+ </Text>
407
+ <Text tone={o.inStock ? "success" : "muted"}>
408
+ {t(o.inStock ? "caimono.inStock" : "caimono.outStock")}
409
+ </Text>
410
+ <Text size="sm" tone="muted">
411
+ {t(o.condition === "new" ? "caimono.manufacturer" : "caimono.sellerWarranty")}
412
+ </Text>
413
+ <Text size="sm" tone="muted">
414
+ {t("caimono.pointsValue", { count: number(o.points) })}
415
+ </Text>
416
+ </Flex>
417
+ ),
418
+ },
419
+ {
420
+ key: "actions",
421
+ header: t("caimono.action"),
422
+ render: (o) => (
423
+ <Button
424
+ variant={!o.reason && o.total === minimum ? "default" : "outline"}
425
+ size="sm"
426
+ onClick={() => openOffer(o.id)}
427
+ aria-label={t("caimono.detailFor", { merchant: t(`caimono.merchants.${o.merchant}`) })}
428
+ >
429
+ {t("caimono.breakdown")}
430
+ </Button>
431
+ ),
432
+ },
433
+ ];
434
+ const chartData = history[period].map(([day, total]) => ({
435
+ day: new Intl.DateTimeFormat(locale, {
436
+ month: "short",
437
+ day: "numeric",
438
+ timeZone: MARKET.timeZone,
439
+ }).format(new Date(`${day}T00:00:00+09:00`)),
440
+ total,
441
+ }));
442
+ return (
443
+ <Flex direction="col" gap="none" data-tenant="caimono" style={scopedTheme}>
444
+ <PageContainer
445
+ title={t("caimono.brand")}
446
+ subtitle={t("caimono.eyebrow")}
447
+ extra={<AppSettingPicker kind="locale" appearance="icon" />}
448
+ stickyFooter
449
+ footer={
450
+ <Flex direction="col" gap="sm">
451
+ <Flex hideFrom="md" justify="between" align="center" gap="sm" wrap>
452
+ <Text weight="bold" tabular>
453
+ {minimum === null ? t("caimono.noEligible") : money(minimum)}
454
+ </Text>
455
+ <Button disabled={!best} onClick={() => best && openOffer(best.id)}>
456
+ {t("caimono.bestDetails")}
457
+ </Button>
458
+ </Flex>
459
+ <Text size="sm" tone="muted">
460
+ {t("caimono.footerNote")}
461
+ </Text>
462
+ </Flex>
463
+ }
464
+ >
465
+ <Alert tone="info">
466
+ <AlertDescription>{t("caimono.demo")}</AlertDescription>
467
+ </Alert>
468
+ <Card style={{ "--card-radius": "var(--radius-2xl)" } as React.CSSProperties}>
469
+ <CardContent solo>
470
+ <ResponsiveGrid columns={{ base: 1, md: 2 }} gap="xl">
471
+ <Flex direction="col" gap="md">
472
+ <Badge tone="neutral">{t("caimono.synthetic")}</Badge>
473
+ <Heading level={1}>{t("caimono.product")}</Heading>
474
+ <Text tone="muted">{t("caimono.productMeta", { count: number(1) })}</Text>
475
+ <FormField label={t("caimono.variant")}>
476
+ <Select
477
+ value={variant}
478
+ onValueChange={setVariant}
479
+ options={["black", "cream"].map((value) => ({
480
+ value,
481
+ label: t(`caimono.${value}`),
482
+ }))}
483
+ />
484
+ </FormField>
485
+ <FormField label={t("caimono.condition")}>
486
+ <Segmented
487
+ value={condition}
488
+ onValueChange={setCondition}
489
+ options={["new", "used"].map((value) => ({
490
+ value,
491
+ label: t(`caimono.${value}`),
492
+ }))}
493
+ />
494
+ </FormField>
495
+ </Flex>
496
+ <Flex direction="col" gap="md">
497
+ <Text tone="muted">{t("caimono.lowest")}</Text>
498
+ <Text size="4xl" weight="bold" tabular>
499
+ {minimum === null ? t("caimono.noEligible") : money(minimum)}
500
+ </Text>
501
+ <Text>{t("caimono.incl")}</Text>
502
+ <Text size="sm" tone="muted">
503
+ {t(coupon ? "caimono.couponActive" : "caimono.couponInactive")}
504
+ </Text>
505
+ <Flex gap="sm" wrap>
506
+ <Button disabled={!best} onClick={() => best && openOffer(best.id)}>
507
+ {t("caimono.bestDetails")}
508
+ </Button>
509
+ <Button
510
+ variant="outline"
511
+ onClick={() => {
512
+ const totals = scoped
513
+ .map((o) => evaluate(o, false))
514
+ .filter((o) => !o.reason && o.total !== null)
515
+ .map((o) => o.total!);
516
+ setThreshold(totals.length ? Math.max(1, Math.min(...totals) - 1000) : 25000);
517
+ setNotice("");
518
+ setDialog("alert");
519
+ }}
520
+ >
521
+ {t("caimono.priceAlert")}
522
+ </Button>
523
+ <Button
524
+ variant="ghost"
525
+ aria-pressed={favorite}
526
+ onClick={() => {
527
+ try {
528
+ localStorage.setItem(favoriteKey, favorite ? "0" : "1");
529
+ setFavorite(!favorite);
530
+ setNotice(t(favorite ? "caimono.favoriteOff" : "caimono.favoriteOn"));
531
+ } catch {
532
+ setNotice(t("caimono.storageFail"));
533
+ }
534
+ }}
535
+ >
536
+ {t(favorite ? "caimono.saved" : "caimono.save")}
537
+ </Button>
538
+ </Flex>
539
+ </Flex>
540
+ </ResponsiveGrid>
541
+ </CardContent>
542
+ </Card>
543
+ <Heading level={2}>{t("caimono.offers")}</Heading>
544
+ <Text tone="muted" size="sm">
545
+ {t("caimono.snapshot", { date: date(snapshot) })}
546
+ </Text>
547
+ <ResponsiveGrid columns={{ base: 1, md: 3 }} gap="md">
548
+ <FormField label={t("caimono.findShop")}>
549
+ <SearchInput
550
+ value={query}
551
+ onValueChange={setQuery}
552
+ placeholder={t("caimono.searchPlaceholder")}
553
+ />
554
+ </FormField>
555
+ <FormField label={t("caimono.deliverTo")}>
556
+ <Select
557
+ value={destination}
558
+ onValueChange={(v: string) => setDestination(v === "Osaka" ? "Osaka" : "Tokyo")}
559
+ options={["Tokyo", "Osaka"].map((value) => ({ value, label: t(`caimono.${value}`) }))}
560
+ />
561
+ </FormField>
562
+ <FormField label={t("caimono.sort")}>
563
+ <Select
564
+ value={sort}
565
+ onValueChange={setSort}
566
+ options={[
567
+ { value: "total", label: t("caimono.totalSort") },
568
+ { value: "delivery", label: t("caimono.deliverySort") },
569
+ ]}
570
+ />
571
+ </FormField>
572
+ </ResponsiveGrid>
573
+ <Flex gap="md" wrap align="center">
574
+ <Checkbox checked={free} onCheckedChange={(v) => setFree(v === true)}>
575
+ {t("caimono.free")}
576
+ </Checkbox>
577
+ <Checkbox checked={stock} onCheckedChange={(v) => setStock(v === true)}>
578
+ {t("caimono.stockOnly")}
579
+ </Checkbox>
580
+ <Checkbox checked={fast} onCheckedChange={(v) => setFast(v === true)}>
581
+ {t("caimono.fast", {
582
+ duration: new Intl.NumberFormat(locale, {
583
+ style: "unit",
584
+ unit: "day",
585
+ unitDisplay: "long",
586
+ }).format(1),
587
+ })}
588
+ </Checkbox>
589
+ <Button variant="ghost" size="sm" onClick={reset}>
590
+ {t("caimono.reset")}
591
+ </Button>
592
+ </Flex>
593
+ <Text role="status" size="sm">
594
+ {t("caimono.counts", { total: number(rows.length), eligible: number(eligible.length) })}
595
+ </Text>
596
+ <Card>
597
+ <CardContent flush>
598
+ <DataTable
599
+ data={rows}
600
+ columns={columns}
601
+ getRowId={(o) => o.id}
602
+ rowTone={(o) => (!o.reason && o.total === minimum ? "success" : undefined)}
603
+ empty={
604
+ <EmptyState
605
+ title={t("caimono.noResultTitle")}
606
+ description={t("caimono.noResultBody")}
607
+ />
608
+ }
609
+ />
610
+ </CardContent>
611
+ </Card>
612
+ <Text size="sm" tone="muted">
613
+ {t("caimono.limitNote")}
614
+ </Text>
615
+ <ResponsiveGrid columns={{ base: 1, md: 3 }} gap="lg">
616
+ <Card>
617
+ <CardHeader>
618
+ <CardTitle>{t("caimono.couponTitle")}</CardTitle>
619
+ </CardHeader>
620
+ <CardContent>
621
+ <Flex direction="col" gap="sm">
622
+ <Checkbox checked={coupon} onCheckedChange={(v) => setCoupon(v === true)}>
623
+ {t("caimono.couponLabel", { amount: money(1000) })}
624
+ </Checkbox>
625
+ <Text size="sm" tone="muted">
626
+ {t("caimono.couponHelp")}
627
+ </Text>
628
+ <Text size="sm" tone="muted">
629
+ {t("caimono.expires", { date: date("2026-09-14T09:00:00Z") })}
630
+ </Text>
631
+ </Flex>
632
+ </CardContent>
633
+ </Card>
634
+ <Card>
635
+ <CardHeader>
636
+ <CardTitle>{t("caimono.whyRank")}</CardTitle>
637
+ </CardHeader>
638
+ <CardContent>
639
+ <Flex direction="col" gap="sm">
640
+ {["rule1", "rule2", "rule3"].map((key) => (
641
+ <Text key={key} size="sm">
642
+ {t(`caimono.${key}`)}
643
+ </Text>
644
+ ))}
645
+ <Text size="sm" tone="muted">
646
+ {t("caimono.kaimiBody")}
647
+ </Text>
648
+ </Flex>
649
+ </CardContent>
650
+ </Card>
651
+ <Card>
652
+ <CardHeader>
653
+ <CardTitle>{t("caimono.buyAssist")}</CardTitle>
654
+ </CardHeader>
655
+ <CardContent>
656
+ <Flex direction="col" gap="md">
657
+ <Text size="sm" tone="muted">
658
+ {t("caimono.buyAssistBody")}
659
+ </Text>
660
+ <Button
661
+ variant="outline"
662
+ onClick={() => {
663
+ setNotice("");
664
+ setDialog("quote");
665
+ }}
666
+ >
667
+ {t("caimono.quote")}
668
+ </Button>
669
+ </Flex>
670
+ </CardContent>
671
+ </Card>
672
+ </ResponsiveGrid>
673
+ <Card>
674
+ <CardHeader>
675
+ <CardTitle>{t("caimono.historyTitle")}</CardTitle>
676
+ <CardDescription>{t("caimono.historyScope")}</CardDescription>
677
+ </CardHeader>
678
+ <CardContent>
679
+ <Flex direction="col" gap="md">
680
+ <FormField label={t("caimono.period")}>
681
+ <Segmented
682
+ value={period}
683
+ onValueChange={(v) => setPeriod(v === "90" ? "90" : "30")}
684
+ options={[30, 90].map((value) => ({
685
+ value: String(value),
686
+ label: new Intl.NumberFormat(locale, {
687
+ style: "unit",
688
+ unit: "day",
689
+ unitDisplay: "long",
690
+ }).format(value),
691
+ }))}
692
+ />
693
+ </FormField>
694
+ <AreaChart
695
+ label={t("caimono.historyTitle")}
696
+ showCaption={false}
697
+ description={t("caimono.historyScope")}
698
+ data={chartData}
699
+ categoryKey="day"
700
+ series={[{ dataKey: "total", label: t("caimono.historyMetric") }]}
701
+ numberFormat={{ style: "currency", currency: MARKET.currency }}
702
+ />
703
+ </Flex>
704
+ </CardContent>
705
+ <CardContent flush>
706
+ <DataTable
707
+ data={chartData}
708
+ getRowId={(r) => r.day}
709
+ columns={[
710
+ { key: "day", header: t("caimono.date") },
711
+ {
712
+ key: "total",
713
+ header: t("caimono.price"),
714
+ render: (r) => <Text tabular>{money(r.total)}</Text>,
715
+ },
716
+ ]}
717
+ />
718
+ </CardContent>
719
+ </Card>
720
+ <Card>
721
+ <CardHeader>
722
+ <CardTitle>{t("caimono.reviewTitle")}</CardTitle>
723
+ </CardHeader>
724
+ <CardContent>
725
+ <ResponsiveGrid columns={{ base: 1, md: 2 }} gap="lg">
726
+ <Flex direction="col" gap="sm">
727
+ <Heading level={3}>{t("caimono.editorial")}</Heading>
728
+ <Text tone="muted">{t("caimono.editorialBody")}</Text>
729
+ </Flex>
730
+ <Flex direction="col" gap="sm">
731
+ <Heading level={3}>{t("caimono.community")}</Heading>
732
+ <Text tone="muted">{t("caimono.communityBody")}</Text>
733
+ </Flex>
734
+ </ResponsiveGrid>
735
+ </CardContent>
736
+ </Card>
737
+ <Card>
738
+ <CardHeader>
739
+ <CardTitle>{t("caimono.specTitle")}</CardTitle>
740
+ </CardHeader>
741
+ <CardContent>
742
+ <Descriptions
743
+ items={[
744
+ { label: t("caimono.model"), value: t("caimono.modelValue") },
745
+ { label: t("caimono.connection"), value: t("caimono.wireless") },
746
+ { label: t("caimono.unit"), value: number(1) },
747
+ { label: t("caimono.market"), value: t("caimono.japan") },
748
+ ]}
749
+ />
750
+ </CardContent>
751
+ </Card>
752
+ <Text weight="semibold">{t("caimono.disclosure")}</Text>
753
+ <Text tone="muted" size="sm">
754
+ {t("caimono.disclosureText", {
755
+ duration: new Intl.NumberFormat(locale, {
756
+ style: "unit",
757
+ unit: "hour",
758
+ unitDisplay: "long",
759
+ }).format(24),
760
+ })}
761
+ </Text>
762
+ <Text role="status">{dialog === null ? notice : ""}</Text>
763
+ </PageContainer>
764
+ <Dialog
765
+ open={dialog !== null}
766
+ onOpenChange={(open) => {
767
+ if (!open) {
768
+ setDialog(null);
769
+ setNotice("");
770
+ }
771
+ }}
772
+ >
773
+ <DialogContent style={scopedTheme}>
774
+ <DialogHeader>
775
+ <DialogTitle>
776
+ {t(
777
+ dialog === "alert"
778
+ ? "caimono.alertTitle"
779
+ : dialog === "quote"
780
+ ? "caimono.quoteTitle"
781
+ : "caimono.breakdownTitle",
782
+ )}
783
+ </DialogTitle>
784
+ <DialogDescription>
785
+ {t(
786
+ dialog === "alert"
787
+ ? "caimono.alertBody"
788
+ : dialog === "quote"
789
+ ? "caimono.quoteBody"
790
+ : "caimono.breakdownNote",
791
+ )}
792
+ </DialogDescription>
793
+ </DialogHeader>
794
+ <DialogBody>
795
+ {dialog === "offer" && detail && (
796
+ <Flex direction="col" gap="md">
797
+ <Heading level={3}>{t(`caimono.merchants.${detail.merchant}`)}</Heading>
798
+ <Descriptions
799
+ columns={1}
800
+ items={[
801
+ { label: t("caimono.item"), value: money(detail.price) },
802
+ {
803
+ label: t("caimono.shipping"),
804
+ value:
805
+ detail.shippingCost === null
806
+ ? t("caimono.unknown")
807
+ : money(detail.shippingCost),
808
+ },
809
+ { label: t("caimono.fee"), value: money(0) },
810
+ { label: t("caimono.discount"), value: money(detail.discount) },
811
+ { label: t("caimono.cash"), value: money(detail.total) },
812
+ { label: t("caimono.points"), value: number(detail.points) },
813
+ ]}
814
+ />
815
+ <Text tone="muted">{t("caimono.notDeducted")}</Text>
816
+ {detail.reason && (
817
+ <Alert tone="warning">
818
+ <AlertDescription>
819
+ {t(`caimono.${detail.reason}`)} {t("caimono.notRankedExplain")}
820
+ </AlertDescription>
821
+ </Alert>
822
+ )}
823
+ </Flex>
824
+ )}
825
+ {dialog === "alert" && (
826
+ <Flex direction="col" gap="md">
827
+ <Text>
828
+ {t("caimono.alertScope", {
829
+ variant: t(`caimono.${variant}`),
830
+ condition: t(`caimono.${condition}`),
831
+ destination: t(`caimono.${destination}`),
832
+ })}
833
+ </Text>
834
+ <FormField label={t("caimono.alertLabel")} helper={t("caimono.alertHelp")}>
835
+ <NumberInput
836
+ value={threshold}
837
+ onValueChange={setThreshold}
838
+ min={1}
839
+ max={10000000}
840
+ precision={0}
841
+ />
842
+ </FormField>
843
+ </Flex>
844
+ )}
845
+ {dialog === "quote" && (
846
+ <Flex direction="col" gap="md">
847
+ <Descriptions
848
+ columns={1}
849
+ items={[
850
+ { label: t("caimono.domestic"), value: money(minimum) },
851
+ ...["assistFee", "intlShip", "importCost"].map((key) => ({
852
+ label: t(`caimono.${key}`),
853
+ value: t("caimono.pendingQuote"),
854
+ })),
855
+ { label: t("caimono.landed"), value: t("caimono.notFinal") },
856
+ ]}
857
+ />
858
+ <Text tone="muted">{t("caimono.quoteNote")}</Text>
859
+ </Flex>
860
+ )}
861
+ <Text role="status">{notice}</Text>
862
+ </DialogBody>
863
+ <DialogFooter>
864
+ <Button
865
+ variant="outline"
866
+ onClick={() => {
867
+ setDialog(null);
868
+ setNotice("");
869
+ }}
870
+ >
871
+ {t("caimono.close")}
872
+ </Button>
873
+ {dialog === "alert" && (
874
+ <Button
875
+ disabled={
876
+ threshold === null ||
877
+ !Number.isSafeInteger(threshold) ||
878
+ threshold < 1 ||
879
+ threshold > 10000000
880
+ }
881
+ onClick={() => {
882
+ try {
883
+ localStorage.setItem(
884
+ `caimono:alert:${variant}:${condition}:${destination}`,
885
+ JSON.stringify({
886
+ model: "AERO-H1",
887
+ variant,
888
+ condition,
889
+ destination,
890
+ currency: MARKET.currency,
891
+ market: "JP",
892
+ threshold,
893
+ metric: "cash_total_without_conditional_coupon",
894
+ createdAt: new Date().toISOString(),
895
+ }),
896
+ );
897
+ setNotice(t("caimono.alertSaved"));
898
+ } catch {
899
+ setNotice(t("caimono.storageFail"));
900
+ }
901
+ }}
902
+ >
903
+ {t("caimono.alertSave")}
904
+ </Button>
905
+ )}
906
+ </DialogFooter>
907
+ </DialogContent>
908
+ </Dialog>
909
+ </Flex>
910
+ );
911
+ }