@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
@@ -1,115 +1,207 @@
1
- import type { ReactNode } from "react";
1
+ import type { ComponentType, ElementType, ReactNode } from "react";
2
+ import { LanguageSwitcher, type Language } from "./LanguageSwitcher";
3
+ import { List } from "./List";
2
4
 
3
- export type FooterColumn = { title: ReactNode; items: ReactNode[] };
5
+ /** lucide-style icon component (the footer sets the size). */
6
+ type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
7
+
8
+ export type FooterLink = { href: string; label: ReactNode };
9
+ export type FooterContact = { href: string; label: ReactNode; Icon: IconType; external?: boolean };
10
+ export type FooterLanguage = Language;
11
+
12
+ type Labels = {
13
+ /** aria-label of the pages nav */
14
+ pagesNav: string;
15
+ /** aria-label of the contacts list */
16
+ contacts: string;
17
+ /** aria-label of the languages nav */
18
+ languages: string;
19
+ /** sr-only after an external link */
20
+ newTab: string;
21
+ };
22
+ const EN: Labels = { pagesNav: "Footer pages", contacts: "Contacts", languages: "Language", newTab: "(opens in a new tab)" };
4
23
 
5
24
  type Props = {
6
- /** Marca (passe o seu <img>/logo). */
25
+ /** Signed brand: the Lockup (horizontal + tagline) or the site's own svg. */
7
26
  logo: ReactNode;
8
- /** Frase/assinatura sob a marca. */
9
- tagline?: ReactNode;
10
- /** Colunas de navegação (o consumidor passa seus próprios <Link>). */
11
- columns: FooterColumn[];
12
- /** Bloco de contato/endereço (título + linhas + ações como "Ver no mapa"). */
13
- contact: { title?: ReactNode; lines: ReactNode[]; actions?: ReactNode };
14
- /** Bloco opcional em destaque (ex.: "Plano de saúde: atendemos por reembolso"). */
15
- notice?: ReactNode;
16
- /** Linha legal (CNPJ, copyright…). */
17
- legal?: ReactNode;
18
- /** Chips de contato (telefone/WhatsApp/e-mail/Instagram) — o consumidor passa os links. */
19
- chips?: ReactNode[];
20
- /** Seletor de idioma (slot). */
21
- localeSwitcher?: ReactNode;
27
+ /** Full institutional name for screen readers (the logo is an image). */
28
+ brandName?: string;
29
+ /** The brand sentence, shown in quotes under the logo. */
30
+ quote?: ReactNode;
31
+ /** "Pages": title on the first line, links in two columns below. Optional:
32
+ * a footer with no internal links drops the column (two-column grid). */
33
+ pages?: { title: ReactNode; links: FooterLink[] };
34
+ /** "Where we are": address lines, opening hours, and the directions links
35
+ * ("Directions ↗ · Waze ↗", external). */
36
+ where: { title: ReactNode; address: ReactNode[]; hours?: ReactNode; directions?: FooterLink[] };
37
+ /** Optional notice band (e.g. "Health insurance: we work by reimbursement"):
38
+ * title, one sentence per item (corner arrow), a CTA and a footnote. */
39
+ notice?: { title: ReactNode; items: ReactNode[]; action?: ReactNode; footnote?: ReactNode; id?: string };
40
+ /** Legal line. REQUIRED in the family: institutional name · CNPJ (company registration). */
41
+ legal: ReactNode;
42
+ /** Contact chips: phone, WhatsApp, e-mail, Instagram… only the real ones. */
43
+ contacts?: FooterContact[];
44
+ /** Language links, side by side, no JavaScript. The current one gets a ✓ and aria-current. */
45
+ languages?: FooterLanguage[];
46
+ /** Small credits line at the very end (photo authors…). */
47
+ credits?: ReactNode;
48
+ /** Link component of the app (e.g. next-intl Link). Default: <a>. */
49
+ linkComponent?: ElementType;
50
+ /** Breakpoint from which the BottomNav is gone (md on creeta, lg on ttcab):
51
+ * below it the card keeps room at the bottom for the floating bar. */
52
+ navHiddenFrom?: "md" | "lg";
53
+ labels?: Partial<Labels>;
54
+ className?: string;
22
55
  };
23
56
 
57
+ const ArrowUpRight = () => (
58
+ <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>
59
+ );
60
+
61
+ const title = "font-heading text-lg font-medium text-ink";
62
+ const link =
63
+ "flex min-h-11 items-center text-ink/80 underline decoration-transparent underline-offset-4 transition-[color,text-decoration-color] duration-150 hover:text-ink hover:decoration-ink motion-reduce:transition-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
64
+ const strongLink =
65
+ "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";
66
+ const chip =
67
+ "inline-flex min-h-11 items-center gap-2 rounded-full bg-ink/[0.08] px-4 text-sm font-medium text-ink transition-colors duration-200 hover:bg-ink hover:text-surface motion-reduce:transition-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
68
+
24
69
  /**
25
- * Footer da família Cuidado Global. Cartão grande arredondado em `surface`,
26
- * com marca + navegação + contato, um bloco opcional em destaque, e a barra
27
- * inferior com legal, chips de contato e idiomas. 100% data-driven e sem
28
- * dependência de app (Link/ícones/logo vêm por props). Tokens: ink/ink-soft
29
- * sobre surface, hairlines `line`.
70
+ * Footer of the Cuidado Global family — the site-footer of novo.creeta, by
71
+ * tokens, edge to edge like the Header (the card version was dropped on 2026-10-29):
72
+ * signed logo + the brand sentence, "Pages" (title on the first line, links in
73
+ * two columns), "Where we are" (address, hours, "Directions ↗ · Waze ↗" — the
74
+ * map only loads on tap, elsewhere), an optional notice band on `surface`
75
+ * (page color), then the legal line with the CNPJ (always) and the contact
76
+ * chips, and the languages as plain links (work without JavaScript).
77
+ * Below the BottomNav breakpoint the card leaves room at the bottom for the bar.
30
78
  */
31
79
  export function Footer({
32
80
  logo,
33
- tagline,
34
- columns,
35
- contact,
81
+ brandName,
82
+ quote,
83
+ pages,
84
+ where,
36
85
  notice,
37
86
  legal,
38
- chips,
39
- localeSwitcher,
87
+ contacts = [],
88
+ languages,
89
+ credits,
90
+ linkComponent,
91
+ navHiddenFrom = "lg",
92
+ labels,
93
+ className = "",
40
94
  }: Props) {
95
+ const L: ElementType = linkComponent ?? "a";
96
+ const t = { ...EN, ...labels };
97
+ // whole class names: Tailwind only generates what it finds written in the code
98
+ // Edge to edge like the Header (family decision 2026-10-29), same inner width
99
+ // and side padding (px-4 md:px-6). Below the BottomNav breakpoint the bottom
100
+ // padding leaves room for the floating bar.
101
+ const outer = navHiddenFrom === "md" ? "mt-16 pb-28 md:mt-24 md:pb-14" : "mt-16 pb-28 lg:mt-24 lg:pb-14";
102
+ const noticeId = notice?.id ?? "footer-notice";
103
+
41
104
  return (
42
- <footer className="px-3 pb-3 md:px-4 md:pb-4">
43
- <div className="mx-auto overflow-hidden rounded-[clamp(24px,3vw,36px)] bg-surface px-[clamp(24px,5vw,72px)] py-[clamp(40px,5vw,64px)] text-ink">
44
- {/* Topo: marca · colunas · contato */}
45
- <div className="grid gap-[clamp(32px,5vw,64px)] lg:grid-cols-[1.2fr_1.4fr_1fr]">
105
+ <footer className={`${outer} bg-card py-10 text-ink md:py-14 ${className}`}>
106
+ <div className="mx-auto max-w-[1600px] px-4 md:px-6">
107
+ {/* without the pages column, "where" keeps to the right edge (auto width) */}
108
+ <div className={`grid gap-x-10 gap-y-10 ${pages ? "md:grid-cols-[minmax(0,4fr)_minmax(0,5fr)_minmax(0,3fr)]" : "md:grid-cols-[minmax(0,1fr)_auto]"}`}>
46
109
  <div>
47
- <div className="mb-5">{logo}</div>
48
- {tagline && (
49
- <p className="max-w-[28ch] text-[1.02rem] leading-[1.5] text-ink-soft">
50
- {tagline}
51
- </p>
110
+ {logo}
111
+ {brandName && <p className="sr-only">{brandName}</p>}
112
+ {quote && (
113
+ <p className="mt-6 max-w-[18rem] font-heading text-xl leading-snug text-ink-body">“{quote}”</p>
52
114
  )}
53
115
  </div>
54
116
 
55
- <div className="grid grid-cols-2 gap-x-6 gap-y-3">
56
- {columns.map((col, i) => (
57
- <div key={i} className={columns.length === 1 ? "col-span-2" : ""}>
58
- <p className="mb-4 font-body text-[0.78rem] font-semibold tracking-[0.18em] text-ink-soft uppercase">
59
- {col.title}
60
- </p>
61
- {/* links herdam hover-underline: sublinhado some→aparece suave,
62
- agnóstico ao que o site passa (<a> ou <Link>) */}
63
- <ul className="flex flex-col gap-3 [&_a]:underline [&_a]:decoration-transparent [&_a]:underline-offset-[5px] [&_a]:transition-[text-decoration-color] [&_a]:duration-200 [&_a:hover]:decoration-current">
64
- {col.items.map((item, j) => (
65
- <li key={j} className="text-[1.02rem] leading-snug text-ink">
66
- {item}
67
- </li>
68
- ))}
69
- </ul>
117
+ {pages && (
118
+ <nav aria-label={t.pagesNav}>
119
+ <h2 className={title}>{pages.title}</h2>
120
+ <div className="mt-3 grid gap-x-6 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
121
+ {pages.links.map((p, i) => (
122
+ <L key={`${i}-${p.href}`} href={p.href} className={link}>
123
+ {p.label}
124
+ </L>
125
+ ))}
70
126
  </div>
71
- ))}
72
- </div>
127
+ </nav>
128
+ )}
73
129
 
74
130
  <div>
75
- {contact.title && (
76
- <p className="mb-4 font-body text-[0.78rem] font-semibold tracking-[0.18em] text-ink-soft uppercase">
77
- {contact.title}
131
+ <h2 className={title}>{where.title}</h2>
132
+ <address className="mt-3 not-italic leading-relaxed text-ink/80">
133
+ {where.address.map((line, i) => (
134
+ <span key={i}>
135
+ {i > 0 && <br />}
136
+ {line}
137
+ </span>
138
+ ))}
139
+ </address>
140
+ {where.hours && <p className="mt-2 leading-relaxed text-ink/80">{where.hours}</p>}
141
+ {where.directions && where.directions.length > 0 && (
142
+ <p className="mt-2">
143
+ {where.directions.map((d, i) => (
144
+ <span key={`${i}-${d.href}`}>
145
+ {i > 0 && <span aria-hidden="true" className="mx-2 text-ink/80">·</span>}
146
+ <a href={d.href} target="_blank" rel="noopener noreferrer" className={strongLink}>
147
+ {d.label}
148
+ <ArrowUpRight />
149
+ <span className="sr-only"> {t.newTab}</span>
150
+ </a>
151
+ </span>
152
+ ))}
78
153
  </p>
79
154
  )}
80
- <div className="space-y-2 text-[1.02rem] leading-[1.5] text-ink-soft">
81
- {contact.lines.map((line, i) => (
82
- <p key={i}>{line}</p>
83
- ))}
84
- </div>
85
- {contact.actions && <div className="mt-4">{contact.actions}</div>}
86
155
  </div>
87
156
  </div>
88
157
 
89
- {/* Bloco opcional em destaque */}
158
+ {/* Notice band: `surface` (the page color) over the white card. One
159
+ sentence per item, with the corner arrow; the arrow is decoration,
160
+ the <ul> is what says "list". Always at reading width (max-w-6xl),
161
+ narrower than the full-width columns. */}
90
162
  {notice && (
91
- <div className="mt-[clamp(32px,4vw,48px)] rounded-2xl bg-ink/[0.04] p-[clamp(24px,3.5vw,44px)]">
92
- {notice}
93
- </div>
163
+ <section aria-labelledby={noticeId} className="mx-auto mt-10 max-w-6xl rounded-[24px] bg-surface px-6 py-8 text-ink md:mt-12 md:px-10 md:py-10">
164
+ <div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between lg:gap-10">
165
+ <div>
166
+ <h2 id={noticeId} className="font-heading text-2xl font-medium leading-snug md:text-[1.75rem]">
167
+ {notice.title}
168
+ </h2>
169
+ <List marker="arrow" className="mt-4" items={notice.items.map((label) => ({ label }))} />
170
+ </div>
171
+ {notice.action && <div className="w-full shrink-0 sm:w-auto sm:self-start lg:self-center [&>*]:w-full [&>*]:justify-center sm:[&>*]:w-auto">{notice.action}</div>}
172
+ </div>
173
+ {notice.footnote && <p className="mt-6 border-t border-ink/10 pt-4 text-xs text-ink/80">{notice.footnote}</p>}
174
+ </section>
94
175
  )}
95
176
 
96
- {/* Barra inferior: legal + chips */}
97
- <div className="mt-[clamp(32px,4vw,48px)] flex flex-col gap-5 border-t border-line pt-[clamp(20px,2.5vw,28px)] md:flex-row md:items-center md:justify-between">
98
- {legal && <p className="text-sm text-ink-soft">{legal}</p>}
99
- {chips && chips.length > 0 && (
100
- <div className="flex flex-wrap items-center gap-2.5">
101
- {chips.map((chip, i) => (
102
- <span key={i}>{chip}</span>
177
+ {/* Legal line (institutional name · CNPJ) + contact chips */}
178
+ <div className="mt-10 flex flex-col gap-5 border-t border-ink/10 pt-6 md:mt-12 md:flex-row md:items-center md:justify-between">
179
+ <p className="text-sm text-ink/80">{legal}</p>
180
+ {contacts.length > 0 && (
181
+ <ul aria-label={t.contacts} className="flex list-none flex-wrap gap-2">
182
+ {contacts.map(({ href, label, Icon, external }, i) => (
183
+ <li key={`${i}-${href}`}>
184
+ <a className={chip} href={href} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}>
185
+ <Icon aria-hidden="true" size={17} strokeWidth={2} />
186
+ {label}
187
+ {external && <span className="sr-only"> {t.newTab}</span>}
188
+ </a>
189
+ </li>
103
190
  ))}
104
- </div>
191
+ </ul>
105
192
  )}
106
193
  </div>
107
194
 
108
- {/* Idiomas */}
109
- {localeSwitcher && (
110
- <div className="mt-[clamp(20px,2.5vw,28px)]">{localeSwitcher}</div>
195
+ {/* Languages as plain links: they work without JavaScript */}
196
+ {languages && languages.length > 0 && (
197
+ <div className="mt-4 border-t border-ink/10 pt-2">
198
+ <LanguageSwitcher mode="list" languages={languages} labels={{ nav: t.languages }} />
199
+ </div>
111
200
  )}
201
+
202
+ {credits && <p className="mt-4 text-xs text-ink/80">{credits}</p>}
112
203
  </div>
113
204
  </footer>
114
205
  );
115
206
  }
207
+
@@ -12,26 +12,40 @@ export type NavItem = {
12
12
  label: ReactNode;
13
13
  href?: string;
14
14
  active?: boolean;
15
- /** Ícone antes do rótulo (ex.: casa no "Início"). */
15
+ /** Icon before the label (e.g. a house on "Home"). */
16
16
  icon?: ReactNode;
17
- /** Só o ícone aparece; o rótulo fica para leitor de tela (padrão dos sites: "Início" é a casa). */
17
+ /** Only the icon shows; the label is left for screen readers (site convention: "Home" is the house). */
18
18
  iconOnly?: boolean;
19
19
  children?: NavChild[];
20
- /** Link final do dropdown. Seta → (vai para outra página do site); com
21
- * `external` vira ↗ (sai para outro site), como nos demais componentes. */
20
+ /** Final link of the dropdown. Arrow → (goes to another page of the site); with
21
+ * `external` it becomes ↗ (leaves to another site), as in the other components. */
22
22
  seeAll?: { label: ReactNode; href: string; external?: boolean };
23
23
  };
24
24
 
25
+ type Labels = {
26
+ /** aria-label of the desktop nav */
27
+ nav: string;
28
+ /** aria-label of the chevron button that toggles a dropdown */
29
+ openSubmenu: string;
30
+ };
31
+
32
+ const EN: Labels = {
33
+ nav: "Main",
34
+ openSubmenu: "Open submenu",
35
+ };
36
+
25
37
  type Props = {
26
- /** Marca (passe o seu logo, já com link para a home se quiser). */
38
+ /** Brand (pass your own logo, already linked to the home page if you want). */
27
39
  logo: ReactNode;
28
40
  items: NavItem[];
29
- /** Componente de link do app (ex.: next-intl Link). Default: <a>. */
41
+ /** The app's link component (e.g. next-intl Link). Default: <a>. */
30
42
  linkComponent?: ElementType;
31
- /** CTA à direita (ex.: Agendar/WhatsApp). */
43
+ /** CTA on the right (e.g. Book/WhatsApp). */
32
44
  cta?: ReactNode;
33
- /** Seletor de idioma (slot). */
45
+ /** Locale switcher (slot). */
34
46
  localeSwitcher?: ReactNode;
47
+ /** Strings (English defaults); the app passes the ones for its locale. */
48
+ labels?: Partial<Labels>;
35
49
  };
36
50
 
37
51
  function Chevron({ open }: { open: boolean }) {
@@ -56,7 +70,7 @@ const linkCls = (active?: boolean) =>
56
70
  active ? "decoration-ink" : "decoration-transparent hover:decoration-ink/40"
57
71
  } transition-[text-decoration-color] duration-150 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ink`;
58
72
 
59
- function Dropdown({ item, L }: { item: NavItem; L: ElementType }) {
73
+ function Dropdown({ item, L, t }: { item: NavItem; L: ElementType; t: Labels }) {
60
74
  const [open, setOpen] = useState(false);
61
75
  return (
62
76
  <div
@@ -79,7 +93,7 @@ function Dropdown({ item, L }: { item: NavItem; L: ElementType }) {
79
93
  <button
80
94
  type="button"
81
95
  aria-expanded={open}
82
- aria-label="Abrir submenu"
96
+ aria-label={t.openSubmenu}
83
97
  onClick={() => setOpen((o) => !o)}
84
98
  className="flex min-h-11 items-center rounded-md px-0.5 text-ink/70 transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
85
99
  >
@@ -137,22 +151,23 @@ function Dropdown({ item, L }: { item: NavItem; L: ElementType }) {
137
151
  }
138
152
 
139
153
  /**
140
- * Header da família Cuidado Global. No desktop (lg+): marca + nav com dropdowns
141
- * (hover/teclado, ícones nos subitens) + idioma + CTA. No mobile: marca +
142
- * idioma + CTA (a navegação vai para o BottomNav). Deps injetáveis: `logo`,
143
- * `cta`, `localeSwitcher` por slot; o `Link` do app por `linkComponent`.
154
+ * Header of the Cuidado Global family. On desktop (lg+): brand + nav with
155
+ * dropdowns (hover/keyboard, icons on sub-items) + locale + CTA. On mobile:
156
+ * brand + locale + CTA (navigation moves to the BottomNav). Injectable deps:
157
+ * `logo`, `cta`, `localeSwitcher` as slots; the app's `Link` via `linkComponent`.
144
158
  */
145
- export function Header({ logo, items, linkComponent, cta, localeSwitcher }: Props) {
159
+ export function Header({ logo, items, linkComponent, cta, localeSwitcher, labels }: Props) {
146
160
  const L: ElementType = linkComponent ?? "a";
161
+ const t = { ...EN, ...labels };
147
162
  return (
148
163
  <header className="sticky top-0 z-50 bg-surface/90 backdrop-blur">
149
164
  <div className="mx-auto flex h-20 w-full max-w-[1600px] items-center justify-between gap-3 px-4 md:px-6">
150
165
  <div className="shrink-0">{logo}</div>
151
166
 
152
- <nav aria-label="Principal" className="hidden items-center gap-6 lg:flex xl:gap-7">
167
+ <nav aria-label={t.nav} className="hidden items-center gap-6 lg:flex xl:gap-7">
153
168
  {items.map((item, i) =>
154
169
  item.children?.length ? (
155
- <Dropdown key={i} item={item} L={L} />
170
+ <Dropdown key={i} item={item} L={L} t={t} />
156
171
  ) : (
157
172
  <L key={i} href={item.href} aria-current={item.active ? "page" : undefined} className={linkCls(item.active)}>
158
173
  {item.icon && <span aria-hidden="true" className="flex">{item.icon}</span>}
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type Level = "h1" | "h2" | "h3" | "h4";
4
+ /** The family's heading scale (novo.creeta):
5
+ * display — page title (SectionHeading as h1) · hero — title over a full-bleed photo/video
6
+ * section — section title (h2) · card — title inside a card · small — h3 */
7
+ export type HeadingSize = "display" | "hero" | "section" | "card" | "small";
8
+
9
+ export const HEADING_SIZE: Record<HeadingSize, string> = {
10
+ display: "text-[2.5rem] leading-[1.04] tracking-[-0.02em] md:text-6xl",
11
+ hero: "text-[2rem] leading-[1.06] tracking-[-0.02em] md:text-5xl xl:text-6xl xl:leading-[1.04]",
12
+ section: "text-[1.75rem] leading-[1.12] tracking-[-0.01em] md:text-4xl",
13
+ card: "text-[1.375rem] leading-snug md:text-2xl",
14
+ small: "text-xl leading-snug",
15
+ };
16
+ const DEFAULT_SIZE: Record<Level, HeadingSize> = { h1: "display", h2: "section", h3: "small", h4: "small" };
17
+
18
+ type HeadingProps = {
19
+ as?: Level;
20
+ size?: HeadingSize;
21
+ children: ReactNode;
22
+ /** Second sentence of the same heading, on its own line, one tone below
23
+ * (`ink-soft`): "Aprenda com quem atende. / Quem criou o método explica." */
24
+ accent?: ReactNode;
25
+ id?: string;
26
+ className?: string;
27
+ };
28
+
29
+ /**
30
+ * Heading — Quicksand medium in `ink`; the optional `accent` sentence sits on
31
+ * the next line in `ink-soft`. Two tones, same heading: the hierarchy comes
32
+ * from color, not from a second element.
33
+ */
34
+ export function Heading({ as: Tag = "h2", size, children, accent, id, className = "" }: HeadingProps) {
35
+ const s = size ?? DEFAULT_SIZE[Tag];
36
+ return (
37
+ <Tag id={id} className={`m-0 font-heading font-medium text-ink ${HEADING_SIZE[s]} ${className}`}>
38
+ {children}
39
+ {accent && (
40
+ <>
41
+ {" "}
42
+ <span className="block text-ink-soft">{accent}</span>
43
+ </>
44
+ )}
45
+ </Tag>
46
+ );
47
+ }
48
+
49
+ type SectionHeadingProps = {
50
+ /** `h1` on an inner page (its main title), `h2` for a home section. */
51
+ as?: "h1" | "h2";
52
+ /** Only when the content really is a sequence ("01"). */
53
+ number?: string;
54
+ eyebrow?: ReactNode;
55
+ title: ReactNode;
56
+ /** Second sentence, one tone below. */
57
+ accent?: ReactNode;
58
+ /** Supporting paragraph right under the title. */
59
+ lead?: ReactNode;
60
+ id?: string;
61
+ className?: string;
62
+ };
63
+
64
+ /**
65
+ * SectionHeading — the SectionHeading of novo.creeta: eyebrow/number line in
66
+ * `ink-soft` semibold, the title (display as h1, section as h2) with its
67
+ * accent sentence, and the lead (text-lg, reading width). Carries the bottom
68
+ * margin of a section opening.
69
+ */
70
+ export function SectionHeading({ as: Tag = "h2", number, eyebrow, title, accent, lead, id, className = "" }: SectionHeadingProps) {
71
+ const main = Tag === "h1";
72
+ return (
73
+ <div className={`${main ? "mb-8 md:mb-12" : "mb-6 md:mb-9"} ${className}`}>
74
+ {(number || eyebrow) && (
75
+ <p className="m-0 mb-3 flex items-center gap-2 text-sm font-semibold text-ink-soft">
76
+ {number && <span>{number}</span>}
77
+ {eyebrow && <span>{eyebrow}</span>}
78
+ </p>
79
+ )}
80
+ <Heading as={Tag} size={main ? "display" : "section"} accent={accent} id={id}>
81
+ {title}
82
+ </Heading>
83
+ {lead && <p className="m-0 mt-4 max-w-xl text-lg leading-relaxed text-ink-body">{lead}</p>}
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,116 @@
1
+ import type { ComponentType, ElementType, ReactNode } from "react";
2
+
3
+ /** lucide-style icon component (the card sets the size). */
4
+ type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
5
+
6
+ export type InfoCardLink = {
7
+ href: string;
8
+ label: ReactNode;
9
+ /** Opens in a new tab (always a plain <a>) with an sr-only note after the label. */
10
+ external?: boolean;
11
+ };
12
+
13
+ type Props = {
14
+ /** Icon in the tinted bubble above the title (lucide, 20px, stroke 1.75). */
15
+ Icon: IconType;
16
+ title: ReactNode;
17
+ /** h2 on a page section (default), h3 inside a section that already has an h2. */
18
+ headingLevel?: "h2" | "h3";
19
+ /** Address lines: one <address> block, lines separated by <br>. */
20
+ address?: ReactNode[];
21
+ /** Body: one paragraph per item. */
22
+ lines?: ReactNode[];
23
+ /** Smaller closing line (e.g. "Outside these hours, by appointment"). */
24
+ note?: ReactNode;
25
+ /** Custom body, rendered after `address`/`lines`/`note`. */
26
+ children?: ReactNode;
27
+ /** Action links at the bottom ("Directions ↗ · Waze ↗"): underlined semibold,
28
+ * outbound arrow, separated by a middle dot. */
29
+ links?: InfoCardLink[];
30
+ /** Wrapper element. `section` by default; `li` inside a list of cards. */
31
+ as?: "section" | "div" | "li" | "article";
32
+ /** Link component of the app for page links (external links always use <a>). */
33
+ linkComponent?: ElementType;
34
+ /** sr-only after an external link. */
35
+ newTabLabel?: string;
36
+ className?: string;
37
+ };
38
+
39
+ const ArrowUpRight = () => (
40
+ <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>
41
+ );
42
+
43
+ const body = "mt-2 leading-relaxed text-ink/80";
44
+ const link =
45
+ "mt-4 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";
46
+
47
+ /**
48
+ * Info card of the Cuidado Global family — the three white cards of the
49
+ * novo.creeta clinic page ("Address", "Opening hours", "Where we see
50
+ * patients") and the contact page: a 44px tinted bubble with the icon, the
51
+ * title, the body (address lines, paragraphs, a smaller note) and, at the
52
+ * bottom, the action links with the outbound arrow, separated by a middle
53
+ * dot ("Directions ↗ · Waze ↗"). White on the page color (`surface`), 28px
54
+ * radius, 24px padding (32px from md).
55
+ */
56
+ export function InfoCard({
57
+ Icon,
58
+ title,
59
+ headingLevel: Heading = "h2",
60
+ address,
61
+ lines,
62
+ note,
63
+ children,
64
+ links,
65
+ as: Tag = "section",
66
+ linkComponent,
67
+ newTabLabel = "(opens in a new tab)",
68
+ className = "",
69
+ }: Props) {
70
+ const L: ElementType = linkComponent ?? "a";
71
+
72
+ return (
73
+ <Tag className={`rounded-2xl bg-card p-6 md:p-8 ${className}`}>
74
+ <span aria-hidden="true" className="flex h-11 w-11 items-center justify-center rounded-full bg-ink/[0.12] text-ink">
75
+ <Icon size={20} strokeWidth={1.75} />
76
+ </span>
77
+ <Heading className="mt-4 font-heading text-xl font-medium text-ink">{title}</Heading>
78
+
79
+ {address && address.length > 0 && (
80
+ <address className={`${body} not-italic`}>
81
+ {address.map((line, i) => (
82
+ <span key={i}>
83
+ {i > 0 && <br />}
84
+ {line}
85
+ </span>
86
+ ))}
87
+ </address>
88
+ )}
89
+ {lines?.map((line, i) => (
90
+ <p key={i} className={body}>
91
+ {line}
92
+ </p>
93
+ ))}
94
+ {note && <p className={`${body} text-sm`}>{note}</p>}
95
+ {children}
96
+
97
+ {links?.map((l, i) => (
98
+ <span key={`${i}-${l.href}`}>
99
+ {i > 0 && <span aria-hidden="true" className="mx-2 text-ink/80">·</span>}
100
+ {l.external ? (
101
+ <a href={l.href} target="_blank" rel="noopener noreferrer" className={link}>
102
+ {l.label}
103
+ <ArrowUpRight />
104
+ <span className="sr-only"> {newTabLabel}</span>
105
+ </a>
106
+ ) : (
107
+ <L href={l.href} className={link}>
108
+ {l.label}
109
+ <ArrowUpRight />
110
+ </L>
111
+ )}
112
+ </span>
113
+ ))}
114
+ </Tag>
115
+ );
116
+ }