@godxjp/ui 30.4.1 → 30.5.2
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/agent/START-HERE.md +6 -6
- package/agent/components/Carousel.json +4 -1
- package/agent/components/RecordPicker.json +103 -0
- package/agent/components/ResponsiveGrid.json +3 -1
- package/agent/components/Reveal.json +6 -1
- package/agent/components/SpaceCompact.json +1 -1
- package/agent/components/Table.json +6 -0
- package/agent/components/Text.json +8 -0
- package/agent/components-index.json +5 -0
- package/agent/components.json +131 -4
- package/agent/index.json +7 -7
- package/agent/llms.txt +7 -7
- package/agent/patterns/brand-theme-block.json +33 -0
- package/agent/patterns-index.json +32 -0
- package/agent/patterns.json +33 -0
- package/agent/tokens.json +12 -0
- package/dist/components/data-display/table.d.ts +30 -1
- package/dist/components/data-display/table.js +2 -1
- package/dist/components/data-entry/index.d.ts +2 -0
- package/dist/components/data-entry/index.js +2 -0
- package/dist/components/data-entry/record-picker.d.ts +62 -0
- package/dist/components/data-entry/record-picker.js +296 -0
- package/dist/components/general/typography.d.ts +1 -0
- package/dist/components/general/typography.js +10 -0
- package/dist/contracts/measurement.json +1 -1
- package/dist/i18n/messages/en.json +18 -0
- package/dist/i18n/messages/ja.json +18 -0
- package/dist/i18n/messages/vi.json +18 -0
- package/dist/props/components/data-entry.prop.d.ts +58 -0
- package/dist/props/components/general.prop.d.ts +15 -1
- package/dist/props/registry.d.ts +17 -0
- package/dist/props/registry.js +17 -0
- package/dist/props/vocabulary/index.d.ts +1 -1
- package/dist/props/vocabulary/interaction.prop.d.ts +13 -0
- package/dist/styles/data-display-layout.css +6 -3
- package/dist/styles/data-entry-layout.css +71 -0
- package/dist/styles/dialog-layout.css +8 -0
- package/dist/styles/focus-ring.css +2 -0
- package/dist/styles/layout.css +4 -0
- package/dist/styles/table-layout.css +13 -0
- package/dist/styles/text-layout.css +8 -0
- package/dist/tokens/components/data-display.css +2 -0
- package/dist/tokens/components/data-entry.css +3 -0
- package/docs/FRAME-COVERAGE-REPORT.md +3 -2
- package/docs/assets/tcgm/card-1.svg +1 -0
- package/docs/assets/tcgm/card-2.svg +1 -0
- package/docs/assets/tcgm/card-3.svg +1 -0
- package/docs/assets/tcgm/card-4.svg +1 -0
- package/docs/assets/tcgm/card-5.svg +1 -0
- package/docs/assets/tcgm/card-6.svg +1 -0
- package/docs/assets/tcgm/hero-prism.svg +1 -0
- package/docs/assets/tcgm/scene-1.svg +1 -0
- package/docs/assets/tcgm/scene-2.svg +1 -0
- package/docs/assets/tcgm/scene-3.svg +1 -0
- package/docs/assets/tcgm/scene-4.svg +1 -0
- package/docs/assets/tcgm/scene-5.svg +1 -0
- package/docs/assets/tcgm/tcgm-lockup-color.svg +1 -0
- package/docs/assets/tcgm/tcgm-symbol-color.svg +1 -0
- package/docs/data-entry/record-picker.tsx +208 -0
- package/docs/general/typography.tsx +28 -0
- package/docs/i18n/messages/en.json +185 -1
- package/docs/i18n/messages/ja.json +185 -1
- package/docs/i18n/messages/vi.json +185 -1
- package/docs/showcase/tcgm-website.tsx +1047 -0
- package/package.json +3 -3
- package/scripts/ui-audit.mjs +23 -0
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Showcase · TCG-M "Layered Prism" — a TOKEN-ONLY rebuild of a THIRD brand, this one with MOTION.
|
|
3
|
+
*
|
|
4
|
+
* Source: the `TCG-M_Complete_Brand_Design_SNS_v1.0` handoff (a hand-written HTML/CSS/JS prototype
|
|
5
|
+
* — `07_prototype/index.html` + `site.css` + `app.js`, with `02_foundations/tokens.css` as its
|
|
6
|
+
* source of truth). Japanese-first consumer product: trading-card price comparison, news and a
|
|
7
|
+
* release calendar. All cards, prices, shops and dates below are the bundle's own FICTIONAL sample
|
|
8
|
+
* data and are reproduced as such.
|
|
9
|
+
*
|
|
10
|
+
* Why this one exists next to acme-website (light/gold) and futurelastic-web (dark/gold): those two
|
|
11
|
+
* prove a re-theme, and both are STILL. This brand ships a written MOTION TABLE
|
|
12
|
+
* (`02_foundations/04_MOTION_AND_STATES.md`) — press 90ms, hover 140ms, state 200ms, hero-once
|
|
13
|
+
* 480ms at y8→0, `cubic-bezier(.2,.8,.2,1)` — plus an explicit motion BUDGET (one decorative motion
|
|
14
|
+
* region per viewport; no parallax, no count-up, no infinite float). So the interesting question is
|
|
15
|
+
* not "can the tokens paint it" but "can the motion tokens be re-tuned to someone else's timing
|
|
16
|
+
* spec, and does the shipped `Reveal` primitive land where the spec says it should". Both are
|
|
17
|
+
* answered in the THEME block below: the brand's timing table IS eight token declarations, and the
|
|
18
|
+
* hero reveals the ILLUSTRATION ONLY because the spec says "chỉ illustration, text hiện ngay".
|
|
19
|
+
*
|
|
20
|
+
* Per docs/COMPOSITION-VS-COMPONENT.md every section here (Header/Hero/GameRail/CardGrid/NewsRail/
|
|
21
|
+
* Releases/Trust/Footer) FAILS the Framework-Component Test → composition over real primitives.
|
|
22
|
+
* ZERO new framework components; nothing is added to `src/components/`.
|
|
23
|
+
*/
|
|
24
|
+
import * as React from "react";
|
|
25
|
+
import {
|
|
26
|
+
ArrowRight,
|
|
27
|
+
CalendarDays,
|
|
28
|
+
Clock,
|
|
29
|
+
Heart,
|
|
30
|
+
Layers,
|
|
31
|
+
Menu,
|
|
32
|
+
Search,
|
|
33
|
+
ShieldCheck,
|
|
34
|
+
Sparkles,
|
|
35
|
+
ArrowLeftRight,
|
|
36
|
+
} from "lucide-react";
|
|
37
|
+
|
|
38
|
+
import { Button, Heading, Icon, Reveal, Text } from "@godxjp/ui/general";
|
|
39
|
+
import {
|
|
40
|
+
Badge,
|
|
41
|
+
Card,
|
|
42
|
+
CardContent,
|
|
43
|
+
Carousel,
|
|
44
|
+
CarouselContent,
|
|
45
|
+
CarouselItem,
|
|
46
|
+
CarouselNext,
|
|
47
|
+
CarouselPrevious,
|
|
48
|
+
EmptyState,
|
|
49
|
+
} from "@godxjp/ui/data-display";
|
|
50
|
+
import { Flex, ResponsiveGrid, Separator } from "@godxjp/ui/layout";
|
|
51
|
+
import { useTranslation } from "@godxjp/ui/i18n";
|
|
52
|
+
import { SearchInput, ToggleGroup, ToggleGroupItem } from "@godxjp/ui/data-entry";
|
|
53
|
+
import {
|
|
54
|
+
Dialog,
|
|
55
|
+
DialogBody,
|
|
56
|
+
DialogContent,
|
|
57
|
+
DialogDescription,
|
|
58
|
+
DialogHeader,
|
|
59
|
+
DialogTitle,
|
|
60
|
+
Sheet,
|
|
61
|
+
SheetBody,
|
|
62
|
+
SheetContent,
|
|
63
|
+
SheetHeader,
|
|
64
|
+
SheetTitle,
|
|
65
|
+
Toaster,
|
|
66
|
+
toast,
|
|
67
|
+
} from "@godxjp/ui/feedback";
|
|
68
|
+
|
|
69
|
+
import heroPrism from "../assets/tcgm/hero-prism.svg";
|
|
70
|
+
import lockup from "../assets/tcgm/tcgm-lockup-color.svg";
|
|
71
|
+
import card1 from "../assets/tcgm/card-1.svg";
|
|
72
|
+
import card2 from "../assets/tcgm/card-2.svg";
|
|
73
|
+
import card3 from "../assets/tcgm/card-3.svg";
|
|
74
|
+
import card4 from "../assets/tcgm/card-4.svg";
|
|
75
|
+
import card5 from "../assets/tcgm/card-5.svg";
|
|
76
|
+
import card6 from "../assets/tcgm/card-6.svg";
|
|
77
|
+
import scene1 from "../assets/tcgm/scene-1.svg";
|
|
78
|
+
import scene2 from "../assets/tcgm/scene-2.svg";
|
|
79
|
+
import scene3 from "../assets/tcgm/scene-3.svg";
|
|
80
|
+
import scene4 from "../assets/tcgm/scene-4.svg";
|
|
81
|
+
|
|
82
|
+
// ── Brand stylesheet: the handoff's `tokens.css`, mapped onto the library's semantic roles. ───────
|
|
83
|
+
// This is what the consumer ships as their own theme.css. Raw brand values live HERE, never as
|
|
84
|
+
// arbitrary utilities in the markup below.
|
|
85
|
+
const THEME = `
|
|
86
|
+
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600;700&family=Noto+Sans+JP:wght@400;500;700&display=swap');
|
|
87
|
+
[data-tenant="tcgm"] {
|
|
88
|
+
/* Colour — 02_foundations/tokens.css, converted to the HSL triplets the library reads.
|
|
89
|
+
Measured against WCAG 2.2: white/teal 9.51, ink/off-white 12.30, muted/off-white 5.47,
|
|
90
|
+
muted/white 5.73, teal/sky-wash 8.71, teal/lavender-wash 8.35, teal/sand-wash 8.77 — every
|
|
91
|
+
pair AA or better. "--input" is the CONTROL boundary held to 3:1 (SC 1.4.11): the brand's own
|
|
92
|
+
"--color-border-strong" #718891 is 3.73:1 on white, so the boundary token is that one and NOT
|
|
93
|
+
the decorative "--color-border" #DCE7EB, which stays chrome. */
|
|
94
|
+
--primary: 192 72% 21%; /* teal #0F4C5C — CTA, logo, heading ink */
|
|
95
|
+
--primary-foreground: 0 0% 100%;
|
|
96
|
+
--ring: 199 89% 29%; /* focus #08618A — the brand rings in a DIFFERENT blue than
|
|
97
|
+
it fills with, so --ring is declared, never inherited */
|
|
98
|
+
--focus-ring-color: 199 89% 29%;
|
|
99
|
+
--accent: 254 100% 97%; /* lavender wash #F2EEFF — discovery/editorial */
|
|
100
|
+
--accent-foreground: 195 57% 16%;
|
|
101
|
+
--background: 210 40% 98%; /* off-white #F8FAFC */
|
|
102
|
+
--foreground: 195 57% 16%; /* ink #123642 */
|
|
103
|
+
--card: 0 0% 100%; --card-foreground: 195 57% 16%;
|
|
104
|
+
--secondary: 203 100% 96%; /* sky wash #EAF7FF — the quiet fill, per "white/sky 2.26:1
|
|
105
|
+
không dùng": the SATURATED sky is illustration-only */
|
|
106
|
+
--secondary-foreground: 192 72% 21%;
|
|
107
|
+
--muted: 198 26% 93%; --muted-foreground: 196 17% 39%;
|
|
108
|
+
--border: 196 27% 89%; --input: 197 13% 51%;
|
|
109
|
+
--success: 161 65% 25%; --warning: 31 70% 31%; --destructive: 349 67% 42%; --info: 202 71% 31%;
|
|
110
|
+
|
|
111
|
+
/* Shape — 01_FOUNDATIONS "Radius": 12 control · 20 card · 24 panel · 32 hero · 999 pill. */
|
|
112
|
+
--radius: 0.75rem; --radius-md: 12px; --radius-lg: 16px; --radius-xl: 20px; --radius-2xl: 24px;
|
|
113
|
+
--card-radius: var(--radius-xl); --control-radius: var(--radius-md);
|
|
114
|
+
|
|
115
|
+
/* Elevation — the brand's four shadows verbatim; the hover step is what Card "hoverable" raises
|
|
116
|
+
to, so "nâng tối đa 2px và chuyển shadow sm→md" is a token pair, not a hand-written rule. */
|
|
117
|
+
--shadow-color: 15 76 92;
|
|
118
|
+
--shadow-xs: 0 1px 2px rgb(15 76 92 / .06);
|
|
119
|
+
--card-shadow: 0 1px 2px rgb(15 76 92 / .06);
|
|
120
|
+
--card-hover-shadow: 0 12px 32px -14px rgb(15 76 92 / .20);
|
|
121
|
+
--shadow-sm: 0 4px 14px -6px rgb(15 76 92 / .12);
|
|
122
|
+
--shadow-md: 0 12px 32px -14px rgb(15 76 92 / .20);
|
|
123
|
+
--shadow-lg: 0 24px 64px -24px rgb(15 76 92 / .26);
|
|
124
|
+
|
|
125
|
+
/* Type — Noto Sans JP for Japanese, Montserrat for Latin labels and prices. The brand is
|
|
126
|
+
explicit that Montserrat carries no Japanese glyph, so it is the DISPLAY/label face only and
|
|
127
|
+
never the body face. */
|
|
128
|
+
--font-family-body: "Noto Sans JP", "Hiragino Kaku Gothic ProN", "Yu Gothic", system-ui, sans-serif;
|
|
129
|
+
--font-family-sans: var(--font-family-body);
|
|
130
|
+
/* HEADINGS ARE NOTO, NOT MONTSERRAT. The prototype points h1-h3 at its --font-jp, and the brand
|
|
131
|
+
says why in one line: Montserrat carries no Japanese glyph. A "Montserrat first" display stack
|
|
132
|
+
LOOKS right because the browser falls through per glyph, but it hands the Latin inside a
|
|
133
|
+
Japanese headline a different face than the headline, which is the opposite of the intent.
|
|
134
|
+
Montserrat is the LATIN-label face, and it arrives through .tm-latin below. */
|
|
135
|
+
--font-family-display: var(--font-family-body);
|
|
136
|
+
--tm-latin: "Montserrat", system-ui, sans-serif;
|
|
137
|
+
/* Hero display. The formula is 02_TYPOGRAPHY's own — "Use clamp(2.125rem, 4vw, 3.5rem) cho
|
|
138
|
+
hero" — and it is a token rather than a call-site style because --font-size-display is the
|
|
139
|
+
BASE KNOB of the whole display ramp, so 3xl/4xl follow it. Pinned at 3.5rem it measured 56px
|
|
140
|
+
at 390, where the same document's mobile column says 34px; 2.125rem IS that 34px. */
|
|
141
|
+
--font-size-display: clamp(2.125rem, 4vw, 3.5rem);
|
|
142
|
+
--letter-spacing-tight: -0.025em; /* JP heading tracking; the Latin −.035em is on .tm-eyebrow */
|
|
143
|
+
|
|
144
|
+
/* MOTION — 04_MOTION_AND_STATES.md's timing table, as tokens. This is the whole point of this
|
|
145
|
+
showcase: another brand's motion spec arrives as CONFIGURATION, and the shipped "Reveal"
|
|
146
|
+
primitive + Card hover + Dialog enter all retime themselves off it with no new CSS.
|
|
147
|
+
press 90ms · hover/focus 140ms · tab/state 200ms · enter 260ms · exit 180ms · hero-once 480ms
|
|
148
|
+
Ease is the brand's "cubic-bezier(.2,.8,.2,1)" everywhere; it forbids spring overshoot. */
|
|
149
|
+
--duration-fast: 140ms;
|
|
150
|
+
--duration-base: 200ms;
|
|
151
|
+
--duration-slow: 480ms; /* motion-heroOnce */
|
|
152
|
+
--ease-standard: cubic-bezier(.2,.8,.2,1);
|
|
153
|
+
--ease-emphasized: cubic-bezier(.2,.8,.2,1);
|
|
154
|
+
--ease-decelerate: cubic-bezier(.2,.8,.2,1);
|
|
155
|
+
--reveal-distance: 8px; /* "Hero once 480ms: 0→1, y8→0" — the 8 is the spec's */
|
|
156
|
+
--reveal-stagger-step: 70ms;
|
|
157
|
+
|
|
158
|
+
/* The carousel arrow TARGET. 03_COMPONENTS asks for "arrows44px" and 01_FOUNDATIONS sets the
|
|
159
|
+
brand floor at 44x44 ("Chuan noi bo target >=44x44 CSS px tren web"), above WCAG 2.2 SC
|
|
160
|
+
2.5.8's 24px. The library default is 2rem/32px; this is the knob that reaches the brand's
|
|
161
|
+
number, and it did not exist until gh#931 - the box was two bare literals, so the only way
|
|
162
|
+
to hit 44 was to override a library selector from this stylesheet. */
|
|
163
|
+
--carousel-arrow-size: 2.75rem;
|
|
164
|
+
|
|
165
|
+
/* Measure - the brand's content-max 1280 with 32px desktop gutters. */
|
|
166
|
+
--page-measure-wide: 80rem;
|
|
167
|
+
color-scheme: light;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ── Marketing surface (the consumer's own section stylesheet) ─────────────────────────────── */
|
|
171
|
+
[data-tenant="tcgm"] { background: hsl(var(--background)); color: hsl(var(--foreground)); min-block-size: 100vh; }
|
|
172
|
+
/* The gutter ladder is the prototype's own: .container is "100% - 64px" on desktop, "- 48px" under
|
|
173
|
+
1024 and "- 32px" under 768. A MEDIA query, not a container one — a container cannot query
|
|
174
|
+
itself, and a page gutter is a viewport concern anyway. It is also what gives the 34px headline
|
|
175
|
+
room at 390: on a flat 32px gutter the hero column was 278px and "もっと見つけよう。" broke
|
|
176
|
+
before the final character. */
|
|
177
|
+
[data-tenant="tcgm"] .tm-shell { margin-inline: auto; inline-size: 100%; max-inline-size: var(--page-measure-wide); padding-inline: var(--space-4); container-type: inline-size; }
|
|
178
|
+
@media (min-width: 768px) { [data-tenant="tcgm"] .tm-shell { padding-inline: var(--space-6); } }
|
|
179
|
+
@media (min-width: 1024px) { [data-tenant="tcgm"] .tm-shell { padding-inline: var(--space-8); } }
|
|
180
|
+
[data-tenant="tcgm"] .tm-section { padding-block-start: var(--space-20); }
|
|
181
|
+
|
|
182
|
+
/* The demo strip the prototype puts ABOVE the header. It is page chrome, not a Banner: it carries
|
|
183
|
+
no action, no dismiss and no live region — the whole page is the sample it is labelling. */
|
|
184
|
+
[data-tenant="tcgm"] .tm-demo-notice { background: hsl(var(--accent)); border-block-end: var(--stroke-hairline) solid hsl(var(--border)); padding-block: var(--space-1); text-align: center; }
|
|
185
|
+
|
|
186
|
+
/* The sticky header IS this page's bar region. Not a <Topbar>: that is the APPLICATION chrome
|
|
187
|
+
(app launcher, org switcher, account cell), and a marketing header is a nav row — the same call
|
|
188
|
+
acme-website.tsx and futurelastic-web.tsx each made. */
|
|
189
|
+
[data-tenant="tcgm"] .tm-header { position: sticky; inset-block-start: 0; z-index: 30; background: hsl(var(--card)); border-block-end: var(--stroke-hairline) solid hsl(var(--border)); }
|
|
190
|
+
[data-tenant="tcgm"] .tm-header-inner { min-block-size: 80px; display: flex; align-items: center; column-gap: var(--space-8); row-gap: var(--space-2); flex-wrap: wrap; padding-block: var(--space-2); }
|
|
191
|
+
[data-tenant="tcgm"] .tm-brand { display: block; inline-size: 164px; max-inline-size: 42vw; }
|
|
192
|
+
[data-tenant="tcgm"] .tm-eyebrow { font-family: var(--tm-latin); font-weight: 700; font-size: 0.6875rem; line-height: 1.4; letter-spacing: .16em; text-transform: uppercase; color: hsl(var(--primary)); }
|
|
193
|
+
|
|
194
|
+
/* The Latin-label face. 02_TYPOGRAPHY: Montserrat is for "Latin short labels/prices" only, so it
|
|
195
|
+
is opted into per element rather than inherited by a whole subtree of Japanese. */
|
|
196
|
+
[data-tenant="tcgm"] .tm-latin { font-family: var(--tm-latin); }
|
|
197
|
+
|
|
198
|
+
/* Responsive VISIBILITY, the only thing the header needs a class for: which controls a phone shows
|
|
199
|
+
is presentation, so it belongs in the brand stylesheet rather than as props on four Buttons. */
|
|
200
|
+
[data-tenant="tcgm"] .tm-desktop-only { display: none; }
|
|
201
|
+
[data-tenant="tcgm"] .tm-mobile-only { display: inline-flex; }
|
|
202
|
+
@container (min-width: 768px) {
|
|
203
|
+
[data-tenant="tcgm"] .tm-desktop-only { display: inline-flex; }
|
|
204
|
+
[data-tenant="tcgm"] .tm-mobile-only { display: none; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* HERO. Prism Wash = two low-opacity radial washes, the brand's own motif, and the reason the
|
|
208
|
+
library's .ui-brand-glow is NOT used here: that halo is one brand-coloured lobe, and this brand
|
|
209
|
+
wants two in two different accents. Static, per "Homepage hero là static, không slideshow". */
|
|
210
|
+
[data-tenant="tcgm"] .tm-hero { position: relative; overflow: hidden; margin-block-start: var(--space-8); border: var(--stroke-hairline) solid hsl(var(--border)); border-radius: 32px;
|
|
211
|
+
background: radial-gradient(ellipse at 90% 0%, hsl(var(--secondary)), transparent 65%), radial-gradient(ellipse at 0% 100%, hsl(var(--accent)), transparent 70%), hsl(var(--card));
|
|
212
|
+
display: grid; grid-template-columns: 1fr; align-items: center; }
|
|
213
|
+
@container (min-width: 768px) { [data-tenant="tcgm"] .tm-hero { grid-template-columns: 1.15fr 1fr; min-block-size: 460px; } }
|
|
214
|
+
[data-tenant="tcgm"] .tm-hero-copy { padding: var(--space-8) var(--space-6) 0; max-inline-size: 650px; }
|
|
215
|
+
@container (min-width: 768px) { [data-tenant="tcgm"] .tm-hero-copy { padding: var(--space-12) 0 var(--space-12) var(--space-12); } }
|
|
216
|
+
[data-tenant="tcgm"] .tm-hero-search { max-inline-size: 550px; }
|
|
217
|
+
[data-tenant="tcgm"] .tm-hero-art { position: relative; min-inline-size: 0; block-size: 212px; overflow: hidden; }
|
|
218
|
+
@container (min-width: 768px) { [data-tenant="tcgm"] .tm-hero-art { block-size: auto; align-self: stretch; overflow: visible; } }
|
|
219
|
+
[data-tenant="tcgm"] .tm-hero-art img { position: absolute; inset-block-start: -58px; inset-inline-end: -14px; inline-size: 460px; max-inline-size: none; }
|
|
220
|
+
@container (min-width: 768px) { [data-tenant="tcgm"] .tm-hero-art img { inset-block: 0; inset-inline: -6% auto; inline-size: 128%; block-size: 100%; object-fit: contain; } }
|
|
221
|
+
/* The reveal WRAPS the art, so the observer measures the art's box and the copy is never inside a
|
|
222
|
+
box that starts at opacity 0 — the spec's "text hiện ngay" is structural, not a delay of 0. */
|
|
223
|
+
[data-tenant="tcgm"] .tm-hero-art .ui-reveal { display: contents; }
|
|
224
|
+
[data-tenant="tcgm"] .tm-art-tag { position: absolute; inset-block-end: 49px; inset-inline-end: 30px; display: none; align-items: center; gap: var(--space-2);
|
|
225
|
+
background: hsl(var(--card) / .92); border: var(--stroke-hairline) solid hsl(var(--border)); padding: var(--space-3) var(--space-4); border-radius: 14px; box-shadow: var(--shadow-sm); rotate: -4deg; }
|
|
226
|
+
@container (min-width: 1024px) { [data-tenant="tcgm"] .tm-art-tag { display: flex; } }
|
|
227
|
+
|
|
228
|
+
/* Card artwork sits in a 3:4-ish WELL and is "contain"-fitted — the brand forbids cropping a card's
|
|
229
|
+
code or border away (04_ART_DIRECTION "Image ratios"). */
|
|
230
|
+
[data-tenant="tcgm"] .tm-artwell { position: relative; display: grid; place-items: center; block-size: 220px; margin-block-end: var(--space-4);
|
|
231
|
+
border-radius: 12px; background: linear-gradient(145deg, hsl(var(--muted)), hsl(var(--background))); }
|
|
232
|
+
[data-tenant="tcgm"] .tm-artwell img { block-size: 196px; inline-size: 140px; object-fit: contain; filter: drop-shadow(0 6px 10px rgb(15 76 92 / .09)); }
|
|
233
|
+
[data-tenant="tcgm"] .tm-sample { position: absolute; inset-block-start: var(--space-2); inset-inline-start: var(--space-2); }
|
|
234
|
+
[data-tenant="tcgm"] .tm-watch { position: absolute; inset-block-start: var(--space-3); inset-inline-end: var(--space-3); z-index: 1; background: hsl(var(--card)); }
|
|
235
|
+
[data-tenant="tcgm"] .tm-watch[aria-pressed="true"] svg { fill: currentColor; }
|
|
236
|
+
[data-tenant="tcgm"] .tm-card-body { position: relative; }
|
|
237
|
+
[data-tenant="tcgm"] .tm-card-name { min-block-size: 3.2em; }
|
|
238
|
+
[data-tenant="tcgm"] .tm-price-row { display: flex; align-items: end; justify-content: space-between; gap: var(--space-2);
|
|
239
|
+
margin-block-start: var(--space-3); padding-block-start: var(--space-3); border-block-start: var(--stroke-hairline) solid hsl(var(--border)); }
|
|
240
|
+
/* The trailing "送料別 / サンプル" note is dropped on a narrow card, exactly as the prototype drops
|
|
241
|
+
it ("@media(max-width:767px){.card-price>.caption{display:none}}"). At 390 the two-up grid gives
|
|
242
|
+
each card ~163px: keeping the note squeezed the price label into three lines and pushed the
|
|
243
|
+
figure — the one thing the card exists to show — off its own baseline. The same sentence is
|
|
244
|
+
already carried by the section note above the grid, so nothing is lost.
|
|
245
|
+
A MEDIA query, like the prototype: an unnamed "@container" resolves against the nearest ancestor
|
|
246
|
+
container, which here is the grid (358px at 390), never the 163px card — it measured "shown" at
|
|
247
|
+
390. Making the card its own container would work but buys nothing over the viewport rule the
|
|
248
|
+
prototype already specifies. */
|
|
249
|
+
[data-tenant="tcgm"] .tm-price-note { display: none; }
|
|
250
|
+
@media (min-width: 768px) { [data-tenant="tcgm"] .tm-price-note { display: block; } }
|
|
251
|
+
|
|
252
|
+
/* News cover. 16:9, the wordmark plate sits OUTSIDE the artwork's focal area. */
|
|
253
|
+
[data-tenant="tcgm"] .tm-news-cover { position: relative; block-size: 180px; overflow: hidden; background: hsl(var(--secondary)); }
|
|
254
|
+
[data-tenant="tcgm"] .tm-news-cover img { inline-size: 100%; block-size: 100%; object-fit: cover; object-position: center 39%; }
|
|
255
|
+
[data-tenant="tcgm"] .tm-news-word { position: absolute; inset-block-end: var(--space-5); inset-inline-start: var(--space-6); font-family: var(--tm-latin); font-weight: 700; font-size: 1.5rem; line-height: 1.2; letter-spacing: .04em;
|
|
256
|
+
color: hsl(var(--primary)); background: hsl(var(--card) / .92); padding: var(--space-1) var(--space-3); border-radius: 6px; }
|
|
257
|
+
|
|
258
|
+
/* Release row — the date box is the brand's Sand surface. */
|
|
259
|
+
[data-tenant="tcgm"] .tm-datebox { inline-size: 58px; flex: 0 0 auto; text-align: center; border-radius: 12px; padding: var(--space-2);
|
|
260
|
+
background: #FAF5EF; font-family: var(--tm-latin); color: hsl(var(--primary)); }
|
|
261
|
+
|
|
262
|
+
/* Trust band — Sand, the one warm surface in the system. */
|
|
263
|
+
[data-tenant="tcgm"] .tm-trust { background: #FAF5EF; border: var(--stroke-hairline) solid #EBE4DB; border-radius: 28px; padding: var(--space-12); }
|
|
264
|
+
[data-tenant="tcgm"] .tm-trust-grid { display: grid; grid-template-columns: 1fr; gap: var(--space-8); }
|
|
265
|
+
@container (min-width: 1024px) { [data-tenant="tcgm"] .tm-trust-grid { grid-template-columns: 1fr 1.35fr; gap: var(--space-16, 4rem); } }
|
|
266
|
+
[data-tenant="tcgm"] .tm-trust-icon { inline-size: 44px; block-size: 44px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 14px; background: hsl(var(--card)); color: hsl(var(--primary)); }
|
|
267
|
+
|
|
268
|
+
[data-tenant="tcgm"] .tm-footer { margin-block-start: var(--space-20); border-block-start: var(--stroke-hairline) solid hsl(var(--border)); background: hsl(var(--card)); padding-block: var(--space-12) var(--space-8); }
|
|
269
|
+
[data-tenant="tcgm"] .tm-footer-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-6); }
|
|
270
|
+
@container (min-width: 768px) { [data-tenant="tcgm"] .tm-footer-grid { grid-template-columns: 2fr 1fr 1fr 1fr; gap: var(--space-10); } }
|
|
271
|
+
[data-tenant="tcgm"] .tm-footer-main { grid-column: 1 / -1; }
|
|
272
|
+
@container (min-width: 768px) { [data-tenant="tcgm"] .tm-footer-main { grid-column: auto; } }
|
|
273
|
+
|
|
274
|
+
/* The one piece of motion written by hand rather than tokenised, and it is written ONCE: the
|
|
275
|
+
artwork inside a hovered card follows the card's own lift. "@media (hover: hover)" so a touch
|
|
276
|
+
device never sticks in a hover state, and the reduced-motion branch is not optional. */
|
|
277
|
+
@media (hover: hover) {
|
|
278
|
+
[data-tenant="tcgm"] .ui-card:hover .tm-artwell img { transform: translateY(-2px) scale(1.02); }
|
|
279
|
+
}
|
|
280
|
+
[data-tenant="tcgm"] .tm-artwell img { transition: transform var(--duration-base) var(--ease-standard); }
|
|
281
|
+
@media (prefers-reduced-motion: reduce) {
|
|
282
|
+
[data-tenant="tcgm"] .tm-artwell img { transition: none; transform: none; }
|
|
283
|
+
}
|
|
284
|
+
`;
|
|
285
|
+
|
|
286
|
+
// ── Sample data. The SHAPE lives here; every word lives in `docs/i18n/messages/{ja,en,vi}.json`
|
|
287
|
+
// under `tcgm.*` and arrives through `t()`.
|
|
288
|
+
//
|
|
289
|
+
// That split is not decoration. A showcase is what the MCP catalog quotes and what a consumer
|
|
290
|
+
// copy-pastes, and the library's own chrome (Carousel slide labels, EmptyState, dismiss buttons)
|
|
291
|
+
// already follows the viewer's locale — so a hard-coded Japanese label next to it renders TWO
|
|
292
|
+
// languages in one row, and teaches the library's first rule backwards. `check:no-consumer-coupling`
|
|
293
|
+
// holds new docs files at zero hard-coded locale content for exactly that reason; it caught this
|
|
294
|
+
// page with 51. `docs/showcase/caimono-price-comparison.tsx` is the same brief solved the same way.
|
|
295
|
+
//
|
|
296
|
+
// Every card, price, shop and date is FICTIONAL by design: the brand's own `01_POSITIONING.md`
|
|
297
|
+
// forbids implying coverage or a market price it does not have.
|
|
298
|
+
type Game = "all" | "pokemon" | "onepiece" | "yugioh" | "duel" | "mtg" | "other";
|
|
299
|
+
|
|
300
|
+
const GAMES: Game[] = ["all", "pokemon", "onepiece", "yugioh", "duel", "mtg", "other"];
|
|
301
|
+
|
|
302
|
+
type SampleCard = {
|
|
303
|
+
id: string;
|
|
304
|
+
game: Exclude<Game, "all">;
|
|
305
|
+
code: string;
|
|
306
|
+
price: number | null;
|
|
307
|
+
art: string;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const CARDS: SampleCard[] = [
|
|
311
|
+
{ id: "demo-01", game: "pokemon", code: "SAMPLE-A001", price: 1280, art: card1 },
|
|
312
|
+
{ id: "demo-02", game: "onepiece", code: "SAMPLE-B002", price: 2480, art: card2 },
|
|
313
|
+
{ id: "demo-03", game: "yugioh", code: "SAMPLE-C003", price: 680, art: card3 },
|
|
314
|
+
{ id: "demo-04", game: "duel", code: "SAMPLE-D004", price: 3980, art: card4 },
|
|
315
|
+
{ id: "demo-05", game: "mtg", code: "SAMPLE-E005", price: 980, art: card5 },
|
|
316
|
+
// The deliberate edge case the handoff ships: NO price. "no price listed" is not zero, and the
|
|
317
|
+
// 〜 suffix is dropped with it — 02_VERBAL_IDENTITY is explicit about both.
|
|
318
|
+
{ id: "demo-06", game: "other", code: "SAMPLE-F006", price: null, art: card6 },
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
const NEWS: Array<{ id: string; word: string; art: string }> = [
|
|
322
|
+
{ id: "compare", word: "COMPARE\nWITH CARE.", art: scene1 },
|
|
323
|
+
{ id: "edition", word: "FIND YOUR\nEDITION.", art: scene2 },
|
|
324
|
+
{ id: "watch", word: "YOUR NEXT\nDISCOVERY.", art: scene3 },
|
|
325
|
+
{ id: "sources", word: "READ THE\nDETAILS.", art: scene4 },
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
const RELEASES: Array<{ id: string; day: string }> = [
|
|
329
|
+
{ id: "prism", day: "25" },
|
|
330
|
+
{ id: "notes", day: "28" },
|
|
331
|
+
{ id: "study", day: "30" },
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
const TRUST: Array<{ id: string; icon: typeof Layers }> = [
|
|
335
|
+
{ id: "edition", icon: Layers },
|
|
336
|
+
{ id: "freshness", icon: Clock },
|
|
337
|
+
{ id: "ads", icon: ShieldCheck },
|
|
338
|
+
];
|
|
339
|
+
|
|
340
|
+
const NAV = ["cards", "news", "calendar"] as const;
|
|
341
|
+
|
|
342
|
+
const SHELL = "tm-shell";
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* `Intl.NumberFormat` in the product's own locale, never a hand-rolled thousands separator — and
|
|
346
|
+
* `currency: "JPY"` rather than a "¥" + number concatenation, so the sign, the grouping and the
|
|
347
|
+
* (absent) minor unit are all the locale's answer. The brand: "JPY không thêm hai số thập phân".
|
|
348
|
+
*/
|
|
349
|
+
function jpy(locale: string) {
|
|
350
|
+
return new Intl.NumberFormat(locale, {
|
|
351
|
+
style: "currency",
|
|
352
|
+
currency: "JPY",
|
|
353
|
+
currencyDisplay: "symbol",
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Sections ───────────────────────────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
function DemoNotice() {
|
|
360
|
+
const { t } = useTranslation();
|
|
361
|
+
return (
|
|
362
|
+
<div className="tm-demo-notice">
|
|
363
|
+
<Text as="p" size="2xs" tone="default">
|
|
364
|
+
{t("tcgm.demoNotice")}
|
|
365
|
+
</Text>
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function Header({ onMenu }: { onMenu: () => void }) {
|
|
371
|
+
const { t } = useTranslation();
|
|
372
|
+
return (
|
|
373
|
+
<header className="tm-header">
|
|
374
|
+
<div className={`${SHELL} tm-header-inner`}>
|
|
375
|
+
<img className="tm-brand" src={lockup} alt={t("tcgm.brandAlt")} width={164} height={52} />
|
|
376
|
+
<nav aria-label={t("tcgm.nav.label")} className="tm-desktop-only">
|
|
377
|
+
<Flex direction="row" gap="xs" align="center" wrap>
|
|
378
|
+
{NAV.map((key, i) => (
|
|
379
|
+
<Button
|
|
380
|
+
key={key}
|
|
381
|
+
variant="ghost"
|
|
382
|
+
size="sm"
|
|
383
|
+
aria-current={i === 0 ? "page" : undefined}
|
|
384
|
+
>
|
|
385
|
+
{t(`tcgm.nav.${key}`)}
|
|
386
|
+
</Button>
|
|
387
|
+
))}
|
|
388
|
+
</Flex>
|
|
389
|
+
</nav>
|
|
390
|
+
<Flex direction="row" gap="xs" align="center" className="ms-auto">
|
|
391
|
+
<Button
|
|
392
|
+
variant="ghost"
|
|
393
|
+
size="icon"
|
|
394
|
+
aria-label={t("tcgm.header.search")}
|
|
395
|
+
className="tm-desktop-only"
|
|
396
|
+
>
|
|
397
|
+
<Search aria-hidden="true" />
|
|
398
|
+
</Button>
|
|
399
|
+
<Button variant="ghost" size="icon" aria-label={t("tcgm.header.watchlist")}>
|
|
400
|
+
<Heart aria-hidden="true" />
|
|
401
|
+
</Button>
|
|
402
|
+
<Button variant="outline" size="sm" className="tm-desktop-only">
|
|
403
|
+
{t("tcgm.header.tryApp")}
|
|
404
|
+
</Button>
|
|
405
|
+
<Button
|
|
406
|
+
variant="ghost"
|
|
407
|
+
size="icon"
|
|
408
|
+
aria-label={t("tcgm.header.openMenu")}
|
|
409
|
+
className="tm-mobile-only"
|
|
410
|
+
onClick={onMenu}
|
|
411
|
+
>
|
|
412
|
+
<Menu aria-hidden="true" />
|
|
413
|
+
</Button>
|
|
414
|
+
</Flex>
|
|
415
|
+
</div>
|
|
416
|
+
</header>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function Hero({ onSearch }: { onSearch: (q: string) => void }) {
|
|
421
|
+
const { t } = useTranslation();
|
|
422
|
+
const hints: Array<[typeof Layers, string]> = [
|
|
423
|
+
[Layers, t("tcgm.hero.hintTitles")],
|
|
424
|
+
[ArrowLeftRight, t("tcgm.hero.hintCompare")],
|
|
425
|
+
[Sparkles, t("tcgm.hero.hintDiscover")],
|
|
426
|
+
];
|
|
427
|
+
return (
|
|
428
|
+
<div className={SHELL}>
|
|
429
|
+
<section className="tm-hero">
|
|
430
|
+
{/* NO Reveal around the copy. 04_MOTION_AND_STATES: "Hero once 480ms … chỉ illustration,
|
|
431
|
+
text hiện ngay" — the headline and the search field must be readable and operable on
|
|
432
|
+
the first frame, so only the artwork below is wrapped. */}
|
|
433
|
+
<div className="tm-hero-copy">
|
|
434
|
+
<Flex direction="col" gap="md" align="start">
|
|
435
|
+
<p className="tm-eyebrow">{t("tcgm.hero.eyebrow")}</p>
|
|
436
|
+
<Heading level={1} size="5xl" weight="bold">
|
|
437
|
+
{t("tcgm.hero.titleLine1")}
|
|
438
|
+
<br />
|
|
439
|
+
{t("tcgm.hero.titleLine2")}
|
|
440
|
+
</Heading>
|
|
441
|
+
<Text as="p" size="md" tone="muted">
|
|
442
|
+
{t("tcgm.hero.leadLine1")}
|
|
443
|
+
<br />
|
|
444
|
+
{t("tcgm.hero.leadLine2")}
|
|
445
|
+
</Text>
|
|
446
|
+
<SearchInput
|
|
447
|
+
className="tm-hero-search"
|
|
448
|
+
placeholder={t("tcgm.hero.searchPlaceholder")}
|
|
449
|
+
ariaLabel={t("tcgm.hero.searchLabel")}
|
|
450
|
+
onSearch={onSearch}
|
|
451
|
+
/>
|
|
452
|
+
<Flex direction="row" gap="md" wrap>
|
|
453
|
+
{hints.map(([Glyph, label]) => (
|
|
454
|
+
<Flex key={label} direction="row" gap="xs" align="center">
|
|
455
|
+
<Icon as={Glyph} size="xs" tone="muted" />
|
|
456
|
+
<Text as="span" size="2xs" tone="muted">
|
|
457
|
+
{label}
|
|
458
|
+
</Text>
|
|
459
|
+
</Flex>
|
|
460
|
+
))}
|
|
461
|
+
</Flex>
|
|
462
|
+
</Flex>
|
|
463
|
+
</div>
|
|
464
|
+
<div className="tm-hero-art">
|
|
465
|
+
{/* The hero's ONE decorative motion region, played once on mount at the brand's
|
|
466
|
+
480ms/y8 timing. `Reveal` drops the animation entirely under prefers-reduced-motion
|
|
467
|
+
and its resting state is the FINISHED one, so the artwork is never hidden. */}
|
|
468
|
+
<Reveal on="mount">
|
|
469
|
+
<img
|
|
470
|
+
src={heroPrism}
|
|
471
|
+
alt={t("tcgm.hero.artAlt")}
|
|
472
|
+
width={1200}
|
|
473
|
+
height={760}
|
|
474
|
+
fetchPriority="high"
|
|
475
|
+
/>
|
|
476
|
+
</Reveal>
|
|
477
|
+
<div className="tm-art-tag">
|
|
478
|
+
<Icon as={Sparkles} size="xs" tone="primary" />
|
|
479
|
+
<Text as="span" size="2xs">
|
|
480
|
+
{t("tcgm.hero.artTag")}
|
|
481
|
+
</Text>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
</section>
|
|
485
|
+
</div>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function GameRail({ value, onValueChange }: { value: Game; onValueChange: (g: Game) => void }) {
|
|
490
|
+
const { t } = useTranslation();
|
|
491
|
+
return (
|
|
492
|
+
<div className={SHELL}>
|
|
493
|
+
<ToggleGroup
|
|
494
|
+
type="single"
|
|
495
|
+
disallowEmptySelection
|
|
496
|
+
variant="outline"
|
|
497
|
+
shape="pill"
|
|
498
|
+
size="lg"
|
|
499
|
+
wrap
|
|
500
|
+
value={value}
|
|
501
|
+
onValueChange={(v) => onValueChange(v as Game)}
|
|
502
|
+
aria-label={t("tcgm.games.label")}
|
|
503
|
+
className="mt-6"
|
|
504
|
+
>
|
|
505
|
+
{GAMES.map((key) => (
|
|
506
|
+
<ToggleGroupItem key={key} value={key}>
|
|
507
|
+
{t(`tcgm.games.${key}`)}
|
|
508
|
+
</ToggleGroupItem>
|
|
509
|
+
))}
|
|
510
|
+
</ToggleGroup>
|
|
511
|
+
</div>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function SectionHead({
|
|
516
|
+
eyebrow,
|
|
517
|
+
title,
|
|
518
|
+
action,
|
|
519
|
+
}: {
|
|
520
|
+
eyebrow: string;
|
|
521
|
+
title: React.ReactNode;
|
|
522
|
+
action?: string;
|
|
523
|
+
}) {
|
|
524
|
+
return (
|
|
525
|
+
<Flex direction="row" justify="between" align="end" gap="md" wrap>
|
|
526
|
+
<Flex direction="col" gap="xs">
|
|
527
|
+
<p className="tm-eyebrow">{eyebrow}</p>
|
|
528
|
+
<Heading level={2} size="3xl" weight="bold">
|
|
529
|
+
{title}
|
|
530
|
+
</Heading>
|
|
531
|
+
</Flex>
|
|
532
|
+
{action ? (
|
|
533
|
+
<Button variant="link" size="sm">
|
|
534
|
+
{action}
|
|
535
|
+
<ArrowRight aria-hidden="true" />
|
|
536
|
+
</Button>
|
|
537
|
+
) : null}
|
|
538
|
+
</Flex>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function TradingCard({
|
|
543
|
+
card,
|
|
544
|
+
watched,
|
|
545
|
+
onWatch,
|
|
546
|
+
}: {
|
|
547
|
+
card: SampleCard;
|
|
548
|
+
watched: boolean;
|
|
549
|
+
onWatch: (card: SampleCard) => void;
|
|
550
|
+
}) {
|
|
551
|
+
// NOT `Card asChild`: this card contains a second control (the watch toggle), and a card that is
|
|
552
|
+
// itself one link may not contain another — `hoverable` + a real control inside is the branch
|
|
553
|
+
// the Card docblock names for exactly this shape.
|
|
554
|
+
const { t, locale } = useTranslation();
|
|
555
|
+
const name = t(`tcgm.cards.${card.id}.name`);
|
|
556
|
+
return (
|
|
557
|
+
<Card hoverable className="tm-card-body">
|
|
558
|
+
<Button
|
|
559
|
+
className="tm-watch"
|
|
560
|
+
variant="ghost"
|
|
561
|
+
size="icon"
|
|
562
|
+
aria-pressed={watched}
|
|
563
|
+
// The accessible name says what the press will DO, and it is a whole message per state —
|
|
564
|
+
// never a sentence glued together from a name and a verb, which is unbuildable in a
|
|
565
|
+
// language that puts them in the other order.
|
|
566
|
+
aria-label={t(watched ? "tcgm.card.watchRemove" : "tcgm.card.watchAdd", { name })}
|
|
567
|
+
onClick={() => onWatch(card)}
|
|
568
|
+
>
|
|
569
|
+
<Heart aria-hidden="true" />
|
|
570
|
+
</Button>
|
|
571
|
+
<CardContent solo>
|
|
572
|
+
<Flex direction="col" gap="xs">
|
|
573
|
+
<div className="tm-artwell">
|
|
574
|
+
<Badge className="tm-sample" variant="outline" tone="default" as="span">
|
|
575
|
+
{t("tcgm.card.sampleBadge")}
|
|
576
|
+
</Badge>
|
|
577
|
+
<img
|
|
578
|
+
src={card.art}
|
|
579
|
+
alt={t("tcgm.card.artAlt", { name })}
|
|
580
|
+
width={140}
|
|
581
|
+
height={196}
|
|
582
|
+
loading="lazy"
|
|
583
|
+
/>
|
|
584
|
+
</div>
|
|
585
|
+
<Text as="p" size="2xs" tone="muted">
|
|
586
|
+
{t("tcgm.card.classifiedAs", { game: t(`tcgm.games.${card.game}`) })}
|
|
587
|
+
</Text>
|
|
588
|
+
<Heading level={3} size="sm" weight="bold" className="tm-card-name">
|
|
589
|
+
{name}
|
|
590
|
+
</Heading>
|
|
591
|
+
<Text as="p" size="2xs" tone="muted" tabular className="tm-latin">
|
|
592
|
+
{card.code} · {t(`tcgm.cards.${card.id}.edition`)}
|
|
593
|
+
</Text>
|
|
594
|
+
<div className="tm-price-row">
|
|
595
|
+
<Flex direction="col" gap={1}>
|
|
596
|
+
<Text as="span" size="2xs" tone="muted">
|
|
597
|
+
{t("tcgm.card.priceLabel")}
|
|
598
|
+
</Text>
|
|
599
|
+
{card.price === null ? (
|
|
600
|
+
<Text as="span" size="sm" weight="bold" tone="muted">
|
|
601
|
+
{t("tcgm.card.noPrice")}
|
|
602
|
+
</Text>
|
|
603
|
+
) : (
|
|
604
|
+
<Text
|
|
605
|
+
as="span"
|
|
606
|
+
size="2xl"
|
|
607
|
+
weight="bold"
|
|
608
|
+
tone="primary"
|
|
609
|
+
tabular
|
|
610
|
+
className="tm-latin"
|
|
611
|
+
>
|
|
612
|
+
{jpy(locale).format(card.price)}
|
|
613
|
+
<Text as="span" size="2xs" weight="regular" tone="muted">
|
|
614
|
+
〜
|
|
615
|
+
</Text>
|
|
616
|
+
</Text>
|
|
617
|
+
)}
|
|
618
|
+
</Flex>
|
|
619
|
+
<Text as="span" size="2xs" tone="muted" align="end" className="tm-price-note">
|
|
620
|
+
{t("tcgm.card.shippingNote")}
|
|
621
|
+
<br />
|
|
622
|
+
{t("tcgm.card.sampleNote")}
|
|
623
|
+
</Text>
|
|
624
|
+
</div>
|
|
625
|
+
</Flex>
|
|
626
|
+
</CardContent>
|
|
627
|
+
</Card>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The card grid. `columns` steps are CONTAINER widths, not viewport ones, which is why the third
|
|
633
|
+
* column is keyed to `sm` (40rem) and not `md`: inside the 1280px shell the grid's own box is the
|
|
634
|
+
* viewport minus the 32px gutters, so a 768 tablet hands it 704px. Keyed to `md` (48rem) it stayed
|
|
635
|
+
* 2-up there, where the prototype shows 3.
|
|
636
|
+
*/
|
|
637
|
+
function Collection({
|
|
638
|
+
cards,
|
|
639
|
+
watched,
|
|
640
|
+
onWatch,
|
|
641
|
+
onReset,
|
|
642
|
+
}: {
|
|
643
|
+
cards: SampleCard[];
|
|
644
|
+
watched: ReadonlySet<string>;
|
|
645
|
+
onWatch: (card: SampleCard) => void;
|
|
646
|
+
onReset: () => void;
|
|
647
|
+
}) {
|
|
648
|
+
const { t } = useTranslation();
|
|
649
|
+
return (
|
|
650
|
+
<section className={`${SHELL} tm-section`}>
|
|
651
|
+
<Flex direction="col" gap="lg">
|
|
652
|
+
<Reveal on="view">
|
|
653
|
+
<SectionHead
|
|
654
|
+
eyebrow={t("tcgm.collection.eyebrow")}
|
|
655
|
+
title={t("tcgm.collection.title")}
|
|
656
|
+
action={t("tcgm.collection.action")}
|
|
657
|
+
/>
|
|
658
|
+
</Reveal>
|
|
659
|
+
<Text as="p" size="2xs" tone="muted" role="status">
|
|
660
|
+
{/* The count goes THROUGH the message, so a locale that pluralises decides its own
|
|
661
|
+
form — never `{n}` glued to a literal counter word at the call site. */}
|
|
662
|
+
{t("tcgm.collection.count", { count: cards.length })} · {t("tcgm.collection.note")}
|
|
663
|
+
</Text>
|
|
664
|
+
{cards.length ? (
|
|
665
|
+
<ResponsiveGrid columns={{ base: 2, sm: 3, lg: 4 }} gap="lg" align="stretch">
|
|
666
|
+
{cards.map((card, i) => (
|
|
667
|
+
// `on="view"` + a stagger ordinal: the row cascades in as it reaches the viewport,
|
|
668
|
+
// capped at the ladder's 6 steps so a long list never waits half a second.
|
|
669
|
+
<Reveal key={card.id} on="view" delay={(Math.min(i, 5) + 1) as 1 | 2 | 3 | 4 | 5 | 6}>
|
|
670
|
+
<TradingCard card={card} watched={watched.has(card.id)} onWatch={onWatch} />
|
|
671
|
+
</Reveal>
|
|
672
|
+
))}
|
|
673
|
+
</ResponsiveGrid>
|
|
674
|
+
) : (
|
|
675
|
+
<EmptyState
|
|
676
|
+
variant="section"
|
|
677
|
+
icon={Layers}
|
|
678
|
+
title={t("tcgm.collection.emptyTitle")}
|
|
679
|
+
description={t("tcgm.collection.emptyBody")}
|
|
680
|
+
action={
|
|
681
|
+
<Button variant="outline" onClick={onReset}>
|
|
682
|
+
{t("tcgm.collection.reset")}
|
|
683
|
+
</Button>
|
|
684
|
+
}
|
|
685
|
+
/>
|
|
686
|
+
)}
|
|
687
|
+
</Flex>
|
|
688
|
+
</section>
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function NewsRail() {
|
|
693
|
+
const { t } = useTranslation();
|
|
694
|
+
return (
|
|
695
|
+
<section className={`${SHELL} tm-section`}>
|
|
696
|
+
<Flex direction="col" gap="lg">
|
|
697
|
+
<Reveal on="view">
|
|
698
|
+
<SectionHead
|
|
699
|
+
eyebrow={t("tcgm.news.eyebrow")}
|
|
700
|
+
title={t("tcgm.news.title")}
|
|
701
|
+
action={t("tcgm.news.action")}
|
|
702
|
+
/>
|
|
703
|
+
</Reveal>
|
|
704
|
+
{/* Manual by default, with real previous/next controls beside the rail. The brand allows
|
|
705
|
+
autoplay only behind a pause control and forbids it as the default; the library's
|
|
706
|
+
Carousel ships no autoplay plugin unless one is passed, so the default IS the spec.
|
|
707
|
+
|
|
708
|
+
THE REVEAL WRAPS THE RAIL, NOT THE SLIDES — and this is a measured correction, not a
|
|
709
|
+
preference. Per-slide `on="view"` put an IntersectionObserver on a box the carousel
|
|
710
|
+
viewport CLIPS (`overflow: hidden`), so a slide parked outside the window has an empty
|
|
711
|
+
intersection rect and never leaves `data-reveal-state="out"`: measured on this page at
|
|
712
|
+
1440, 3 of the 4 slides sat at opacity 0 after scrolling the whole document, and stayed
|
|
713
|
+
there. That is the one failure Reveal's own docblock forbids — "a reveal that never
|
|
714
|
+
reveals". One observer on the unclipped section answers instead, and it also keeps the
|
|
715
|
+
brand's motion budget (one decorative motion region per viewport) rather than spending
|
|
716
|
+
it on four staggered cards inside a rail. */}
|
|
717
|
+
<Reveal on="view">
|
|
718
|
+
<Carousel opts={{ align: "start", containScroll: "trimSnaps" }}>
|
|
719
|
+
<CarouselContent>
|
|
720
|
+
{NEWS.map((n) => (
|
|
721
|
+
<CarouselItem key={n.id} className="basis-4/5 sm:basis-1/2 lg:basis-1/3">
|
|
722
|
+
<Card hoverable className="h-full overflow-hidden">
|
|
723
|
+
<div className="tm-news-cover">
|
|
724
|
+
<img src={n.art} alt="" loading="lazy" />
|
|
725
|
+
<span className="tm-news-word">
|
|
726
|
+
{n.word.split("\n").map((line, li) => (
|
|
727
|
+
<React.Fragment key={line}>
|
|
728
|
+
{li ? <br /> : null}
|
|
729
|
+
{line}
|
|
730
|
+
</React.Fragment>
|
|
731
|
+
))}
|
|
732
|
+
</span>
|
|
733
|
+
</div>
|
|
734
|
+
<CardContent solo>
|
|
735
|
+
<Flex direction="col" gap="sm" align="start">
|
|
736
|
+
<Flex direction="row" gap="sm" align="center" wrap>
|
|
737
|
+
<Badge variant="secondary" as="span">
|
|
738
|
+
{t(`tcgm.articles.${n.id}.cat`)}
|
|
739
|
+
</Badge>
|
|
740
|
+
<Text as="span" size="2xs" tone="muted">
|
|
741
|
+
{t("tcgm.news.sample")}
|
|
742
|
+
</Text>
|
|
743
|
+
</Flex>
|
|
744
|
+
<Heading level={3} size="md" weight="bold">
|
|
745
|
+
{t(`tcgm.articles.${n.id}.title`)}
|
|
746
|
+
</Heading>
|
|
747
|
+
<Text as="p" size="2xs" tone="muted" tabular>
|
|
748
|
+
{t("tcgm.news.meta")}
|
|
749
|
+
</Text>
|
|
750
|
+
</Flex>
|
|
751
|
+
</CardContent>
|
|
752
|
+
</Card>
|
|
753
|
+
</CarouselItem>
|
|
754
|
+
))}
|
|
755
|
+
</CarouselContent>
|
|
756
|
+
<Flex direction="row" gap="sm" align="center" justify="end" className="mt-4">
|
|
757
|
+
<Text as="span" size="2xs" tone="muted" className="me-auto">
|
|
758
|
+
{t("tcgm.news.manual")}
|
|
759
|
+
</Text>
|
|
760
|
+
<CarouselPrevious />
|
|
761
|
+
<CarouselNext />
|
|
762
|
+
</Flex>
|
|
763
|
+
</Carousel>
|
|
764
|
+
</Reveal>
|
|
765
|
+
</Flex>
|
|
766
|
+
</section>
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function Releases({ onPick }: { onPick: (title: string) => void }) {
|
|
771
|
+
const { t } = useTranslation();
|
|
772
|
+
return (
|
|
773
|
+
<section className={`${SHELL} tm-section`}>
|
|
774
|
+
<Flex direction="col" gap="lg">
|
|
775
|
+
<Reveal on="view">
|
|
776
|
+
<SectionHead
|
|
777
|
+
eyebrow={t("tcgm.releases.eyebrow")}
|
|
778
|
+
title={t("tcgm.releases.title")}
|
|
779
|
+
action={t("tcgm.releases.action")}
|
|
780
|
+
/>
|
|
781
|
+
</Reveal>
|
|
782
|
+
<ResponsiveGrid columns={{ base: 1, md: 3 }} gap="md" align="stretch">
|
|
783
|
+
{RELEASES.map((r, i) => (
|
|
784
|
+
<Reveal key={r.id} on="view" delay={(i + 1) as 1 | 2 | 3}>
|
|
785
|
+
<Card hoverable className="h-full">
|
|
786
|
+
<CardContent solo>
|
|
787
|
+
<Flex direction="row" gap="md" align="center">
|
|
788
|
+
<div className="tm-datebox">
|
|
789
|
+
<Text as="span" size="2xs" tone="inherit" className="block tracking-widest">
|
|
790
|
+
SEP
|
|
791
|
+
</Text>
|
|
792
|
+
<Text
|
|
793
|
+
as="span"
|
|
794
|
+
size="2xl"
|
|
795
|
+
weight="bold"
|
|
796
|
+
tone="inherit"
|
|
797
|
+
tabular
|
|
798
|
+
className="tm-latin"
|
|
799
|
+
>
|
|
800
|
+
{r.day}
|
|
801
|
+
</Text>
|
|
802
|
+
</div>
|
|
803
|
+
<Flex direction="col" gap={1} className="min-w-0">
|
|
804
|
+
<Text as="span" size="2xs" tone="muted">
|
|
805
|
+
{t(`tcgm.release.${r.id}.kind`)}
|
|
806
|
+
</Text>
|
|
807
|
+
<Heading level={3} size="sm" weight="bold">
|
|
808
|
+
{t(`tcgm.release.${r.id}.title`)}
|
|
809
|
+
</Heading>
|
|
810
|
+
<Button
|
|
811
|
+
variant="link"
|
|
812
|
+
size="xs"
|
|
813
|
+
className="justify-start"
|
|
814
|
+
onClick={() => onPick(t(`tcgm.release.${r.id}.title`))}
|
|
815
|
+
>
|
|
816
|
+
{t("tcgm.releases.view")}
|
|
817
|
+
<ArrowRight aria-hidden="true" />
|
|
818
|
+
</Button>
|
|
819
|
+
</Flex>
|
|
820
|
+
</Flex>
|
|
821
|
+
</CardContent>
|
|
822
|
+
</Card>
|
|
823
|
+
</Reveal>
|
|
824
|
+
))}
|
|
825
|
+
</ResponsiveGrid>
|
|
826
|
+
</Flex>
|
|
827
|
+
</section>
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function Trust() {
|
|
832
|
+
const { t } = useTranslation();
|
|
833
|
+
return (
|
|
834
|
+
<section className={`${SHELL} tm-section`}>
|
|
835
|
+
<Reveal on="view">
|
|
836
|
+
<div className="tm-trust">
|
|
837
|
+
<div className="tm-trust-grid">
|
|
838
|
+
<Flex direction="col" gap="md" align="start">
|
|
839
|
+
<p className="tm-eyebrow">{t("tcgm.trust.eyebrow")}</p>
|
|
840
|
+
<Heading level={2} size="3xl" weight="bold">
|
|
841
|
+
{t("tcgm.trust.titleLine1")}
|
|
842
|
+
<br />
|
|
843
|
+
{t("tcgm.trust.titleLine2")}
|
|
844
|
+
</Heading>
|
|
845
|
+
<Text as="p" size="sm" tone="muted">
|
|
846
|
+
{t("tcgm.trust.bodyLine1")}
|
|
847
|
+
<br />
|
|
848
|
+
{t("tcgm.trust.bodyLine2")}
|
|
849
|
+
</Text>
|
|
850
|
+
<Button variant="link" size="sm" className="px-0">
|
|
851
|
+
{t("tcgm.trust.link")}
|
|
852
|
+
<ArrowRight aria-hidden="true" />
|
|
853
|
+
</Button>
|
|
854
|
+
</Flex>
|
|
855
|
+
<Flex direction="col" gap="lg">
|
|
856
|
+
{TRUST.map((item, i) => (
|
|
857
|
+
<Reveal key={item.id} on="view" delay={(i + 1) as 1 | 2 | 3}>
|
|
858
|
+
<Flex direction="row" gap="md" align="start">
|
|
859
|
+
<span className="tm-trust-icon">
|
|
860
|
+
<Icon as={item.icon} size="md" tone="inherit" />
|
|
861
|
+
</span>
|
|
862
|
+
<Flex direction="col" gap={1}>
|
|
863
|
+
<Heading level={3} size="sm" weight="bold">
|
|
864
|
+
{t(`tcgm.trust.${item.id}Title`)}
|
|
865
|
+
</Heading>
|
|
866
|
+
<Text as="p" size="sm" tone="muted">
|
|
867
|
+
{t(`tcgm.trust.${item.id}Body`)}
|
|
868
|
+
</Text>
|
|
869
|
+
</Flex>
|
|
870
|
+
</Flex>
|
|
871
|
+
</Reveal>
|
|
872
|
+
))}
|
|
873
|
+
</Flex>
|
|
874
|
+
</div>
|
|
875
|
+
</div>
|
|
876
|
+
</Reveal>
|
|
877
|
+
</section>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function Footer() {
|
|
882
|
+
const { t } = useTranslation();
|
|
883
|
+
const cols: Array<[string, string[]]> = [
|
|
884
|
+
["colFind", ["linkSearch", "linkCompare", "linkWatchlist"]],
|
|
885
|
+
["colRead", ["linkNews", "linkCalendar", "linkBasics"]],
|
|
886
|
+
["colAbout", ["linkMethod", "linkComponents", "linkConsent"]],
|
|
887
|
+
];
|
|
888
|
+
return (
|
|
889
|
+
<footer className="tm-footer">
|
|
890
|
+
<div className={`${SHELL} tm-footer-grid`}>
|
|
891
|
+
<Flex direction="col" gap="sm" className="tm-footer-main">
|
|
892
|
+
<img
|
|
893
|
+
className="tm-brand"
|
|
894
|
+
src={lockup}
|
|
895
|
+
alt={t("tcgm.brandAlt")}
|
|
896
|
+
width={164}
|
|
897
|
+
height={52}
|
|
898
|
+
loading="lazy"
|
|
899
|
+
/>
|
|
900
|
+
<Text as="p" size="sm" tone="muted">
|
|
901
|
+
{t("tcgm.footer.taglineLine1")}
|
|
902
|
+
<br />
|
|
903
|
+
{t("tcgm.footer.taglineLine2")}
|
|
904
|
+
</Text>
|
|
905
|
+
<Text as="p" size="2xs" tone="muted">
|
|
906
|
+
{t("tcgm.footer.accent")}
|
|
907
|
+
</Text>
|
|
908
|
+
</Flex>
|
|
909
|
+
{cols.map(([head, items]) => (
|
|
910
|
+
<Flex key={head} direction="col" gap="xs" align="start">
|
|
911
|
+
<Text as="div" size="2xs" weight="bold">
|
|
912
|
+
{t(`tcgm.footer.${head}`)}
|
|
913
|
+
</Text>
|
|
914
|
+
{items.map((item) => (
|
|
915
|
+
<Button key={item} variant="link" size="sm" className="text-muted-foreground px-0">
|
|
916
|
+
{t(`tcgm.footer.${item}`)}
|
|
917
|
+
</Button>
|
|
918
|
+
))}
|
|
919
|
+
</Flex>
|
|
920
|
+
))}
|
|
921
|
+
</div>
|
|
922
|
+
<div className={SHELL}>
|
|
923
|
+
<Separator space="lg" />
|
|
924
|
+
<Flex direction="row" justify="between" gap="sm" wrap>
|
|
925
|
+
<Text as="span" size="2xs" tone="muted">
|
|
926
|
+
{t("tcgm.footer.legal")}
|
|
927
|
+
</Text>
|
|
928
|
+
<Text as="span" size="2xs" tone="muted">
|
|
929
|
+
{t("tcgm.footer.disclaimer")}
|
|
930
|
+
</Text>
|
|
931
|
+
</Flex>
|
|
932
|
+
</div>
|
|
933
|
+
</footer>
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ── Page ───────────────────────────────────────────────────────────────────────────────────────
|
|
938
|
+
|
|
939
|
+
export default function TcgmWebsiteShowcase() {
|
|
940
|
+
const { t } = useTranslation();
|
|
941
|
+
const [game, setGame] = React.useState<Game>("all");
|
|
942
|
+
const [query, setQuery] = React.useState("");
|
|
943
|
+
const [watched, setWatched] = React.useState<ReadonlySet<string>>(() => new Set());
|
|
944
|
+
const [menuOpen, setMenuOpen] = React.useState(false);
|
|
945
|
+
const [release, setRelease] = React.useState<string | null>(null);
|
|
946
|
+
|
|
947
|
+
const cards = React.useMemo(() => {
|
|
948
|
+
const q = query.trim().toLowerCase();
|
|
949
|
+
// Searching the RENDERED strings, not a frozen copy of them: the card's name and edition are
|
|
950
|
+
// whatever the active locale says they are, so a viewer typing in the language on screen finds
|
|
951
|
+
// the card. `localeCompare`-free because this is a substring test, not an ordering.
|
|
952
|
+
return CARDS.filter(
|
|
953
|
+
(c) =>
|
|
954
|
+
(game === "all" || c.game === game) &&
|
|
955
|
+
(!q ||
|
|
956
|
+
[
|
|
957
|
+
t(`tcgm.cards.${c.id}.name`),
|
|
958
|
+
c.code,
|
|
959
|
+
t(`tcgm.cards.${c.id}.edition`),
|
|
960
|
+
t(`tcgm.games.${c.game}`),
|
|
961
|
+
].some((field) => field.toLowerCase().includes(q))),
|
|
962
|
+
);
|
|
963
|
+
}, [game, query, t]);
|
|
964
|
+
|
|
965
|
+
const handleWatch = React.useCallback(
|
|
966
|
+
(card: SampleCard) => {
|
|
967
|
+
setWatched((prev) => {
|
|
968
|
+
const next = new Set(prev);
|
|
969
|
+
const had = next.has(card.id);
|
|
970
|
+
if (had) next.delete(card.id);
|
|
971
|
+
else next.add(card.id);
|
|
972
|
+
// The brand's own copy for this pair (02_VERBAL_IDENTITY), and the demo must never imply a
|
|
973
|
+
// sync or a notification it does not perform.
|
|
974
|
+
toast(t(had ? "tcgm.toast.removed" : "tcgm.toast.saved"));
|
|
975
|
+
return next;
|
|
976
|
+
});
|
|
977
|
+
},
|
|
978
|
+
[t],
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
const reset = React.useCallback(() => {
|
|
982
|
+
setGame("all");
|
|
983
|
+
setQuery("");
|
|
984
|
+
}, []);
|
|
985
|
+
|
|
986
|
+
return (
|
|
987
|
+
<div data-tenant="tcgm">
|
|
988
|
+
<style>{THEME}</style>
|
|
989
|
+
<DemoNotice />
|
|
990
|
+
<Header onMenu={() => setMenuOpen(true)} />
|
|
991
|
+
<main>
|
|
992
|
+
<Hero onSearch={setQuery} />
|
|
993
|
+
<GameRail value={game} onValueChange={setGame} />
|
|
994
|
+
<Collection cards={cards} watched={watched} onWatch={handleWatch} onReset={reset} />
|
|
995
|
+
<NewsRail />
|
|
996
|
+
<Releases onPick={setRelease} />
|
|
997
|
+
<Trust />
|
|
998
|
+
</main>
|
|
999
|
+
<Footer />
|
|
1000
|
+
|
|
1001
|
+
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
|
1002
|
+
<SheetContent side="right">
|
|
1003
|
+
<SheetHeader>
|
|
1004
|
+
<SheetTitle>{t("tcgm.header.menu")}</SheetTitle>
|
|
1005
|
+
</SheetHeader>
|
|
1006
|
+
<SheetBody>
|
|
1007
|
+
<Flex direction="col" gap="xs" align="start">
|
|
1008
|
+
{[...NAV, "watchlist", "method"].map((key) => (
|
|
1009
|
+
<Button key={key} variant="ghost" size="lg" fullWidth className="justify-start">
|
|
1010
|
+
{t(`tcgm.nav.${key}`)}
|
|
1011
|
+
<ArrowRight aria-hidden="true" className="ms-auto" />
|
|
1012
|
+
</Button>
|
|
1013
|
+
))}
|
|
1014
|
+
</Flex>
|
|
1015
|
+
</SheetBody>
|
|
1016
|
+
</SheetContent>
|
|
1017
|
+
</Sheet>
|
|
1018
|
+
|
|
1019
|
+
<Dialog open={release !== null} onOpenChange={(open) => !open && setRelease(null)}>
|
|
1020
|
+
<DialogContent>
|
|
1021
|
+
<DialogHeader>
|
|
1022
|
+
<DialogTitle>{t("tcgm.dialog.title")}</DialogTitle>
|
|
1023
|
+
<DialogDescription>{t("tcgm.dialog.body")}</DialogDescription>
|
|
1024
|
+
</DialogHeader>
|
|
1025
|
+
<DialogBody>
|
|
1026
|
+
<Flex direction="col" gap="sm" align="start">
|
|
1027
|
+
<Badge variant="secondary" as="span">
|
|
1028
|
+
{t("tcgm.dialog.sample")}
|
|
1029
|
+
</Badge>
|
|
1030
|
+
<Heading level={3} size="md" weight="bold">
|
|
1031
|
+
{release}
|
|
1032
|
+
</Heading>
|
|
1033
|
+
<Flex direction="row" gap="xs" align="center">
|
|
1034
|
+
<Icon as={CalendarDays} size="xs" tone="muted" />
|
|
1035
|
+
<Text as="span" size="2xs" tone="muted" tabular>
|
|
1036
|
+
{t("tcgm.dialog.date")}
|
|
1037
|
+
</Text>
|
|
1038
|
+
</Flex>
|
|
1039
|
+
</Flex>
|
|
1040
|
+
</DialogBody>
|
|
1041
|
+
</DialogContent>
|
|
1042
|
+
</Dialog>
|
|
1043
|
+
|
|
1044
|
+
<Toaster />
|
|
1045
|
+
</div>
|
|
1046
|
+
);
|
|
1047
|
+
}
|