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(
+ /