@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -142
  3. package/components/Accordion.tsx +17 -14
  4. package/components/AnchorNav.tsx +33 -21
  5. package/components/Avatar.tsx +37 -0
  6. package/components/BackgroundVideo.tsx +14 -14
  7. package/components/BottomNav.tsx +70 -69
  8. package/components/Breadcrumb.tsx +45 -0
  9. package/components/BreatheMark.tsx +12 -24
  10. package/components/Button.tsx +12 -12
  11. package/components/Byline.tsx +103 -0
  12. package/components/CaptionBand.tsx +74 -0
  13. package/components/Card.tsx +5 -5
  14. package/components/Container.tsx +4 -4
  15. package/components/CtaCard.tsx +52 -0
  16. package/components/Curtain.tsx +152 -0
  17. package/components/FactStrip.tsx +55 -0
  18. package/components/Footer.tsx +169 -77
  19. package/components/Header.tsx +32 -17
  20. package/components/Heading.tsx +86 -0
  21. package/components/InfoCard.tsx +116 -0
  22. package/components/LanguageSwitcher.tsx +139 -0
  23. package/components/List.tsx +81 -0
  24. package/components/Lockup.tsx +38 -55
  25. package/components/MapCard.tsx +158 -0
  26. package/components/MediaCard.tsx +266 -0
  27. package/components/PageVideoHero.tsx +53 -53
  28. package/components/PersonChip.tsx +49 -0
  29. package/components/Pill.tsx +42 -0
  30. package/components/PillList.tsx +21 -48
  31. package/components/ProfessionalCard.tsx +341 -0
  32. package/components/ProfessionalModal.tsx +243 -0
  33. package/components/Rows.tsx +131 -0
  34. package/components/SectionKicker.tsx +3 -3
  35. package/components/TestimonialCard.tsx +138 -0
  36. package/components/Text.tsx +31 -0
  37. package/components/Timeline.tsx +69 -0
  38. package/components/VideoInviteCard.tsx +168 -0
  39. package/components/VideoStoryCard.tsx +382 -0
  40. package/components/icons.tsx +6 -6
  41. package/components/marks/creeta.tsx +22 -22
  42. package/components/marks/ttcab.tsx +13 -13
  43. package/package.json +28 -5
  44. package/theme.css +88 -22
  45. package/tokens/creeta.css +14 -12
  46. package/tokens/neutral.css +16 -10
  47. package/tokens/ttcab.css +9 -8
  48. 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
+
@@ -2,20 +2,20 @@ import type { CSSProperties, ReactNode } from "react";
2
2
  import { BreatheMark } from "./BreatheMark";
3
3
 
4
4
  /**
5
- * Arranjos do logo (sistema de branding flexível da família Cuidado Global).
6
- * - `stacked` empilhado: símbolo em cima, nome embaixo — perfis, splash, pôster.
7
- * - `horizontal` símbolo + nome lado a lado (+ tagline) — cabeçalho, documento.
8
- * - `wordmark` só texto (nome + tagline) — anúncio, quando a leitura é essencial.
9
- * - `symbol` só o símbolo — favicon, ícone de app, marca d'água.
10
- * - `merged` fundido: símbolo preenchido com o nome dentro — avatar, embalagem.
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
- /* Nome em `font-heading`; símbolo ~1.3× o corpo do nome (o anel é aberto e
16
- "lê" mais leve, então pede um tamanho a mais pra fechar em harmonia).
17
- A caixa do BreatheMark é justa ao glifo: o vão entre símbolo e nome é só o gap.
18
- `md` = header dos sites (símbolo de 44px, medido no creeta e no ttcab). */
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
- /** Nome da marca (cada site passa o seu). */
28
+ /** Brand name (each site passes its own). */
29
29
  name?: string;
30
30
  tagline?: ReactNode;
31
- /** Símbolo customizado; por padrão o BreatheMark (anel + ponto). Um <svg>
32
- * injetado recebe a altura do tamanho (`size`); a largura segue a proporção. */
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
- /** Wordmark desenhado (svg) no lugar do nome em texto. `merged` ignora
35
- * (lá o nome é texto). */
34
+ /** Drawn wordmark (svg) in place of the text name. Ignored by `merged`
35
+ * (there the name is text). */
36
36
  wordmark?: ReactNode;
37
- /** Altura do wordmark como fração da altura do símbolo, do manual da marca
38
- * (ex.: `TTCAB_LOCKUP.wordmarkPerSymbol`). Chega ao svg por `--cg-wordmark`. */
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
- /** Lado do símbolo no horizontal. Padrão `left` (creeta e os demais); o TTCAB
41
- * é `right` (`TTCAB_LOCKUP.symbolSide`). */
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
- /** O símbolo "respira"? Padrão `false` — logo de cabeçalho não fica animando. */
44
+ /** Does the symbol "breathe"? Default `false`: a header logo should not keep animating. */
45
45
  animated?: boolean;
46
- /** Entrada ao montar, como no header do novo.creeta: o símbolo chega
47
- * primeiro, depois o nome, depois a tagline. Respeita prefers-reduced-motion
48
- * (vira só um fade curto). Remonte (key) para repetir. */
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
- /** Atraso da entrada em ms (ex.: esperar a Cortina subir). */
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 — monta o logo da família num dos arranjos acima. Neutro por tokens:
57
- * nome/símbolo em `ink`, tagline em `ink-soft`; a cor real vem de cada app.
58
- * Tipografia é fixa da família (Quicksand no nome, Figtree na tagline).
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
- // No horizontal com tagline, o símbolo cobre o bloco inteiro (nome + tagline),
77
- // virando um "bookend" proporcional — não só a altura do nome.
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
- // O wordmark segue o símbolo-base do tamanho (não o "bookend" do horizontal
85
- // com tagline), como no logotipo assinado do creeta: nome ≈ 0.26 do símbolo grande.
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`: como item inline, a entrelinha do pai (1.5 do body) mandava na
95
- // linha e a tagline de duas linhas ficava folgada demais.
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
- // Fundido: nome dentro de um disco preenchido (ink). Avatar / ícone de app.
133
- // Nomes curtos (creeta, ttcab) fecham melhor; nome longo reduz o corpo.
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 (padrão). Símbolo à direita (TTCAB): encosta no nome, na mesma
146
- // linha; a tagline fica embaixo (senão o símbolo iria parar depois da tagline).
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
+ }