@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.
- package/LICENSE +21 -0
- package/README.md +157 -142
- package/components/Accordion.tsx +11 -10
- package/components/AnchorNav.tsx +33 -21
- package/components/BackgroundVideo.tsx +14 -14
- package/components/BottomNav.tsx +69 -68
- package/components/BreatheMark.tsx +12 -11
- package/components/Button.tsx +12 -12
- package/components/Card.tsx +5 -5
- package/components/Container.tsx +4 -4
- package/components/Curtain.tsx +152 -0
- package/components/Footer.tsx +186 -77
- package/components/Header.tsx +32 -17
- package/components/LanguageSwitcher.tsx +139 -0
- package/components/Lockup.tsx +40 -39
- package/components/PageVideoHero.tsx +53 -53
- package/components/PillList.tsx +4 -4
- package/components/SectionKicker.tsx +3 -3
- package/components/icons.tsx +6 -6
- package/components/marks/creeta.tsx +22 -22
- package/components/marks/ttcab.tsx +13 -13
- package/package.json +7 -5
- package/theme.css +21 -21
- package/tokens/creeta.css +10 -11
- package/tokens/neutral.css +7 -7
- package/tokens/ttcab.css +8 -8
- package/components/Cortina.tsx +0 -152
|
@@ -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
|
+
`;
|
package/components/Footer.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
24
|
+
/** Signed brand: the Lockup (horizontal + tagline) or the site's own svg. */
|
|
7
25
|
logo: ReactNode;
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
83
|
+
brandName,
|
|
84
|
+
quote,
|
|
85
|
+
pages,
|
|
86
|
+
where,
|
|
36
87
|
notice,
|
|
37
88
|
legal,
|
|
38
|
-
|
|
39
|
-
|
|
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=
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
{
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
{
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
130
|
+
</nav>
|
|
131
|
+
)}
|
|
73
132
|
|
|
74
133
|
<div>
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
{/*
|
|
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
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
{/*
|
|
97
|
-
<div className="mt-
|
|
98
|
-
|
|
99
|
-
{
|
|
100
|
-
<
|
|
101
|
-
{
|
|
102
|
-
<
|
|
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
|
-
</
|
|
201
|
+
</ul>
|
|
105
202
|
)}
|
|
106
203
|
</div>
|
|
107
204
|
|
|
108
|
-
{/*
|
|
109
|
-
{
|
|
110
|
-
<div className="mt-
|
|
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
|
+
`;
|
package/components/Header.tsx
CHANGED
|
@@ -12,26 +12,40 @@ export type NavItem = {
|
|
|
12
12
|
label: ReactNode;
|
|
13
13
|
href?: string;
|
|
14
14
|
active?: boolean;
|
|
15
|
-
/**
|
|
15
|
+
/** Icon before the label (e.g. a house on "Home"). */
|
|
16
16
|
icon?: ReactNode;
|
|
17
|
-
/**
|
|
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
|
-
/**
|
|
21
|
-
* `external`
|
|
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
|
-
/**
|
|
38
|
+
/** Brand (pass your own logo, already linked to the home page if you want). */
|
|
27
39
|
logo: ReactNode;
|
|
28
40
|
items: NavItem[];
|
|
29
|
-
/**
|
|
41
|
+
/** The app's link component (e.g. next-intl Link). Default: <a>. */
|
|
30
42
|
linkComponent?: ElementType;
|
|
31
|
-
/** CTA
|
|
43
|
+
/** CTA on the right (e.g. Book/WhatsApp). */
|
|
32
44
|
cta?: ReactNode;
|
|
33
|
-
/**
|
|
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=
|
|
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
|
|
141
|
-
* (hover/
|
|
142
|
-
*
|
|
143
|
-
* `cta`, `localeSwitcher`
|
|
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=
|
|
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>}
|