diff --git a/components/AnalyticsPage.tsx b/components/AnalyticsPage.tsx index ab9adaf..d1e3c76 100644 --- a/components/AnalyticsPage.tsx +++ b/components/AnalyticsPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { motion } from 'framer-motion'; import { ArrowLeft, @@ -34,6 +34,8 @@ type AnalyticsEvent = { language: string | null; screen_w: number | null; screen_h: number | null; + viewport_w: number | null; + viewport_h: number | null; visitor_id: string | null; session_id: string | null; duration_seconds: number | null; @@ -84,50 +86,53 @@ const AnalyticsPage: React.FC = () => { .finally(() => setInitialLoading(false)); }, []); - const fetchAnalytics = async (customDays?: number) => { - const daysToFetch = customDays ?? days; - if (!projectUrl || !dbPassword) { - setError('Please enter your Supabase URL and database password'); - return; - } + const fetchAnalytics = useCallback( + async (customDays?: number) => { + const daysToFetch = customDays ?? days; + if (!projectUrl || !dbPassword) { + setError('Please enter your Supabase URL and database password'); + return; + } - setLoading(true); - setError(null); - - try { - const res = await fetch('/__openbento/analytics/fetch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ projectUrl, dbPassword, days: daysToFetch }), - }); - - const data = await res.json(); - if (data.ok) { - setEvents(data.events || []); - setIsConfigured(true); - } else { - setError(data.error || 'Failed to fetch analytics'); + setLoading(true); + setError(null); + + try { + const res = await fetch('/__openbento/analytics/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectUrl, dbPassword, days: daysToFetch }), + }); + + const data = await res.json(); + if (data.ok) { + setEvents(data.events || []); + setIsConfigured(true); + } else { + setError(data.error || 'Failed to fetch analytics'); + } + } catch (e) { + setError('Network error: ' + (e as Error).message); + } finally { + setLoading(false); } - } catch (e) { - setError('Network error: ' + (e as Error).message); - } finally { - setLoading(false); - } - }; + }, + [days, projectUrl, dbPassword] + ); // Auto-fetch when config is ready useEffect(() => { if (!initialLoading && projectUrl && dbPassword && !isConfigured) { fetchAnalytics(); } - }, [initialLoading, projectUrl, dbPassword]); + }, [initialLoading, projectUrl, dbPassword, isConfigured, fetchAnalytics]); // Auto-refresh when days change (if already configured) useEffect(() => { if (isConfigured && projectUrl && dbPassword) { fetchAnalytics(days); } - }, [days]); + }, [days, isConfigured, projectUrl, dbPassword, fetchAnalytics]); // Compute analytics stats const stats = useMemo(() => { diff --git a/components/Builder.tsx b/components/Builder.tsx index fb4e2b8..6cdc114 100644 --- a/components/Builder.tsx +++ b/components/Builder.tsx @@ -572,6 +572,23 @@ const Builder: React.FC = ({ onBack }) => { [profile, blocks, setSiteData, autoSave] ); + const applyImportedBento = useCallback( + (newBento: SavedBento) => { + const nextGridVersion = newBento.data.gridVersion ?? GRID_VERSION; + const normalizedBlocks = ensureBlocksHavePositions(newBento.data.blocks); + + setActiveBento(newBento); + setGridVersion(nextGridVersion); + setActiveBentoId(newBento.id); + reset({ + profile: newBento.data.profile, + blocks: normalizedBlocks, + }); + setEditingBlockId(null); + }, + [reset] + ); + // Note: Block positioning is handled when blocks are created (addBlock function) // No automatic repositioning to avoid conflicts with user-placed blocks @@ -2229,7 +2246,8 @@ const Builder: React.FC = ({ onBack }) => { } }} blocks={blocks} - setBlocks={handleSetBlocks} + onBlocksChange={handleSetBlocks} + onBentoImported={applyImportedBento} /> {/* 4. AVATAR CROP MODAL */} @@ -2269,13 +2287,7 @@ const Builder: React.FC = ({ onBack }) => { setShowAIGeneratorModal(false)} - onBentoImported={(newBento) => { - // Reload the app with the new bento - setActiveBento(newBento); - handleSetProfile(newBento.data.profile); - handleSetBlocks(newBento.data.blocks); - setGridVersion(newBento.data.gridVersion ?? GRID_VERSION); - }} + onBentoImported={applyImportedBento} /> {/* 7. DEPLOY MODAL */} diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 110dee5..0d702fd 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -16,7 +16,7 @@ import { Database, Globe, } from 'lucide-react'; -import type { SocialPlatform, UserProfile, BlockData } from '../types'; +import type { SocialPlatform, UserProfile, BlockData, SavedBento } from '../types'; import { AVATAR_PLACEHOLDER } from '../constants'; import ImageCropModal from './ImageCropModal'; import { @@ -25,6 +25,9 @@ import { SOCIAL_PLATFORM_OPTIONS, formatFollowerCount, } from '../socialPlatforms'; +import { importLinktreeToBento } from '../services/linktreeImportService'; + +const ENABLE_IMPORT = import.meta.env.VITE_ENABLE_IMPORT === 'true'; type SettingsModalProps = { isOpen: boolean; @@ -36,6 +39,7 @@ type SettingsModalProps = { // For raw JSON editing blocks?: BlockData[]; onBlocksChange?: (blocks: BlockData[]) => void; + onBentoImported?: (bento: SavedBento) => void; }; type TabType = 'general' | 'social' | 'seo' | 'analytics' | 'json'; @@ -49,6 +53,7 @@ const SettingsModal: React.FC = ({ onBentoNameChange, blocks, onBlocksChange, + onBentoImported, }) => { const avatarInputRef = useRef(null); const [pendingAvatarSrc, setPendingAvatarSrc] = useState(null); @@ -62,6 +67,12 @@ const SettingsModal: React.FC = ({ // JSON editor state const [jsonText, setJsonText] = useState(''); const [jsonError, setJsonError] = useState(null); + const [linktreeInput, setLinktreeInput] = useState(''); + const [linktreeImportState, setLinktreeImportState] = useState<{ + status: 'loading' | 'success' | 'error'; + message: string; + details?: string[]; + } | null>(null); // Supabase Analytics state const [supabaseProjectUrl, setSupabaseProjectUrl] = useState(''); @@ -254,6 +265,31 @@ const SettingsModal: React.FC = ({ } }; + const handleLinktreeImport = async () => { + if (!onBentoImported || !linktreeInput.trim()) return; + + setLinktreeImportState({ + status: 'loading', + message: 'Importing Linktree content...', + }); + + try { + const result = await importLinktreeToBento(linktreeInput); + onBentoImported(result.bento); + setLinktreeInput(''); + setLinktreeImportState({ + status: 'success', + message: `${result.importedCount} link(s) imported into a new Bento.`, + details: result.warnings, + }); + } catch (error) { + setLinktreeImportState({ + status: 'error', + message: (error as Error).message || 'Linktree import failed.', + }); + } + }; + const tabs: { id: TabType; label: string; icon: React.ReactNode }[] = [ { id: 'general', label: 'General', icon: }, { id: 'social', label: 'Social', icon: }, @@ -349,6 +385,78 @@ const SettingsModal: React.FC = ({ Used as filename when exporting JSON

+ + {ENABLE_IMPORT && onBentoImported && ( +
+
+ +

+ Import from Linktree +

+

+ This feature is experimental. Some links, media, or custom sections + may not be imported correctly. +

+
+ +
+ setLinktreeInput(e.target.value)} + placeholder="https://linktr.ee/yourname or yourname" + className="flex-1 bg-white border border-amber-200 rounded-lg px-3 py-2 text-sm text-gray-800 focus:ring-2 focus:ring-amber-400/40 focus:border-amber-400 focus:outline-none transition-all" + /> + +
+ + {linktreeImportState && ( +
+

{linktreeImportState.message}

+ {linktreeImportState.details?.length ? ( +
+ {linktreeImportState.details.map((detail) => ( +

+ {detail} +

+ ))} +
+ ) : null} +
+ )} +
+ )} )} diff --git a/docs/builder/configuration.md b/docs/builder/configuration.md index 0fa69b9..c6342fc 100644 --- a/docs/builder/configuration.md +++ b/docs/builder/configuration.md @@ -7,6 +7,7 @@ OpenBento can be customized through environment variables. | Variable | Description | Default | |----------|-------------|---------| | `VITE_ENABLE_LANDING` | Show landing page before builder | `false` | +| `VITE_ENABLE_IMPORT` | Enable experimental import actions in Settings, including Linktree import | `false` | ## Landing Page @@ -26,6 +27,24 @@ VITE_ENABLE_LANDING=true npm run dev VITE_ENABLE_LANDING=true npm run build ``` +## Experimental Import + +Import actions in **Settings** are disabled by default. + +To enable the experimental import UI: + +```bash +VITE_ENABLE_IMPORT=true npm run dev +``` + +Or for a production build: + +```bash +VITE_ENABLE_IMPORT=true npm run build +``` + +This import flow is experimental. Imported Linktree content may require manual cleanup after import. + ## Data Storage All user data is stored in the browser's localStorage: diff --git a/services/linktreeImportService.ts b/services/linktreeImportService.ts new file mode 100644 index 0000000..aafa3af --- /dev/null +++ b/services/linktreeImportService.ts @@ -0,0 +1,324 @@ +import { AVATAR_PLACEHOLDER } from '../constants'; +import { + extractHandleFromUrl, + inferSocialPlatformFromUrl, + normalizeSocialHandle, +} from '../socialPlatforms'; +import type { BlockData, SavedBento, SocialAccount, UserProfile } from '../types'; +import { BlockType } from '../types'; +import { GRID_VERSION, importBentoFromJSON, type BentoJSON } from './storageService'; + +type LinktreeSocialLink = { + url?: string; + profileUrl?: string; + href?: string; + link?: string; + type?: string; + platform?: string; +}; + +type LinktreePageProps = { + username?: string; + pageTitle?: string; + description?: string | null; + metaTitle?: string | null; + metaDescription?: string | null; + customAvatar?: string | null; + socialLinks?: LinktreeSocialLink[]; + seoSchema?: { + mainEntity?: { + sameAs?: string[]; + }; + }; + account?: { + username?: string; + pageTitle?: string; + description?: string | null; + profilePictureUrl?: string | null; + customAvatar?: string | null; + dynamicMetaTitle?: string | null; + dynamicMetaDescription?: string | null; + socialLinks?: LinktreeSocialLink[]; + links?: Array>; + theme?: { + background?: { + color?: string; + }; + buttonStyle?: { + backgroundStyle?: { + color?: string; + }; + textStyle?: { + color?: string; + }; + }; + typeface?: { + color?: string; + }; + }; + }; +}; + +export type LinktreeImportResult = { + bento: SavedBento; + importedCount: number; + skippedCount: number; + warnings: string[]; +}; + +const normalizeLinktreeInput = (input: string): string => { + const trimmed = input.trim(); + if (!trimmed) throw new Error('Enter a Linktree URL or username.'); + + if (/^https?:\/\//i.test(trimmed)) { + const parsed = new URL(trimmed); + const hostname = parsed.hostname.toLowerCase(); + if (hostname !== 'linktr.ee' && hostname !== 'www.linktr.ee') { + throw new Error('Only linktr.ee URLs are supported.'); + } + const username = parsed.pathname.split('/').filter(Boolean)[0]; + if (!username) throw new Error('The Linktree URL must include a username.'); + return `https://linktr.ee/${username}`; + } + + const username = trimmed + .replace(/^@/, '') + .replace(/^https?:\/\/(www\.)?linktr\.ee\//i, '') + .split(/[/?#]/)[0]; + + if (!username) throw new Error('The Linktree username is invalid.'); + return `https://linktr.ee/${username}`; +}; + +const isHttpUrl = (value: unknown): value is string => { + if (typeof value !== 'string') return false; + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +}; + +const pickFirstText = (...values: unknown[]): string => { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +}; + +const pickFirstUrl = (...values: unknown[]): string | undefined => { + for (const value of values) { + if (isHttpUrl(value)) return value; + } + return undefined; +}; + +const sanitizeImportedTitle = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return ''; + return trimmed.replace(/\s+\|\s*Linktree$/i, '').trim(); +}; + +const collectSocialUrls = (pageProps: LinktreePageProps): string[] => { + const urls = new Set(); + const socialLists = [pageProps.socialLinks, pageProps.account?.socialLinks]; + + for (const list of socialLists) { + for (const item of list || []) { + const url = pickFirstUrl(item.url, item.profileUrl, item.href, item.link); + if (url) urls.add(url); + } + } + + for (const sameAs of pageProps.seoSchema?.mainEntity?.sameAs || []) { + if (isHttpUrl(sameAs)) urls.add(sameAs); + } + + return Array.from(urls); +}; + +const extractSocialAccounts = (pageProps: LinktreePageProps): SocialAccount[] => { + const dedupe = new Map(); + + for (const url of collectSocialUrls(pageProps)) { + const platform = inferSocialPlatformFromUrl(url); + if (!platform) continue; + + const rawHandle = extractHandleFromUrl(platform, url) || url; + const handle = normalizeSocialHandle(platform, rawHandle); + if (!handle) continue; + + dedupe.set(platform, { platform, handle }); + } + + return Array.from(dedupe.values()); +}; + +const createLinkBlocks = (pageProps: LinktreePageProps) => { + const links = Array.isArray(pageProps.account?.links) ? pageProps.account.links : []; + const buttonColor = pageProps.account?.theme?.buttonStyle?.backgroundStyle?.color || '#111827'; + const textColorHex = pageProps.account?.theme?.buttonStyle?.textStyle?.color || '#ffffff'; + + let importedCount = 0; + let skippedCount = 0; + + const blocks = links.flatMap((link, index) => { + const url = pickFirstUrl(link.url, link.href); + const title = pickFirstText(link.title); + const isActive = link.isActive !== false; + const isLocked = Boolean(link.isLocked || link.locked); + + if (!url || !title || !isActive || isLocked) { + skippedCount += 1; + return []; + } + + importedCount += 1; + + let hostname = ''; + try { + hostname = new URL(url).hostname.replace(/^www\./, ''); + } catch { + hostname = 'External link'; + } + + return [ + { + id: `linktree-link-${index}`, + type: BlockType.LINK, + title, + subtext: hostname, + content: url, + colSpan: 9, + rowSpan: 3, + gridColumn: 1, + gridRow: importedCount * 3 - 2, + customBackground: buttonColor, + textColor: textColorHex.toLowerCase() === '#000000' ? 'text-gray-900' : 'text-white', + } satisfies BlockData, + ]; + }); + + return { blocks, importedCount, skippedCount }; +}; + +const createProfile = ( + pageProps: LinktreePageProps, + socialAccounts: SocialAccount[] +): UserProfile => { + const title = sanitizeImportedTitle( + pickFirstText( + pageProps.account?.pageTitle, + pageProps.pageTitle, + pageProps.account?.dynamicMetaTitle, + pageProps.metaTitle, + pageProps.account?.username, + pageProps.username, + 'Imported Linktree' + ) + ); + const description = pickFirstText( + pageProps.account?.description, + pageProps.description, + pageProps.account?.dynamicMetaDescription, + pageProps.metaDescription + ); + const avatarUrl = pickFirstUrl( + pageProps.account?.profilePictureUrl, + pageProps.account?.customAvatar, + pageProps.customAvatar + ); + + return { + name: title || 'Imported Linktree', + bio: description, + avatarUrl: + avatarUrl && !avatarUrl.includes('/blank-avatar.svg') ? avatarUrl : AVATAR_PLACEHOLDER, + theme: 'light', + primaryColor: 'blue', + showBranding: true, + showSocialInHeader: socialAccounts.length > 0, + showFollowerCount: false, + socialAccounts, + analytics: { enabled: false, supabaseUrl: '' }, + backgroundColor: pageProps.account?.theme?.background?.color || '#f8fafc', + openGraph: { + title: title || 'Imported Linktree', + description, + image: avatarUrl && !avatarUrl.includes('/blank-avatar.svg') ? avatarUrl : undefined, + siteName: 'Linktree import', + twitterCardType: 'summary_large_image', + }, + }; +}; + +const buildBentoJsonFromLinktree = (pageProps: LinktreePageProps) => { + const socialAccounts = extractSocialAccounts(pageProps); + const { blocks: linkBlocks, importedCount, skippedCount } = createLinkBlocks(pageProps); + const profile = createProfile(pageProps, socialAccounts); + const warnings = [ + 'Experimental import: some Linktree elements may need manual cleanup after import.', + ]; + + if (skippedCount > 0) { + warnings.push(`${skippedCount} item(s) could not be imported automatically.`); + } + if (socialAccounts.length === 0) { + warnings.push('No supported social accounts were detected on this Linktree page.'); + } + + const json: BentoJSON = { + id: `linktree_${Date.now()}`, + name: `${profile.name.replace(/^@/, '').trim() || 'Linktree Import'} Bento`, + version: '1.0', + gridVersion: GRID_VERSION, + profile, + blocks: linkBlocks, + exportedAt: Date.now(), + }; + + return { json, importedCount, skippedCount, warnings }; +}; + +export const importLinktreeToBento = async (input: string): Promise => { + if (import.meta.env.VITE_ENABLE_IMPORT !== 'true') { + throw new Error('Import feature is disabled. Set VITE_ENABLE_IMPORT=true to enable it.'); + } + + const sourceUrl = normalizeLinktreeInput(input); + const endpoint = `/__openbento/import/linktree?url=${encodeURIComponent(sourceUrl)}`; + + let response: Response; + try { + response = await fetch(endpoint); + } catch (error) { + throw new Error( + `Unable to reach the Linktree import service. ${(error as Error).message || ''}`.trim() + ); + } + + const payload = (await response.json().catch(() => null)) as { + ok?: boolean; + error?: string; + pageProps?: LinktreePageProps; + } | null; + + if (!response.ok || !payload?.ok || !payload.pageProps) { + if (response.status === 404) { + throw new Error('Linktree import is unavailable in this environment.'); + } + throw new Error(payload?.error || 'Failed to fetch this Linktree page.'); + } + + const { json, importedCount, skippedCount, warnings } = buildBentoJsonFromLinktree( + payload.pageProps + ); + + return { + bento: importBentoFromJSON(json), + importedCount, + skippedCount, + warnings, + }; +}; diff --git a/services/storageService.ts b/services/storageService.ts index 46ccf68..ef87262 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -305,6 +305,7 @@ export const downloadBentoJSON = (bento: SavedBento): void => { // Import a bento from JSON export const importBentoFromJSON = (json: BentoJSON): SavedBento => { const now = Date.now(); + const profile = json.profile || ({} as UserProfile); const newBento: SavedBento = { id: generateId(), // Always generate new ID to avoid conflicts @@ -314,14 +315,15 @@ export const importBentoFromJSON = (json: BentoJSON): SavedBento => { data: { gridVersion: json.gridVersion ?? GRID_VERSION, profile: { - name: json.profile?.name || 'My Bento', - bio: json.profile?.bio || '', - avatarUrl: json.profile?.avatarUrl || AVATAR_PLACEHOLDER, - theme: json.profile?.theme || 'light', - primaryColor: json.profile?.primaryColor || 'blue', - showBranding: json.profile?.showBranding ?? true, - analytics: json.profile?.analytics || { enabled: false, supabaseUrl: '' }, - socialAccounts: json.profile?.socialAccounts || [], + ...profile, + name: profile.name || 'My Bento', + bio: profile.bio || '', + avatarUrl: profile.avatarUrl || AVATAR_PLACEHOLDER, + theme: profile.theme || 'light', + primaryColor: profile.primaryColor || 'blue', + showBranding: profile.showBranding ?? true, + analytics: profile.analytics || { enabled: false, supabaseUrl: '' }, + socialAccounts: profile.socialAccounts || [], }, blocks: (json.blocks || []).map((b) => ({ ...b, diff --git a/socialPlatforms.ts b/socialPlatforms.ts index e5a2812..474a5ea 100644 --- a/socialPlatforms.ts +++ b/socialPlatforms.ts @@ -16,6 +16,7 @@ import { Link as LinkIcon, Linkedin, MessageCircle, + Music, Newspaper, Phone, Pin, @@ -280,7 +281,7 @@ export const SOCIAL_PLATFORM_OPTIONS: SocialPlatformOption[] = [ { id: 'spotify', label: 'Spotify', - icon: SiSpotify, + icon: Music, brandIcon: SiSpotify, brandColor: '#1DB954', placeholder: 'spotify username', diff --git a/vite.config.ts b/vite.config.ts index 92631df..afa3601 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -114,6 +114,37 @@ const randomPassword = () => { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const normalizeLinktreeUrl = (input: string): string | null => { + const trimmed = input.trim(); + if (!trimmed) return null; + + try { + const parsed = /^https?:\/\//i.test(trimmed) + ? new URL(trimmed) + : new URL(`https://linktr.ee/${trimmed.replace(/^@/, '')}`); + const hostname = parsed.hostname.toLowerCase(); + if (hostname !== 'linktr.ee' && hostname !== 'www.linktr.ee') return null; + const username = parsed.pathname.split('/').filter(Boolean)[0]; + if (!username) return null; + return `https://linktr.ee/${username}`; + } catch { + return null; + } +}; + +const extractNextData = (html: string) => { + const match = html.match( + /