@breathe-ds/ui 0.3.0 → 0.5.1
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/README.md +88 -7
- package/components/AnchorNav.tsx +12 -4
- package/components/Button.tsx +11 -4
- package/components/Byline.tsx +3 -1
- package/components/CareSteps.tsx +86 -0
- package/components/ChatPreview.tsx +138 -0
- package/components/CourseCard.tsx +139 -0
- package/components/DateBadge.tsx +24 -0
- package/components/EventFacts.tsx +62 -0
- package/components/EventList.tsx +49 -0
- package/components/EventRow.tsx +102 -0
- package/components/FeaturedPosts.tsx +80 -0
- package/components/Footer.tsx +47 -10
- package/components/Header.tsx +4 -2
- package/components/PageVideoHero.tsx +65 -23
- package/components/PauseButton.tsx +41 -0
- package/components/PhotoSequence.tsx +99 -0
- package/components/PlanTimeline.tsx +66 -0
- package/components/PostArticle.tsx +102 -0
- package/components/PostCard.tsx +69 -0
- package/components/ProfessionalCard.tsx +155 -50
- package/components/Rail.tsx +94 -0
- package/components/RegistrationBox.tsx +82 -0
- package/components/Rows.tsx +9 -3
- package/components/SidePanel.tsx +96 -0
- package/components/StoryPanel.tsx +107 -0
- package/components/useOnAppear.ts +39 -0
- package/package.json +36 -6
- package/theme.css +140 -0
- package/tokens/creeta.css +2 -0
- package/tokens/neutral.css +2 -0
- package/tokens/ttcab.css +13 -5
package/README.md
CHANGED
|
@@ -57,6 +57,21 @@ For a site without a ready-made token file, define the roles on `:root` itself:
|
|
|
57
57
|
and `scroll-padding` (6rem top, 7rem bottom) so keyboard focus never lands
|
|
58
58
|
hidden behind the sticky Header or the BottomNav (WCAG 2.4.11).
|
|
59
59
|
|
|
60
|
+
## Using it in a site (Tailwind v4)
|
|
61
|
+
|
|
62
|
+
```css
|
|
63
|
+
@import "tailwindcss";
|
|
64
|
+
@import "@breathe-ds/ui/theme.css"; /* the roles */
|
|
65
|
+
@import "@breathe-ds/ui/tokens/ttcab.css"; /* this brand's hex (or creeta.css, neutral.css) */
|
|
66
|
+
/* Tailwind only generates the classes it finds written: scan the components */
|
|
67
|
+
@source "../../node_modules/@breathe-ds/ui/components";
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The components are TSX sources, not a build: in Next.js add
|
|
71
|
+
`transpilePackages: ["@breathe-ds/ui"]` to `next.config`. The CSS entries of
|
|
72
|
+
the package are exported with the `style` condition, which Tailwind's resolver
|
|
73
|
+
requires (a plain string export fails with "Can't resolve").
|
|
74
|
+
|
|
60
75
|
## Token roles
|
|
61
76
|
|
|
62
77
|
| Token (Tailwind) | Role | `--cg-*` |
|
|
@@ -69,7 +84,17 @@ hidden behind the sticky Header or the BottomNav (WCAG 2.4.11).
|
|
|
69
84
|
| `ink-body` | reading body text | `--cg-ink-body` |
|
|
70
85
|
| `action` | CTA / highlight — **never plain text** | `--cg-action` |
|
|
71
86
|
| `action-text` | text in the action color (AA) | `--cg-action-text` |
|
|
87
|
+
| `on-action` | text ON the action color (the accent button) | `--cg-on-action` |
|
|
88
|
+
| `action-tint` | highlighted panel or card: a 6% wash of the action color over the surface | `--cg-action-tint` |
|
|
89
|
+
| `action-line` | border of a highlighted card (closed tone, 3:1 on the surface; pure ttcab orange is 1.91:1) | `--cg-action-line` |
|
|
72
90
|
| `line` | hairlines / borders | `--cg-line` |
|
|
91
|
+
|
|
92
|
+
**Dose of the action color** (what brands with a hot accent do, Cloudflare orange,
|
|
93
|
+
Airbnb red): the action color is the fill of ONE element per block, with
|
|
94
|
+
`on-action` text on it; links stay in `ink`/`ink-soft` with an underline; a
|
|
95
|
+
highlighted card is `action-tint` + `action-line`, not the full color; the
|
|
96
|
+
color as text only as `action-text`. The "Action dose" section of the playground
|
|
97
|
+
shows the disciplined and the loud version side by side.
|
|
73
98
|
| `font-heading` / `font-body` | typography | `--cg-font-*` |
|
|
74
99
|
| `radius-xl/2xl/pill` · `ease-cg` | radius and motion curve | fixed |
|
|
75
100
|
|
|
@@ -82,10 +107,11 @@ Values from the standard WCAG formula (the same ones `preview/` computes live):
|
|
|
82
107
|
| surface `#fbf5e9` / ink `#144753` | 9.41:1 | AAA |
|
|
83
108
|
| surface `#fbf5e9` / ink-soft `#54707a` | 4.86:1 | AA |
|
|
84
109
|
| surface `#fbf5e9` / ink-body `#1e4f5b` | 8.32:1 | AAA |
|
|
85
|
-
| surface `#fbf5e9` / action-text `#
|
|
110
|
+
| surface `#fbf5e9` / action-text `#bd3f0c` | 4.98:1 | AA |
|
|
111
|
+
| action `#e8541e` / on-action `#ffffff` (accent button) | 3.67:1 | 3:1 UI component; below AA text, conscious exception |
|
|
86
112
|
| surface-panel `#144753` / cream text | 9.41:1 | AAA |
|
|
87
113
|
|
|
88
|
-
`action` `#
|
|
114
|
+
`action` `#e8541e` is a vivid red-orange with white text, the Cloudflare/Airbnb route: 3.67:1 passes the 3:1 of UI components, not the 4.5:1 of normal text. A conscious exception, limited to the accent Button (one per block, same type scale as the other buttons so they never look mismatched); small controls (chips, tabs, segments) use `ink` or `action-tint` + `action-text` instead. The UI orange does not match the logo's `#f6a22a` on purpose. As text, use `action-text`.
|
|
89
115
|
|
|
90
116
|
## Family typography
|
|
91
117
|
|
|
@@ -200,9 +226,17 @@ from color, not from extra markup.
|
|
|
200
226
|
replace the arrow). The ProfessionalCard is built on them.
|
|
201
227
|
- **ProfessionalCard** / **ProfessionalModal** / **CaptionBand**: the team card of
|
|
202
228
|
novo.creeta — portrait (plain, living clip on hover, or with the "Presentation"
|
|
203
|
-
chip), name, role, registration (**always**: COFFITO 414/2012)
|
|
204
|
-
|
|
205
|
-
|
|
229
|
+
chip), name, role, registration (**always**: COFFITO 414/2012) and generic
|
|
230
|
+
`rows` (collapse with text or links, link with arrow or icon — on creeta:
|
|
231
|
+
trajectory, guidance, Instagram); the modal plays the presentation with sound,
|
|
232
|
+
the live caption band (active WebVTT cue) above the name, and the full `bio`.
|
|
233
|
+
Three sizes (`size`): `full` (team page), `medium` (the home's tile — 3:4
|
|
234
|
+
portrait, name, "Meet" badge on hover, a link to the full card; `shortName`,
|
|
235
|
+
`href`) and `micro` (the "who treats" row — 72px photo, name, role, link).
|
|
236
|
+
- **Rail** / **RailItem**: novo.creeta's `Trilho` — on the phone a snapping
|
|
237
|
+
horizontal rail with a progress bar underneath (shown only when it scrolls),
|
|
238
|
+
a grid from md (`grid` classes written in full). RailItem presets the phone
|
|
239
|
+
width: `wide` (treatments, videos) or `narrow` (team).
|
|
206
240
|
- **Byline** / **PersonChip** / **Avatar**: who signs or explains content
|
|
207
241
|
(novo.creeta) — stacked portraits + eyebrow + linked names, or portrait + name
|
|
208
242
|
+ "Watch the video ↗"; the person chip (portrait, name, role · registration,
|
|
@@ -234,9 +268,56 @@ from color, not from extra markup.
|
|
|
234
268
|
- **CtaCard** / **VideoInviteCard**: the final call of a novo.creeta page — white
|
|
235
269
|
card (title, lead, CTA slot) beside a video card: muted loop with the speaker's
|
|
236
270
|
name over it, pause control, play button that plays the full video with sound.
|
|
271
|
+
- **CareSteps**: "how the care works" of novo.creeta — a bento mosaic of
|
|
272
|
+
numbered steps (drawing above, text below; the first takes two columns) with
|
|
273
|
+
the invite on the dark panel closing the sequence. The drawings are slots:
|
|
274
|
+
**ChatPreview** (a sample chat, bubble by bubble, the clinic "types"; loops
|
|
275
|
+
while on screen, pause button), **PlanTimeline** (three stages, the line comes
|
|
276
|
+
down and each dot lights up; 6s loop, pause button), **PhotoSequence** (photos
|
|
277
|
+
with fade and a slow zoom, CSS only, paused by a hidden checkbox), or a still
|
|
278
|
+
image. All stand still and whole with reduced motion. `useOnAppear` (animate
|
|
279
|
+
when half on screen, only while on screen) and `PauseButton` (44px, 32px
|
|
280
|
+
circle) are exported for your own drawings.
|
|
281
|
+
- **Blog**: creeta's "guidance" area — **PostCard** (the card of the list:
|
|
282
|
+
cover, category, title as the link stretched over the card, excerpt in three
|
|
283
|
+
lines, author · date), **FeaturedPosts** ("in the words of who treats you":
|
|
284
|
+
bento, the first post wide with the cover beside the text, a Byline with the
|
|
285
|
+
speaker and "Watch the video ↗" at the foot) and **PostArticle** (the page:
|
|
286
|
+
trail, title, PersonChip and date, the text with its notice, the video beside
|
|
287
|
+
it sticky from md, the transcript open; children for "other posts" and the
|
|
288
|
+
final call).
|
|
289
|
+
- **SidePanel**: Luma's drawer — a native `<dialog>` that slides in from the
|
|
290
|
+
right, full height, up to 32rem wide (full width on the phone), with a top
|
|
291
|
+
bar (close on the left, actions such as "Copy link" and "Course page ↗" on
|
|
292
|
+
the right) and a scrolling body. Esc, the backdrop and the button close it;
|
|
293
|
+
focus goes back to what opened it. The playground opens one from a
|
|
294
|
+
CourseCard (`onSelect`) with the whole class inside: cover, facts,
|
|
295
|
+
RegistrationBox with the button straight to the school's cart, programme,
|
|
296
|
+
discounts, rules.
|
|
297
|
+
- **CourseCard**: the class card (ttcab's "Formação", the school's class list)
|
|
298
|
+
in the family's own vocabulary: course name as the category line, the city as
|
|
299
|
+
the title with the seats pill, the facts as EventFacts tiles (DateBadge for
|
|
300
|
+
the start, pin for the venue with its map link, clock for the workload), who
|
|
301
|
+
teaches as a Byline, a cover photo at the top, and at the foot the price with
|
|
302
|
+
its note beside the Button `accent`; `soldOut` puts the pill in the button's
|
|
303
|
+
place; `onSelect` makes the card a button stretched over it (the links inside
|
|
304
|
+
keep working) to open its details. By tone: no border, no shadow, no lift.
|
|
305
|
+
- **Events** (after Luma and Meetup, for the school's classes): **EventList**
|
|
306
|
+
(days down a dotted rail, each with its rows), **EventRow** (time with an
|
|
307
|
+
accented end, title as the stretched link, organizers, location, who is going
|
|
308
|
+
with "+N", square cover, an optional badge), **EventFacts** (date on a
|
|
309
|
+
calendar tile, venue on a pin tile with its link), **DateBadge** and
|
|
310
|
+
**RegistrationBox** (a strip title, ticket options as a radio group with the
|
|
311
|
+
remaining pill and the price, the one button at the foot).
|
|
312
|
+
- **StoryPanel**: creeta's "origin" block — one dark panel (surface-panel)
|
|
313
|
+
grouping a story: media at the top with a film that fades into the panel, the
|
|
314
|
+
logo at its base, title, lead, three pillars in columns with a hairline above
|
|
315
|
+
each, and the outbound links. For "who we are" and "why us" blocks.
|
|
237
316
|
- **Footer**: the site-footer of novo.creeta, by tokens — full width like the
|
|
238
317
|
Header, with the signed brand + sentence, "Pages" (title on the first line, links in two
|
|
239
|
-
columns
|
|
318
|
+
columns; with many pages, `pages.groups` — titled columns, up to four side by
|
|
319
|
+
side, so the list gets wider instead of taller), "Where we are" (address, hours,
|
|
320
|
+
"Directions ↗ · Waze ↗"), an optional
|
|
240
321
|
notice band on `surface`, the legal line with the **CNPJ (always required in
|
|
241
322
|
the family)** plus the contact chips, and the languages as plain links.
|
|
242
323
|
`navHiddenFrom` keeps room at the bottom for the BottomNav.
|
|
@@ -264,7 +345,7 @@ from color, not from extra markup.
|
|
|
264
345
|
**Data rule:** every professional MUST have a **CREFITO** attached (COFFITO requirement).
|
|
265
346
|
- [x] **Pill / PillList** — the Atalho of novo.creeta (white 44px pill, arrow bubble: ↓ anchor, ↗ other page, optional meta); wrapping row + "See all"
|
|
266
347
|
- [x] **AnchorNav** (floating capsule of anchors, scroll-spy + sliding indicator) — extracted from ttcab (10/29)
|
|
267
|
-
- [x] **Footer** · **PageVideoHero** (loop and player) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
|
|
348
|
+
- [x] **Footer** · **PageVideoHero** (loop, and player from a YouTube id or the site's own mp4) · **Header** (dropdown, house) · **BottomNav** · **Curtain** · **Lockup** (5 arrangements) · **BreatheMark**
|
|
268
349
|
- [x] **Site brand marks** (`components/marks/`): real symbol + wordmark for TTCAB and Creeta (10/29)
|
|
269
350
|
- [ ] **ThreadRail** (side graphic device — a thread running down the left linking sections) — approved,
|
|
270
351
|
**already exists in ttcab** → extract.
|
package/components/AnchorNav.tsx
CHANGED
|
@@ -36,7 +36,8 @@ type Props = {
|
|
|
36
36
|
* The active signal = the indicator (ink/12 fill + outline), NOT text color:
|
|
37
37
|
* the text is always `ink` on `surface`, with no low-contrast frame.
|
|
38
38
|
* prefers-reduced-motion removes the slide. Target sections need a
|
|
39
|
-
* `scroll-mt-*`
|
|
39
|
+
* `scroll-mt-*` that, added to the theme's `scroll-padding-top` (96px), clears
|
|
40
|
+
* the header plus the capsule (e.g. `scroll-mt-20` with an 80px header).
|
|
40
41
|
*/
|
|
41
42
|
export function AnchorNav({ items, stickyTop = "top-20", linkComponent, className = "", labels }: Props) {
|
|
42
43
|
const L: ElementType = linkComponent ?? "a";
|
|
@@ -45,6 +46,10 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
45
46
|
const listRef = useRef<HTMLUListElement>(null);
|
|
46
47
|
const linkRefs = useRef<Record<string, HTMLAnchorElement | null>>({});
|
|
47
48
|
const [pill, setPill] = useState({ x: 0, y: 0, w: 0, h: 0, ready: false });
|
|
49
|
+
// Stable key of the items: sites pass the array inline, so a new reference
|
|
50
|
+
// on every render. Keyed by reference, the arrival effect below re-ran on
|
|
51
|
+
// every page scroll and kept dragging the page back to the hash.
|
|
52
|
+
const key = items.map((it) => it.id).join("|");
|
|
48
53
|
|
|
49
54
|
// Scroll-spy
|
|
50
55
|
useEffect(() => {
|
|
@@ -63,11 +68,13 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
63
68
|
);
|
|
64
69
|
sections.forEach((s) => io.observe(s));
|
|
65
70
|
return () => io.disconnect();
|
|
66
|
-
|
|
71
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
72
|
+
}, [key]);
|
|
67
73
|
|
|
68
74
|
// Arriving with a #hash via client-side navigation: scrolls to the anchor (on
|
|
69
75
|
// a full load the browser already does it; here it's idempotent). Reapplies a
|
|
70
|
-
// few times to beat the router's late scroll reset.
|
|
76
|
+
// few times to beat the router's late scroll reset. Runs once, on arrival:
|
|
77
|
+
// the hash the person sets by clicking a pill is the browser's job.
|
|
71
78
|
useEffect(() => {
|
|
72
79
|
const hash = window.location.hash.slice(1);
|
|
73
80
|
if (!hash || !items.some((it) => it.id === hash)) return;
|
|
@@ -80,7 +87,8 @@ export function AnchorNav({ items, stickyTop = "top-20", linkComponent, classNam
|
|
|
80
87
|
if (++tries >= 4) window.clearInterval(id);
|
|
81
88
|
}, 60);
|
|
82
89
|
return () => window.clearInterval(id);
|
|
83
|
-
|
|
90
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
+
}, [key]);
|
|
84
92
|
|
|
85
93
|
const measure = () => {
|
|
86
94
|
const el = linkRefs.current[active];
|
package/components/Button.tsx
CHANGED
|
@@ -15,10 +15,17 @@ const linkBase =
|
|
|
15
15
|
"group inline-flex items-center gap-1.5 font-body font-semibold underline decoration-[1.5px] decoration-current/50 underline-offset-4 transition-[text-decoration-color,text-underline-offset] duration-200 hover:decoration-current hover:underline-offset-[5px] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink rounded-[2px] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&>svg]:transition-transform [&>svg]:duration-200 hover:[&>svg]:-translate-y-0.5 hover:[&>svg]:translate-x-0.5 motion-reduce:[&>svg]:transform-none";
|
|
16
16
|
|
|
17
17
|
const variants: Record<Exclude<Variant, "link">, string> = {
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
// action color
|
|
21
|
-
|
|
18
|
+
// Flat, as the pill buttons of novo.creeta: no drop shadow, no lift; the hover
|
|
19
|
+
// is a change of fill and the tap scales the pill (pillBase). A shadow under a
|
|
20
|
+
// hot action color read as a grey halo (2026-10-29).
|
|
21
|
+
// solid petrol, cream text (e.g. "Falar com a equipe"): hover goes to ink-soft (creeta's teal-escuro)
|
|
22
|
+
solid: "bg-ink text-surface hover:bg-ink-soft",
|
|
23
|
+
// action color (orange/magenta) + on-action text (from the token, varies per site).
|
|
24
|
+
// Same type scale as the other variants: buttons side by side must share a size.
|
|
25
|
+
// On ttcab (white on the vivid red-orange) that is 3.67:1, a conscious exception
|
|
26
|
+
// (Cloudflare and Airbnb ship the same): passes the 3:1 of UI components, not
|
|
27
|
+
// the 4.5:1 of normal text. Keep the accent for ONE large button per block.
|
|
28
|
+
accent: "bg-action text-on-action hover:brightness-[0.92]",
|
|
22
29
|
outline: "border border-line text-ink hover:bg-ink/[0.05]",
|
|
23
30
|
ghost: "text-ink hover:bg-ink/[0.06]",
|
|
24
31
|
};
|
package/components/Byline.tsx
CHANGED
|
@@ -51,11 +51,13 @@ export function Byline({ people, eyebrow, action, joiners = { sep: ", ", last: "
|
|
|
51
51
|
)}
|
|
52
52
|
</span>
|
|
53
53
|
));
|
|
54
|
+
// min-h-11 keeps the 44px tap target; -my-3 takes those extra 12px out of the
|
|
55
|
+
// flow, so when the link wraps under the names the two lines read as one caption
|
|
54
56
|
const actionEl = action ? (
|
|
55
57
|
<L
|
|
56
58
|
href={action.href}
|
|
57
59
|
{...(action.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
58
|
-
className="inline-flex min-h-11 items-center gap-1 font-semibold text-ink underline underline-offset-4"
|
|
60
|
+
className="-my-3 inline-flex min-h-11 items-center gap-1 font-semibold text-ink underline underline-offset-4"
|
|
59
61
|
>
|
|
60
62
|
{action.label}
|
|
61
63
|
<ArrowUpRight />
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type CareStep = {
|
|
4
|
+
title: ReactNode;
|
|
5
|
+
/** What fits the phone card (two columns). */
|
|
6
|
+
summary: ReactNode;
|
|
7
|
+
/** The full text, from lg. */
|
|
8
|
+
text: ReactNode;
|
|
9
|
+
/** The drawing above the text: a ChatPreview, a photo, a PlanTimeline, a
|
|
10
|
+
* PhotoSequence… It fills the frame (absolute inset-0). Decorative: it
|
|
11
|
+
* repeats what the text says; only a pause button should reach the keyboard. */
|
|
12
|
+
visual: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Labels = {
|
|
16
|
+
/** sr-only before the title: "Step 1: ". The number on screen is decoration. */
|
|
17
|
+
step: (n: number) => string;
|
|
18
|
+
};
|
|
19
|
+
const EN: Labels = { step: (n) => `Step ${n}: ` };
|
|
20
|
+
|
|
21
|
+
type Props = {
|
|
22
|
+
steps: CareStep[];
|
|
23
|
+
/** The last tile, on the dark panel: the sentence and the action (a WhatsApp link, a Button). */
|
|
24
|
+
invite: { text: ReactNode; action: ReactNode };
|
|
25
|
+
/** Heading level of the step titles. */
|
|
26
|
+
headingLevel?: "h2" | "h3";
|
|
27
|
+
labels?: Partial<Labels>;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The frame of each drawing: surface colour, corner concentric with the
|
|
32
|
+
* card's (28px minus the 12px of breathing room). */
|
|
33
|
+
const FRAME = "relative h-32 overflow-hidden rounded-2xl bg-surface md:h-52";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How the care works, from the first contact to the treatment — creeta's
|
|
37
|
+
* `Passos`, by tokens. A bento mosaic: each step has its drawing above and
|
|
38
|
+
* its text below; the first takes two columns and the invite closes the
|
|
39
|
+
* sequence:
|
|
40
|
+
*
|
|
41
|
+
* [ 01, wide ] 02 phone: [ 01, wide ]
|
|
42
|
+
* 03 04 invite 02 03
|
|
43
|
+
* 04 invite
|
|
44
|
+
*
|
|
45
|
+
* The invite comes last: it is the conclusion of the steps.
|
|
46
|
+
*/
|
|
47
|
+
export function CareSteps({ steps, invite, headingLevel: Title = "h3", labels, className = "" }: Props) {
|
|
48
|
+
const t = { ...EN, ...labels };
|
|
49
|
+
return (
|
|
50
|
+
<div className={`grid grid-cols-2 gap-3 md:grid-cols-3 md:gap-5 ${className}`}>
|
|
51
|
+
{/* contents: the <li> become tiles of the mosaic and the list stays a list */}
|
|
52
|
+
<ol className="contents">
|
|
53
|
+
{steps.map((s, i) => (
|
|
54
|
+
<li key={i} className={`flex flex-col rounded-[28px] bg-card p-3 ${i === 0 ? "col-span-2 md:flex-row-reverse" : ""}`}>
|
|
55
|
+
{i === 0 ? (
|
|
56
|
+
<div className="relative overflow-hidden rounded-2xl bg-surface md:w-1/2">{s.visual}</div>
|
|
57
|
+
) : (
|
|
58
|
+
<div className={FRAME}>{s.visual}</div>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
<div className="flex flex-1 flex-col px-2 pb-2 pt-4 md:px-3 md:pb-3 md:pt-5 lg:px-5 lg:pb-5 lg:pt-6">
|
|
62
|
+
{/* The number is visual reinforcement; the order goes as text for the screen reader. */}
|
|
63
|
+
<span aria-hidden="true" className="font-heading text-[1.75rem] font-normal leading-none tracking-[-0.04em] text-ink-soft md:text-[2.5rem]">
|
|
64
|
+
{String(i + 1).padStart(2, "0")}
|
|
65
|
+
</span>
|
|
66
|
+
{/* 16px on the phone: the column has 141px, and at 18px titles broke into three lines */}
|
|
67
|
+
<Title className={`mt-2 font-heading text-base font-medium leading-snug text-ink md:text-xl ${i === 0 ? "md:mt-auto md:pt-6" : "md:mt-3"}`}>
|
|
68
|
+
<span className="sr-only">{t.step(i + 1)}</span>
|
|
69
|
+
{s.title}
|
|
70
|
+
</Title>
|
|
71
|
+
{/* the full text only when the column fits it: up to lg it has less than 180px */}
|
|
72
|
+
<p className="mt-1.5 text-sm leading-relaxed text-ink-body lg:hidden">{s.summary}</p>
|
|
73
|
+
<p className="mt-2 hidden leading-relaxed text-ink-body lg:block">{s.text}</p>
|
|
74
|
+
</div>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ol>
|
|
78
|
+
|
|
79
|
+
{/* The invite closes the mosaic: it is the last tile. */}
|
|
80
|
+
<div className="flex min-h-[200px] flex-col justify-between gap-6 rounded-[28px] bg-surface-panel p-5 text-surface md:p-6 lg:p-8">
|
|
81
|
+
<p className="font-heading text-[1.375rem] font-medium leading-[1.15] md:text-[2rem] md:leading-[1.12]">{invite.text}</p>
|
|
82
|
+
<div className="self-start">{invite.action}</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
|
4
|
+
import { Clock } from "lucide-react";
|
|
5
|
+
import { reducedMotion, useOnAppear } from "./useOnAppear";
|
|
6
|
+
import { PauseButton } from "./PauseButton";
|
|
7
|
+
|
|
8
|
+
export type ChatMessage = { from: "person" | "clinic"; text: string };
|
|
9
|
+
|
|
10
|
+
type Labels = {
|
|
11
|
+
/** Completes the pause button's name: "Pause the sample conversation". */
|
|
12
|
+
pause: string;
|
|
13
|
+
};
|
|
14
|
+
const EN: Labels = { pause: "the sample conversation" };
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
messages: ChatMessage[];
|
|
18
|
+
/** Small line at the top, with a clock: the opening hours. */
|
|
19
|
+
hours?: string;
|
|
20
|
+
labels?: Partial<Labels>;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** How long the clinic is "typing" before each reply, in ms. */
|
|
25
|
+
const TYPING = 1100;
|
|
26
|
+
/** At the end the conversation rests this long, fades out and restarts. */
|
|
27
|
+
const REST = 3200;
|
|
28
|
+
const EXIT = 350;
|
|
29
|
+
/** Time to read the previous message before the next: grows with its length, 0.9s to 1.9s. */
|
|
30
|
+
export const readingTime = (previous?: ChatMessage) =>
|
|
31
|
+
previous ? Math.min(1900, Math.max(900, 600 + previous.text.length * 28)) : 400;
|
|
32
|
+
|
|
33
|
+
const BUBBLE = "cg-bubble max-w-[15rem] rounded-[20px] px-4 py-3 text-sm leading-snug lg:text-[0.9375rem]";
|
|
34
|
+
const CURVE = "cubic-bezier(0.23, 1, 0.32, 1)";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Step 1 of the care flow (creeta's `Conversa`): a sample conversation, bubble
|
|
38
|
+
* by bubble. The person writes, the clinic "types" and answers.
|
|
39
|
+
* - Starts when half of it is on screen and loops: at the end rests 3.2s,
|
|
40
|
+
* fades and restarts. Only runs while on screen.
|
|
41
|
+
* - Works like a real chat: fixed height, new bubbles come in at the bottom,
|
|
42
|
+
* old ones slide up and vanish at the top. Nothing pushes the page.
|
|
43
|
+
* - Moves by itself for more than 5s, so it has a pause button (WCAG 2.2.2).
|
|
44
|
+
* - Without JavaScript, IntersectionObserver or with reduced motion, the whole
|
|
45
|
+
* conversation shows still, without the button.
|
|
46
|
+
* - The chat is decoration (aria-hidden): the step's text already says what
|
|
47
|
+
* matters. The button stays outside it, for the keyboard and screen reader.
|
|
48
|
+
*/
|
|
49
|
+
export function ChatPreview({ messages, hours, labels, className = "" }: Props) {
|
|
50
|
+
const t = { ...EN, ...labels };
|
|
51
|
+
const { ref, state, onScreen } = useOnAppear<HTMLDivElement>();
|
|
52
|
+
const stack = useRef<HTMLDivElement>(null);
|
|
53
|
+
const height = useRef(0);
|
|
54
|
+
const [shown, setShown] = useState(0);
|
|
55
|
+
const [typing, setTyping] = useState(false);
|
|
56
|
+
const [paused, setPaused] = useState(false);
|
|
57
|
+
const [leaving, setLeaving] = useState(false);
|
|
58
|
+
|
|
59
|
+
const visible = state === "whole" ? messages : state === "waiting" ? [] : messages.slice(0, shown);
|
|
60
|
+
|
|
61
|
+
// The clock: one message at a time, and back to the start.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (state !== "playing" || paused || !onScreen) return;
|
|
64
|
+
const wait = readingTime(messages[shown - 1]);
|
|
65
|
+
const timer =
|
|
66
|
+
shown >= messages.length
|
|
67
|
+
? leaving
|
|
68
|
+
? setTimeout(() => {
|
|
69
|
+
setShown(0);
|
|
70
|
+
setLeaving(false);
|
|
71
|
+
}, EXIT)
|
|
72
|
+
: setTimeout(() => setLeaving(true), REST)
|
|
73
|
+
: messages[shown].from === "person"
|
|
74
|
+
? setTimeout(() => setShown((v) => v + 1), wait + 300)
|
|
75
|
+
: typing
|
|
76
|
+
? setTimeout(() => {
|
|
77
|
+
setTyping(false);
|
|
78
|
+
setShown((v) => v + 1);
|
|
79
|
+
}, TYPING)
|
|
80
|
+
: setTimeout(() => setTyping(true), wait);
|
|
81
|
+
return () => clearTimeout(timer);
|
|
82
|
+
}, [state, paused, onScreen, shown, typing, leaving, messages]);
|
|
83
|
+
|
|
84
|
+
// New bubble at the bottom: the stack grows upward at once. To slide the old
|
|
85
|
+
// ones up, it starts offset by the height it gained and returns (transform only).
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
const el = stack.current;
|
|
88
|
+
if (!el) return;
|
|
89
|
+
const gained = el.offsetHeight - height.current;
|
|
90
|
+
height.current = el.offsetHeight;
|
|
91
|
+
if (state !== "playing" || gained <= 0 || reducedMotion() || !el.animate) return;
|
|
92
|
+
el.animate([{ transform: `translateY(${gained}px)` }, { transform: "translateY(0)" }], { duration: 320, easing: CURVE });
|
|
93
|
+
}, [state, shown, typing]);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div ref={ref} data-state={state} data-paused={paused ? "" : undefined} className={`cg-chat relative h-full min-h-[17.5rem] md:min-h-[20rem] ${className}`}>
|
|
97
|
+
<div aria-hidden="true">
|
|
98
|
+
{hours && (
|
|
99
|
+
<span className="absolute left-4 top-0 flex h-11 items-center gap-1.5 text-xs font-medium text-ink/80 lg:left-7">
|
|
100
|
+
<Clock size={13} strokeWidth={2} />
|
|
101
|
+
{hours}
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
104
|
+
{/* the chat window: what goes past the top fades out */}
|
|
105
|
+
<div className="absolute inset-x-0 bottom-0 top-11 overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_2rem)]">
|
|
106
|
+
<div
|
|
107
|
+
ref={stack}
|
|
108
|
+
className={`absolute inset-x-0 bottom-0 flex flex-col gap-2.5 px-4 pb-4 transition-opacity duration-300 ease-out motion-reduce:transition-none lg:px-7 lg:pb-7 ${leaving ? "opacity-0" : ""}`}
|
|
109
|
+
>
|
|
110
|
+
{visible.map((m, i) => (
|
|
111
|
+
<span
|
|
112
|
+
key={i}
|
|
113
|
+
className={
|
|
114
|
+
m.from === "person"
|
|
115
|
+
? `${BUBBLE} origin-bottom-right self-end rounded-br-md bg-ink text-surface`
|
|
116
|
+
: `${BUBBLE} origin-bottom-left self-start rounded-bl-md bg-card text-ink`
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
{m.text}
|
|
120
|
+
</span>
|
|
121
|
+
))}
|
|
122
|
+
{typing && (
|
|
123
|
+
<span className="cg-bubble cg-typing flex origin-bottom-left gap-1 self-start rounded-[20px] rounded-bl-md bg-card px-4 py-3.5">
|
|
124
|
+
{[0, 1, 2].map((n) => (
|
|
125
|
+
<i key={n} style={{ "--n": n } as CSSProperties} className="h-1.5 w-1.5 rounded-full bg-ink-soft" />
|
|
126
|
+
))}
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{state !== "whole" && (
|
|
134
|
+
<PauseButton paused={paused} onToggle={() => setPaused((p) => !p)} what={t.pause} className="right-0 top-0 lg:right-2" />
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
import { Clock } from "lucide-react";
|
|
3
|
+
import { Byline, type Person } from "./Byline";
|
|
4
|
+
import { EventFacts, DateBadge, type EventFact } from "./EventFacts";
|
|
5
|
+
|
|
6
|
+
type Labels = {
|
|
7
|
+
/** Prefix before the instructors' names ("With"). */
|
|
8
|
+
with: ReactNode;
|
|
9
|
+
/** The pill on the title line and the one in the button's place when sold out. */
|
|
10
|
+
soldOut: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
const EN: Labels = { with: "With", soldOut: "Sold out" };
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
/** Cover photo at the top (the school's pages always have one), 4:3,
|
|
16
|
+
* focused near the top for the portrait photos the school uses. */
|
|
17
|
+
cover?: { src: string; alt?: string; position?: string };
|
|
18
|
+
/** The course ("Basic RTA course 2026"): the heading of the card. */
|
|
19
|
+
course?: ReactNode;
|
|
20
|
+
/** The class ("City · ST"): the line under the heading, with the seats pill.
|
|
21
|
+
* Without `course`, it is the heading itself. */
|
|
22
|
+
title: ReactNode;
|
|
23
|
+
/** The start, for the calendar tile. */
|
|
24
|
+
start: { month: ReactNode; day: ReactNode };
|
|
25
|
+
/** The dates: "3 to 9 November 2026 · 08:30–17:30" and, below, the extra day. */
|
|
26
|
+
when: { title: ReactNode; subtitle?: ReactNode };
|
|
27
|
+
/** The venue (a link to the map when it has an href) and the address. */
|
|
28
|
+
where?: { title: ReactNode; subtitle?: ReactNode; href?: string; external?: boolean };
|
|
29
|
+
/** "69 class hours". */
|
|
30
|
+
workload?: ReactNode;
|
|
31
|
+
/** Seats, as a pill on the title line ("28 seats"). */
|
|
32
|
+
seats?: ReactNode;
|
|
33
|
+
/** Who teaches: portraits and names, each a link when it has an href. */
|
|
34
|
+
instructors?: Person[];
|
|
35
|
+
/** The price beside the button ("R$ 7.280") and a small note ("up to 12 instalments"). */
|
|
36
|
+
price?: { value: ReactNode; note?: ReactNode };
|
|
37
|
+
/** Small note at the foot ("Only physiotherapists can attend."). */
|
|
38
|
+
footnote?: ReactNode;
|
|
39
|
+
/** The call to action: a Button `accent`, the one full-colour element of the card. */
|
|
40
|
+
action?: ReactNode;
|
|
41
|
+
/** No seats left: the pill says so and takes the button's place. */
|
|
42
|
+
soldOut?: boolean;
|
|
43
|
+
/** Makes the card clickable (the title becomes a button stretched over the
|
|
44
|
+
* card) to open its details, e.g. in a SidePanel. Links inside (the map,
|
|
45
|
+
* the instructors, the action) keep working on their own. */
|
|
46
|
+
onSelect?: () => void;
|
|
47
|
+
headingLevel?: "h2" | "h3";
|
|
48
|
+
linkComponent?: ElementType;
|
|
49
|
+
labels?: Partial<Labels>;
|
|
50
|
+
className?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const PILL = "inline-flex items-center rounded-pill bg-ink/[0.08] px-2.5 py-0.5 text-xs font-semibold text-ink";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Course card of the Cuidado Global family — the class card (ttcab's
|
|
57
|
+
* "Formação", the school's class list), in the family's own vocabulary: the
|
|
58
|
+
* course name as the category line, the city as the title with the seats pill
|
|
59
|
+
* (as the EventRow), the facts as tiles (the DateBadge for the start, the pin
|
|
60
|
+
* for the venue, a clock for the workload, as the EventFacts), who teaches as
|
|
61
|
+
* a Byline, and the Button accent pinned at the foot. By tone: no border, no
|
|
62
|
+
* shadow, no lift.
|
|
63
|
+
*/
|
|
64
|
+
export function CourseCard({ cover, course, title, start, when, where, workload, seats, instructors, price, footnote, action, soldOut = false, onSelect, headingLevel: Title = "h3", linkComponent, labels, className = "" }: Props) {
|
|
65
|
+
const t = { ...EN, ...labels };
|
|
66
|
+
const facts: EventFact[] = [{ tile: <DateBadge month={start.month} day={start.day} />, title: when.title, subtitle: when.subtitle }];
|
|
67
|
+
if (where) facts.push({ tile: "pin", title: where.title, subtitle: where.subtitle, href: where.href, external: where.external });
|
|
68
|
+
if (workload)
|
|
69
|
+
facts.push({
|
|
70
|
+
tile: (
|
|
71
|
+
<span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-ink/[0.06] text-ink">
|
|
72
|
+
<Clock size={20} strokeWidth={2} />
|
|
73
|
+
</span>
|
|
74
|
+
),
|
|
75
|
+
title: workload,
|
|
76
|
+
});
|
|
77
|
+
const pill = soldOut ? t.soldOut : seats;
|
|
78
|
+
|
|
79
|
+
const foot = soldOut ? (
|
|
80
|
+
<span className="inline-flex min-h-11 items-center rounded-pill bg-ink/[0.08] px-5 font-heading font-semibold text-ink">{t.soldOut}</span>
|
|
81
|
+
) : (
|
|
82
|
+
action
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<article className={`relative flex flex-col overflow-hidden rounded-[28px] bg-card ${onSelect ? "transition-transform duration-200 ease-out active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 [&_a]:relative [&_a]:z-10 [&_button]:relative [&_button]:z-10" : ""} ${className}`}>
|
|
87
|
+
{cover && (
|
|
88
|
+
<div className="relative aspect-[4/3] overflow-hidden">
|
|
89
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
90
|
+
<img src={cover.src} alt={cover.alt ?? ""} loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover" style={{ objectPosition: cover.position ?? "50% 25%" }} />
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
<div className="flex flex-1 flex-col p-6 md:p-7">
|
|
94
|
+
{/* the course is what the person looks for: it is the heading; the city
|
|
95
|
+
is the variant, on the line below with the seats pill (2026-10-29) */}
|
|
96
|
+
<Title className="font-heading text-xl font-medium leading-snug text-ink md:text-[1.375rem]">
|
|
97
|
+
{onSelect ? (
|
|
98
|
+
// the stretched hit area of the card: this button is the exception to [&_button]:z-10 (static, after: covers the card)
|
|
99
|
+
<button type="button" onClick={onSelect} className="static! z-auto! text-left after:absolute after:inset-0 after:z-0 after:rounded-[28px] focus-visible:outline-none focus-visible:after:outline focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-ink">
|
|
100
|
+
{course ?? title}
|
|
101
|
+
</button>
|
|
102
|
+
) : (
|
|
103
|
+
course ?? title
|
|
104
|
+
)}
|
|
105
|
+
</Title>
|
|
106
|
+
{(course || pill) && (
|
|
107
|
+
<p className="mt-2 flex flex-wrap items-center gap-2 text-base font-semibold text-ink">
|
|
108
|
+
{course && <span>{title}</span>}
|
|
109
|
+
{pill && <span className={PILL}>{pill}</span>}
|
|
110
|
+
</p>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<EventFacts className="mt-5" facts={facts} linkComponent={linkComponent} />
|
|
114
|
+
|
|
115
|
+
{instructors && instructors.length > 0 && (
|
|
116
|
+
<div className="mt-5 flex items-center gap-3 text-sm text-ink-body">
|
|
117
|
+
<span className="shrink-0 text-ink-soft">{t.with}</span>
|
|
118
|
+
<Byline people={instructors} linkComponent={linkComponent} className="min-w-0" />
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{footnote && <p className="mt-5 text-sm text-ink-soft">{footnote}</p>}
|
|
123
|
+
|
|
124
|
+
{/* at the foot: the price on the left, the button on the right; cards side by side align here */}
|
|
125
|
+
{(foot || price) && (
|
|
126
|
+
<div className="mt-auto flex flex-wrap items-center justify-between gap-x-4 gap-y-3 pt-6">
|
|
127
|
+
{price && (
|
|
128
|
+
<p className="leading-tight">
|
|
129
|
+
<span className="block font-heading text-xl font-semibold text-ink">{price.value}</span>
|
|
130
|
+
{price.note && <span className="block text-sm text-ink-soft">{price.note}</span>}
|
|
131
|
+
</p>
|
|
132
|
+
)}
|
|
133
|
+
{foot}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</article>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
/** Short month ("Nov"). */
|
|
5
|
+
month: ReactNode;
|
|
6
|
+
/** Day of the month. */
|
|
7
|
+
day: ReactNode;
|
|
8
|
+
/** 48px (default) or 56px. */
|
|
9
|
+
size?: "md" | "lg";
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** The date as a small calendar tile (Luma's "NOV / 6"): the month small on
|
|
14
|
+
* top, the day large below, on a tinted bubble. Decorative beside the written
|
|
15
|
+
* date; alone, give the parent an accessible text. */
|
|
16
|
+
export function DateBadge({ month, day, size = "md", className = "" }: Props) {
|
|
17
|
+
const box = size === "lg" ? "h-14 w-14 rounded-2xl" : "h-12 w-12 rounded-xl";
|
|
18
|
+
return (
|
|
19
|
+
<span aria-hidden="true" className={`flex ${box} shrink-0 flex-col items-center justify-center bg-ink/[0.06] text-ink ${className}`}>
|
|
20
|
+
<span className="text-[0.6rem] font-bold uppercase leading-none tracking-[0.12em] text-ink-soft">{month}</span>
|
|
21
|
+
<span className={`mt-1 font-heading font-bold leading-none ${size === "lg" ? "text-2xl" : "text-lg"}`}>{day}</span>
|
|
22
|
+
</span>
|
|
23
|
+
);
|
|
24
|
+
}
|