@breathe-ds/ui 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +219 -142
- package/components/Accordion.tsx +17 -14
- package/components/AnchorNav.tsx +33 -21
- package/components/Avatar.tsx +37 -0
- package/components/BackgroundVideo.tsx +14 -14
- package/components/BottomNav.tsx +70 -69
- package/components/Breadcrumb.tsx +45 -0
- package/components/BreatheMark.tsx +12 -24
- package/components/Button.tsx +12 -12
- package/components/Byline.tsx +103 -0
- package/components/CaptionBand.tsx +74 -0
- package/components/Card.tsx +5 -5
- package/components/Container.tsx +4 -4
- package/components/CtaCard.tsx +52 -0
- package/components/Curtain.tsx +152 -0
- package/components/FactStrip.tsx +55 -0
- package/components/Footer.tsx +169 -77
- package/components/Header.tsx +32 -17
- package/components/Heading.tsx +86 -0
- package/components/InfoCard.tsx +116 -0
- package/components/LanguageSwitcher.tsx +139 -0
- package/components/List.tsx +81 -0
- package/components/Lockup.tsx +38 -55
- package/components/MapCard.tsx +158 -0
- package/components/MediaCard.tsx +266 -0
- package/components/PageVideoHero.tsx +53 -53
- package/components/PersonChip.tsx +49 -0
- package/components/Pill.tsx +42 -0
- package/components/PillList.tsx +21 -48
- package/components/ProfessionalCard.tsx +341 -0
- package/components/ProfessionalModal.tsx +243 -0
- package/components/Rows.tsx +131 -0
- package/components/SectionKicker.tsx +3 -3
- package/components/TestimonialCard.tsx +138 -0
- package/components/Text.tsx +31 -0
- package/components/Timeline.tsx +69 -0
- package/components/VideoInviteCard.tsx +168 -0
- package/components/VideoStoryCard.tsx +382 -0
- package/components/icons.tsx +6 -6
- package/components/marks/creeta.tsx +22 -22
- package/components/marks/ttcab.tsx +13 -13
- package/package.json +28 -5
- package/theme.css +88 -22
- package/tokens/creeta.css +14 -12
- package/tokens/neutral.css +16 -10
- package/tokens/ttcab.css +9 -8
- package/components/Cortina.tsx +0 -152
package/components/Footer.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
25
|
+
/** Signed brand: the Lockup (horizontal + tagline) or the site's own svg. */
|
|
7
26
|
logo: ReactNode;
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
81
|
+
brandName,
|
|
82
|
+
quote,
|
|
83
|
+
pages,
|
|
84
|
+
where,
|
|
36
85
|
notice,
|
|
37
86
|
legal,
|
|
38
|
-
|
|
39
|
-
|
|
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=
|
|
43
|
-
<div className="mx-auto
|
|
44
|
-
{/*
|
|
45
|
-
<div className=
|
|
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
|
-
|
|
48
|
-
{
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
127
|
+
</nav>
|
|
128
|
+
)}
|
|
73
129
|
|
|
74
130
|
<div>
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
{/*
|
|
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
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
{/*
|
|
97
|
-
<div className="mt-
|
|
98
|
-
|
|
99
|
-
{
|
|
100
|
-
<
|
|
101
|
-
{
|
|
102
|
-
<
|
|
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
|
-
</
|
|
191
|
+
</ul>
|
|
105
192
|
)}
|
|
106
193
|
</div>
|
|
107
194
|
|
|
108
|
-
{/*
|
|
109
|
-
{
|
|
110
|
-
<div className="mt-
|
|
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
|
+
|
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>}
|
|
@@ -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
|
+
}
|