@breathe-ds/ui 0.1.1 → 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/LICENSE +21 -0
- package/README.md +219 -142
- package/components/Accordion.tsx +17 -14
- package/components/AnchorNav.tsx +33 -21
- package/components/Avatar.tsx +37 -0
- package/components/BackgroundVideo.tsx +14 -14
- package/components/BottomNav.tsx +70 -69
- package/components/Breadcrumb.tsx +45 -0
- package/components/BreatheMark.tsx +12 -24
- package/components/Button.tsx +12 -12
- package/components/Byline.tsx +103 -0
- package/components/CaptionBand.tsx +74 -0
- package/components/Card.tsx +5 -5
- package/components/Container.tsx +4 -4
- package/components/CtaCard.tsx +52 -0
- package/components/Curtain.tsx +152 -0
- package/components/FactStrip.tsx +55 -0
- package/components/Footer.tsx +169 -77
- package/components/Header.tsx +32 -17
- package/components/Heading.tsx +86 -0
- package/components/InfoCard.tsx +116 -0
- package/components/LanguageSwitcher.tsx +139 -0
- package/components/List.tsx +81 -0
- package/components/Lockup.tsx +38 -55
- package/components/MapCard.tsx +158 -0
- package/components/MediaCard.tsx +266 -0
- package/components/PageVideoHero.tsx +53 -53
- package/components/PersonChip.tsx +49 -0
- package/components/Pill.tsx +42 -0
- package/components/PillList.tsx +21 -48
- package/components/ProfessionalCard.tsx +341 -0
- package/components/ProfessionalModal.tsx +243 -0
- package/components/Rows.tsx +131 -0
- package/components/SectionKicker.tsx +3 -3
- 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/icons.tsx +6 -6
- package/components/marks/creeta.tsx +22 -22
- package/components/marks/ttcab.tsx +13 -13
- package/package.json +28 -5
- package/theme.css +88 -22
- package/tokens/creeta.css +14 -12
- package/tokens/neutral.css +16 -10
- package/tokens/ttcab.css +9 -8
- package/components/Cortina.tsx +0 -152
package/components/Button.tsx
CHANGED
|
@@ -6,18 +6,18 @@ type Size = "sm" | "md" | "lg";
|
|
|
6
6
|
const pillBase =
|
|
7
7
|
"inline-flex items-center justify-center gap-2 rounded-pill font-body font-semibold whitespace-nowrap transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100 disabled:pointer-events-none disabled:opacity-50";
|
|
8
8
|
|
|
9
|
-
/*
|
|
10
|
-
|
|
11
|
-
(currentColor),
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
/* `link` variant: underlined text action (not a pill) — the button's lightweight
|
|
10
|
+
sibling, same typography (font-body semibold). Inherits the color from the
|
|
11
|
+
context (currentColor), so it works on light and on dark backgrounds. The
|
|
12
|
+
underline sits at 50% and goes solid on hover; an arrow (icon passed as a
|
|
13
|
+
child) nudges up-and-right. Ignores `size` (it is inline, no pill). */
|
|
14
14
|
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
|
-
//
|
|
18
|
+
// solid petrol, cream text (e.g. "Falar com a equipe")
|
|
19
19
|
solid: "bg-ink text-surface hover:-translate-y-0.5 shadow-[0_6px_18px_rgba(20,71,83,0.28)]",
|
|
20
|
-
//
|
|
20
|
+
// action color (orange/magenta) + on-action text (from the token, varies per site)
|
|
21
21
|
accent: "bg-action text-on-action hover:-translate-y-0.5 shadow-[0_6px_18px_rgba(20,71,83,0.22)]",
|
|
22
22
|
outline: "border border-line text-ink hover:bg-ink/[0.05]",
|
|
23
23
|
ghost: "text-ink hover:bg-ink/[0.06]",
|
|
@@ -40,11 +40,11 @@ type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement> & { hre
|
|
|
40
40
|
type LinkProps = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Button
|
|
44
|
-
* (
|
|
45
|
-
*
|
|
46
|
-
* `link` (
|
|
47
|
-
* "
|
|
43
|
+
* Button of the Cuidado Global family. Polymorphic: with `href` it renders an
|
|
44
|
+
* `<a>` (inject your Link via a wrapper if you need a router), otherwise a
|
|
45
|
+
* `<button>`. Variants: solid (petrol), accent (action color), outline, ghost
|
|
46
|
+
* and `link` (underlined text action — e.g. "See all", "Secondary action",
|
|
47
|
+
* "View on Google Maps"; inherits the color, works on light and dark).
|
|
48
48
|
*/
|
|
49
49
|
export function Button(props: ButtonProps | LinkProps) {
|
|
50
50
|
const { variant = "solid", size = "md", className = "", children, ...rest } = props;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
import { Avatar } from "./Avatar";
|
|
3
|
+
|
|
4
|
+
export type Person = { name: string; href?: string; photo?: string; avatar?: ReactNode };
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
people: Person[];
|
|
8
|
+
/** Small line above the names ("Who explains"). With it, the avatars stack
|
|
9
|
+
* and the names go on their own line; without it, one inline line:
|
|
10
|
+
* avatar · name · action. */
|
|
11
|
+
eyebrow?: ReactNode;
|
|
12
|
+
/** Link after the name(s) ("Watch the video ↗"). */
|
|
13
|
+
action?: { label: ReactNode; href: string; external?: boolean };
|
|
14
|
+
/** Joiners between names: ", " and " and ". */
|
|
15
|
+
joiners?: { sep: string; last: string };
|
|
16
|
+
/** Wrap the names in <cite> (a testimonial's author). */
|
|
17
|
+
cite?: boolean;
|
|
18
|
+
/** sr-only after an external action link. */
|
|
19
|
+
newTabLabel?: string;
|
|
20
|
+
linkComponent?: ElementType;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ArrowUpRight = () => (
|
|
25
|
+
<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>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const nameLink =
|
|
29
|
+
"underline decoration-ink/30 underline-offset-4 transition-colors duration-150 hover:decoration-ink motion-reduce:transition-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Byline — who signs or explains a piece of content, from novo.creeta:
|
|
33
|
+
* - group ("Quem explica" under the timeline): stacked portraits, an eyebrow
|
|
34
|
+
* and the names, each one a link to the person's card;
|
|
35
|
+
* - inline (under a topic card): portrait, name and "Watch the video ↗".
|
|
36
|
+
* Portraits are decorative: the names carry the information.
|
|
37
|
+
*/
|
|
38
|
+
export function Byline({ people, eyebrow, action, joiners = { sep: ", ", last: " and " }, cite = false, newTabLabel = "(opens in a new tab)", linkComponent, className = "" }: Props) {
|
|
39
|
+
const L: ElementType = linkComponent ?? "a";
|
|
40
|
+
// portraits stack with a ring (as on the site); initials would lose their letters, so they sit side by side
|
|
41
|
+
const stack = people.length > 1 && people.every((p) => p.photo || p.avatar);
|
|
42
|
+
const row = `flex shrink-0 ${stack ? "-space-x-2" : "gap-1.5"}`;
|
|
43
|
+
const Names: ElementType = cite ? "cite" : "span";
|
|
44
|
+
const names = people.map((p, i) => (
|
|
45
|
+
<span key={p.name}>
|
|
46
|
+
{i > 0 && (i === people.length - 1 ? joiners.last : joiners.sep)}
|
|
47
|
+
{p.href ? (
|
|
48
|
+
<L href={p.href} className={nameLink}>{p.name}</L>
|
|
49
|
+
) : (
|
|
50
|
+
p.name
|
|
51
|
+
)}
|
|
52
|
+
</span>
|
|
53
|
+
));
|
|
54
|
+
const actionEl = action ? (
|
|
55
|
+
<L
|
|
56
|
+
href={action.href}
|
|
57
|
+
{...(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"
|
|
59
|
+
>
|
|
60
|
+
{action.label}
|
|
61
|
+
<ArrowUpRight />
|
|
62
|
+
{action.external && <span className="sr-only"> {newTabLabel}</span>}
|
|
63
|
+
</L>
|
|
64
|
+
) : null;
|
|
65
|
+
|
|
66
|
+
if (eyebrow) {
|
|
67
|
+
return (
|
|
68
|
+
<div className={`flex items-center gap-3 ${className}`}>
|
|
69
|
+
<span className={row}>
|
|
70
|
+
{people.map((p) => (
|
|
71
|
+
<Avatar key={p.name} name={p.name} photo={p.photo} size={36} ring={stack}>{p.avatar}</Avatar>
|
|
72
|
+
))}
|
|
73
|
+
</span>
|
|
74
|
+
<div className="leading-snug">
|
|
75
|
+
<p className="m-0 text-sm text-ink/80">{eyebrow}</p>
|
|
76
|
+
<p className="m-0 mt-0.5 font-semibold text-ink">
|
|
77
|
+
<Names className="not-italic">{names}</Names>
|
|
78
|
+
{actionEl && <> · {actionEl}</>}
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<p className={`m-0 flex items-center gap-3 text-sm text-ink/80 ${className}`}>
|
|
87
|
+
{people.length === 1 ? (
|
|
88
|
+
<Avatar name={people[0].name} photo={people[0].photo} size={40}>{people[0].avatar}</Avatar>
|
|
89
|
+
) : (
|
|
90
|
+
<span className={row}>
|
|
91
|
+
{people.map((p) => (
|
|
92
|
+
<Avatar key={p.name} name={p.name} photo={p.photo} size={36} ring={stack}>{p.avatar}</Avatar>
|
|
93
|
+
))}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
<span className="flex flex-wrap items-center gap-x-2">
|
|
97
|
+
<Names className="not-italic font-semibold text-ink">{names}</Names>
|
|
98
|
+
{actionEl}
|
|
99
|
+
</span>
|
|
100
|
+
</p>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The line being spoken right now, read from the video's WebVTT <track>
|
|
7
|
+
* (novo.creeta's `useLegenda`, components/legenda.tsx).
|
|
8
|
+
*
|
|
9
|
+
* The captions are not drawn over the picture: many of the site's videos
|
|
10
|
+
* already carry burned-in Portuguese captions, in positions that vary, and one
|
|
11
|
+
* on top of the other is unreadable. The track is the browser's (a hidden
|
|
12
|
+
* <track> it loads and syncs itself) and the text comes out beside/under the
|
|
13
|
+
* video, in `CaptionBand`.
|
|
14
|
+
*
|
|
15
|
+
* Usage: `const { line, attach } = useCaption()` and `<track ref={attach} … />`.
|
|
16
|
+
*/
|
|
17
|
+
export function useCaption() {
|
|
18
|
+
const [line, setLine] = useState("");
|
|
19
|
+
|
|
20
|
+
const attach = useCallback((el: HTMLTrackElement | null) => {
|
|
21
|
+
const track = el?.track;
|
|
22
|
+
if (!track) return;
|
|
23
|
+
// 'hidden': the browser loads the file and fires the events, but does not draw
|
|
24
|
+
track.mode = "hidden";
|
|
25
|
+
track.addEventListener("cuechange", () => {
|
|
26
|
+
const cue = track.activeCues?.[0] as VTTCue | undefined;
|
|
27
|
+
setLine(cue ? cue.text : "");
|
|
28
|
+
});
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
return { line, attach };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Props = {
|
|
35
|
+
/** The current cue text (from `useCaption`). Empty between cues: the band
|
|
36
|
+
* stays, so the layout does not jump at every line. */
|
|
37
|
+
text: string;
|
|
38
|
+
/** BCP 47 tag of the captions ("en", "pt-BR"). */
|
|
39
|
+
lang: string;
|
|
40
|
+
className?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Caption band of the Cuidado Global family (novo.creeta's `FaixaDeLegenda`).
|
|
45
|
+
* Fixed height of three lines so the page does not jump at every line. White
|
|
46
|
+
* on `ink` (creeta's petrol: 10.2:1). Hidden from screen readers: who neither
|
|
47
|
+
* hears nor sees uses the transcript on the page; here the text would change
|
|
48
|
+
* every two seconds.
|
|
49
|
+
*
|
|
50
|
+
* When it appears (the person pressed play) the page scrolls the least needed
|
|
51
|
+
* for the band to be in view: on phones it would be born behind the bottom
|
|
52
|
+
* bar, and the person would watch without knowing there are captions. The
|
|
53
|
+
* scroll margin is the bar's height.
|
|
54
|
+
*/
|
|
55
|
+
export function CaptionBand({ text, lang, className = "" }: Props) {
|
|
56
|
+
const band = useRef<HTMLParagraphElement>(null);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
60
|
+
band.current?.scrollIntoView?.({ block: "nearest", behavior: calm ? "auto" : "smooth" });
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<p
|
|
65
|
+
ref={band}
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
lang={lang}
|
|
68
|
+
data-caption-band=""
|
|
69
|
+
className={`mt-2 flex min-h-[4.75rem] scroll-mb-28 items-center justify-center rounded-[20px] bg-ink px-4 py-2.5 text-center text-[0.9375rem] font-medium leading-snug text-white md:scroll-mb-6 ${className}`}
|
|
70
|
+
>
|
|
71
|
+
{text}
|
|
72
|
+
</p>
|
|
73
|
+
);
|
|
74
|
+
}
|
package/components/Card.tsx
CHANGED
|
@@ -2,17 +2,17 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
type Props = {
|
|
4
4
|
children: ReactNode;
|
|
5
|
-
/**
|
|
5
|
+
/** Renders as a link when href is passed. */
|
|
6
6
|
href?: string;
|
|
7
|
-
/**
|
|
7
|
+
/** Lifts on hover (for clickable cards). */
|
|
8
8
|
interactive?: boolean;
|
|
9
9
|
className?: string;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `interactive`
|
|
13
|
+
* Base card of the family — style decided in the DS: **subtle border + subtle
|
|
14
|
+
* shadow**, 2xl radius (28px), surface background. With `href` it becomes a
|
|
15
|
+
* link; with `interactive` it lifts on hover. No strong borders or heavy shadows.
|
|
16
16
|
*/
|
|
17
17
|
export function Card({ children, href, interactive, className = "" }: Props) {
|
|
18
18
|
const cls = `block rounded-2xl border border-line bg-surface p-[clamp(20px,3vw,32px)] shadow-[0_2px_12px_rgba(20,71,83,0.06)] ${
|
package/components/Container.tsx
CHANGED
|
@@ -2,15 +2,15 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
type Props = {
|
|
4
4
|
children: ReactNode;
|
|
5
|
-
/**
|
|
5
|
+
/** Max width. "content" (default) for reading; "wide" for heroes/footer. */
|
|
6
6
|
width?: "content" | "wide";
|
|
7
7
|
className?: string;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Container
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Container of the Cuidado Global family — max width + consistent side gutter
|
|
12
|
+
* (16px on mobile). "content" for text/sections, "wide" for full-bleed blocks
|
|
13
|
+
* (heroes, footer).
|
|
14
14
|
*/
|
|
15
15
|
export function Container({ children, width = "content", className = "" }: Props) {
|
|
16
16
|
const max = width === "wide" ? "max-w-[1600px]" : "max-w-[1120px]";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { VideoInviteCard, type VideoInviteCardProps } from "./VideoInviteCard";
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
/** The question that closes the page, e.g. "Shall we find out what your body needs?". */
|
|
6
|
+
title: ReactNode;
|
|
7
|
+
/** Supporting line under the title. Omit it to drop the paragraph. */
|
|
8
|
+
lead?: ReactNode;
|
|
9
|
+
/** The call to action — pass the DS `Button` (variant="accent" on the family's sites). */
|
|
10
|
+
action: ReactNode;
|
|
11
|
+
/** Side card with the speaker's video (VideoInviteCard props). Omit for the text card alone. */
|
|
12
|
+
video?: Omit<VideoInviteCardProps, "className">;
|
|
13
|
+
/** Any other side media (e.g. a still photo), placed in the same frame as the video. */
|
|
14
|
+
media?: ReactNode;
|
|
15
|
+
className?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Side frame: 3:2 on mobile (video on top), free height from md on, same radius as the text card. */
|
|
19
|
+
const sideFrame = "aspect-[3/2] md:order-2 md:aspect-auto md:min-h-[340px]";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Final call of the Cuidado Global family — mirrors the `ChamadaFinal` of
|
|
23
|
+
* novo.creeta with the Breathe tokens: every page ends with the same
|
|
24
|
+
* invitation, in the same place.
|
|
25
|
+
*
|
|
26
|
+
* Two cards side by side, on the same grid as the site's other card pairs
|
|
27
|
+
* (gap-3, gap-5 from md): the text on a white card — title, lead and the CTA —
|
|
28
|
+
* and the video on another, each with its own four corners (28px). On mobile
|
|
29
|
+
* the video comes first. Without video/media it is a single card.
|
|
30
|
+
*
|
|
31
|
+
* The white card keeps ink text (10.21:1 on creeta); the lead is ink at 80%
|
|
32
|
+
* (5.78:1 on white). The CTA is a slot: the site passes its own Button/link.
|
|
33
|
+
*/
|
|
34
|
+
export function CtaCard({ title, lead, action, video, media, className = "" }: Props) {
|
|
35
|
+
const hasSide = Boolean(video || media);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<section className={`text-ink ${hasSide ? "grid gap-3 md:grid-cols-2 md:gap-5" : ""} ${className}`}>
|
|
39
|
+
{video ? (
|
|
40
|
+
<VideoInviteCard {...video} className={sideFrame} />
|
|
41
|
+
) : media ? (
|
|
42
|
+
<div className={`relative overflow-hidden rounded-2xl ${sideFrame}`}>{media}</div>
|
|
43
|
+
) : null}
|
|
44
|
+
<div className="flex flex-col justify-center rounded-2xl bg-card px-6 py-10 md:px-10 md:py-14">
|
|
45
|
+
<h2 className="max-w-xl font-heading text-3xl leading-[1.1] font-medium md:text-4xl">{title}</h2>
|
|
46
|
+
{lead && <p className="mt-4 max-w-md text-ink/80">{lead}</p>}
|
|
47
|
+
{/* The button follows the site's pattern (accent on white), same as the header's "Book". */}
|
|
48
|
+
<div className="mt-7">{action}</div>
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
4
|
+
import { BreatheMark } from "./BreatheMark";
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
/** Brand name (e.g. "creeta", "TTCAB", "Breathe"). */
|
|
8
|
+
name: ReactNode;
|
|
9
|
+
/** Line below the name (e.g. "Centro de Reequilíbrio Toracoabdominal"). */
|
|
10
|
+
tagline?: ReactNode;
|
|
11
|
+
/** Symbol next to the name. Default: the BreatheMark (ring + dot). Each
|
|
12
|
+
* site injects its own logo here. */
|
|
13
|
+
logo?: ReactNode;
|
|
14
|
+
/** When `name` is an svg wordmark: its height as a fraction of the symbol
|
|
15
|
+
* height, from the brand manual (e.g. `CREETA_LOCKUP.wordmarkPerSymbol`). */
|
|
16
|
+
wordmarkRatio?: number;
|
|
17
|
+
/** Which side the symbol sits on. Default `left`; TTCAB is `right` (`TTCAB_LOCKUP.symbolSide`). */
|
|
18
|
+
symbolSide?: "left" | "right";
|
|
19
|
+
/** sessionStorage key (when the head script is in place). */
|
|
20
|
+
storageKey?: string;
|
|
21
|
+
/** Called when the curtain finishes rising. */
|
|
22
|
+
onDone?: () => void;
|
|
23
|
+
className?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Entrance curtain of the Cuidado Global family: full screen, in the site's
|
|
28
|
+
* colors (surface/ink tokens), with the logo + name forming in the center and
|
|
29
|
+
* the tagline below; then it rises and reveals the page. Mirrors creeta's
|
|
30
|
+
* `Curtain`, but neutral: palette and logo come from each app.
|
|
31
|
+
*
|
|
32
|
+
* - Pure CSS (inline styles): without JavaScript it still rises the same way,
|
|
33
|
+
* so it never gets stuck in front of the site.
|
|
34
|
+
* - Takes no click or focus and does not exist for screen readers (aria-hidden).
|
|
35
|
+
* - With reduced motion (or print) it is not shown.
|
|
36
|
+
* - To show it only on the first visit of the session, put `curtainHeadScript()`
|
|
37
|
+
* in the <head> (before paint): on a repeat visit the <html> is born with
|
|
38
|
+
* data-cg-curtain="seen" and the curtain is never drawn, so there is no flash.
|
|
39
|
+
*
|
|
40
|
+
* When it finishes rising, the <html> gets data-cg-curtain-done (so page tops
|
|
41
|
+
* "land" only after it, never on a route change) and onDone is called.
|
|
42
|
+
*/
|
|
43
|
+
export function Curtain({ name, tagline, logo, wordmarkRatio, symbolSide = "left", storageKey, onDone, className }: Props) {
|
|
44
|
+
const el = useRef<HTMLDivElement>(null);
|
|
45
|
+
const [gone, setGone] = useState(false);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const node = el.current;
|
|
49
|
+
if (!node) return;
|
|
50
|
+
const handleEnd = (e: AnimationEvent) => {
|
|
51
|
+
if (e.animationName !== "cg-curtain-rise") return;
|
|
52
|
+
document.documentElement.setAttribute("data-cg-curtain-done", "");
|
|
53
|
+
try {
|
|
54
|
+
if (storageKey) sessionStorage.setItem(storageKey, "1");
|
|
55
|
+
} catch {
|
|
56
|
+
/* sessionStorage blocked: the curtain just shows again, nothing breaks */
|
|
57
|
+
}
|
|
58
|
+
onDone?.();
|
|
59
|
+
setGone(true);
|
|
60
|
+
};
|
|
61
|
+
node.addEventListener("animationend", handleEnd);
|
|
62
|
+
return () => node.removeEventListener("animationend", handleEnd);
|
|
63
|
+
}, [onDone, storageKey]);
|
|
64
|
+
|
|
65
|
+
if (gone) return null;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div ref={el} className={`cg-curtain${className ? ` ${className}` : ""}`} aria-hidden="true">
|
|
69
|
+
<style>{CURTAIN_CSS}</style>
|
|
70
|
+
{/* Symbol on the left: it hugs the name+tagline block. On the right (TTCAB):
|
|
71
|
+
it sits next to the name, on the same line, with the tagline below;
|
|
72
|
+
otherwise the symbol would end up after the tagline, far from the wordmark. */}
|
|
73
|
+
{(() => {
|
|
74
|
+
const mark = <span className="cg-curtain__mark">{logo ?? <BreatheMark size={60} duration={5} />}</span>;
|
|
75
|
+
const nameEl = (
|
|
76
|
+
<span
|
|
77
|
+
className="cg-curtain__name"
|
|
78
|
+
style={wordmarkRatio ? ({ "--cg-wordmark": `calc(var(--cg-curtain-mark) * ${wordmarkRatio})` } as CSSProperties) : undefined}
|
|
79
|
+
>
|
|
80
|
+
{name}
|
|
81
|
+
</span>
|
|
82
|
+
);
|
|
83
|
+
const tag = tagline ? <span className="cg-curtain__tag">{tagline}</span> : null;
|
|
84
|
+
return symbolSide === "right" ? (
|
|
85
|
+
<div className="cg-curtain__lockup cg-curtain__lockup--right">
|
|
86
|
+
<span className="cg-curtain__row">{nameEl}{mark}</span>
|
|
87
|
+
{tag}
|
|
88
|
+
</div>
|
|
89
|
+
) : (
|
|
90
|
+
<div className="cg-curtain__lockup">
|
|
91
|
+
{mark}
|
|
92
|
+
<span className="cg-curtain__text">{nameEl}{tag}</span>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
})()}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Script for the <head> (before paint): marks the curtain as seen in this
|
|
102
|
+
* session, so it does not repeat on navigation or reload. Without it, the
|
|
103
|
+
* curtain shows on every load.
|
|
104
|
+
* // Next (app/layout):
|
|
105
|
+
* <script dangerouslySetInnerHTML={{ __html: curtainHeadScript("cg-curtain") }} />
|
|
106
|
+
*/
|
|
107
|
+
export function curtainHeadScript(storageKey = "cg-curtain") {
|
|
108
|
+
const k = JSON.stringify(storageKey);
|
|
109
|
+
return `try{var d=document.documentElement;if(sessionStorage.getItem(${k})){d.setAttribute('data-cg-curtain','seen')}else{sessionStorage.setItem(${k},'1')}}catch(e){}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const CURTAIN_CSS = `
|
|
113
|
+
.cg-curtain{
|
|
114
|
+
--cg-curtain-mark: clamp(40px,7.4vw,60px); /* symbol height */
|
|
115
|
+
position:fixed; inset:0 0 -40px; padding-bottom:40px;
|
|
116
|
+
z-index:120; display:grid; place-items:center;
|
|
117
|
+
border-radius:0 0 32px 32px;
|
|
118
|
+
background:var(--color-surface,#fff); color:var(--color-ink,#14323a);
|
|
119
|
+
box-shadow:0 24px 60px -20px rgba(0,0,0,.28);
|
|
120
|
+
pointer-events:none;
|
|
121
|
+
animation:cg-curtain-rise 700ms cubic-bezier(.77,0,.175,1) 2200ms both;
|
|
122
|
+
}
|
|
123
|
+
/* gap ≈ 1/3 of the name's body size: the ring is curved, so the optical gap reads a bit larger than the geometric one */
|
|
124
|
+
.cg-curtain__lockup{ display:flex; align-items:center; gap:clamp(10px,1.6vw,16px); padding:0 24px; }
|
|
125
|
+
.cg-curtain__lockup--right{ flex-direction:column; align-items:flex-start; }
|
|
126
|
+
.cg-curtain__row{ display:flex; align-items:center; gap:clamp(10px,1.6vw,16px); }
|
|
127
|
+
.cg-curtain__lockup--right .cg-curtain__name{ animation-name:cg-curtain-name-right; }
|
|
128
|
+
.cg-curtain__mark{ display:inline-flex; color:var(--color-ink,#14323a);
|
|
129
|
+
animation:cg-curtain-mark 700ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) both; }
|
|
130
|
+
/* symbol ~1.25× the name's body size (the SVG box is tight to the glyph), fluid.
|
|
131
|
+
Sized by height: a non-square injected symbol (TTCAB) keeps its aspect ratio. */
|
|
132
|
+
.cg-curtain__mark svg{ height:var(--cg-curtain-mark); width:auto; }
|
|
133
|
+
.cg-curtain__text{ display:flex; flex-direction:column; min-width:0; }
|
|
134
|
+
.cg-curtain__name{
|
|
135
|
+
font-family:var(--font-heading,system-ui,sans-serif);
|
|
136
|
+
font-weight:700; font-size:clamp(2rem,6vw,3rem); line-height:1; letter-spacing:-.02em;
|
|
137
|
+
animation:cg-curtain-name 600ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) 480ms both;
|
|
138
|
+
}
|
|
139
|
+
.cg-curtain__tag{
|
|
140
|
+
margin-top:.55rem; font-family:var(--font-body,system-ui,sans-serif);
|
|
141
|
+
font-size:clamp(.8rem,2.4vw,1rem); line-height:1.3; color:var(--color-ink-soft,#54707a);
|
|
142
|
+
animation:cg-curtain-tag 500ms ease 820ms both;
|
|
143
|
+
}
|
|
144
|
+
@keyframes cg-curtain-rise{ to{ transform:translateY(-100%); visibility:hidden; } }
|
|
145
|
+
@keyframes cg-curtain-mark{ from{ opacity:0; transform:scale(.6) rotate(-16deg); } }
|
|
146
|
+
@keyframes cg-curtain-name{ from{ opacity:0; transform:translateX(-8px); } }
|
|
147
|
+
@keyframes cg-curtain-name-right{ from{ opacity:0; transform:translateX(8px); } }
|
|
148
|
+
@keyframes cg-curtain-tag{ from{ opacity:0; transform:translateY(4px); } }
|
|
149
|
+
html[data-cg-curtain="seen"] .cg-curtain{ display:none; }
|
|
150
|
+
@media (prefers-reduced-motion:reduce){ .cg-curtain{ display:none; } }
|
|
151
|
+
@media print{ .cg-curtain{ display:none; } }
|
|
152
|
+
`;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
|
|
4
|
+
|
|
5
|
+
export type Fact = {
|
|
6
|
+
Icon: IconType;
|
|
7
|
+
/** First line, strong ("Monday to Friday"). */
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
/** Second line, lighter ("8 am to 6 pm"). */
|
|
10
|
+
text?: ReactNode;
|
|
11
|
+
/** Read between the two lines by screen readers only, so the sentence
|
|
12
|
+
* still reads whole (", " or " "). Default: ", ". */
|
|
13
|
+
separator?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
facts: Fact[];
|
|
18
|
+
/** aria-label of the list ("How we see patients"). */
|
|
19
|
+
label?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fact strip — the three facts under the hero of novo.creeta ("Monday to
|
|
25
|
+
* Friday / 8 am to 6 pm", "At the clinic / or at home", "Individual care /
|
|
26
|
+
* from babies to the elderly"). Each fact is an icon bubble plus two lines of
|
|
27
|
+
* equal visual weight, so none wraps differently from the others. On desktop
|
|
28
|
+
* a thin line separates the columns; on phones it separates the rows.
|
|
29
|
+
*/
|
|
30
|
+
export function FactStrip({ facts, label, className = "" }: Props) {
|
|
31
|
+
const cols = facts.length === 2 ? "md:grid-cols-2" : facts.length === 4 ? "md:grid-cols-4" : "md:grid-cols-3";
|
|
32
|
+
return (
|
|
33
|
+
<ul
|
|
34
|
+
aria-label={label}
|
|
35
|
+
className={`m-0 list-none divide-y divide-ink/10 rounded-2xl bg-card px-6 md:grid ${cols} md:divide-x md:divide-y-0 md:px-0 md:py-7 ${className}`}
|
|
36
|
+
>
|
|
37
|
+
{facts.map(({ Icon, title, text, separator = ", " }, i) => (
|
|
38
|
+
<li key={i} className="flex items-center gap-4 py-5 md:px-8 md:py-0 lg:px-10">
|
|
39
|
+
<span aria-hidden="true" className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink">
|
|
40
|
+
<Icon size={22} strokeWidth={1.75} />
|
|
41
|
+
</span>
|
|
42
|
+
<p className="m-0 leading-snug">
|
|
43
|
+
<span className="block font-heading text-lg font-medium text-ink">{title}</span>
|
|
44
|
+
{text && (
|
|
45
|
+
<>
|
|
46
|
+
<span className="sr-only">{separator}</span>
|
|
47
|
+
<span className="block text-[0.9375rem] text-ink/80">{text}</span>
|
|
48
|
+
</>
|
|
49
|
+
)}
|
|
50
|
+
</p>
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
</ul>
|
|
54
|
+
);
|
|
55
|
+
}
|