@breathe-ds/ui 0.2.0 → 0.3.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 +71 -9
- package/components/Accordion.tsx +6 -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/Byline.tsx +103 -0
- package/components/CaptionBand.tsx +74 -0
- package/components/CtaCard.tsx +52 -0
- package/components/FactStrip.tsx +55 -0
- package/components/Footer.tsx +3 -20
- 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/PersonChip.tsx +49 -0
- package/components/Pill.tsx +42 -0
- package/components/PillList.tsx +20 -47
- package/components/ProfessionalCard.tsx +341 -0
- package/components/ProfessionalModal.tsx +243 -0
- package/components/Rows.tsx +131 -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/package.json +22 -1
- package/theme.css +66 -0
- package/tokens/creeta.css +5 -2
- package/tokens/neutral.css +10 -4
- package/tokens/ttcab.css +1 -0
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
const OPEN_CHEVRON =
|
|
22
|
+
"shrink-0 text-ink-soft transition-transform duration-200 group-open:rotate-180 motion-reduce:transition-none";
|
|
23
|
+
|
|
24
|
+
/** What appears out of nowhere (the content of a <details>) fades in briefly
|
|
25
|
+
* instead of jumping on screen: `@starting-style` via Tailwind's `starting:`
|
|
26
|
+
* variant, no JavaScript. Browsers without support show it at once. */
|
|
27
|
+
export const FADE_IN = "transition-[opacity,translate] duration-[220ms] ease-[var(--ease-cg)] starting:opacity-0 starting:-translate-y-1";
|
|
28
|
+
|
|
29
|
+
const ROW_LINK_ITEM =
|
|
30
|
+
"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";
|
|
31
|
+
|
|
32
|
+
export type RowListProps = ComponentPropsWithoutRef<"div">;
|
|
33
|
+
|
|
34
|
+
/** The hairlines: a line above the first row and between rows. */
|
|
35
|
+
export function RowList({ className = "", ...rest }: RowListProps) {
|
|
36
|
+
return <div className={`divide-y divide-ink/10 border-t border-ink/10 ${className}`} {...rest} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type RowLink = {
|
|
40
|
+
label: ReactNode;
|
|
41
|
+
href: string;
|
|
42
|
+
/** ↗ another page (default) or ↓ an anchor on this page. */
|
|
43
|
+
arrow?: "go" | "down";
|
|
44
|
+
/** Opens in a new tab (target=_blank). The arrow stays ↗. */
|
|
45
|
+
external?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function Arrow({ arrow = "go", size }: { arrow?: "go" | "down"; size: number }) {
|
|
49
|
+
return arrow === "down" ? (
|
|
50
|
+
<ArrowDown aria-hidden="true" size={size} className="cg-arrow-down shrink-0" />
|
|
51
|
+
) : (
|
|
52
|
+
<ArrowUpRight aria-hidden="true" size={size} className="cg-arrow-go shrink-0" />
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type CollapseRowProps = Omit<ComponentPropsWithoutRef<"details">, "children"> & {
|
|
57
|
+
label: ReactNode;
|
|
58
|
+
/** Read by screen readers right after the label (e.g. " of Person name"). */
|
|
59
|
+
srLabel?: ReactNode;
|
|
60
|
+
/** Number beside the label. With `links` it defaults to their count. */
|
|
61
|
+
count?: number;
|
|
62
|
+
/** A list of links as the content: one row per link, with the arrow. */
|
|
63
|
+
links?: RowLink[];
|
|
64
|
+
/** Link component of the app for `links` (e.g. next-intl Link). Default: <a>. */
|
|
65
|
+
linkComponent?: ElementType;
|
|
66
|
+
/** Free content (a string becomes one paragraph). Ignored with `links`. */
|
|
67
|
+
children?: ReactNode;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** A row that opens in place: a native <details>, no JavaScript. */
|
|
71
|
+
export function CollapseRow({ label, srLabel, count, links, linkComponent, children, className = "", ...rest }: CollapseRowProps) {
|
|
72
|
+
const L: ElementType = linkComponent ?? "a";
|
|
73
|
+
const n = count ?? links?.length;
|
|
74
|
+
return (
|
|
75
|
+
<details className={`group ${className}`} {...rest}>
|
|
76
|
+
<summary className={ROW}>
|
|
77
|
+
<span>
|
|
78
|
+
{label}
|
|
79
|
+
{srLabel && <span className="sr-only">{srLabel}</span>}
|
|
80
|
+
{n !== undefined && <span className="ml-2 font-normal text-ink/80">{n}</span>}
|
|
81
|
+
</span>
|
|
82
|
+
<ChevronDown aria-hidden="true" size={18} className={OPEN_CHEVRON} />
|
|
83
|
+
</summary>
|
|
84
|
+
{links ? (
|
|
85
|
+
<ul className={`${FADE_IN} pb-2`}>
|
|
86
|
+
{links.map((g, i) => (
|
|
87
|
+
<li key={`${i}-${g.href}`}>
|
|
88
|
+
<L href={g.href} className={ROW_LINK_ITEM} {...(g.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}>
|
|
89
|
+
{g.label}
|
|
90
|
+
<Arrow arrow={g.arrow} size={16} />
|
|
91
|
+
</L>
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</ul>
|
|
95
|
+
) : typeof children === "string" ? (
|
|
96
|
+
<p className={`${FADE_IN} pb-4 text-sm leading-relaxed text-ink/80`}>{children}</p>
|
|
97
|
+
) : (
|
|
98
|
+
<div className={`${FADE_IN} space-y-3 pb-4 text-sm leading-relaxed text-ink/80`}>{children}</div>
|
|
99
|
+
)}
|
|
100
|
+
</details>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type LinkRowProps = Omit<ComponentPropsWithoutRef<"a">, "children"> & {
|
|
105
|
+
label: ReactNode;
|
|
106
|
+
href: string;
|
|
107
|
+
/** Read by screen readers right after the label (e.g. " of Person name (opens in a new tab)"). */
|
|
108
|
+
srLabel?: ReactNode;
|
|
109
|
+
/** ↗ another page (default) or ↓ an anchor on this page. */
|
|
110
|
+
arrow?: "go" | "down";
|
|
111
|
+
/** Opens in a new tab (target=_blank). The arrow stays ↗. */
|
|
112
|
+
external?: boolean;
|
|
113
|
+
/** Replaces the arrow (an 18px glyph; the slot is `ink-soft`). */
|
|
114
|
+
icon?: ReactNode;
|
|
115
|
+
/** Link component of the app (e.g. next-intl Link). Default: <a>. */
|
|
116
|
+
linkComponent?: ElementType;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** A row that goes somewhere: the label and, on the right, the arrow or an icon. */
|
|
120
|
+
export function LinkRow({ label, href, srLabel, arrow, external = false, icon, linkComponent, className = "", ...rest }: LinkRowProps) {
|
|
121
|
+
const L: ElementType = linkComponent ?? "a";
|
|
122
|
+
return (
|
|
123
|
+
<L href={href} className={`${ROW} ${className}`} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} {...rest}>
|
|
124
|
+
<span>
|
|
125
|
+
{label}
|
|
126
|
+
{srLabel && <span className="sr-only">{srLabel}</span>}
|
|
127
|
+
</span>
|
|
128
|
+
<span className="flex shrink-0 text-ink-soft">{icon ?? <Arrow arrow={arrow} size={18} />}</span>
|
|
129
|
+
</L>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { BackgroundVideo } from "./BackgroundVideo";
|
|
5
|
+
|
|
6
|
+
/** Muted looping clip shown while idle (same fields as BackgroundVideo). */
|
|
7
|
+
export type InviteLoop = {
|
|
8
|
+
src: string;
|
|
9
|
+
/** Larger version, downloaded only on wide screens (from `from` up). */
|
|
10
|
+
srcLarge?: string;
|
|
11
|
+
/** Minimum width (px) for the larger version. Defaults to 1024. */
|
|
12
|
+
from?: 768 | 1024;
|
|
13
|
+
/** Still frame: shown before the clip loads, without JS, and with reduced motion. */
|
|
14
|
+
poster: string;
|
|
15
|
+
posterLarge?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Text track drawn by the browser over the full video (.vtt). */
|
|
19
|
+
export type InviteTrack = {
|
|
20
|
+
src: string;
|
|
21
|
+
/** BCP 47 language tag, e.g. "pt-BR", "en". */
|
|
22
|
+
srcLang: string;
|
|
23
|
+
/** Name shown in the browser's track menu, e.g. "Português". */
|
|
24
|
+
label: string;
|
|
25
|
+
kind?: "captions" | "subtitles";
|
|
26
|
+
default?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Full video with sound, played on tap. Only downloaded then. */
|
|
30
|
+
export type InviteVideo = {
|
|
31
|
+
src: string;
|
|
32
|
+
poster?: string;
|
|
33
|
+
tracks?: InviteTrack[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type Labels = {
|
|
37
|
+
/** Screen-reader label of the play button (the circle, bottom-right). */
|
|
38
|
+
listen: string;
|
|
39
|
+
/** aria-label of the full video while it plays. */
|
|
40
|
+
video: string;
|
|
41
|
+
/** Labels of the loop's pause button (top-right), passed to BackgroundVideo. */
|
|
42
|
+
pause: string;
|
|
43
|
+
play: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const EN: Labels = {
|
|
47
|
+
listen: "Listen to the invitation",
|
|
48
|
+
video: "Video invitation, with sound",
|
|
49
|
+
pause: "Pause the video",
|
|
50
|
+
play: "Play the video",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type VideoInviteCardProps = {
|
|
54
|
+
/** Overlay title, top-left, in both phases (on the site: who appears). Ink text, no background. */
|
|
55
|
+
title: ReactNode;
|
|
56
|
+
/** Second line under the title (on the site: the person's role). */
|
|
57
|
+
description?: ReactNode;
|
|
58
|
+
/** Idle phase: muted looping clip. */
|
|
59
|
+
loop: InviteLoop;
|
|
60
|
+
/** Tap phase: the full video with sound, browser controls and captions. */
|
|
61
|
+
video: InviteVideo;
|
|
62
|
+
/** Called when the person taps play (e.g. to pause other media on the page). */
|
|
63
|
+
onPlay?: () => void;
|
|
64
|
+
/** Called when the full video ends and the card goes back to the loop. */
|
|
65
|
+
onEnded?: () => void;
|
|
66
|
+
labels?: Partial<Labels>;
|
|
67
|
+
/** Size/position of the frame (aspect, min-height, grid order…). */
|
|
68
|
+
className?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function PlayGlyph({ size = 22 }: { size?: number }) {
|
|
72
|
+
return <svg viewBox="0 0 24 24" width={size} height={size} className="ml-0.5" fill="currentColor" aria-hidden="true"><path d="M6 3v18l15-9z" /></svg>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Video invitation card of the Cuidado Global family — mirrors the
|
|
77
|
+
* `VideoConvite` of novo.creeta with the Breathe tokens, in two phases.
|
|
78
|
+
*
|
|
79
|
+
* Idle, it is a background video: the speaker on a muted loop (BackgroundVideo:
|
|
80
|
+
* respects reduced motion and data saver, has the pause button top-right —
|
|
81
|
+
* WCAG 2.2.2). Over it, the title and description top-left and a play button
|
|
82
|
+
* bottom-right, outside the face. On tap, the second video takes over, with
|
|
83
|
+
* sound: the invitation itself, with captions and the browser's controls.
|
|
84
|
+
* Because play is a click by the person, the browser allows the audio. When
|
|
85
|
+
* the invitation ends, the card goes back to the loop.
|
|
86
|
+
*
|
|
87
|
+
* The full video only downloads on tap. The frame is a 28px-radius card
|
|
88
|
+
* (`rounded-2xl` in the DS scale); the consumer sets aspect/min-height via
|
|
89
|
+
* `className`.
|
|
90
|
+
*/
|
|
91
|
+
export function VideoInviteCard({ title, description, loop, video, onPlay, onEnded, labels, className = "" }: VideoInviteCardProps) {
|
|
92
|
+
const [inviting, setInviting] = useState(false);
|
|
93
|
+
const t = { ...EN, ...labels };
|
|
94
|
+
|
|
95
|
+
/* No background, straight over the video, in ink top-left, where the frame
|
|
96
|
+
is the light wall in both phases (measured on the site's footage: 6.18:1
|
|
97
|
+
median on the loop, 5.86:1 on the invitation). The light text-shadow
|
|
98
|
+
holds it on the darker frames. */
|
|
99
|
+
const caption = (
|
|
100
|
+
<p className="inline-block text-sm font-medium leading-snug text-ink [text-shadow:0_1px_2px_rgba(255,255,255,0.5)]">
|
|
101
|
+
<span className="block">{title}</span>
|
|
102
|
+
{description && <span className="block font-normal">{description}</span>}
|
|
103
|
+
</p>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const frame = `relative overflow-hidden rounded-2xl bg-ink ${className}`;
|
|
107
|
+
|
|
108
|
+
if (inviting) {
|
|
109
|
+
return (
|
|
110
|
+
<div className={frame}>
|
|
111
|
+
<video
|
|
112
|
+
src={video.src}
|
|
113
|
+
poster={video.poster}
|
|
114
|
+
controls
|
|
115
|
+
autoPlay
|
|
116
|
+
playsInline
|
|
117
|
+
onEnded={() => {
|
|
118
|
+
setInviting(false);
|
|
119
|
+
onEnded?.();
|
|
120
|
+
}}
|
|
121
|
+
aria-label={t.video}
|
|
122
|
+
className="absolute inset-0 h-full w-full bg-ink object-cover"
|
|
123
|
+
>
|
|
124
|
+
{video.tracks?.map((track) => (
|
|
125
|
+
<track
|
|
126
|
+
key={track.src}
|
|
127
|
+
kind={track.kind ?? "captions"}
|
|
128
|
+
src={track.src}
|
|
129
|
+
srcLang={track.srcLang}
|
|
130
|
+
label={track.label}
|
|
131
|
+
default={track.default}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
</video>
|
|
135
|
+
<div className="pointer-events-none absolute top-4 left-4 right-4">{caption}</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className={frame}>
|
|
142
|
+
<BackgroundVideo
|
|
143
|
+
src={loop.src}
|
|
144
|
+
srcLarge={loop.srcLarge}
|
|
145
|
+
from={loop.from}
|
|
146
|
+
poster={loop.poster}
|
|
147
|
+
posterLarge={loop.posterLarge}
|
|
148
|
+
labels={{ pause: t.pause, play: t.play }}
|
|
149
|
+
className="absolute inset-0"
|
|
150
|
+
/>
|
|
151
|
+
{/* right-16: the loop's pause button sits in the top-right corner. */}
|
|
152
|
+
<div className="pointer-events-none absolute top-4 left-4 right-16">{caption}</div>
|
|
153
|
+
{/* Only the circle is a button, so the loop's pause button (top corner)
|
|
154
|
+
stays reachable. Bottom-right, outside the face. */}
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={() => {
|
|
158
|
+
onPlay?.();
|
|
159
|
+
setInviting(true);
|
|
160
|
+
}}
|
|
161
|
+
className="group absolute right-4 bottom-4 z-10 flex h-14 w-14 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 hover:scale-105 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface motion-reduce:transition-none motion-reduce:hover:scale-100 motion-reduce:active:scale-100"
|
|
162
|
+
>
|
|
163
|
+
<PlayGlyph size={22} />
|
|
164
|
+
<span className="sr-only">{t.listen}</span>
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|