@breathe-ds/ui 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -13
- package/components/Accordion.tsx +6 -4
- package/components/AnchorNav.tsx +12 -4
- package/components/Avatar.tsx +37 -0
- package/components/BottomNav.tsx +1 -1
- package/components/Breadcrumb.tsx +45 -0
- package/components/BreatheMark.tsx +0 -13
- package/components/Button.tsx +11 -4
- package/components/Byline.tsx +105 -0
- package/components/CaptionBand.tsx +74 -0
- package/components/CareSteps.tsx +86 -0
- package/components/ChatPreview.tsx +138 -0
- package/components/CtaCard.tsx +52 -0
- package/components/FactStrip.tsx +55 -0
- package/components/FeaturedPosts.tsx +80 -0
- package/components/Footer.tsx +50 -30
- package/components/Header.tsx +4 -2
- package/components/Heading.tsx +86 -0
- package/components/InfoCard.tsx +116 -0
- package/components/LanguageSwitcher.tsx +1 -1
- package/components/List.tsx +81 -0
- package/components/Lockup.tsx +0 -18
- package/components/MapCard.tsx +158 -0
- package/components/MediaCard.tsx +266 -0
- package/components/PageVideoHero.tsx +65 -23
- package/components/PauseButton.tsx +41 -0
- package/components/PersonChip.tsx +49 -0
- package/components/PhotoSequence.tsx +99 -0
- package/components/Pill.tsx +42 -0
- package/components/PillList.tsx +20 -47
- package/components/PlanTimeline.tsx +66 -0
- package/components/PostArticle.tsx +102 -0
- package/components/PostCard.tsx +69 -0
- package/components/ProfessionalCard.tsx +446 -0
- package/components/ProfessionalModal.tsx +243 -0
- package/components/Rail.tsx +94 -0
- package/components/Rows.tsx +137 -0
- package/components/StoryPanel.tsx +107 -0
- package/components/TestimonialCard.tsx +138 -0
- package/components/Text.tsx +31 -0
- package/components/Timeline.tsx +69 -0
- package/components/VideoInviteCard.tsx +168 -0
- package/components/VideoStoryCard.tsx +382 -0
- package/components/useOnAppear.ts +39 -0
- package/package.json +34 -2
- package/theme.css +206 -0
- package/tokens/creeta.css +7 -2
- package/tokens/neutral.css +12 -4
- package/tokens/ttcab.css +14 -5
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
type RailProps = {
|
|
6
|
+
/** Name of the list for screen readers: "Treatments", "Team". */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Grid classes from md up, written in full (Tailwind only generates whole
|
|
9
|
+
* classes). Default: three columns. */
|
|
10
|
+
grid?: string;
|
|
11
|
+
/** <li> children, usually RailItem. */
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Rail — novo.creeta's `Trilho` (components/trilho.tsx) with the Breathe
|
|
18
|
+
* tokens. On a phone it is a horizontal rail with snap, and the next card
|
|
19
|
+
* shows by half to say there is more. From md it becomes a grid.
|
|
20
|
+
*
|
|
21
|
+
* Under the rail, a small bar follows the finger: it starts the size of what
|
|
22
|
+
* is already in view and fills to the end — it says how much is left. Shown
|
|
23
|
+
* only when the rail really scrolls, decoration for screen readers. The fill
|
|
24
|
+
* is written straight to the style, without re-rendering on every scroll frame.
|
|
25
|
+
*/
|
|
26
|
+
export function Rail({ label, grid = "md:grid-cols-3", children, className = "" }: RailProps) {
|
|
27
|
+
const list = useRef<HTMLUListElement>(null);
|
|
28
|
+
const bar = useRef<HTMLSpanElement>(null);
|
|
29
|
+
const [scrolls, setScrolls] = useState(false);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const ul = list.current;
|
|
33
|
+
if (!ul) return;
|
|
34
|
+
let frame = 0;
|
|
35
|
+
const measure = () => {
|
|
36
|
+
frame = 0;
|
|
37
|
+
const overflow = ul.scrollWidth - ul.clientWidth;
|
|
38
|
+
setScrolls(overflow > 8);
|
|
39
|
+
if (bar.current && ul.scrollWidth > 0) {
|
|
40
|
+
const seen = Math.min(1, (ul.scrollLeft + ul.clientWidth) / ul.scrollWidth);
|
|
41
|
+
bar.current.style.transform = `scaleX(${seen})`;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const ask = () => {
|
|
45
|
+
if (!frame) frame = requestAnimationFrame(measure);
|
|
46
|
+
};
|
|
47
|
+
measure();
|
|
48
|
+
ul.addEventListener("scroll", ask, { passive: true });
|
|
49
|
+
window.addEventListener("resize", ask);
|
|
50
|
+
return () => {
|
|
51
|
+
ul.removeEventListener("scroll", ask);
|
|
52
|
+
window.removeEventListener("resize", ask);
|
|
53
|
+
if (frame) cancelAnimationFrame(frame);
|
|
54
|
+
};
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<ul
|
|
60
|
+
ref={list}
|
|
61
|
+
aria-label={label}
|
|
62
|
+
className={`cg-no-scrollbar -mx-4 flex snap-x snap-mandatory scroll-px-4 gap-3 overflow-x-auto px-4 pb-1 md:mx-0 md:grid md:snap-none md:gap-5 md:overflow-visible md:px-0 ${grid} ${className}`}
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</ul>
|
|
66
|
+
<div
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
data-cg-rail-progress=""
|
|
69
|
+
className={`mx-auto mt-4 h-1 w-14 overflow-hidden rounded-full bg-ink/15 md:hidden ${scrolls ? "" : "hidden"}`}
|
|
70
|
+
>
|
|
71
|
+
<span ref={bar} className="block h-full w-full origin-left rounded-full bg-ink-soft" />
|
|
72
|
+
</div>
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Widths of one item on the rail (phone / sm); from md the grid decides.
|
|
78
|
+
* The next card shows by half: that is what says "there is more". */
|
|
79
|
+
export const RAIL_ITEM = {
|
|
80
|
+
/** Treatments, videos: one card almost full width. */
|
|
81
|
+
wide: "w-[78%] shrink-0 snap-start sm:w-[46%] md:w-auto",
|
|
82
|
+
/** Team (medium ProfessionalCard): two cards and a bit. */
|
|
83
|
+
narrow: "w-[46%] shrink-0 snap-start sm:w-[30%] md:w-auto",
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
type RailItemProps = ComponentPropsWithoutRef<"li"> & {
|
|
87
|
+
/** `wide` (default) or `narrow`. */
|
|
88
|
+
width?: keyof typeof RAIL_ITEM;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** One <li> of the Rail, with the phone width preset. */
|
|
92
|
+
export function RailItem({ width = "wide", className = "", ...rest }: RailItemProps) {
|
|
93
|
+
return <li className={`${RAIL_ITEM[width]} ${className}`} {...rest} />;
|
|
94
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
|
|
4
|
+
import { ArrowDown, ArrowUpRight, ChevronDown } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rows — the list of equal rows at the foot of a card (the Trajectory /
|
|
8
|
+
* Guidance / Instagram block of novo.creeta's ProfessionalCard), made generic.
|
|
9
|
+
*
|
|
10
|
+
* `RowList` draws the hairlines; each child is a `CollapseRow` (opens in place:
|
|
11
|
+
* a text, or a list of links with the count beside the label) or a `LinkRow`
|
|
12
|
+
* (goes somewhere). The arrow follows the family's Pill: ↗ another page, ↓ an
|
|
13
|
+
* anchor on this page; `external` only adds the new tab. Every row is 48px
|
|
14
|
+
* tall for the finger, label in `ink` on `card` (creeta's petrol on white: 10.2:1).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One row: label left, glyph right, 48px tall. */
|
|
18
|
+
export const ROW =
|
|
19
|
+
"flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 text-sm font-semibold text-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink [&::-webkit-details-marker]:hidden";
|
|
20
|
+
|
|
21
|
+
/** Hover tells the two rows apart (2026-10-29): a link row underlines its label
|
|
22
|
+
* (the family's sign for "goes somewhere", as the Footer and the Byline) and
|
|
23
|
+
* its arrow takes the step; a collapse row never underlines — it goes nowhere —
|
|
24
|
+
* its chevron dips 2px, "opens down here", and turns up when open. */
|
|
25
|
+
const ROW_LINK = `${ROW} underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink motion-reduce:transition-none`;
|
|
26
|
+
|
|
27
|
+
const OPEN_CHEVRON =
|
|
28
|
+
"shrink-0 text-ink-soft transition-[transform,translate] duration-200 group-hover/row:translate-y-0.5 group-open/row:rotate-180 group-open/row:group-hover/row:translate-y-0 motion-reduce:transition-none";
|
|
29
|
+
|
|
30
|
+
/** What appears out of nowhere (the content of a <details>) fades in briefly
|
|
31
|
+
* instead of jumping on screen: `@starting-style` via Tailwind's `starting:`
|
|
32
|
+
* variant, no JavaScript. Browsers without support show it at once. */
|
|
33
|
+
export const FADE_IN = "transition-[opacity,translate] duration-[220ms] ease-[var(--ease-cg)] starting:opacity-0 starting:-translate-y-1";
|
|
34
|
+
|
|
35
|
+
const ROW_LINK_ITEM =
|
|
36
|
+
"flex min-h-11 items-center justify-between gap-3 py-1.5 text-sm leading-snug text-ink-soft underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-ink-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none";
|
|
37
|
+
|
|
38
|
+
export type RowListProps = ComponentPropsWithoutRef<"div">;
|
|
39
|
+
|
|
40
|
+
/** The hairlines: a line above the first row and between rows. */
|
|
41
|
+
export function RowList({ className = "", ...rest }: RowListProps) {
|
|
42
|
+
return <div className={`divide-y divide-ink/10 border-t border-ink/10 ${className}`} {...rest} />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RowLink = {
|
|
46
|
+
label: ReactNode;
|
|
47
|
+
href: string;
|
|
48
|
+
/** ↗ another page (default) or ↓ an anchor on this page. */
|
|
49
|
+
arrow?: "go" | "down";
|
|
50
|
+
/** Opens in a new tab (target=_blank). The arrow stays ↗. */
|
|
51
|
+
external?: boolean;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function Arrow({ arrow = "go", size }: { arrow?: "go" | "down"; size: number }) {
|
|
55
|
+
return arrow === "down" ? (
|
|
56
|
+
<ArrowDown aria-hidden="true" size={size} className="cg-arrow-down shrink-0" />
|
|
57
|
+
) : (
|
|
58
|
+
<ArrowUpRight aria-hidden="true" size={size} className="cg-arrow-go shrink-0" />
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type CollapseRowProps = Omit<ComponentPropsWithoutRef<"details">, "children"> & {
|
|
63
|
+
label: ReactNode;
|
|
64
|
+
/** Read by screen readers right after the label (e.g. " of Person name"). */
|
|
65
|
+
srLabel?: ReactNode;
|
|
66
|
+
/** Number beside the label. With `links` it defaults to their count. */
|
|
67
|
+
count?: number;
|
|
68
|
+
/** A list of links as the content: one row per link, with the arrow. */
|
|
69
|
+
links?: RowLink[];
|
|
70
|
+
/** Link component of the app for `links` (e.g. next-intl Link). Default: <a>. */
|
|
71
|
+
linkComponent?: ElementType;
|
|
72
|
+
/** Free content (a string becomes one paragraph). Ignored with `links`. */
|
|
73
|
+
children?: ReactNode;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** A row that opens in place: a native <details>, no JavaScript. */
|
|
77
|
+
export function CollapseRow({ label, srLabel, count, links, linkComponent, children, className = "", ...rest }: CollapseRowProps) {
|
|
78
|
+
const L: ElementType = linkComponent ?? "a";
|
|
79
|
+
const n = count ?? links?.length;
|
|
80
|
+
return (
|
|
81
|
+
<details className={`group/row ${className}`} {...rest}>
|
|
82
|
+
<summary className={ROW}>
|
|
83
|
+
<span>
|
|
84
|
+
{label}
|
|
85
|
+
{srLabel && <span className="sr-only">{srLabel}</span>}
|
|
86
|
+
{n !== undefined && <span className="ml-2 font-normal text-ink/80">{n}</span>}
|
|
87
|
+
</span>
|
|
88
|
+
<ChevronDown aria-hidden="true" size={18} className={OPEN_CHEVRON} />
|
|
89
|
+
</summary>
|
|
90
|
+
{links ? (
|
|
91
|
+
<ul className={`${FADE_IN} pb-2`}>
|
|
92
|
+
{links.map((g, i) => (
|
|
93
|
+
<li key={`${i}-${g.href}`}>
|
|
94
|
+
<L href={g.href} className={ROW_LINK_ITEM} {...(g.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}>
|
|
95
|
+
{g.label}
|
|
96
|
+
<Arrow arrow={g.arrow} size={16} />
|
|
97
|
+
</L>
|
|
98
|
+
</li>
|
|
99
|
+
))}
|
|
100
|
+
</ul>
|
|
101
|
+
) : typeof children === "string" ? (
|
|
102
|
+
<p className={`${FADE_IN} pb-4 text-sm leading-relaxed text-ink/80`}>{children}</p>
|
|
103
|
+
) : (
|
|
104
|
+
<div className={`${FADE_IN} space-y-3 pb-4 text-sm leading-relaxed text-ink/80`}>{children}</div>
|
|
105
|
+
)}
|
|
106
|
+
</details>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type LinkRowProps = Omit<ComponentPropsWithoutRef<"a">, "children"> & {
|
|
111
|
+
label: ReactNode;
|
|
112
|
+
href: string;
|
|
113
|
+
/** Read by screen readers right after the label (e.g. " of Person name (opens in a new tab)"). */
|
|
114
|
+
srLabel?: ReactNode;
|
|
115
|
+
/** ↗ another page (default) or ↓ an anchor on this page. */
|
|
116
|
+
arrow?: "go" | "down";
|
|
117
|
+
/** Opens in a new tab (target=_blank). The arrow stays ↗. */
|
|
118
|
+
external?: boolean;
|
|
119
|
+
/** Replaces the arrow (an 18px glyph; the slot is `ink-soft`). */
|
|
120
|
+
icon?: ReactNode;
|
|
121
|
+
/** Link component of the app (e.g. next-intl Link). Default: <a>. */
|
|
122
|
+
linkComponent?: ElementType;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** A row that goes somewhere: the label and, on the right, the arrow or an icon. */
|
|
126
|
+
export function LinkRow({ label, href, srLabel, arrow, external = false, icon, linkComponent, className = "", ...rest }: LinkRowProps) {
|
|
127
|
+
const L: ElementType = linkComponent ?? "a";
|
|
128
|
+
return (
|
|
129
|
+
<L href={href} className={`${ROW_LINK} ${className}`} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} {...rest}>
|
|
130
|
+
<span>
|
|
131
|
+
{label}
|
|
132
|
+
{srLabel && <span className="sr-only">{srLabel}</span>}
|
|
133
|
+
</span>
|
|
134
|
+
<span className="flex shrink-0 text-ink-soft">{icon ?? <Arrow arrow={arrow} size={18} />}</span>
|
|
135
|
+
</L>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type StoryPillar = { title: ReactNode; text: ReactNode };
|
|
4
|
+
export type StoryLink = { label: ReactNode; href: string; external?: boolean };
|
|
5
|
+
|
|
6
|
+
type Labels = {
|
|
7
|
+
/** sr-only after an external link. */
|
|
8
|
+
newTab: string;
|
|
9
|
+
};
|
|
10
|
+
const EN: Labels = { newTab: "(opens in a new tab)" };
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
/** What fills the media box at the top: a BackgroundVideo, an <img>
|
|
14
|
+
* (absolute inset-0, object-cover). The panel's film fades it into the panel. */
|
|
15
|
+
media: ReactNode;
|
|
16
|
+
/** Box of the media: 4:3 on the phone, 21:8 from md (creeta). */
|
|
17
|
+
mediaAspect?: string;
|
|
18
|
+
/** Logo for the dark background, at the base of the media where the film is already full. */
|
|
19
|
+
logo?: ReactNode;
|
|
20
|
+
title: ReactNode;
|
|
21
|
+
lead?: ReactNode;
|
|
22
|
+
/** Three columns with a hairline on top: a short title and a paragraph. */
|
|
23
|
+
pillars: StoryPillar[];
|
|
24
|
+
/** Outbound links at the end ("Learn about the method ↗"). */
|
|
25
|
+
links?: StoryLink[];
|
|
26
|
+
headingLevel?: "h2" | "h3";
|
|
27
|
+
/** Colour of the pillar titles. Default `text-surface`; creeta passes its light teal. */
|
|
28
|
+
pillarTitleClassName?: string;
|
|
29
|
+
linkComponent?: ElementType;
|
|
30
|
+
labels?: Partial<Labels>;
|
|
31
|
+
className?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ArrowUpRight = () => (
|
|
35
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const link =
|
|
39
|
+
"inline-flex min-h-11 items-center gap-1.5 font-semibold underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Story panel of the Cuidado Global family — creeta's `OrigemRta`, by tokens:
|
|
43
|
+
* a dark panel (surface-panel) that groups a story in one piece. Media at the
|
|
44
|
+
* top with a film that fades into the panel, the logo at its base, a title and
|
|
45
|
+
* a lead, three pillars in columns with a hairline above each, and the outbound
|
|
46
|
+
* links. Good for "who we are" blocks: one rounded card, everything inside.
|
|
47
|
+
*/
|
|
48
|
+
export function StoryPanel({
|
|
49
|
+
media,
|
|
50
|
+
mediaAspect = "aspect-[4/3] md:aspect-[21/8]",
|
|
51
|
+
logo,
|
|
52
|
+
title,
|
|
53
|
+
lead,
|
|
54
|
+
pillars,
|
|
55
|
+
links,
|
|
56
|
+
headingLevel: Title = "h2",
|
|
57
|
+
pillarTitleClassName = "text-surface",
|
|
58
|
+
linkComponent,
|
|
59
|
+
labels,
|
|
60
|
+
className = "",
|
|
61
|
+
}: Props) {
|
|
62
|
+
const L: ElementType = linkComponent ?? "a";
|
|
63
|
+
const t = { ...EN, ...labels };
|
|
64
|
+
return (
|
|
65
|
+
<section className={`overflow-hidden rounded-[28px] bg-surface-panel text-surface ${className}`}>
|
|
66
|
+
<div className={`relative ${mediaAspect}`}>
|
|
67
|
+
{media}
|
|
68
|
+
{/* -bottom-px: the box's height is fractional and a light half pixel showed under the film */}
|
|
69
|
+
<span aria-hidden="true" className="pointer-events-none absolute inset-0 -bottom-px bg-gradient-to-t from-surface-panel from-10% via-surface-panel/70 via-40% to-surface-panel/10" />
|
|
70
|
+
{logo && <div className="absolute bottom-2 left-6 md:bottom-4 md:left-12">{logo}</div>}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div className="px-6 pb-9 pt-7 md:px-12 md:pb-14 md:pt-9">
|
|
74
|
+
<Title className="max-w-3xl font-heading text-[1.75rem] font-medium leading-[1.12] tracking-[-0.01em] md:text-5xl">{title}</Title>
|
|
75
|
+
{lead && <p className="mt-4 max-w-2xl text-lg leading-relaxed text-surface/85">{lead}</p>}
|
|
76
|
+
|
|
77
|
+
<ul className="mt-8 grid list-none gap-6 md:mt-12 md:grid-cols-3 md:gap-10">
|
|
78
|
+
{pillars.map((p, i) => (
|
|
79
|
+
<li key={i} className="border-t border-surface/25 pt-5">
|
|
80
|
+
<p className={`font-heading text-2xl font-medium ${pillarTitleClassName}`}>{p.title}</p>
|
|
81
|
+
<p className="mt-2 leading-relaxed text-surface/85">{p.text}</p>
|
|
82
|
+
</li>
|
|
83
|
+
))}
|
|
84
|
+
</ul>
|
|
85
|
+
|
|
86
|
+
{links && links.length > 0 && (
|
|
87
|
+
<div className="mt-8 flex flex-wrap gap-x-8 gap-y-1 md:mt-10">
|
|
88
|
+
{links.map((l, i) =>
|
|
89
|
+
l.external ? (
|
|
90
|
+
<a key={i} href={l.href} target="_blank" rel="noopener noreferrer" className={link}>
|
|
91
|
+
{l.label}
|
|
92
|
+
<ArrowUpRight />
|
|
93
|
+
<span className="sr-only"> {t.newTab}</span>
|
|
94
|
+
</a>
|
|
95
|
+
) : (
|
|
96
|
+
<L key={i} href={l.href} className={link}>
|
|
97
|
+
{l.label}
|
|
98
|
+
<ArrowUpRight />
|
|
99
|
+
</L>
|
|
100
|
+
),
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
</section>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ElementType, type ReactNode } from "react";
|
|
4
|
+
import { Play } from "lucide-react";
|
|
5
|
+
import { Byline } from "./Byline";
|
|
6
|
+
|
|
7
|
+
type Labels = {
|
|
8
|
+
/** sr-only text of the play button: "Watch: {description}". */
|
|
9
|
+
watch: (description: string) => string;
|
|
10
|
+
/** sr-only after the outbound link. */
|
|
11
|
+
newTab: string;
|
|
12
|
+
};
|
|
13
|
+
const EN: Labels = { watch: (description) => `Watch: ${description}`, newTab: " (opens in a new tab)" };
|
|
14
|
+
|
|
15
|
+
export type TestimonialCaptions = {
|
|
16
|
+
/** WebVTT file in the page's language. Drawn by the browser over the video:
|
|
17
|
+
* only for clips WITHOUT burned-in captions. */
|
|
18
|
+
src: string;
|
|
19
|
+
/** BCP 47 tag ("en", "pt-BR"). */
|
|
20
|
+
srcLang: string;
|
|
21
|
+
/** Name of the language, for the browser's captions menu. */
|
|
22
|
+
label?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type TestimonialVideo = {
|
|
26
|
+
/** The clip. Downloaded only on tap. */
|
|
27
|
+
src: string;
|
|
28
|
+
/** Still frame shown until the person taps. */
|
|
29
|
+
poster: string;
|
|
30
|
+
/** What the video shows, for screen readers ("Tereza's mother tells… (1 min 39 s)"). */
|
|
31
|
+
description: string;
|
|
32
|
+
/** Plays with audio (testimonials have speech). Default: true. The play is
|
|
33
|
+
* a click by the person, so the browser allows the sound. */
|
|
34
|
+
withSound?: boolean;
|
|
35
|
+
captions?: TestimonialCaptions;
|
|
36
|
+
/** Replaces the default poster <img> (the site's own image component).
|
|
37
|
+
* Must fill the box (h-full w-full object-cover). */
|
|
38
|
+
media?: ReactNode;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type Props = {
|
|
42
|
+
/** The family's words, with their own cuts, in a <blockquote>. */
|
|
43
|
+
quote: ReactNode;
|
|
44
|
+
/** Who speaks, as shown on the site ("Tereza's family"). Goes in <cite>, with
|
|
45
|
+
* the portrait (or the initials) in front — the family's Byline. */
|
|
46
|
+
author?: string;
|
|
47
|
+
authorPhoto?: string;
|
|
48
|
+
authorAvatar?: ReactNode;
|
|
49
|
+
/** Two or more voices ("Ana and João"): stacked portraits, names joined. Replaces `author`. */
|
|
50
|
+
authors?: { name: string; photo?: string; avatar?: ReactNode }[];
|
|
51
|
+
/** The public source of the quote ("View on Instagram ↗"), opened in a new tab. */
|
|
52
|
+
link?: { href: string; label: ReactNode };
|
|
53
|
+
/** The family's video, on the left. */
|
|
54
|
+
video: TestimonialVideo;
|
|
55
|
+
/** Element rendered as the card root (default <div>); `li` inside a <ul>. */
|
|
56
|
+
as?: ElementType;
|
|
57
|
+
labels?: Partial<Labels>;
|
|
58
|
+
className?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Short video that only downloads and plays on tap, with the family's round
|
|
62
|
+
* play button (creeta's `VideoAoToque`). Before the tap it is just the poster:
|
|
63
|
+
* the browser's controls (0:00, volume) over the image clashed. No aspect
|
|
64
|
+
* ratio: the height comes from the grid row (the text beside it). */
|
|
65
|
+
function TapVideo({ src, poster, description, withSound = true, captions, media, label }: TestimonialVideo & { label: string }) {
|
|
66
|
+
const [playing, setPlaying] = useState(false);
|
|
67
|
+
const frame = "relative min-h-[16rem] overflow-hidden bg-ink";
|
|
68
|
+
|
|
69
|
+
if (playing) {
|
|
70
|
+
return (
|
|
71
|
+
<div className={frame}>
|
|
72
|
+
<video
|
|
73
|
+
src={src}
|
|
74
|
+
poster={poster}
|
|
75
|
+
controls
|
|
76
|
+
autoPlay
|
|
77
|
+
muted={!withSound}
|
|
78
|
+
playsInline
|
|
79
|
+
aria-label={description}
|
|
80
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
81
|
+
>
|
|
82
|
+
{captions && <track kind="subtitles" src={captions.src} srcLang={captions.srcLang} label={captions.label} default />}
|
|
83
|
+
</video>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className={frame}>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={() => setPlaying(true)}
|
|
93
|
+
className="group absolute inset-0 h-full w-full focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
|
|
94
|
+
>
|
|
95
|
+
{media ?? (
|
|
96
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
97
|
+
<img src={poster} alt="" loading="lazy" decoding="async" className="h-full w-full object-cover" />
|
|
98
|
+
)}
|
|
99
|
+
<span
|
|
100
|
+
aria-hidden="true"
|
|
101
|
+
className="absolute left-1/2 top-1/2 flex h-14 w-14 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-card text-ink shadow-[0_8px_24px_-8px_rgba(0,0,0,0.6)] transition-transform duration-200 ease-out group-hover:scale-105 group-active:scale-95 motion-reduce:transition-none motion-reduce:group-hover:scale-100 motion-reduce:group-active:scale-100"
|
|
102
|
+
>
|
|
103
|
+
<Play size={22} strokeWidth={2} className="ml-0.5 fill-current" />
|
|
104
|
+
</span>
|
|
105
|
+
<span className="sr-only">{label}</span>
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Testimonial card of the Cuidado Global family — one item of novo.creeta's
|
|
113
|
+
* `Depoimentos`, by tokens. White card: the family's video on the left (only
|
|
114
|
+
* downloads on tap, plays with sound and the browser's controls, in place), the
|
|
115
|
+
* quote on the right, who speaks, and the link to the public post so anyone can
|
|
116
|
+
* check the source. The quote is a real <blockquote> with <cite>: it is someone
|
|
117
|
+
* else's words and the screen reader announces it as such.
|
|
118
|
+
*/
|
|
119
|
+
export function TestimonialCard({ quote, author, authorPhoto, authorAvatar, authors, link, video, as: Root = "div", labels, className = "" }: Props) {
|
|
120
|
+
const t = { ...EN, ...labels };
|
|
121
|
+
const people = authors ?? [{ name: author ?? "", photo: authorPhoto, avatar: authorAvatar }];
|
|
122
|
+
return (
|
|
123
|
+
<Root className={`grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] overflow-hidden rounded-[28px] bg-card ${className}`}>
|
|
124
|
+
<TapVideo {...video} label={t.watch(video.description)} />
|
|
125
|
+
<figure className="flex flex-col justify-between gap-5 p-5 md:p-7">
|
|
126
|
+
<blockquote className="font-heading text-[0.9375rem] font-medium leading-relaxed text-ink md:text-base">{quote}</blockquote>
|
|
127
|
+
<figcaption>
|
|
128
|
+
<Byline
|
|
129
|
+
cite
|
|
130
|
+
people={people}
|
|
131
|
+
action={link ? { label: link.label, href: link.href, external: true } : undefined}
|
|
132
|
+
newTabLabel={t.newTab.trim()}
|
|
133
|
+
/>
|
|
134
|
+
</figcaption>
|
|
135
|
+
</figure>
|
|
136
|
+
</Root>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type TextVariant = "lead" | "body" | "small" | "eyebrow";
|
|
4
|
+
|
|
5
|
+
export const TEXT: Record<TextVariant, string> = {
|
|
6
|
+
/** supporting paragraph under a title */
|
|
7
|
+
lead: "text-lg leading-relaxed text-ink-body",
|
|
8
|
+
/** reading text */
|
|
9
|
+
body: "leading-relaxed text-ink-body",
|
|
10
|
+
/** notes, captions */
|
|
11
|
+
small: "text-sm text-ink-soft",
|
|
12
|
+
/** small label one tone below, over a title or a date ("Década de 1980") */
|
|
13
|
+
eyebrow: "text-sm font-semibold text-ink-soft",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
as?: ElementType;
|
|
18
|
+
variant?: TextVariant;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Text — the family's paragraph styles (Figtree): lead, body, small, eyebrow. */
|
|
24
|
+
export function Text({ as: Tag = "p", variant = "body", children, className = "" }: Props) {
|
|
25
|
+
return <Tag className={`m-0 ${TEXT[variant]} ${className}`}>{children}</Tag>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Prose — a run of paragraphs at reading width: 1rem between them, relaxed leading, `ink-body`. */
|
|
29
|
+
export function Prose({ children, className = "" }: { children: ReactNode; className?: string }) {
|
|
30
|
+
return <div className={`max-w-2xl space-y-4 leading-relaxed text-ink-body [&>p]:m-0 ${className}`}>{children}</div>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ComponentType, ElementType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
|
|
4
|
+
|
|
5
|
+
export type TimelineStep = {
|
|
6
|
+
Icon: IconType;
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
body: ReactNode;
|
|
9
|
+
/** Optional link under the body ("See the team ↗"): the family's strong
|
|
10
|
+
* link, semibold, underlined, with the outbound arrow. `external` opens a new tab. */
|
|
11
|
+
link?: { label: ReactNode; href: string; external?: boolean };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
steps: TimelineStep[];
|
|
16
|
+
/** Heading level of each step's title (h3 under an h2 section title). */
|
|
17
|
+
headingLevel?: "h3" | "h4";
|
|
18
|
+
/** Link component of the app (e.g. next-intl Link). Default: <a>. */
|
|
19
|
+
linkComponent?: ElementType;
|
|
20
|
+
/** sr-only after an external link. */
|
|
21
|
+
newTabLabel?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ArrowUpRight = () => (
|
|
26
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Timeline — the "Como cuidamos" of novo.creeta, by tokens: an ordered
|
|
31
|
+
* sequence of steps, each with its own drawing (a 44px bubble with a lucide
|
|
32
|
+
* icon, decoration) and a title answered by a paragraph. A thin line links
|
|
33
|
+
* one drawing to the next: it is a path, from the first question to the last.
|
|
34
|
+
*/
|
|
35
|
+
export function Timeline({ steps, headingLevel = "h3", linkComponent, newTabLabel = "(opens in a new tab)", className = "" }: Props) {
|
|
36
|
+
const H = headingLevel;
|
|
37
|
+
const L: ElementType = linkComponent ?? "a";
|
|
38
|
+
return (
|
|
39
|
+
<ol className={`list-none ${className}`}>
|
|
40
|
+
{steps.map(({ Icon, title, body, link }, i) => {
|
|
41
|
+
const last = i === steps.length - 1;
|
|
42
|
+
return (
|
|
43
|
+
<li key={i} className={`relative grid grid-cols-[2.75rem_minmax(0,1fr)] gap-x-4 ${last ? "" : "pb-7"}`}>
|
|
44
|
+
{/* the line linking this drawing to the next */}
|
|
45
|
+
{!last && <span aria-hidden="true" className="absolute bottom-1 left-[1.375rem] top-12 w-px -translate-x-1/2 bg-ink/15" />}
|
|
46
|
+
<span aria-hidden="true" className="flex h-11 w-11 items-center justify-center rounded-full bg-ink/[0.12] text-ink">
|
|
47
|
+
<Icon size={20} strokeWidth={1.75} />
|
|
48
|
+
</span>
|
|
49
|
+
<div>
|
|
50
|
+
<H className="m-0 pt-2 font-heading text-lg font-medium leading-snug text-ink">{title}</H>
|
|
51
|
+
<p className="m-0 mt-1.5 leading-relaxed text-ink/80">{body}</p>
|
|
52
|
+
{link && (
|
|
53
|
+
<L
|
|
54
|
+
href={link.href}
|
|
55
|
+
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
56
|
+
className="mt-2 inline-flex min-h-11 items-center gap-1.5 font-semibold text-ink underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
|
|
57
|
+
>
|
|
58
|
+
{link.label}
|
|
59
|
+
<ArrowUpRight />
|
|
60
|
+
{link.external && <span className="sr-only"> {newTabLabel}</span>}
|
|
61
|
+
</L>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
</li>
|
|
65
|
+
);
|
|
66
|
+
})}
|
|
67
|
+
</ol>
|
|
68
|
+
);
|
|
69
|
+
}
|