@fayz-ai/plugin-linkinbio 0.9.1 → 0.10.1
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/dist/public/{data.d.ts → data/index.d.ts} +2 -2
- package/dist/public/data/index.d.ts.map +1 -0
- package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
- package/dist/public/data/supabase.d.ts.map +1 -0
- package/dist/public/index.d.ts +8 -8
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +2 -2
- package/dist/public/index.js.map +1 -1
- package/dist/public/{registry.d.ts → lib/registry.d.ts} +1 -1
- package/dist/public/lib/registry.d.ts.map +1 -0
- package/dist/public/{utils.d.ts → lib/utils.d.ts} +1 -1
- package/dist/public/lib/utils.d.ts.map +1 -0
- package/dist/public/views/BioPage.d.ts.map +1 -0
- package/dist/public/{BioPageRenderer.d.ts → views/BioPageRenderer.d.ts} +1 -1
- package/dist/public/views/BioPageRenderer.d.ts.map +1 -0
- package/package.json +12 -15
- package/dist/index.cjs +0 -33
- package/dist/index.cjs.map +0 -1
- package/dist/public/BioPage.d.ts.map +0 -1
- package/dist/public/BioPageRenderer.d.ts.map +0 -1
- package/dist/public/__tests__/manifest.test.d.ts +0 -2
- package/dist/public/__tests__/manifest.test.d.ts.map +0 -1
- package/dist/public/data.d.ts.map +0 -1
- package/dist/public/data.supabase.d.ts.map +0 -1
- package/dist/public/index.cjs +0 -1984
- package/dist/public/index.cjs.map +0 -1
- package/dist/public/registry.d.ts.map +0 -1
- package/dist/public/utils.d.ts.map +0 -1
- package/src/index.ts +0 -56
- package/src/public/BioPage.tsx +0 -123
- package/src/public/BioPageRenderer.tsx +0 -251
- package/src/public/__tests__/manifest.test.ts +0 -39
- package/src/public/blocks/BioSection.tsx +0 -66
- package/src/public/blocks/CTASection.tsx +0 -56
- package/src/public/blocks/EmbedSection.tsx +0 -53
- package/src/public/blocks/FeaturedBlock.tsx +0 -70
- package/src/public/blocks/GallerySection.tsx +0 -148
- package/src/public/blocks/HeroSection.tsx +0 -107
- package/src/public/blocks/LinksBlock.tsx +0 -80
- package/src/public/blocks/MusicReleasesBlock.tsx +0 -83
- package/src/public/blocks/ProfileHeader.tsx +0 -174
- package/src/public/blocks/ProjectSection.tsx +0 -158
- package/src/public/blocks/SocialLinksSection.tsx +0 -125
- package/src/public/blocks/StatsSection.tsx +0 -46
- package/src/public/blocks/TextSection.tsx +0 -29
- package/src/public/blocks/TourDatesBlock.tsx +0 -83
- package/src/public/blocks/VenueListSection.tsx +0 -91
- package/src/public/blocks/VideoGridSection.tsx +0 -105
- package/src/public/blocks/index.ts +0 -16
- package/src/public/components/Carousel.tsx +0 -90
- package/src/public/components/MediaKit.tsx +0 -249
- package/src/public/components/PlatformIcon.tsx +0 -56
- package/src/public/components/Reveal.tsx +0 -58
- package/src/public/context.tsx +0 -30
- package/src/public/createPublicLinkInBioPlugin.ts +0 -81
- package/src/public/data.supabase.ts +0 -31
- package/src/public/data.ts +0 -29
- package/src/public/index.ts +0 -75
- package/src/public/registry.ts +0 -40
- package/src/public/utils.ts +0 -102
- package/src/types.ts +0 -337
- /package/dist/public/{BioPage.d.ts → views/BioPage.d.ts} +0 -0
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useRef } from 'react';
|
|
2
|
-
import { ChevronUp, MessageCircle } from 'lucide-react';
|
|
3
|
-
import type { BioPage, BioBlock } from '../types';
|
|
4
|
-
import { getBlock } from './registry';
|
|
5
|
-
import { Reveal } from './components/Reveal';
|
|
6
|
-
import { MediaKit } from './components/MediaKit';
|
|
7
|
-
import HeroSection from './blocks/HeroSection';
|
|
8
|
-
import BioSection from './blocks/BioSection';
|
|
9
|
-
import GallerySection from './blocks/GallerySection';
|
|
10
|
-
import VideoGridSection from './blocks/VideoGridSection';
|
|
11
|
-
import SocialLinksSection from './blocks/SocialLinksSection';
|
|
12
|
-
import EmbedSection from './blocks/EmbedSection';
|
|
13
|
-
import ProjectSection from './blocks/ProjectSection';
|
|
14
|
-
import StatsSection from './blocks/StatsSection';
|
|
15
|
-
import VenueListSection from './blocks/VenueListSection';
|
|
16
|
-
import CTASection from './blocks/CTASection';
|
|
17
|
-
import TextSection from './blocks/TextSection';
|
|
18
|
-
import ProfileHeader from './blocks/ProfileHeader';
|
|
19
|
-
import LinksBlock from './blocks/LinksBlock';
|
|
20
|
-
import MusicReleasesBlock from './blocks/MusicReleasesBlock';
|
|
21
|
-
import TourDatesBlock from './blocks/TourDatesBlock';
|
|
22
|
-
import FeaturedBlock from './blocks/FeaturedBlock';
|
|
23
|
-
|
|
24
|
-
export interface BioPageRendererProps {
|
|
25
|
-
page: BioPage;
|
|
26
|
-
/** Optional "made with" footer link. Omit for just the copyright line. */
|
|
27
|
-
poweredBy?: { label: string; url: string };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Blocks that get alternating backgrounds in scroll mode (hero/header/cta excluded).
|
|
31
|
-
const ALT_BG_TYPES = new Set(['bio', 'gallery', 'video-grid', 'project', 'embed', 'stats', 'venue-list', 'text', 'social-links']);
|
|
32
|
-
|
|
33
|
-
export default function BioPageRenderer({ page, poweredBy }: BioPageRendererProps) {
|
|
34
|
-
const { identity, branding, sections, floatingCta } = page;
|
|
35
|
-
const isHub = page.layout === 'hub';
|
|
36
|
-
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
37
|
-
const [scrollProgress, setScrollProgress] = useState(0);
|
|
38
|
-
const [showFloatingCta, setShowFloatingCta] = useState(false);
|
|
39
|
-
|
|
40
|
-
// Scroll progress & floating CTA visibility
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
const container = scrollRef.current?.closest('.fixed.inset-0') as HTMLElement | null;
|
|
43
|
-
if (!container) return;
|
|
44
|
-
|
|
45
|
-
const onScroll = () => {
|
|
46
|
-
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
47
|
-
const progress = scrollHeight > clientHeight ? scrollTop / (scrollHeight - clientHeight) : 0;
|
|
48
|
-
setScrollProgress(progress);
|
|
49
|
-
setShowFloatingCta(scrollTop > clientHeight * 0.5);
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
container.addEventListener('scroll', onScroll, { passive: true });
|
|
53
|
-
return () => container.removeEventListener('scroll', onScroll);
|
|
54
|
-
}, []);
|
|
55
|
-
|
|
56
|
-
// Assign alternating bg to non-hero/cta sections (scroll mode only)
|
|
57
|
-
let altIndex = 0;
|
|
58
|
-
const sectionBgs: Map<string, string | undefined> = new Map();
|
|
59
|
-
if (!isHub) {
|
|
60
|
-
for (const s of sections) {
|
|
61
|
-
if (s.visible === false) continue;
|
|
62
|
-
if (ALT_BG_TYPES.has(s.type)) {
|
|
63
|
-
const bgs = [undefined, 'var(--pk-alt-bg)', 'var(--pk-alt-bg2)'];
|
|
64
|
-
sectionBgs.set(s.id, bgs[altIndex % bgs.length]);
|
|
65
|
-
altIndex++;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function renderInner(section: BioBlock) {
|
|
71
|
-
// Host-registered niche blocks win over the built-in switch.
|
|
72
|
-
const Custom = getBlock(section.type);
|
|
73
|
-
if (Custom) return <Custom block={section as never} page={page} />;
|
|
74
|
-
switch (section.type) {
|
|
75
|
-
case 'hero':
|
|
76
|
-
return <HeroSection section={section} identity={identity} branding={branding} />;
|
|
77
|
-
case 'profile-header':
|
|
78
|
-
return <ProfileHeader section={section} page={page} />;
|
|
79
|
-
case 'bio':
|
|
80
|
-
return <BioSection section={section} />;
|
|
81
|
-
case 'gallery':
|
|
82
|
-
return <GallerySection section={section} />;
|
|
83
|
-
case 'video-grid':
|
|
84
|
-
return <VideoGridSection section={section} />;
|
|
85
|
-
case 'social-links':
|
|
86
|
-
return <SocialLinksSection section={section} />;
|
|
87
|
-
case 'embed':
|
|
88
|
-
return <EmbedSection section={section} />;
|
|
89
|
-
case 'project':
|
|
90
|
-
return <ProjectSection section={section} />;
|
|
91
|
-
case 'stats':
|
|
92
|
-
return <StatsSection section={section} />;
|
|
93
|
-
case 'venue-list':
|
|
94
|
-
return <VenueListSection section={section} />;
|
|
95
|
-
case 'cta':
|
|
96
|
-
return <CTASection section={section} />;
|
|
97
|
-
case 'text':
|
|
98
|
-
return <TextSection section={section} />;
|
|
99
|
-
case 'links':
|
|
100
|
-
return <LinksBlock section={section} />;
|
|
101
|
-
case 'music-releases':
|
|
102
|
-
return <MusicReleasesBlock section={section} />;
|
|
103
|
-
case 'tour-dates':
|
|
104
|
-
return <TourDatesBlock section={section} />;
|
|
105
|
-
case 'featured':
|
|
106
|
-
return <FeaturedBlock section={section} />;
|
|
107
|
-
default:
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function renderSection(section: BioBlock, index: number) {
|
|
113
|
-
if (section.visible === false) return null;
|
|
114
|
-
const inner = renderInner(section);
|
|
115
|
-
if (inner === null) return null;
|
|
116
|
-
|
|
117
|
-
// ── Hub layout: everything sits inside the mobile-width column (padding is
|
|
118
|
-
// provided by the wrapper below). The header renders without a reveal
|
|
119
|
-
// (it's above the fold); other blocks fade in on scroll.
|
|
120
|
-
if (isHub) {
|
|
121
|
-
// Header breaks out of the column padding to sit full-bleed on mobile;
|
|
122
|
-
// on desktop it becomes a contained rounded card within the column.
|
|
123
|
-
if (section.type === 'profile-header')
|
|
124
|
-
return (
|
|
125
|
-
<div key={section.id} className="-mx-3 -mt-3 sm:mx-0 sm:mt-0">
|
|
126
|
-
{inner}
|
|
127
|
-
</div>
|
|
128
|
-
);
|
|
129
|
-
return <Reveal key={section.id}>{inner}</Reveal>;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── Scroll (press-kit) layout: full-width sections, alternating bg ──
|
|
133
|
-
if (section.type === 'hero') return <div key={section.id}>{inner}</div>;
|
|
134
|
-
const bg = sectionBgs.get(section.id);
|
|
135
|
-
return (
|
|
136
|
-
<div key={section.id} style={bg ? { backgroundColor: bg } : undefined}>
|
|
137
|
-
<Reveal delay={index === 0 ? 0 : 40}>{inner}</Reveal>
|
|
138
|
-
</div>
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return (
|
|
143
|
-
<div ref={scrollRef} className="min-h-full">
|
|
144
|
-
{/* Scroll progress bar */}
|
|
145
|
-
<div className="fixed top-0 left-0 right-0 z-50 h-[3px]" style={{ backgroundColor: 'transparent' }}>
|
|
146
|
-
<div
|
|
147
|
-
className="h-full transition-[width] duration-100 ease-out"
|
|
148
|
-
style={{
|
|
149
|
-
width: `${scrollProgress * 100}%`,
|
|
150
|
-
background: `linear-gradient(90deg, var(--pk-primary), var(--pk-accent))`,
|
|
151
|
-
}}
|
|
152
|
-
/>
|
|
153
|
-
</div>
|
|
154
|
-
|
|
155
|
-
{/* Sections + footer. Hub = a centered mobile-width column (komi/linktree
|
|
156
|
-
style) that stays phone-width even on desktop, with breathing room so
|
|
157
|
-
the hero reads as a rounded floating card. */}
|
|
158
|
-
<div className={isHub ? 'max-w-[480px] mx-auto px-3 sm:px-4 pt-3 pb-6' : ''}>
|
|
159
|
-
{sections.map(renderSection)}
|
|
160
|
-
|
|
161
|
-
{/* Footer */}
|
|
162
|
-
<footer className="pt-10 pb-24 px-6 text-center" style={{ backgroundColor: 'var(--pk-bg)' }}>
|
|
163
|
-
<p className="text-xs opacity-40" style={{ color: 'var(--pk-muted)' }}>
|
|
164
|
-
© {new Date().getFullYear()} {identity.name}
|
|
165
|
-
</p>
|
|
166
|
-
{poweredBy && (
|
|
167
|
-
<a
|
|
168
|
-
href={poweredBy.url}
|
|
169
|
-
className="text-xs opacity-25 mt-1 hover:opacity-40 transition-opacity"
|
|
170
|
-
style={{ color: 'var(--pk-muted)' }}
|
|
171
|
-
>
|
|
172
|
-
{poweredBy.label}
|
|
173
|
-
</a>
|
|
174
|
-
)}
|
|
175
|
-
</footer>
|
|
176
|
-
</div>
|
|
177
|
-
|
|
178
|
-
{/* Floating actions — hub: circular stack (scroll-to-top + primary action,
|
|
179
|
-
presskitpro style); scroll: the classic wide pill. */}
|
|
180
|
-
{isHub ? (
|
|
181
|
-
<div className="fixed z-50 right-4 bottom-6 flex flex-col items-center gap-3">
|
|
182
|
-
<button
|
|
183
|
-
type="button"
|
|
184
|
-
aria-label="Voltar ao topo"
|
|
185
|
-
onClick={() =>
|
|
186
|
-
(scrollRef.current?.closest('.fixed.inset-0') as HTMLElement | null)?.scrollTo({
|
|
187
|
-
top: 0,
|
|
188
|
-
behavior: 'smooth',
|
|
189
|
-
})
|
|
190
|
-
}
|
|
191
|
-
className="flex items-center justify-center w-11 h-11 rounded-full transition-all duration-300"
|
|
192
|
-
style={{
|
|
193
|
-
opacity: showFloatingCta ? 1 : 0,
|
|
194
|
-
transform: showFloatingCta ? 'none' : 'translateY(12px)',
|
|
195
|
-
pointerEvents: showFloatingCta ? 'auto' : 'none',
|
|
196
|
-
color: 'var(--pk-primary)',
|
|
197
|
-
border: '1.5px solid var(--pk-primary)',
|
|
198
|
-
backgroundColor: 'color-mix(in srgb, var(--pk-bg) 70%, transparent)',
|
|
199
|
-
backdropFilter: 'blur(6px)',
|
|
200
|
-
}}
|
|
201
|
-
>
|
|
202
|
-
<ChevronUp size={20} />
|
|
203
|
-
</button>
|
|
204
|
-
<MediaKit page={page} variant="fab" />
|
|
205
|
-
{floatingCta && (
|
|
206
|
-
<a
|
|
207
|
-
href={floatingCta.url}
|
|
208
|
-
target="_blank"
|
|
209
|
-
rel="noopener noreferrer"
|
|
210
|
-
aria-label={floatingCta.label}
|
|
211
|
-
className="flex items-center justify-center w-14 h-14 rounded-full transition-transform hover:scale-105"
|
|
212
|
-
style={{
|
|
213
|
-
backgroundColor: 'var(--pk-primary)',
|
|
214
|
-
color: 'var(--pk-bg)',
|
|
215
|
-
boxShadow: '0 8px 28px rgba(0,0,0,0.45)',
|
|
216
|
-
}}
|
|
217
|
-
>
|
|
218
|
-
<MessageCircle size={24} />
|
|
219
|
-
</a>
|
|
220
|
-
)}
|
|
221
|
-
</div>
|
|
222
|
-
) : (
|
|
223
|
-
floatingCta && (
|
|
224
|
-
<a
|
|
225
|
-
href={floatingCta.url}
|
|
226
|
-
target="_blank"
|
|
227
|
-
rel="noopener noreferrer"
|
|
228
|
-
className="fixed z-50 transition-all duration-500 ease-out"
|
|
229
|
-
style={{
|
|
230
|
-
bottom: showFloatingCta ? '2rem' : '-4rem',
|
|
231
|
-
left: '50%',
|
|
232
|
-
transform: 'translateX(-50%)',
|
|
233
|
-
opacity: showFloatingCta ? 1 : 0,
|
|
234
|
-
backgroundColor: 'var(--pk-primary)',
|
|
235
|
-
color: 'var(--pk-bg)',
|
|
236
|
-
fontFamily: 'var(--pk-font-heading)',
|
|
237
|
-
borderRadius: '9999px',
|
|
238
|
-
padding: '1rem 2.5rem',
|
|
239
|
-
fontSize: '1rem',
|
|
240
|
-
fontWeight: 700,
|
|
241
|
-
letterSpacing: '0.1em',
|
|
242
|
-
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
|
243
|
-
}}
|
|
244
|
-
>
|
|
245
|
-
{floatingCta.label}
|
|
246
|
-
</a>
|
|
247
|
-
)
|
|
248
|
-
)}
|
|
249
|
-
</div>
|
|
250
|
-
);
|
|
251
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { assertPluginManifestContract } from '@fayz-ai/core/testing';
|
|
3
|
-
import { createPublicLinkInBioPlugin } from '../createPublicLinkInBioPlugin';
|
|
4
|
-
import type { BioPage } from '../../types';
|
|
5
|
-
|
|
6
|
-
const demo: BioPage = {
|
|
7
|
-
identity: { name: 'Demo', slug: 'demo', genres: [] },
|
|
8
|
-
branding: {
|
|
9
|
-
primaryColor: '#F97316',
|
|
10
|
-
secondaryColor: '#FFB347',
|
|
11
|
-
accentColor: '#FFCF40',
|
|
12
|
-
backgroundColor: '#0A0A0A',
|
|
13
|
-
textColor: '#FFFFFF',
|
|
14
|
-
mutedTextColor: '#9CA3AF',
|
|
15
|
-
},
|
|
16
|
-
seo: {},
|
|
17
|
-
socialLinks: [],
|
|
18
|
-
sections: [{ id: 'hero', type: 'hero', backgroundImageUrl: '', showGenres: true }],
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
describe('createPublicLinkInBioPlugin', () => {
|
|
22
|
-
it('returns a manifest that passes the plugin contract', () => {
|
|
23
|
-
const { manifest } = createPublicLinkInBioPlugin({ seed: { demo } });
|
|
24
|
-
expect(() => assertPluginManifestContract(manifest)).not.toThrow();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('exposes a public route at basePath/:slug', () => {
|
|
28
|
-
const { manifest } = createPublicLinkInBioPlugin({ basePath: '/p' });
|
|
29
|
-
const route = manifest.routes.find((r) => r.guard === 'public');
|
|
30
|
-
expect(route?.path).toBe('/p/:slug');
|
|
31
|
-
expect(route?.component).toBeTruthy();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('mock provider resolves a seeded page and returns null for unknown slugs', async () => {
|
|
35
|
-
const { dataProvider } = createPublicLinkInBioPlugin({ useSupabase: false, seed: { demo } });
|
|
36
|
-
expect(await dataProvider.getBySlug('demo')).toEqual(demo);
|
|
37
|
-
expect(await dataProvider.getBySlug('nope')).toBeNull();
|
|
38
|
-
});
|
|
39
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import type { BioSection as BioSectionType } from '../../types';
|
|
2
|
-
import { renderMarkdown } from '../utils';
|
|
3
|
-
|
|
4
|
-
interface Props {
|
|
5
|
-
section: BioSectionType;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export default function BioSection({ section }: Props) {
|
|
9
|
-
const hasImage = section.imageUrl && section.imagePosition !== 'none';
|
|
10
|
-
const isTop = section.imagePosition === 'top';
|
|
11
|
-
const isSide = section.imagePosition === 'left' || section.imagePosition === 'right';
|
|
12
|
-
const isRight = section.imagePosition === 'right';
|
|
13
|
-
|
|
14
|
-
return (
|
|
15
|
-
<section className="py-16 md:py-24 px-6" id={section.id}>
|
|
16
|
-
<div className="max-w-4xl mx-auto">
|
|
17
|
-
{section.title && (
|
|
18
|
-
<h2
|
|
19
|
-
className="text-3xl md:text-4xl font-bold mb-8"
|
|
20
|
-
style={{ fontFamily: 'var(--pk-font-heading)', color: 'var(--pk-text)' }}
|
|
21
|
-
>
|
|
22
|
-
{section.title}
|
|
23
|
-
</h2>
|
|
24
|
-
)}
|
|
25
|
-
|
|
26
|
-
<div className={isSide && hasImage ? `grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 items-start` : ''}>
|
|
27
|
-
{hasImage && isTop && (
|
|
28
|
-
<img
|
|
29
|
-
src={section.imageUrl}
|
|
30
|
-
alt=""
|
|
31
|
-
className="w-full max-h-96 object-cover mb-8"
|
|
32
|
-
style={{ borderRadius: 'var(--pk-radius)' }}
|
|
33
|
-
loading="lazy"
|
|
34
|
-
/>
|
|
35
|
-
)}
|
|
36
|
-
|
|
37
|
-
{hasImage && isSide && !isRight && (
|
|
38
|
-
<img
|
|
39
|
-
src={section.imageUrl}
|
|
40
|
-
alt=""
|
|
41
|
-
className="w-full aspect-[3/4] object-cover"
|
|
42
|
-
style={{ borderRadius: 'var(--pk-radius)' }}
|
|
43
|
-
loading="lazy"
|
|
44
|
-
/>
|
|
45
|
-
)}
|
|
46
|
-
|
|
47
|
-
<div
|
|
48
|
-
className="prose prose-invert max-w-none text-base md:text-lg leading-relaxed [&_p]:mb-4 [&_strong]:font-semibold [&_a]:underline [&_a]:decoration-[var(--pk-primary)]"
|
|
49
|
-
style={{ color: 'var(--pk-muted)' }}
|
|
50
|
-
dangerouslySetInnerHTML={{ __html: renderMarkdown(section.content) }}
|
|
51
|
-
/>
|
|
52
|
-
|
|
53
|
-
{hasImage && isSide && isRight && (
|
|
54
|
-
<img
|
|
55
|
-
src={section.imageUrl}
|
|
56
|
-
alt=""
|
|
57
|
-
className="w-full aspect-[3/4] object-cover"
|
|
58
|
-
style={{ borderRadius: 'var(--pk-radius)' }}
|
|
59
|
-
loading="lazy"
|
|
60
|
-
/>
|
|
61
|
-
)}
|
|
62
|
-
</div>
|
|
63
|
-
</div>
|
|
64
|
-
</section>
|
|
65
|
-
);
|
|
66
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import type { CTASection as CTASectionType } from '../../types';
|
|
2
|
-
import { renderMarkdown } from '../utils';
|
|
3
|
-
|
|
4
|
-
interface Props {
|
|
5
|
-
section: CTASectionType;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export default function CTASection({ section }: Props) {
|
|
9
|
-
return (
|
|
10
|
-
<section className="py-24 md:py-36 px-6 text-center" id={section.id}>
|
|
11
|
-
<div className="max-w-2xl mx-auto">
|
|
12
|
-
{section.title && (
|
|
13
|
-
<h2
|
|
14
|
-
className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6"
|
|
15
|
-
style={{ fontFamily: 'var(--pk-font-heading)', color: 'var(--pk-text)' }}
|
|
16
|
-
>
|
|
17
|
-
{section.title}
|
|
18
|
-
</h2>
|
|
19
|
-
)}
|
|
20
|
-
|
|
21
|
-
{section.content && (
|
|
22
|
-
<div
|
|
23
|
-
className="text-base md:text-lg mb-10 [&_p]:mb-2"
|
|
24
|
-
style={{ color: 'var(--pk-muted)' }}
|
|
25
|
-
dangerouslySetInnerHTML={{ __html: renderMarkdown(section.content) }}
|
|
26
|
-
/>
|
|
27
|
-
)}
|
|
28
|
-
|
|
29
|
-
<div className="flex flex-col sm:flex-row items-center justify-center gap-5">
|
|
30
|
-
{section.ctaButtons.map((btn) => {
|
|
31
|
-
const isPrimary = btn.variant === 'primary';
|
|
32
|
-
const isOutline = btn.variant === 'outline';
|
|
33
|
-
return (
|
|
34
|
-
<a
|
|
35
|
-
key={btn.url}
|
|
36
|
-
href={btn.url}
|
|
37
|
-
target="_blank"
|
|
38
|
-
rel="noopener noreferrer"
|
|
39
|
-
className="inline-flex items-center justify-center px-10 py-4 text-base font-bold tracking-[0.1em] uppercase transition-all duration-300 hover:scale-105 min-w-[200px]"
|
|
40
|
-
style={{
|
|
41
|
-
backgroundColor: isPrimary ? 'var(--pk-primary)' : isOutline ? 'transparent' : 'var(--pk-secondary)',
|
|
42
|
-
color: isPrimary ? 'var(--pk-bg)' : 'var(--pk-text)',
|
|
43
|
-
border: isOutline ? '2px solid rgba(255,255,255,0.2)' : 'none',
|
|
44
|
-
borderRadius: '9999px',
|
|
45
|
-
boxShadow: isPrimary ? '0 8px 32px rgba(0,0,0,0.3)' : 'none',
|
|
46
|
-
}}
|
|
47
|
-
>
|
|
48
|
-
{btn.label}
|
|
49
|
-
</a>
|
|
50
|
-
);
|
|
51
|
-
})}
|
|
52
|
-
</div>
|
|
53
|
-
</div>
|
|
54
|
-
</section>
|
|
55
|
-
);
|
|
56
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { EmbedSection as EmbedSectionType } from '../../types';
|
|
2
|
-
|
|
3
|
-
interface Props {
|
|
4
|
-
section: EmbedSectionType;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export default function EmbedSection({ section }: Props) {
|
|
8
|
-
const isVideo = section.aspectRatio === '16:9' || section.embedType === 'youtube';
|
|
9
|
-
const height = section.height ?? (isVideo ? undefined : 352);
|
|
10
|
-
|
|
11
|
-
return (
|
|
12
|
-
<section className="py-16 md:py-24 px-6" id={section.id}>
|
|
13
|
-
<div className="max-w-4xl mx-auto">
|
|
14
|
-
{section.title && (
|
|
15
|
-
<h2
|
|
16
|
-
className="text-3xl md:text-4xl font-bold mb-8"
|
|
17
|
-
style={{ fontFamily: 'var(--pk-font-heading)', color: 'var(--pk-text)' }}
|
|
18
|
-
>
|
|
19
|
-
{section.title}
|
|
20
|
-
</h2>
|
|
21
|
-
)}
|
|
22
|
-
|
|
23
|
-
{section.embedHtml && section.embedType === 'custom' ? (
|
|
24
|
-
<div
|
|
25
|
-
className="overflow-hidden"
|
|
26
|
-
style={{ borderRadius: 'var(--pk-radius)' }}
|
|
27
|
-
dangerouslySetInnerHTML={{ __html: section.embedHtml }}
|
|
28
|
-
/>
|
|
29
|
-
) : isVideo ? (
|
|
30
|
-
<div className="aspect-video overflow-hidden" style={{ borderRadius: 'var(--pk-radius)' }}>
|
|
31
|
-
<iframe
|
|
32
|
-
src={section.embedUrl}
|
|
33
|
-
className="h-full w-full"
|
|
34
|
-
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
|
35
|
-
loading="lazy"
|
|
36
|
-
title={section.title ?? 'Embed'}
|
|
37
|
-
/>
|
|
38
|
-
</div>
|
|
39
|
-
) : (
|
|
40
|
-
<div className="overflow-hidden" style={{ borderRadius: 'var(--pk-radius)', height }}>
|
|
41
|
-
<iframe
|
|
42
|
-
src={section.embedUrl}
|
|
43
|
-
className="w-full h-full"
|
|
44
|
-
allow="autoplay; clipboard-write; encrypted-media; fullscreen"
|
|
45
|
-
loading="lazy"
|
|
46
|
-
title={section.title ?? 'Embed'}
|
|
47
|
-
/>
|
|
48
|
-
</div>
|
|
49
|
-
)}
|
|
50
|
-
</div>
|
|
51
|
-
</section>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { ArrowUpRight } from 'lucide-react';
|
|
2
|
-
import type { FeaturedSection } from '../../types';
|
|
3
|
-
|
|
4
|
-
interface Props {
|
|
5
|
-
section: FeaturedSection;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export default function FeaturedBlock({ section }: Props) {
|
|
9
|
-
return (
|
|
10
|
-
<section className="py-6" id={section.id}>
|
|
11
|
-
{section.title && (
|
|
12
|
-
<h2
|
|
13
|
-
className="text-xl md:text-2xl font-bold mb-4 px-1"
|
|
14
|
-
style={{ fontFamily: 'var(--pk-font-heading)', color: 'var(--pk-text)' }}
|
|
15
|
-
>
|
|
16
|
-
{section.title}
|
|
17
|
-
</h2>
|
|
18
|
-
)}
|
|
19
|
-
<a
|
|
20
|
-
href={section.url}
|
|
21
|
-
target="_blank"
|
|
22
|
-
rel="noopener noreferrer"
|
|
23
|
-
className="group flex items-center gap-4 p-4 overflow-hidden transition-all duration-300 hover:scale-[1.01]"
|
|
24
|
-
style={{
|
|
25
|
-
borderRadius: 'var(--pk-radius)',
|
|
26
|
-
border: '1px solid rgba(255,255,255,0.09)',
|
|
27
|
-
background:
|
|
28
|
-
'linear-gradient(135deg, color-mix(in srgb, var(--pk-primary) 14%, transparent), rgba(255,255,255,0.03) 55%)',
|
|
29
|
-
}}
|
|
30
|
-
>
|
|
31
|
-
{section.imageUrl && (
|
|
32
|
-
<div
|
|
33
|
-
className="shrink-0 w-20 h-20 overflow-hidden"
|
|
34
|
-
style={{ borderRadius: 'calc(var(--pk-radius) - 4px)' }}
|
|
35
|
-
>
|
|
36
|
-
<img
|
|
37
|
-
src={section.imageUrl}
|
|
38
|
-
alt={section.headline}
|
|
39
|
-
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
|
40
|
-
loading="lazy"
|
|
41
|
-
/>
|
|
42
|
-
</div>
|
|
43
|
-
)}
|
|
44
|
-
<div className="min-w-0 flex-1">
|
|
45
|
-
<p className="text-base font-bold leading-tight" style={{ color: 'var(--pk-text)', fontFamily: 'var(--pk-font-heading)' }}>
|
|
46
|
-
{section.headline}
|
|
47
|
-
</p>
|
|
48
|
-
{section.subtitle && (
|
|
49
|
-
<p className="text-xs mt-1 line-clamp-2" style={{ color: 'var(--pk-muted)' }}>
|
|
50
|
-
{section.subtitle}
|
|
51
|
-
</p>
|
|
52
|
-
)}
|
|
53
|
-
{section.ctaLabel && (
|
|
54
|
-
<span
|
|
55
|
-
className="inline-flex items-center gap-1 mt-2 px-3 py-1 text-xs font-semibold rounded-full"
|
|
56
|
-
style={{ backgroundColor: 'var(--pk-primary)', color: 'var(--pk-bg)' }}
|
|
57
|
-
>
|
|
58
|
-
{section.ctaLabel}
|
|
59
|
-
</span>
|
|
60
|
-
)}
|
|
61
|
-
</div>
|
|
62
|
-
<ArrowUpRight
|
|
63
|
-
size={20}
|
|
64
|
-
className="shrink-0 opacity-50 group-hover:opacity-90 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all"
|
|
65
|
-
style={{ color: 'var(--pk-text)' }}
|
|
66
|
-
/>
|
|
67
|
-
</a>
|
|
68
|
-
</section>
|
|
69
|
-
);
|
|
70
|
-
}
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
import type { GallerySection as GallerySectionType, BioMediaItem } from '../../types';
|
|
3
|
-
|
|
4
|
-
interface Props {
|
|
5
|
-
section: GallerySectionType;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
function isInstagramEmbed(item: BioMediaItem) {
|
|
9
|
-
return item.url.includes('instagram.com');
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function getInstagramEmbedUrl(url: string) {
|
|
13
|
-
// Convert instagram reel/post URL to embed URL
|
|
14
|
-
const match = url.match(/instagram\.com\/(reel|p)\/([^/?]+)/);
|
|
15
|
-
if (match) return `https://www.instagram.com/${match[1]}/${match[2]}/embed`;
|
|
16
|
-
return url;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function MediaItem({ item, index, cols, isFeatured, onImageClick }: {
|
|
20
|
-
item: BioMediaItem;
|
|
21
|
-
index: number;
|
|
22
|
-
cols: number;
|
|
23
|
-
isFeatured: boolean;
|
|
24
|
-
onImageClick: (i: number) => void;
|
|
25
|
-
}) {
|
|
26
|
-
if (isInstagramEmbed(item)) {
|
|
27
|
-
return (
|
|
28
|
-
<div
|
|
29
|
-
className={`overflow-hidden ${isFeatured && index === 0 ? 'col-span-2' : ''}`}
|
|
30
|
-
style={{ borderRadius: 'var(--pk-radius)' }}
|
|
31
|
-
>
|
|
32
|
-
<iframe
|
|
33
|
-
src={getInstagramEmbedUrl(item.url)}
|
|
34
|
-
className="w-full border-0"
|
|
35
|
-
style={{ minHeight: cols <= 2 ? '500px' : '420px' }}
|
|
36
|
-
loading="lazy"
|
|
37
|
-
// @ts-expect-error React wants lowercase for custom DOM attributes
|
|
38
|
-
allowtransparency="true"
|
|
39
|
-
allow="encrypted-media"
|
|
40
|
-
title={item.alt ?? 'Instagram'}
|
|
41
|
-
/>
|
|
42
|
-
</div>
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<img
|
|
48
|
-
src={item.url}
|
|
49
|
-
alt={item.alt ?? ''}
|
|
50
|
-
className={`w-full object-cover cursor-pointer hover:opacity-90 transition-opacity ${
|
|
51
|
-
isFeatured && index === 0 ? 'col-span-2 aspect-[16/9]' : 'aspect-square'
|
|
52
|
-
}`}
|
|
53
|
-
style={{ borderRadius: 'var(--pk-radius)' }}
|
|
54
|
-
loading="lazy"
|
|
55
|
-
onClick={() => onImageClick(index)}
|
|
56
|
-
/>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export default function GallerySection({ section }: Props) {
|
|
61
|
-
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
|
|
62
|
-
const cols = section.columns ?? 3;
|
|
63
|
-
const isFeatured = section.layout === 'featured-grid';
|
|
64
|
-
const isCarousel = section.layout === 'carousel';
|
|
65
|
-
|
|
66
|
-
// Only images for lightbox navigation
|
|
67
|
-
const imageItems = section.media.filter((m) => !isInstagramEmbed(m));
|
|
68
|
-
|
|
69
|
-
return (
|
|
70
|
-
<section className="py-16 md:py-24 px-6" id={section.id}>
|
|
71
|
-
<div className="max-w-5xl mx-auto">
|
|
72
|
-
{section.title && (
|
|
73
|
-
<h2
|
|
74
|
-
className="text-3xl md:text-4xl font-bold mb-8"
|
|
75
|
-
style={{ fontFamily: 'var(--pk-font-heading)', color: 'var(--pk-text)' }}
|
|
76
|
-
>
|
|
77
|
-
{section.title}
|
|
78
|
-
</h2>
|
|
79
|
-
)}
|
|
80
|
-
|
|
81
|
-
{isCarousel ? (
|
|
82
|
-
<div className="flex gap-4 overflow-x-auto snap-x snap-mandatory pb-4 -mx-6 px-6 scrollbar-hide">
|
|
83
|
-
{section.media.map((item, i) => (
|
|
84
|
-
<div key={i} className="flex-none w-72 md:w-96 snap-center">
|
|
85
|
-
<MediaItem item={item} index={i} cols={cols} isFeatured={false} onImageClick={setLightboxIdx} />
|
|
86
|
-
</div>
|
|
87
|
-
))}
|
|
88
|
-
</div>
|
|
89
|
-
) : (
|
|
90
|
-
<div
|
|
91
|
-
className={`grid gap-3 md:gap-4 ${
|
|
92
|
-
isFeatured
|
|
93
|
-
? 'grid-cols-2'
|
|
94
|
-
: cols === 2
|
|
95
|
-
? 'grid-cols-1 sm:grid-cols-2'
|
|
96
|
-
: cols === 4
|
|
97
|
-
? 'grid-cols-2 md:grid-cols-4'
|
|
98
|
-
: 'grid-cols-2 md:grid-cols-3'
|
|
99
|
-
}`}
|
|
100
|
-
>
|
|
101
|
-
{section.media.map((item, i) => (
|
|
102
|
-
<MediaItem key={i} item={item} index={i} cols={cols} isFeatured={isFeatured} onImageClick={setLightboxIdx} />
|
|
103
|
-
))}
|
|
104
|
-
</div>
|
|
105
|
-
)}
|
|
106
|
-
|
|
107
|
-
{/* Lightbox — images only */}
|
|
108
|
-
{lightboxIdx !== null && imageItems[lightboxIdx] && (
|
|
109
|
-
<div
|
|
110
|
-
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
|
|
111
|
-
onClick={() => setLightboxIdx(null)}
|
|
112
|
-
>
|
|
113
|
-
<button
|
|
114
|
-
className="absolute top-4 right-4 text-white text-3xl font-light hover:opacity-70 z-10"
|
|
115
|
-
onClick={() => setLightboxIdx(null)}
|
|
116
|
-
>
|
|
117
|
-
×
|
|
118
|
-
</button>
|
|
119
|
-
<button
|
|
120
|
-
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl font-light hover:opacity-70 z-10"
|
|
121
|
-
onClick={(e) => {
|
|
122
|
-
e.stopPropagation();
|
|
123
|
-
setLightboxIdx((lightboxIdx - 1 + imageItems.length) % imageItems.length);
|
|
124
|
-
}}
|
|
125
|
-
>
|
|
126
|
-
‹
|
|
127
|
-
</button>
|
|
128
|
-
<button
|
|
129
|
-
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl font-light hover:opacity-70 z-10"
|
|
130
|
-
onClick={(e) => {
|
|
131
|
-
e.stopPropagation();
|
|
132
|
-
setLightboxIdx((lightboxIdx + 1) % imageItems.length);
|
|
133
|
-
}}
|
|
134
|
-
>
|
|
135
|
-
›
|
|
136
|
-
</button>
|
|
137
|
-
<img
|
|
138
|
-
src={imageItems[lightboxIdx].url}
|
|
139
|
-
alt={imageItems[lightboxIdx].alt ?? ''}
|
|
140
|
-
className="max-h-[85vh] max-w-[90vw] object-contain"
|
|
141
|
-
onClick={(e) => e.stopPropagation()}
|
|
142
|
-
/>
|
|
143
|
-
</div>
|
|
144
|
-
)}
|
|
145
|
-
</div>
|
|
146
|
-
</section>
|
|
147
|
-
);
|
|
148
|
-
}
|