@breathe-ds/ui 0.1.0 → 0.2.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.
@@ -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
+ `;
@@ -1,115 +1,224 @@
1
- import type { ReactNode } from "react";
1
+ import type { ComponentType, ElementType, ReactNode } from "react";
2
+ import { LanguageSwitcher, type Language } from "./LanguageSwitcher";
2
3
 
3
- export type FooterColumn = { title: ReactNode; items: ReactNode[] };
4
+ /** lucide-style icon component (the footer sets the size). */
5
+ type IconType = ComponentType<{ size?: number; strokeWidth?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
6
+
7
+ export type FooterLink = { href: string; label: ReactNode };
8
+ export type FooterContact = { href: string; label: ReactNode; Icon: IconType; external?: boolean };
9
+ export type FooterLanguage = Language;
10
+
11
+ type Labels = {
12
+ /** aria-label of the pages nav */
13
+ pagesNav: string;
14
+ /** aria-label of the contacts list */
15
+ contacts: string;
16
+ /** aria-label of the languages nav */
17
+ languages: string;
18
+ /** sr-only after an external link */
19
+ newTab: string;
20
+ };
21
+ const EN: Labels = { pagesNav: "Footer pages", contacts: "Contacts", languages: "Language", newTab: "(opens in a new tab)" };
4
22
 
5
23
  type Props = {
6
- /** Marca (passe o seu <img>/logo). */
24
+ /** Signed brand: the Lockup (horizontal + tagline) or the site's own svg. */
7
25
  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;
26
+ /** Full institutional name for screen readers (the logo is an image). */
27
+ brandName?: string;
28
+ /** The brand sentence, shown in quotes under the logo. */
29
+ quote?: ReactNode;
30
+ /** "Pages": title on the first line, links in two columns below. Optional:
31
+ * a footer with no internal links drops the column (two-column grid). */
32
+ pages?: { title: ReactNode; links: FooterLink[] };
33
+ /** "Where we are": address lines, opening hours, and the directions links
34
+ * ("Directions ↗ · Waze ↗", external). */
35
+ where: { title: ReactNode; address: ReactNode[]; hours?: ReactNode; directions?: FooterLink[] };
36
+ /** Optional notice band (e.g. "Health insurance: we work by reimbursement"):
37
+ * title, one sentence per item (corner arrow), a CTA and a footnote. */
38
+ notice?: { title: ReactNode; items: ReactNode[]; action?: ReactNode; footnote?: ReactNode; id?: string };
39
+ /** Legal line. REQUIRED in the family: institutional name · CNPJ (company registration). */
40
+ legal: ReactNode;
41
+ /** Contact chips: phone, WhatsApp, e-mail, Instagram… only the real ones. */
42
+ contacts?: FooterContact[];
43
+ /** Language links, side by side, no JavaScript. The current one gets a ✓ and aria-current. */
44
+ languages?: FooterLanguage[];
45
+ /** Small credits line at the very end (photo authors…). */
46
+ credits?: ReactNode;
47
+ /** Link component of the app (e.g. next-intl Link). Default: <a>. */
48
+ linkComponent?: ElementType;
49
+ /** Breakpoint from which the BottomNav is gone (md on creeta, lg on ttcab):
50
+ * below it the card keeps room at the bottom for the floating bar. */
51
+ navHiddenFrom?: "md" | "lg";
52
+ labels?: Partial<Labels>;
53
+ className?: string;
22
54
  };
23
55
 
56
+ const ArrowUpRight = () => (
57
+ <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>
58
+ );
59
+ const CornerDownRight = () => (
60
+ <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>
61
+ );
62
+
63
+ const title = "font-heading text-lg font-medium text-ink";
64
+ const link =
65
+ "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";
66
+ const strongLink =
67
+ "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";
68
+ const chip =
69
+ "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";
70
+
24
71
  /**
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`.
72
+ * Footer of the Cuidado Global family — the site-footer of novo.creeta, by
73
+ * tokens, edge to edge like the Header (the card version was dropped on 2026-10-29):
74
+ * signed logo + the brand sentence, "Pages" (title on the first line, links in
75
+ * two columns), "Where we are" (address, hours, "Directions ↗ · Waze ↗" — the
76
+ * map only loads on tap, elsewhere), an optional notice band on `surface`
77
+ * (page color), then the legal line with the CNPJ (always) and the contact
78
+ * chips, and the languages as plain links (work without JavaScript).
79
+ * Below the BottomNav breakpoint the card leaves room at the bottom for the bar.
30
80
  */
31
81
  export function Footer({
32
82
  logo,
33
- tagline,
34
- columns,
35
- contact,
83
+ brandName,
84
+ quote,
85
+ pages,
86
+ where,
36
87
  notice,
37
88
  legal,
38
- chips,
39
- localeSwitcher,
89
+ contacts = [],
90
+ languages,
91
+ credits,
92
+ linkComponent,
93
+ navHiddenFrom = "lg",
94
+ labels,
95
+ className = "",
40
96
  }: Props) {
97
+ const L: ElementType = linkComponent ?? "a";
98
+ const t = { ...EN, ...labels };
99
+ // whole class names: Tailwind only generates what it finds written in the code
100
+ // Edge to edge like the Header (family decision 2026-10-29), same inner width
101
+ // and side padding (px-4 md:px-6). Below the BottomNav breakpoint the bottom
102
+ // padding leaves room for the floating bar.
103
+ const outer = navHiddenFrom === "md" ? "mt-16 pb-28 md:mt-24 md:pb-14" : "mt-16 pb-28 lg:mt-24 lg:pb-14";
104
+ const noticeId = notice?.id ?? "footer-notice";
105
+
41
106
  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]">
107
+ <footer className={`${outer} bg-white py-10 text-ink md:py-14 ${className}`}>
108
+ <style>{CSS}</style>
109
+ <div className="mx-auto max-w-[1600px] px-4 md:px-6">
110
+ {/* without the pages column, "where" keeps to the right edge (auto width) */}
111
+ <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
112
  <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>
113
+ {logo}
114
+ {brandName && <p className="sr-only">{brandName}</p>}
115
+ {quote && (
116
+ <p className="mt-6 max-w-[18rem] font-heading text-xl leading-snug text-ink-body">“{quote}”</p>
52
117
  )}
53
118
  </div>
54
119
 
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>
120
+ {pages && (
121
+ <nav aria-label={t.pagesNav}>
122
+ <h2 className={title}>{pages.title}</h2>
123
+ <div className="mt-3 grid gap-x-6 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2">
124
+ {pages.links.map((p, i) => (
125
+ <L key={`${i}-${p.href}`} href={p.href} className={link}>
126
+ {p.label}
127
+ </L>
128
+ ))}
70
129
  </div>
71
- ))}
72
- </div>
130
+ </nav>
131
+ )}
73
132
 
74
133
  <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}
134
+ <h2 className={title}>{where.title}</h2>
135
+ <address className="mt-3 not-italic leading-relaxed text-ink/80">
136
+ {where.address.map((line, i) => (
137
+ <span key={i}>
138
+ {i > 0 && <br />}
139
+ {line}
140
+ </span>
141
+ ))}
142
+ </address>
143
+ {where.hours && <p className="mt-2 leading-relaxed text-ink/80">{where.hours}</p>}
144
+ {where.directions && where.directions.length > 0 && (
145
+ <p className="mt-2">
146
+ {where.directions.map((d, i) => (
147
+ <span key={`${i}-${d.href}`}>
148
+ {i > 0 && <span aria-hidden="true" className="mx-2 text-ink/80">·</span>}
149
+ <a href={d.href} target="_blank" rel="noopener noreferrer" className={strongLink}>
150
+ {d.label}
151
+ <ArrowUpRight />
152
+ <span className="sr-only"> {t.newTab}</span>
153
+ </a>
154
+ </span>
155
+ ))}
78
156
  </p>
79
157
  )}
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
158
  </div>
87
159
  </div>
88
160
 
89
- {/* Bloco opcional em destaque */}
161
+ {/* Notice band: `surface` (the page color) over the white card. One
162
+ sentence per item, with the corner arrow; the arrow is decoration,
163
+ the <ul> is what says "list". Always at reading width (max-w-6xl),
164
+ narrower than the full-width columns. */}
90
165
  {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>
166
+ <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">
167
+ <div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between lg:gap-10">
168
+ <div>
169
+ <h2 id={noticeId} className="font-heading text-2xl font-medium leading-snug md:text-[1.75rem]">
170
+ {notice.title}
171
+ </h2>
172
+ <ul className="mt-4 list-none space-y-2 leading-relaxed text-ink/80">
173
+ {notice.items.map((item, i) => (
174
+ <li key={i} className="flex gap-2.5">
175
+ <CornerDownRight />
176
+ <span>{item}</span>
177
+ </li>
178
+ ))}
179
+ </ul>
180
+ </div>
181
+ {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>}
182
+ </div>
183
+ {notice.footnote && <p className="mt-6 border-t border-ink/10 pt-4 text-xs text-ink/80">{notice.footnote}</p>}
184
+ </section>
94
185
  )}
95
186
 
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>
187
+ {/* Legal line (institutional name · CNPJ) + contact chips */}
188
+ <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">
189
+ <p className="text-sm text-ink/80">{legal}</p>
190
+ {contacts.length > 0 && (
191
+ <ul aria-label={t.contacts} className="flex list-none flex-wrap gap-2">
192
+ {contacts.map(({ href, label, Icon, external }, i) => (
193
+ <li key={`${i}-${href}`}>
194
+ <a className={chip} href={href} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}>
195
+ <Icon aria-hidden="true" size={17} strokeWidth={2} />
196
+ {label}
197
+ {external && <span className="sr-only"> {t.newTab}</span>}
198
+ </a>
199
+ </li>
103
200
  ))}
104
- </div>
201
+ </ul>
105
202
  )}
106
203
  </div>
107
204
 
108
- {/* Idiomas */}
109
- {localeSwitcher && (
110
- <div className="mt-[clamp(20px,2.5vw,28px)]">{localeSwitcher}</div>
205
+ {/* Languages as plain links: they work without JavaScript */}
206
+ {languages && languages.length > 0 && (
207
+ <div className="mt-4 border-t border-ink/10 pt-2">
208
+ <LanguageSwitcher mode="list" languages={languages} labels={{ nav: t.languages }} />
209
+ </div>
111
210
  )}
211
+
212
+ {credits && <p className="mt-4 text-xs text-ink/80">{credits}</p>}
112
213
  </div>
113
214
  </footer>
114
215
  );
115
216
  }
217
+
218
+ /* Outbound arrow: takes a small step toward where the link goes, on hover. */
219
+ const CSS = `
220
+ @media (prefers-reduced-motion:no-preference){
221
+ .cg-arrow-go{ transition:transform 150ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)); }
222
+ a:hover .cg-arrow-go{ transform:translate(2px,-2px); }
223
+ }
224
+ `;
@@ -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>}