@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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
export type Language = {
|
|
6
|
+
/** BCP 47 code for `lang`/`hrefLang` (pt-BR, en, es). */
|
|
7
|
+
lang: string;
|
|
8
|
+
/** The language written in itself ("Português", "English", "Español"). */
|
|
9
|
+
label: ReactNode;
|
|
10
|
+
/** The same page in that language. */
|
|
11
|
+
href: string;
|
|
12
|
+
current?: boolean;
|
|
13
|
+
/** Short code shown on the menu button; default: the first part of `lang`, uppercase (PT). */
|
|
14
|
+
code?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Labels = {
|
|
18
|
+
/** aria-label of the nav */
|
|
19
|
+
nav: string;
|
|
20
|
+
/** Accessible name of the menu button. Starts with the code written on it
|
|
21
|
+
* ("PT"): voice-control users say what they see (WCAG 2.5.3). */
|
|
22
|
+
change: (code: string, current: string) => string;
|
|
23
|
+
};
|
|
24
|
+
const EN: Labels = { nav: "Language", change: (code, current) => `${code}. Change language. Current: ${current}` };
|
|
25
|
+
|
|
26
|
+
type Props = {
|
|
27
|
+
languages: Language[];
|
|
28
|
+
/** `menu`: button with the current code that opens the list (header).
|
|
29
|
+
* `list`: the languages side by side, no JavaScript (footer). */
|
|
30
|
+
mode?: "menu" | "list";
|
|
31
|
+
labels?: Partial<Labels>;
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const Globe = () => (
|
|
36
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="max-[359px]:hidden md:max-lg:hidden" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20M2 12h20" /></svg>
|
|
37
|
+
);
|
|
38
|
+
const Check = ({ size = 16 }: { size?: number }) => (
|
|
39
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const codeOf = (l: Language) => l.code ?? l.lang.split("-")[0].toUpperCase();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Language switcher — the SeletorDeIdioma of novo.creeta, by tokens.
|
|
46
|
+
* - Leads to the SAME page in the other language, never to the home.
|
|
47
|
+
* - Each language is written in itself ("English", "Español"): that is how
|
|
48
|
+
* the reader recognizes theirs. The button shows the code (PT, EN, ES).
|
|
49
|
+
* - The current one is marked by a ✓ and `aria-current`, never by color alone.
|
|
50
|
+
* - Plain links (<a>): switching reloads the page, each language has its own
|
|
51
|
+
* document. Closed, the menu stays in the HTML: the links exist for crawlers
|
|
52
|
+
* and agents even if nobody opens it.
|
|
53
|
+
* - The site never redirects by browser language: the choice is the reader's.
|
|
54
|
+
*/
|
|
55
|
+
export function LanguageSwitcher({ languages, mode = "menu", labels, className = "" }: Props) {
|
|
56
|
+
const t = { ...EN, ...labels };
|
|
57
|
+
const [open, setOpen] = useState(false);
|
|
58
|
+
const box = useRef<HTMLDivElement>(null);
|
|
59
|
+
const button = useRef<HTMLButtonElement>(null);
|
|
60
|
+
const id = useId();
|
|
61
|
+
const current = languages.find((l) => l.current) ?? languages[0];
|
|
62
|
+
|
|
63
|
+
if (mode === "list") {
|
|
64
|
+
return (
|
|
65
|
+
<nav aria-label={t.nav} className={className}>
|
|
66
|
+
<ul className="flex list-none flex-wrap items-center gap-x-1 gap-y-0">
|
|
67
|
+
{languages.map((l) => (
|
|
68
|
+
<li key={l.lang}>
|
|
69
|
+
<a
|
|
70
|
+
href={l.href}
|
|
71
|
+
lang={l.lang}
|
|
72
|
+
hrefLang={l.lang}
|
|
73
|
+
aria-current={l.current ? "true" : undefined}
|
|
74
|
+
className={`inline-flex min-h-11 items-center gap-1.5 rounded-full px-3 text-sm ${
|
|
75
|
+
l.current ? "font-semibold text-ink" : "text-ink/80 underline underline-offset-4"
|
|
76
|
+
}`}
|
|
77
|
+
>
|
|
78
|
+
{l.current && <Check size={15} />}
|
|
79
|
+
{l.label}
|
|
80
|
+
</a>
|
|
81
|
+
</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
</nav>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
ref={box}
|
|
91
|
+
className={`relative ${className}`}
|
|
92
|
+
onBlur={(e) => {
|
|
93
|
+
if (!box.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
|
94
|
+
}}
|
|
95
|
+
onKeyDown={(e) => {
|
|
96
|
+
if (e.key === "Escape" && open) {
|
|
97
|
+
setOpen(false);
|
|
98
|
+
button.current?.focus();
|
|
99
|
+
}
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<button
|
|
103
|
+
ref={button}
|
|
104
|
+
type="button"
|
|
105
|
+
aria-expanded={open}
|
|
106
|
+
aria-controls={id}
|
|
107
|
+
aria-label={t.change(codeOf(current), typeof current.label === "string" ? current.label : codeOf(current))}
|
|
108
|
+
onClick={() => setOpen((o) => !o)}
|
|
109
|
+
className="flex min-h-11 items-center gap-1.5 rounded-full border border-ink/25 px-3 text-[0.8125rem] font-semibold text-ink transition-colors duration-150 hover:bg-ink/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink motion-reduce:transition-none"
|
|
110
|
+
>
|
|
111
|
+
{/* between 768 and 1023px the whole menu shares the row with the CTA: the globe goes, only the code stays */}
|
|
112
|
+
<Globe />
|
|
113
|
+
<span aria-hidden="true">{codeOf(current)}</span>
|
|
114
|
+
</button>
|
|
115
|
+
<ul
|
|
116
|
+
id={id}
|
|
117
|
+
className={`absolute right-0 top-full z-50 mt-2 min-w-[11rem] list-none origin-top-right rounded-[20px] bg-card p-1.5 shadow-[0_16px_40px_-16px_rgba(0,0,0,0.35)] transition duration-150 ease-out motion-reduce:transition-none ${
|
|
118
|
+
open ? "visible scale-100 opacity-100" : "invisible scale-95 opacity-0"
|
|
119
|
+
}`}
|
|
120
|
+
>
|
|
121
|
+
{languages.map((l) => (
|
|
122
|
+
<li key={l.lang}>
|
|
123
|
+
<a
|
|
124
|
+
href={l.href}
|
|
125
|
+
lang={l.lang}
|
|
126
|
+
hrefLang={l.lang}
|
|
127
|
+
aria-current={l.current ? "true" : undefined}
|
|
128
|
+
onClick={() => setOpen(false)}
|
|
129
|
+
className="flex min-h-11 items-center justify-between gap-3 rounded-[14px] px-3.5 text-sm font-medium text-ink hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ink"
|
|
130
|
+
>
|
|
131
|
+
{l.label}
|
|
132
|
+
{l.current && <Check />}
|
|
133
|
+
</a>
|
|
134
|
+
</li>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type ListItem = {
|
|
4
|
+
label: ReactNode;
|
|
5
|
+
/** With an href the item is a link: semibold, underlined, with an outbound arrow at the end. */
|
|
6
|
+
href?: string;
|
|
7
|
+
external?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
items: ListItem[];
|
|
12
|
+
/** `check` (default): bubble with a check — "what we assess", "what we treat".
|
|
13
|
+
* `arrow`: corner arrow (↳) — one sentence per item, as in the Footer notice. */
|
|
14
|
+
marker?: "check" | "arrow";
|
|
15
|
+
/** Link component of the app (e.g. next-intl Link). Default: <a>. */
|
|
16
|
+
linkComponent?: ElementType;
|
|
17
|
+
/** sr-only after an external link. */
|
|
18
|
+
newTabLabel?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const Check = () => (
|
|
23
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={14} height={14} fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
|
24
|
+
);
|
|
25
|
+
const CornerDownRight = () => (
|
|
26
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} className="mt-[0.2rem] shrink-0" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m15 10 5 5-5 5" /><path d="M4 4v7a4 4 0 0 0 4 4h12" /></svg>
|
|
27
|
+
);
|
|
28
|
+
const ArrowUpRight = () => (
|
|
29
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-go ml-auto mt-1 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The family's list with a marker in front of each item (from novo.creeta:
|
|
34
|
+
* `Lista` on the method page, "O que tratamos aqui" on a treatment page, the
|
|
35
|
+
* notice in the footer).
|
|
36
|
+
* - check: a 24px bubble (ink at 12%) with the check; the marker is
|
|
37
|
+
* decoration, the <ul> is what says "list".
|
|
38
|
+
* - arrow: the corner arrow, for one sentence per item.
|
|
39
|
+
* - An item with `href` becomes a link: semibold, underline that darkens on
|
|
40
|
+
* hover, outbound arrow at the end stepping toward where it goes; 44px tap
|
|
41
|
+
* height without changing the rhythm (negative vertical margin).
|
|
42
|
+
*/
|
|
43
|
+
export function List({ items, marker = "check", linkComponent, newTabLabel = "(opens in a new tab)", className = "" }: Props) {
|
|
44
|
+
const L: ElementType = linkComponent ?? "a";
|
|
45
|
+
const bubble = (
|
|
46
|
+
<span aria-hidden="true" className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink">
|
|
47
|
+
<Check />
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
const mark = marker === "check" ? bubble : <CornerDownRight />;
|
|
51
|
+
const gap = marker === "check" ? "gap-3" : "gap-2.5";
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<ul className={`list-none ${marker === "check" ? "space-y-3 text-ink" : "space-y-2 leading-relaxed text-ink/80"} ${className}`}>
|
|
55
|
+
{items.map((it, i) => (
|
|
56
|
+
<li key={`${i}-${it.href ?? ""}`}>
|
|
57
|
+
{it.href ? (
|
|
58
|
+
<L
|
|
59
|
+
href={it.href}
|
|
60
|
+
{...(it.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
61
|
+
className={`group/item -my-2 flex min-h-11 items-start ${gap} py-2.5 font-semibold text-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink`}
|
|
62
|
+
>
|
|
63
|
+
{mark}
|
|
64
|
+
<span className="underline decoration-ink/40 underline-offset-4 transition-[text-decoration-color] duration-150 group-hover/item:decoration-ink motion-reduce:transition-none">
|
|
65
|
+
{it.label}
|
|
66
|
+
{it.external && <span className="sr-only"> {newTabLabel}</span>}
|
|
67
|
+
</span>
|
|
68
|
+
<ArrowUpRight />
|
|
69
|
+
</L>
|
|
70
|
+
) : (
|
|
71
|
+
<span className={`flex items-start ${gap}`}>
|
|
72
|
+
{mark}
|
|
73
|
+
<span>{it.label}</span>
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
package/components/Lockup.tsx
CHANGED
|
@@ -2,20 +2,20 @@ import type { CSSProperties, ReactNode } from "react";
|
|
|
2
2
|
import { BreatheMark } from "./BreatheMark";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* - `stacked`
|
|
7
|
-
* - `horizontal`
|
|
8
|
-
* - `wordmark`
|
|
9
|
-
* - `symbol`
|
|
10
|
-
* - `merged`
|
|
5
|
+
* Logo arrangements (flexible branding system of the Cuidado Global family).
|
|
6
|
+
* - `stacked` symbol on top, name below: profiles, splash, poster.
|
|
7
|
+
* - `horizontal` symbol + name side by side (+ tagline): header, documents.
|
|
8
|
+
* - `wordmark` text only (name + tagline): ads, when legibility is essential.
|
|
9
|
+
* - `symbol` symbol only: favicon, app icon, watermark.
|
|
10
|
+
* - `merged` filled symbol with the name inside: avatar, packaging.
|
|
11
11
|
*/
|
|
12
12
|
export type LockupVariant = "stacked" | "horizontal" | "wordmark" | "symbol" | "merged";
|
|
13
13
|
type Size = "sm" | "md" | "lg";
|
|
14
14
|
|
|
15
|
-
/*
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
`md` =
|
|
15
|
+
/* Name in `font-heading`; symbol ~1.3× the name's body size (the ring is open
|
|
16
|
+
and "reads" lighter, so it needs one size up to sit in harmony).
|
|
17
|
+
The BreatheMark box is tight to the glyph: the space between symbol and name is just the gap.
|
|
18
|
+
`md` = the sites' header (44px symbol, measured on creeta and ttcab). */
|
|
19
19
|
const NAME: Record<Size, string> = { sm: "text-2xl", md: "text-[2.125rem]", lg: "text-[2.7rem]" };
|
|
20
20
|
const TAG: Record<Size, string> = { sm: "text-[0.78rem]", md: "text-sm", lg: "text-base" };
|
|
21
21
|
const MARK: Record<Size, number> = { sm: 32, md: 44, lg: 56 };
|
|
@@ -25,37 +25,38 @@ const TILE: Record<Size, string> = { sm: "size-16 text-[0.7rem]", md: "size-24 t
|
|
|
25
25
|
|
|
26
26
|
type Props = {
|
|
27
27
|
variant?: LockupVariant;
|
|
28
|
-
/**
|
|
28
|
+
/** Brand name (each site passes its own). */
|
|
29
29
|
name?: string;
|
|
30
30
|
tagline?: ReactNode;
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
31
|
+
/** Custom symbol; defaults to the BreatheMark (ring + dot). An injected <svg>
|
|
32
|
+
* gets the height of the size (`size`); width follows the aspect ratio. */
|
|
33
33
|
logo?: ReactNode;
|
|
34
|
-
/**
|
|
35
|
-
* (
|
|
34
|
+
/** Drawn wordmark (svg) in place of the text name. Ignored by `merged`
|
|
35
|
+
* (there the name is text). */
|
|
36
36
|
wordmark?: ReactNode;
|
|
37
|
-
/**
|
|
38
|
-
* (
|
|
37
|
+
/** Wordmark height as a fraction of the symbol height, from the brand manual
|
|
38
|
+
* (e.g. `TTCAB_LOCKUP.wordmarkPerSymbol`). Reaches the svg via `--cg-wordmark`. */
|
|
39
39
|
wordmarkRatio?: number;
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
40
|
+
/** Which side the symbol sits on in `horizontal`. Default `left` (creeta and
|
|
41
|
+
* the others); TTCAB is `right` (`TTCAB_LOCKUP.symbolSide`). */
|
|
42
42
|
symbolSide?: "left" | "right";
|
|
43
43
|
size?: Size;
|
|
44
|
-
/**
|
|
44
|
+
/** Does the symbol "breathe"? Default `false`: a header logo should not keep animating. */
|
|
45
45
|
animated?: boolean;
|
|
46
|
-
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
/** Entrance on mount, as in the novo.creeta header: the symbol arrives first,
|
|
47
|
+
* then the name, then the tagline. Respects prefers-reduced-motion (becomes
|
|
48
|
+
* just a short fade). Remount (key) to replay. */
|
|
49
49
|
entrance?: boolean;
|
|
50
|
-
/**
|
|
50
|
+
/** Entrance delay in ms (e.g. to wait for the Curtain to rise). */
|
|
51
51
|
entranceDelay?: number;
|
|
52
52
|
className?: string;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Lockup
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* Lockup: assembles the family logo in one of the arrangements above. Neutral
|
|
57
|
+
* through tokens: name/symbol in `ink`, tagline in `ink-soft`; the actual color
|
|
58
|
+
* comes from each app. Typography is fixed by the family (Quicksand for the
|
|
59
|
+
* name, Figtree for the tagline).
|
|
59
60
|
*/
|
|
60
61
|
export function Lockup({
|
|
61
62
|
variant = "horizontal",
|
|
@@ -73,16 +74,16 @@ export function Lockup({
|
|
|
73
74
|
}: Props) {
|
|
74
75
|
const entranceCls = entrance ? " cg-lockup--entrance" : "";
|
|
75
76
|
const entranceStyle = entrance ? ({ "--cg-entrance-delay": `${entranceDelay}ms` } as CSSProperties) : undefined;
|
|
76
|
-
//
|
|
77
|
-
//
|
|
77
|
+
// In horizontal with a tagline, the symbol spans the whole block (name + tagline),
|
|
78
|
+
// becoming a proportional "bookend", not just the name height.
|
|
78
79
|
const markPx = variant === "horizontal" && tagline && symbolSide !== "right" ? Math.round(NAME_PX[size] * 2.3) : MARK[size];
|
|
79
80
|
const mark = (
|
|
80
81
|
<span className="cg-lockup__mark inline-flex text-ink [&>svg]:h-[var(--mark)] [&>svg]:w-auto" style={{ "--mark": `${markPx}px` } as CSSProperties}>
|
|
81
82
|
{logo ?? <BreatheMark size={markPx} animated={animated} title={name} />}
|
|
82
83
|
</span>
|
|
83
84
|
);
|
|
84
|
-
//
|
|
85
|
-
//
|
|
85
|
+
// The wordmark follows the size's base symbol (not the "bookend" of horizontal
|
|
86
|
+
// with tagline), as in creeta's signed logo: name ≈ 0.26 of the large symbol.
|
|
86
87
|
const nameEl = (
|
|
87
88
|
<span
|
|
88
89
|
className={`cg-lockup__name font-heading font-bold leading-none tracking-[-0.02em] text-ink ${NAME[size]}`}
|
|
@@ -91,8 +92,8 @@ export function Lockup({
|
|
|
91
92
|
{wordmark ?? name}
|
|
92
93
|
</span>
|
|
93
94
|
);
|
|
94
|
-
// `block`:
|
|
95
|
-
//
|
|
95
|
+
// `block`: as an inline item, the parent's line-height (1.5 from body) ruled
|
|
96
|
+
// the line and a two-line tagline came out too loose.
|
|
96
97
|
const tagEl = tagline ? (
|
|
97
98
|
<span className={`cg-lockup__tag block font-body leading-[1.3] text-ink-soft ${TAG[size]}`}>{tagline}</span>
|
|
98
99
|
) : null;
|
|
@@ -108,7 +109,6 @@ export function Lockup({
|
|
|
108
109
|
if (variant === "wordmark") {
|
|
109
110
|
return (
|
|
110
111
|
<span className={`inline-flex flex-col${entranceCls} ${className}`} style={entranceStyle}>
|
|
111
|
-
{entrance && <style>{ENTRANCE_CSS}</style>}
|
|
112
112
|
{nameEl}
|
|
113
113
|
{tagEl && <span className="mt-1">{tagEl}</span>}
|
|
114
114
|
</span>
|
|
@@ -118,7 +118,6 @@ export function Lockup({
|
|
|
118
118
|
if (variant === "stacked") {
|
|
119
119
|
return (
|
|
120
120
|
<span className={`inline-flex flex-col items-center text-center${entranceCls} ${className}`} style={entranceStyle}>
|
|
121
|
-
{entrance && <style>{ENTRANCE_CSS}</style>}
|
|
122
121
|
{mark}
|
|
123
122
|
<span className="mt-4 flex flex-col items-center">
|
|
124
123
|
{nameEl}
|
|
@@ -129,8 +128,8 @@ export function Lockup({
|
|
|
129
128
|
}
|
|
130
129
|
|
|
131
130
|
if (variant === "merged") {
|
|
132
|
-
//
|
|
133
|
-
//
|
|
131
|
+
// Merged: name inside a filled disc (ink). Avatar / app icon.
|
|
132
|
+
// Short names (creeta, ttcab) fit best; a long name needs a smaller body size.
|
|
134
133
|
return (
|
|
135
134
|
<span
|
|
136
135
|
className={`inline-flex items-center justify-center whitespace-nowrap rounded-full bg-ink px-2 text-center font-heading font-bold leading-none tracking-[-0.01em] text-surface ${TILE[size]} ${className}`}
|
|
@@ -142,12 +141,11 @@ export function Lockup({
|
|
|
142
141
|
);
|
|
143
142
|
}
|
|
144
143
|
|
|
145
|
-
// horizontal (
|
|
146
|
-
//
|
|
144
|
+
// horizontal (default). Symbol on the right (TTCAB): sits next to the name, on
|
|
145
|
+
// the same line; the tagline goes below (otherwise the symbol would end up after the tagline).
|
|
147
146
|
if (symbolSide === "right") {
|
|
148
147
|
return (
|
|
149
148
|
<span className={`inline-flex flex-col items-start${entranceCls} ${className}`} style={entranceStyle}>
|
|
150
|
-
{entrance && <style>{ENTRANCE_CSS}</style>}
|
|
151
149
|
<span className={`inline-flex items-center ${GAP[size]}`}>
|
|
152
150
|
{nameEl}
|
|
153
151
|
{mark}
|
|
@@ -158,7 +156,6 @@ export function Lockup({
|
|
|
158
156
|
}
|
|
159
157
|
return (
|
|
160
158
|
<span className={`inline-flex items-center ${GAP[size]}${entranceCls} ${className}`} style={entranceStyle}>
|
|
161
|
-
{entrance && <style>{ENTRANCE_CSS}</style>}
|
|
162
159
|
{mark}
|
|
163
160
|
<span className="flex min-w-0 flex-col">
|
|
164
161
|
{nameEl}
|
|
@@ -168,17 +165,3 @@ export function Lockup({
|
|
|
168
165
|
);
|
|
169
166
|
}
|
|
170
167
|
|
|
171
|
-
/* Entrada: símbolo (escala + giro leve) → nome (desliza da esquerda) → tagline.
|
|
172
|
-
Mesmo vocabulário da Cortina; com movimento reduzido fica só um fade curto. */
|
|
173
|
-
const ENTRANCE_CSS = `
|
|
174
|
-
.cg-lockup--entrance .cg-lockup__mark{ animation: cg-lockup-mark 500ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) var(--cg-entrance-delay,0ms) both; }
|
|
175
|
-
.cg-lockup--entrance .cg-lockup__name{ animation: cg-lockup-name 420ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) calc(var(--cg-entrance-delay,0ms) + 420ms) both; }
|
|
176
|
-
.cg-lockup--entrance .cg-lockup__tag{ animation: cg-lockup-tag 360ms ease calc(var(--cg-entrance-delay,0ms) + 700ms) both; }
|
|
177
|
-
@keyframes cg-lockup-mark{ from{ opacity:0; transform:scale(.6) rotate(-16deg); } }
|
|
178
|
-
@keyframes cg-lockup-name{ from{ opacity:0; transform:translateX(-8px); } }
|
|
179
|
-
@keyframes cg-lockup-tag{ from{ opacity:0; transform:translateY(4px); } }
|
|
180
|
-
@media (prefers-reduced-motion:reduce){
|
|
181
|
-
.cg-lockup--entrance .cg-lockup__mark, .cg-lockup--entrance .cg-lockup__name, .cg-lockup--entrance .cg-lockup__tag{ animation: cg-lockup-fade 240ms ease var(--cg-entrance-delay,0ms) both; }
|
|
182
|
-
@keyframes cg-lockup-fade{ from{ opacity:0; } }
|
|
183
|
-
}
|
|
184
|
-
`;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
|
|
6
|
+
type Labels = {
|
|
7
|
+
/** The button that loads the map. */
|
|
8
|
+
open: string;
|
|
9
|
+
/** The outbound link ("Directions"). */
|
|
10
|
+
directions: string;
|
|
11
|
+
/** sr-only after the outbound link. */
|
|
12
|
+
newTab: string;
|
|
13
|
+
/** The note under the actions. */
|
|
14
|
+
note: string;
|
|
15
|
+
/** The close button on the bar under the open map. */
|
|
16
|
+
close: string;
|
|
17
|
+
/** Accessible title of the map frame; receives the address. */
|
|
18
|
+
frameTitle: (address: string) => string;
|
|
19
|
+
};
|
|
20
|
+
const EN: Labels = {
|
|
21
|
+
open: "View the map",
|
|
22
|
+
directions: "Directions",
|
|
23
|
+
newTab: "(opens in a new tab)",
|
|
24
|
+
note: "The map is Google's and only loads after your tap.",
|
|
25
|
+
close: "Close the map",
|
|
26
|
+
frameTitle: (address) => `Map: ${address}`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type Props = {
|
|
30
|
+
/** First line, large: the street and number. */
|
|
31
|
+
address: string;
|
|
32
|
+
/** Second line: neighbourhood, city/state. */
|
|
33
|
+
locality?: ReactNode;
|
|
34
|
+
/** Still image of the map (a file of the site, never a live tile): says
|
|
35
|
+
* "there is a map here" without calling Google. Wide, pin near the top-middle. */
|
|
36
|
+
preview: { src: string; width?: number; height?: number; position?: string };
|
|
37
|
+
/** Embed URL, requested only after the tap (e.g. https://www.google.com/maps?q=…&output=embed). */
|
|
38
|
+
embedSrc: string;
|
|
39
|
+
/** Google Maps search/route link; works without JavaScript. */
|
|
40
|
+
directionsHref: string;
|
|
41
|
+
/** Caption on the close bar while the map is open. Default: the address. */
|
|
42
|
+
caption?: ReactNode;
|
|
43
|
+
/** Height of the open map. */
|
|
44
|
+
mapHeight?: string;
|
|
45
|
+
labels?: Partial<Labels>;
|
|
46
|
+
className?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const MapPin = ({ size = 20, strokeWidth = 1.75 }: { size?: number; strokeWidth?: number }) => (
|
|
50
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /><circle cx="12" cy="10" r="3" /></svg>
|
|
51
|
+
);
|
|
52
|
+
const ArrowUpRight = () => (
|
|
53
|
+
<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>
|
|
54
|
+
);
|
|
55
|
+
const X = () => (
|
|
56
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Map card — the Mapa of novo.creeta: a Google map that only loads on tap.
|
|
61
|
+
*
|
|
62
|
+
* The embedded map sets third-party cookies as soon as it appears, and the
|
|
63
|
+
* sites have no consent banner. So the card shows a still preview of the map
|
|
64
|
+
* fading into the card, the address, a "View the map" button and the outbound
|
|
65
|
+
* "Directions" link (works without JavaScript); Google is only called when the
|
|
66
|
+
* person asks. The page is also lighter on phones.
|
|
67
|
+
*
|
|
68
|
+
* Open, the map fills the card and a bar at the bottom (the same as the
|
|
69
|
+
* PageVideoHero's) carries the caption and the close button, so the person
|
|
70
|
+
* can put the still back.
|
|
71
|
+
*/
|
|
72
|
+
export function MapCard({ address, locality, preview, embedSrc, directionsHref, caption, mapHeight = "min-h-[420px]", labels, className = "" }: Props) {
|
|
73
|
+
const t = { ...EN, ...labels };
|
|
74
|
+
const [open, setOpen] = useState(false);
|
|
75
|
+
const [loaded, setLoaded] = useState(false);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={`relative overflow-hidden rounded-2xl bg-card ${className}`}>
|
|
79
|
+
{open ? (
|
|
80
|
+
<div className="flex flex-col">
|
|
81
|
+
<div className={`relative ${mapHeight} bg-surface`}>
|
|
82
|
+
<iframe
|
|
83
|
+
title={t.frameTitle(address)}
|
|
84
|
+
src={embedSrc}
|
|
85
|
+
referrerPolicy="no-referrer-when-downgrade"
|
|
86
|
+
allowFullScreen
|
|
87
|
+
onLoad={() => setLoaded(true)}
|
|
88
|
+
// only shown once Google has drawn it, with a fade: before that the frame is an empty white
|
|
89
|
+
className={`absolute inset-0 block h-full w-full border-0 transition-opacity duration-300 motion-reduce:transition-none ${loaded ? "opacity-100" : "opacity-0"}`}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
{/* close bar — the same as the hero video's: caption on the left, close on the right */}
|
|
93
|
+
<div className="relative z-10 flex h-[4.5rem] items-center justify-between gap-6 bg-ink px-6">
|
|
94
|
+
<p className="m-0 min-w-0 truncate text-sm text-surface/80">{caption ?? address}</p>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => {
|
|
98
|
+
setOpen(false);
|
|
99
|
+
setLoaded(false);
|
|
100
|
+
}}
|
|
101
|
+
className="inline-flex min-h-11 shrink-0 items-center gap-2 rounded-full border border-surface/25 px-5 text-sm font-semibold text-surface transition-colors duration-200 hover:bg-surface/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface motion-reduce:transition-none"
|
|
102
|
+
>
|
|
103
|
+
<X />
|
|
104
|
+
{t.close}
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
) : (
|
|
109
|
+
<div className="flex h-full min-h-[320px] flex-col">
|
|
110
|
+
{/* Top: a still crop of the map fading into the card towards the bottom.
|
|
111
|
+
The text sits below, outside the image, always on the card color. */}
|
|
112
|
+
<div aria-hidden="true" className="relative min-h-[9rem] flex-1 overflow-hidden">
|
|
113
|
+
<img
|
|
114
|
+
src={preview.src}
|
|
115
|
+
alt=""
|
|
116
|
+
width={preview.width}
|
|
117
|
+
height={preview.height}
|
|
118
|
+
loading="lazy"
|
|
119
|
+
decoding="async"
|
|
120
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
121
|
+
style={{ objectPosition: preview.position ?? "48% 18%" }}
|
|
122
|
+
/>
|
|
123
|
+
<span className="absolute inset-0 bg-gradient-to-b from-card/0 via-card/35 via-60% to-card" />
|
|
124
|
+
{/* on the right: on the left it covered the place's name on the map */}
|
|
125
|
+
<span className="absolute right-6 top-6 flex h-11 w-11 items-center justify-center rounded-full bg-card text-ink shadow-[0_2px_8px_rgba(0,0,0,0.18)] md:right-8 md:top-8">
|
|
126
|
+
<MapPin />
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="px-6 pb-6 md:px-8 md:pb-8">
|
|
131
|
+
<address className="m-0 not-italic font-heading text-2xl font-medium leading-snug text-ink">
|
|
132
|
+
{address}
|
|
133
|
+
{locality && <span className="mt-1 block font-body text-base font-normal text-ink/80">{locality}</span>}
|
|
134
|
+
</address>
|
|
135
|
+
|
|
136
|
+
<div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2">
|
|
137
|
+
<Button variant="solid" onClick={() => setOpen(true)}>
|
|
138
|
+
<MapPin size={19} strokeWidth={2} />
|
|
139
|
+
{t.open}
|
|
140
|
+
</Button>
|
|
141
|
+
<a
|
|
142
|
+
href={directionsHref}
|
|
143
|
+
target="_blank"
|
|
144
|
+
rel="noopener noreferrer"
|
|
145
|
+
className="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"
|
|
146
|
+
>
|
|
147
|
+
{t.directions}
|
|
148
|
+
<ArrowUpRight />
|
|
149
|
+
<span className="sr-only"> {t.newTab}</span>
|
|
150
|
+
</a>
|
|
151
|
+
</div>
|
|
152
|
+
<p className="m-0 mt-4 text-sm leading-relaxed text-ink/80">{t.note}</p>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|