@breathe-ds/ui 0.2.0 → 0.4.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/README.md +118 -13
- package/components/Accordion.tsx +6 -4
- package/components/AnchorNav.tsx +12 -4
- package/components/Avatar.tsx +37 -0
- package/components/BottomNav.tsx +1 -1
- package/components/Breadcrumb.tsx +45 -0
- package/components/BreatheMark.tsx +0 -13
- package/components/Button.tsx +11 -4
- package/components/Byline.tsx +105 -0
- package/components/CaptionBand.tsx +74 -0
- package/components/CareSteps.tsx +86 -0
- package/components/ChatPreview.tsx +138 -0
- package/components/CtaCard.tsx +52 -0
- package/components/FactStrip.tsx +55 -0
- package/components/FeaturedPosts.tsx +80 -0
- package/components/Footer.tsx +50 -30
- package/components/Header.tsx +4 -2
- package/components/Heading.tsx +86 -0
- package/components/InfoCard.tsx +116 -0
- package/components/LanguageSwitcher.tsx +1 -1
- package/components/List.tsx +81 -0
- package/components/Lockup.tsx +0 -18
- package/components/MapCard.tsx +158 -0
- package/components/MediaCard.tsx +266 -0
- package/components/PageVideoHero.tsx +65 -23
- package/components/PauseButton.tsx +41 -0
- package/components/PersonChip.tsx +49 -0
- package/components/PhotoSequence.tsx +99 -0
- package/components/Pill.tsx +42 -0
- package/components/PillList.tsx +20 -47
- package/components/PlanTimeline.tsx +66 -0
- package/components/PostArticle.tsx +102 -0
- package/components/PostCard.tsx +69 -0
- package/components/ProfessionalCard.tsx +446 -0
- package/components/ProfessionalModal.tsx +243 -0
- package/components/Rail.tsx +94 -0
- package/components/Rows.tsx +137 -0
- package/components/StoryPanel.tsx +107 -0
- 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/useOnAppear.ts +39 -0
- package/package.json +34 -2
- package/theme.css +206 -0
- package/tokens/creeta.css +7 -2
- package/tokens/neutral.css +12 -4
- package/tokens/ttcab.css +14 -5
|
@@ -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
|
+
}
|
|
@@ -114,7 +114,7 @@ export function LanguageSwitcher({ languages, mode = "menu", labels, className =
|
|
|
114
114
|
</button>
|
|
115
115
|
<ul
|
|
116
116
|
id={id}
|
|
117
|
-
className={`absolute right-0 top-full z-50 mt-2 min-w-[11rem] list-none origin-top-right rounded-[20px] bg-
|
|
117
|
+
className={`absolute right-0 top-full z-50 mt-2 min-w-[11rem] list-none origin-top-right rounded-[20px] bg-card p-1.5 shadow-[0_16px_40px_-16px_rgba(0,0,0,0.35)] transition duration-150 ease-out motion-reduce:transition-none ${
|
|
118
118
|
open ? "visible scale-100 opacity-100" : "invisible scale-95 opacity-0"
|
|
119
119
|
}`}
|
|
120
120
|
>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type ListItem = {
|
|
4
|
+
label: ReactNode;
|
|
5
|
+
/** With an href the item is a link: semibold, underlined, with an outbound arrow at the end. */
|
|
6
|
+
href?: string;
|
|
7
|
+
external?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
items: ListItem[];
|
|
12
|
+
/** `check` (default): bubble with a check — "what we assess", "what we treat".
|
|
13
|
+
* `arrow`: corner arrow (↳) — one sentence per item, as in the Footer notice. */
|
|
14
|
+
marker?: "check" | "arrow";
|
|
15
|
+
/** Link component of the app (e.g. next-intl Link). Default: <a>. */
|
|
16
|
+
linkComponent?: ElementType;
|
|
17
|
+
/** sr-only after an external link. */
|
|
18
|
+
newTabLabel?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const Check = () => (
|
|
23
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={14} height={14} fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
|
24
|
+
);
|
|
25
|
+
const CornerDownRight = () => (
|
|
26
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} className="mt-[0.2rem] shrink-0" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m15 10 5 5-5 5" /><path d="M4 4v7a4 4 0 0 0 4 4h12" /></svg>
|
|
27
|
+
);
|
|
28
|
+
const ArrowUpRight = () => (
|
|
29
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={16} height={16} className="cg-arrow-go ml-auto mt-1 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The family's list with a marker in front of each item (from novo.creeta:
|
|
34
|
+
* `Lista` on the method page, "O que tratamos aqui" on a treatment page, the
|
|
35
|
+
* notice in the footer).
|
|
36
|
+
* - check: a 24px bubble (ink at 12%) with the check; the marker is
|
|
37
|
+
* decoration, the <ul> is what says "list".
|
|
38
|
+
* - arrow: the corner arrow, for one sentence per item.
|
|
39
|
+
* - An item with `href` becomes a link: semibold, underline that darkens on
|
|
40
|
+
* hover, outbound arrow at the end stepping toward where it goes; 44px tap
|
|
41
|
+
* height without changing the rhythm (negative vertical margin).
|
|
42
|
+
*/
|
|
43
|
+
export function List({ items, marker = "check", linkComponent, newTabLabel = "(opens in a new tab)", className = "" }: Props) {
|
|
44
|
+
const L: ElementType = linkComponent ?? "a";
|
|
45
|
+
const bubble = (
|
|
46
|
+
<span aria-hidden="true" className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ink/[0.12] text-ink">
|
|
47
|
+
<Check />
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
const mark = marker === "check" ? bubble : <CornerDownRight />;
|
|
51
|
+
const gap = marker === "check" ? "gap-3" : "gap-2.5";
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<ul className={`list-none ${marker === "check" ? "space-y-3 text-ink" : "space-y-2 leading-relaxed text-ink/80"} ${className}`}>
|
|
55
|
+
{items.map((it, i) => (
|
|
56
|
+
<li key={`${i}-${it.href ?? ""}`}>
|
|
57
|
+
{it.href ? (
|
|
58
|
+
<L
|
|
59
|
+
href={it.href}
|
|
60
|
+
{...(it.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
61
|
+
className={`group/item -my-2 flex min-h-11 items-start ${gap} py-2.5 font-semibold text-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink`}
|
|
62
|
+
>
|
|
63
|
+
{mark}
|
|
64
|
+
<span className="underline decoration-ink/40 underline-offset-4 transition-[text-decoration-color] duration-150 group-hover/item:decoration-ink motion-reduce:transition-none">
|
|
65
|
+
{it.label}
|
|
66
|
+
{it.external && <span className="sr-only"> {newTabLabel}</span>}
|
|
67
|
+
</span>
|
|
68
|
+
<ArrowUpRight />
|
|
69
|
+
</L>
|
|
70
|
+
) : (
|
|
71
|
+
<span className={`flex items-start ${gap}`}>
|
|
72
|
+
{mark}
|
|
73
|
+
<span>{it.label}</span>
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
package/components/Lockup.tsx
CHANGED
|
@@ -109,7 +109,6 @@ export function Lockup({
|
|
|
109
109
|
if (variant === "wordmark") {
|
|
110
110
|
return (
|
|
111
111
|
<span className={`inline-flex flex-col${entranceCls} ${className}`} style={entranceStyle}>
|
|
112
|
-
{entrance && <style>{ENTRANCE_CSS}</style>}
|
|
113
112
|
{nameEl}
|
|
114
113
|
{tagEl && <span className="mt-1">{tagEl}</span>}
|
|
115
114
|
</span>
|
|
@@ -119,7 +118,6 @@ export function Lockup({
|
|
|
119
118
|
if (variant === "stacked") {
|
|
120
119
|
return (
|
|
121
120
|
<span className={`inline-flex flex-col items-center text-center${entranceCls} ${className}`} style={entranceStyle}>
|
|
122
|
-
{entrance && <style>{ENTRANCE_CSS}</style>}
|
|
123
121
|
{mark}
|
|
124
122
|
<span className="mt-4 flex flex-col items-center">
|
|
125
123
|
{nameEl}
|
|
@@ -148,7 +146,6 @@ export function Lockup({
|
|
|
148
146
|
if (symbolSide === "right") {
|
|
149
147
|
return (
|
|
150
148
|
<span className={`inline-flex flex-col items-start${entranceCls} ${className}`} style={entranceStyle}>
|
|
151
|
-
{entrance && <style>{ENTRANCE_CSS}</style>}
|
|
152
149
|
<span className={`inline-flex items-center ${GAP[size]}`}>
|
|
153
150
|
{nameEl}
|
|
154
151
|
{mark}
|
|
@@ -159,7 +156,6 @@ export function Lockup({
|
|
|
159
156
|
}
|
|
160
157
|
return (
|
|
161
158
|
<span className={`inline-flex items-center ${GAP[size]}${entranceCls} ${className}`} style={entranceStyle}>
|
|
162
|
-
{entrance && <style>{ENTRANCE_CSS}</style>}
|
|
163
159
|
{mark}
|
|
164
160
|
<span className="flex min-w-0 flex-col">
|
|
165
161
|
{nameEl}
|
|
@@ -169,17 +165,3 @@ export function Lockup({
|
|
|
169
165
|
);
|
|
170
166
|
}
|
|
171
167
|
|
|
172
|
-
/* Entrance: symbol (scale + slight rotation) → name (slides in from the left) → tagline.
|
|
173
|
-
Same vocabulary as the Curtain; with reduced motion it becomes just a short fade. */
|
|
174
|
-
const ENTRANCE_CSS = `
|
|
175
|
-
.cg-lockup--entrance .cg-lockup__mark{ animation: cg-lockup-mark 500ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) var(--cg-entrance-delay,0ms) both; }
|
|
176
|
-
.cg-lockup--entrance .cg-lockup__name{ animation: cg-lockup-name 420ms var(--ease-cg,cubic-bezier(.2,.8,.2,1)) calc(var(--cg-entrance-delay,0ms) + 420ms) both; }
|
|
177
|
-
.cg-lockup--entrance .cg-lockup__tag{ animation: cg-lockup-tag 360ms ease calc(var(--cg-entrance-delay,0ms) + 700ms) both; }
|
|
178
|
-
@keyframes cg-lockup-mark{ from{ opacity:0; transform:scale(.6) rotate(-16deg); } }
|
|
179
|
-
@keyframes cg-lockup-name{ from{ opacity:0; transform:translateX(-8px); } }
|
|
180
|
-
@keyframes cg-lockup-tag{ from{ opacity:0; transform:translateY(4px); } }
|
|
181
|
-
@media (prefers-reduced-motion:reduce){
|
|
182
|
-
.cg-lockup--entrance .cg-lockup__mark, .cg-lockup--entrance .cg-lockup__name, .cg-lockup--entrance .cg-lockup__tag{ animation: cg-lockup-fade 240ms ease var(--cg-entrance-delay,0ms) both; }
|
|
183
|
-
@keyframes cg-lockup-fade{ from{ opacity:0; } }
|
|
184
|
-
}
|
|
185
|
-
`;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
|
|
6
|
+
type Labels = {
|
|
7
|
+
/** The button that loads the map. */
|
|
8
|
+
open: string;
|
|
9
|
+
/** The outbound link ("Directions"). */
|
|
10
|
+
directions: string;
|
|
11
|
+
/** sr-only after the outbound link. */
|
|
12
|
+
newTab: string;
|
|
13
|
+
/** The note under the actions. */
|
|
14
|
+
note: string;
|
|
15
|
+
/** The close button on the bar under the open map. */
|
|
16
|
+
close: string;
|
|
17
|
+
/** Accessible title of the map frame; receives the address. */
|
|
18
|
+
frameTitle: (address: string) => string;
|
|
19
|
+
};
|
|
20
|
+
const EN: Labels = {
|
|
21
|
+
open: "View the map",
|
|
22
|
+
directions: "Directions",
|
|
23
|
+
newTab: "(opens in a new tab)",
|
|
24
|
+
note: "The map is Google's and only loads after your tap.",
|
|
25
|
+
close: "Close the map",
|
|
26
|
+
frameTitle: (address) => `Map: ${address}`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type Props = {
|
|
30
|
+
/** First line, large: the street and number. */
|
|
31
|
+
address: string;
|
|
32
|
+
/** Second line: neighbourhood, city/state. */
|
|
33
|
+
locality?: ReactNode;
|
|
34
|
+
/** Still image of the map (a file of the site, never a live tile): says
|
|
35
|
+
* "there is a map here" without calling Google. Wide, pin near the top-middle. */
|
|
36
|
+
preview: { src: string; width?: number; height?: number; position?: string };
|
|
37
|
+
/** Embed URL, requested only after the tap (e.g. https://www.google.com/maps?q=…&output=embed). */
|
|
38
|
+
embedSrc: string;
|
|
39
|
+
/** Google Maps search/route link; works without JavaScript. */
|
|
40
|
+
directionsHref: string;
|
|
41
|
+
/** Caption on the close bar while the map is open. Default: the address. */
|
|
42
|
+
caption?: ReactNode;
|
|
43
|
+
/** Height of the open map. */
|
|
44
|
+
mapHeight?: string;
|
|
45
|
+
labels?: Partial<Labels>;
|
|
46
|
+
className?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const MapPin = ({ size = 20, strokeWidth = 1.75 }: { size?: number; strokeWidth?: number }) => (
|
|
50
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /><circle cx="12" cy="10" r="3" /></svg>
|
|
51
|
+
);
|
|
52
|
+
const ArrowUpRight = () => (
|
|
53
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} className="cg-arrow-go" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17 17 7M9 7h8v8" /></svg>
|
|
54
|
+
);
|
|
55
|
+
const X = () => (
|
|
56
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" width={18} height={18} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Map card — the Mapa of novo.creeta: a Google map that only loads on tap.
|
|
61
|
+
*
|
|
62
|
+
* The embedded map sets third-party cookies as soon as it appears, and the
|
|
63
|
+
* sites have no consent banner. So the card shows a still preview of the map
|
|
64
|
+
* fading into the card, the address, a "View the map" button and the outbound
|
|
65
|
+
* "Directions" link (works without JavaScript); Google is only called when the
|
|
66
|
+
* person asks. The page is also lighter on phones.
|
|
67
|
+
*
|
|
68
|
+
* Open, the map fills the card and a bar at the bottom (the same as the
|
|
69
|
+
* PageVideoHero's) carries the caption and the close button, so the person
|
|
70
|
+
* can put the still back.
|
|
71
|
+
*/
|
|
72
|
+
export function MapCard({ address, locality, preview, embedSrc, directionsHref, caption, mapHeight = "min-h-[420px]", labels, className = "" }: Props) {
|
|
73
|
+
const t = { ...EN, ...labels };
|
|
74
|
+
const [open, setOpen] = useState(false);
|
|
75
|
+
const [loaded, setLoaded] = useState(false);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={`relative overflow-hidden rounded-2xl bg-card ${className}`}>
|
|
79
|
+
{open ? (
|
|
80
|
+
<div className="flex flex-col">
|
|
81
|
+
<div className={`relative ${mapHeight} bg-surface`}>
|
|
82
|
+
<iframe
|
|
83
|
+
title={t.frameTitle(address)}
|
|
84
|
+
src={embedSrc}
|
|
85
|
+
referrerPolicy="no-referrer-when-downgrade"
|
|
86
|
+
allowFullScreen
|
|
87
|
+
onLoad={() => setLoaded(true)}
|
|
88
|
+
// only shown once Google has drawn it, with a fade: before that the frame is an empty white
|
|
89
|
+
className={`absolute inset-0 block h-full w-full border-0 transition-opacity duration-300 motion-reduce:transition-none ${loaded ? "opacity-100" : "opacity-0"}`}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
{/* close bar — the same as the hero video's: caption on the left, close on the right */}
|
|
93
|
+
<div className="relative z-10 flex h-[4.5rem] items-center justify-between gap-6 bg-ink px-6">
|
|
94
|
+
<p className="m-0 min-w-0 truncate text-sm text-surface/80">{caption ?? address}</p>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => {
|
|
98
|
+
setOpen(false);
|
|
99
|
+
setLoaded(false);
|
|
100
|
+
}}
|
|
101
|
+
className="inline-flex min-h-11 shrink-0 items-center gap-2 rounded-full border border-surface/25 px-5 text-sm font-semibold text-surface transition-colors duration-200 hover:bg-surface/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface motion-reduce:transition-none"
|
|
102
|
+
>
|
|
103
|
+
<X />
|
|
104
|
+
{t.close}
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
) : (
|
|
109
|
+
<div className="flex h-full min-h-[320px] flex-col">
|
|
110
|
+
{/* Top: a still crop of the map fading into the card towards the bottom.
|
|
111
|
+
The text sits below, outside the image, always on the card color. */}
|
|
112
|
+
<div aria-hidden="true" className="relative min-h-[9rem] flex-1 overflow-hidden">
|
|
113
|
+
<img
|
|
114
|
+
src={preview.src}
|
|
115
|
+
alt=""
|
|
116
|
+
width={preview.width}
|
|
117
|
+
height={preview.height}
|
|
118
|
+
loading="lazy"
|
|
119
|
+
decoding="async"
|
|
120
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
121
|
+
style={{ objectPosition: preview.position ?? "48% 18%" }}
|
|
122
|
+
/>
|
|
123
|
+
<span className="absolute inset-0 bg-gradient-to-b from-card/0 via-card/35 via-60% to-card" />
|
|
124
|
+
{/* on the right: on the left it covered the place's name on the map */}
|
|
125
|
+
<span className="absolute right-6 top-6 flex h-11 w-11 items-center justify-center rounded-full bg-card text-ink shadow-[0_2px_8px_rgba(0,0,0,0.18)] md:right-8 md:top-8">
|
|
126
|
+
<MapPin />
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="px-6 pb-6 md:px-8 md:pb-8">
|
|
131
|
+
<address className="m-0 not-italic font-heading text-2xl font-medium leading-snug text-ink">
|
|
132
|
+
{address}
|
|
133
|
+
{locality && <span className="mt-1 block font-body text-base font-normal text-ink/80">{locality}</span>}
|
|
134
|
+
</address>
|
|
135
|
+
|
|
136
|
+
<div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2">
|
|
137
|
+
<Button variant="solid" onClick={() => setOpen(true)}>
|
|
138
|
+
<MapPin size={19} strokeWidth={2} />
|
|
139
|
+
{t.open}
|
|
140
|
+
</Button>
|
|
141
|
+
<a
|
|
142
|
+
href={directionsHref}
|
|
143
|
+
target="_blank"
|
|
144
|
+
rel="noopener noreferrer"
|
|
145
|
+
className="inline-flex min-h-11 items-center gap-1.5 font-semibold text-ink underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
|
|
146
|
+
>
|
|
147
|
+
{t.directions}
|
|
148
|
+
<ArrowUpRight />
|
|
149
|
+
<span className="sr-only"> {t.newTab}</span>
|
|
150
|
+
</a>
|
|
151
|
+
</div>
|
|
152
|
+
<p className="m-0 mt-4 text-sm leading-relaxed text-ink/80">{t.note}</p>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|