// Profile text is copy-paste content the merchant pastes ONTO the external // platform, so the preview renders each one recognisably WITHOUT impersonating // it: no platform logos, no fabricated follower counts. export type ProfilePlatform = | 'linkedin' | 'x' | 'instagram' | 'facebook' | 'tiktok' | 'youtube' | 'pinterest' | 'whatsapp' | 'google_business' | 'press' | 'amazon' | 'etsy' | 'ebay' | 'review_platform'; export type CharUnit = 'chars' | 'words' | 'items'; export type AvatarShape = 'circle' | 'rounded' | 'tile' | 'doc'; export type IdentityKind = | 'handle' | 'page' | 'channel' | 'store' | 'name' | 'business'; export type DomainMode = | 'inline' | 'native-link' | 'forbidden' | 'closing-line' | 'none'; export type ProfileShell = 'cover' | 'ig' | 'centered' | 'plain' | 'doc'; export type FieldSlot = 'headline' | 'section' | 'chips'; export interface ProfileChrome { buttons?: { label: string; solid?: boolean; muted?: boolean }[]; stats?: string[]; shape?: 'pill' | 'rect'; buttonAccent?: string; accentOutline?: boolean; tabs?: string[]; grid?: 'square' | 'video'; } export interface ProfileFieldSpec { key: string; label: string; aliases?: string[]; limit: number; unit: CharUnit; warnAt: number; soft?: boolean; multiline?: boolean; render?: 'text' | 'chips'; frontLoadAt?: number; slot?: FieldSlot; } export interface ProfilePlatformSpec { slug: ProfilePlatform | 'generic'; label: string; shell: ProfileShell; accent: string; cover?: string; avatarShape: AvatarShape; identity: IdentityKind; domainMode: DomainMode; note?: string; chrome?: ProfileChrome; fields: ProfileFieldSpec[]; } const LINKEDIN_COVER = 'linear-gradient(120deg,#a7c4e2,#7fa8d0)'; const X_COVER = 'linear-gradient(120deg,#2b3138,#0f1419)'; const FACEBOOK_COVER = 'linear-gradient(120deg,#c9d6ea,#9db4d6)'; const YOUTUBE_COVER = 'linear-gradient(120deg,#3a3a3a,#111111)'; const AMAZON_COVER = 'linear-gradient(120deg,#232f3e,#131a22)'; export const PROFILE_PLATFORM_SPECS: Record< ProfilePlatform, ProfilePlatformSpec > = { linkedin: { slug: 'linkedin', label: 'LinkedIn', shell: 'cover', accent: '#0A66C2', cover: LINKEDIN_COVER, avatarShape: 'rounded', identity: 'page', domainMode: 'native-link', chrome: { buttons: [{ label: '+ Follow', solid: true }, { label: 'Visit website' }], accentOutline: true, tabs: ['Home', 'About', 'Posts', 'Jobs'], }, fields: [ { key: 'tagline', label: 'Tagline', aliases: ['Headline'], limit: 120, unit: 'chars', warnAt: 114, slot: 'headline', }, { key: 'about', label: 'About', aliases: ['Description', 'Overview'], limit: 2000, unit: 'chars', warnAt: 1900, multiline: true, slot: 'section', }, { key: 'specialties', label: 'Specialties', aliases: ['Skills', 'Services'], limit: 20, unit: 'items', warnAt: 18, render: 'chips', slot: 'chips', }, ], }, x: { slug: 'x', label: 'X', shell: 'cover', accent: '#0f1419', cover: X_COVER, avatarShape: 'circle', identity: 'handle', domainMode: 'inline', chrome: { buttons: [{ label: 'Edit profile' }], stats: ['Following', 'Followers'], tabs: ['Posts', 'Replies', 'Media', 'Likes'], }, fields: [ { key: 'bio', label: 'Bio', limit: 160, unit: 'chars', warnAt: 152, multiline: true, slot: 'headline', }, ], }, instagram: { slug: 'instagram', label: 'Instagram', shell: 'ig', accent: '#e1306c', avatarShape: 'circle', identity: 'handle', domainMode: 'native-link', chrome: { shape: 'rect', buttonAccent: '#0095f6', buttons: [ { label: 'Follow', solid: true }, { label: 'Message', muted: true }, ], stats: ['posts', 'followers', 'following'], tabs: ['Posts', 'Reels', 'Tagged'], grid: 'square', }, fields: [ { key: 'bio', label: 'Bio', limit: 150, unit: 'chars', warnAt: 143, multiline: true, slot: 'headline', }, ], }, facebook: { slug: 'facebook', label: 'Facebook', shell: 'cover', accent: '#1877F2', cover: FACEBOOK_COVER, avatarShape: 'circle', identity: 'page', domainMode: 'closing-line', chrome: { shape: 'rect', buttons: [ { label: 'Like', solid: true }, { label: 'Follow', muted: true }, { label: 'Message', muted: true }, ], stats: ['likes', 'followers'], tabs: ['Posts', 'About', 'Photos', 'Mentions'], }, fields: [ { key: 'intro', label: 'Intro', aliases: ['Short description'], limit: 101, unit: 'chars', warnAt: 96, multiline: true, slot: 'section', }, { key: 'about', label: 'About', aliases: ['Description'], limit: 255, unit: 'chars', warnAt: 242, multiline: true, slot: 'section', }, ], }, tiktok: { slug: 'tiktok', label: 'TikTok', shell: 'centered', accent: '#fe2c55', avatarShape: 'circle', identity: 'handle', domainMode: 'none', chrome: { shape: 'rect', buttons: [{ label: 'Follow', solid: true }, { label: 'Message' }], stats: ['Following', 'Followers', 'Likes'], tabs: ['Videos', 'Liked'], grid: 'video', }, fields: [ { key: 'bio', label: 'Bio', limit: 80, unit: 'chars', warnAt: 76, multiline: true, slot: 'headline', }, ], }, youtube: { slug: 'youtube', label: 'YouTube', shell: 'cover', accent: '#FF0000', cover: YOUTUBE_COVER, avatarShape: 'circle', identity: 'channel', domainMode: 'closing-line', chrome: { // YouTube's Subscribe button is black, not brand red. buttonAccent: '#0f0f0f', buttons: [{ label: 'Subscribe', solid: true }], stats: ['subscribers', 'videos'], tabs: ['Home', 'Videos', 'Shorts', 'About'], }, fields: [ { key: 'description', label: 'Description', aliases: ['About'], limit: 1000, unit: 'chars', warnAt: 950, multiline: true, slot: 'section', }, ], }, pinterest: { slug: 'pinterest', label: 'Pinterest', // Pinterest profiles are centered: avatar on top, name, handle, stats. shell: 'centered', accent: '#E60023', avatarShape: 'circle', identity: 'handle', domainMode: 'inline', chrome: { buttons: [{ label: 'Follow', solid: true }], stats: ['followers', 'following'], }, fields: [ { key: 'about', label: 'About', aliases: ['Bio'], limit: 500, unit: 'chars', warnAt: 475, multiline: true, slot: 'headline', }, ], }, whatsapp: { slug: 'whatsapp', label: 'WhatsApp Business', // WhatsApp's contact-info screen is centered: avatar, name, account label. shell: 'centered', accent: '#25D366', avatarShape: 'circle', identity: 'business', domainMode: 'closing-line', chrome: { buttons: [{ label: 'Message', solid: true }] }, fields: [ { key: 'about', label: 'About', aliases: ['Status'], limit: 139, unit: 'chars', warnAt: 132, multiline: true, slot: 'section', }, { key: 'description', label: 'Business description', aliases: ['Description'], limit: 512, unit: 'chars', warnAt: 486, multiline: true, slot: 'section', }, ], }, google_business: { slug: 'google_business', label: 'Google Business', shell: 'plain', accent: '#4285F4', avatarShape: 'rounded', identity: 'name', domainMode: 'forbidden', note: 'No links in the description (Google Business policy).', chrome: { // Google's knowledge-panel actions are blue-tinted outline pills. accentOutline: true, buttonAccent: '#1a73e8', buttons: [ { label: 'Website' }, { label: 'Directions' }, { label: 'Call' }, ], }, fields: [ { key: 'description', label: 'From the business', aliases: ['About', 'Description'], limit: 750, unit: 'chars', warnAt: 712, multiline: true, frontLoadAt: 250, slot: 'section', }, ], }, press: { slug: 'press', label: 'Press', shell: 'doc', accent: '#334155', avatarShape: 'doc', identity: 'name', domainMode: 'closing-line', fields: [ { key: 'boilerplate', label: 'Boilerplate', aliases: ['About'], limit: 100, unit: 'words', warnAt: 90, soft: true, multiline: true, slot: 'section', }, ], }, amazon: { slug: 'amazon', label: 'Amazon', shell: 'cover', accent: '#FF9900', cover: AMAZON_COVER, avatarShape: 'tile', identity: 'store', domainMode: 'forbidden', note: 'No external links in an Amazon storefront About.', chrome: { // Amazon's storefront Follow is a white outline button, not orange. buttons: [{ label: 'Follow' }], tabs: ['Home', 'Products', 'Posts'], }, fields: [ { key: 'about', label: 'About the brand', aliases: ['About', 'Description'], limit: 1000, unit: 'chars', warnAt: 950, multiline: true, slot: 'section', }, ], }, etsy: { slug: 'etsy', label: 'Etsy', shell: 'plain', accent: '#F1641E', // Etsy shop icons are square tiles, not circles. avatarShape: 'tile', identity: 'store', domainMode: 'none', chrome: { buttons: [{ label: 'Follow', solid: true }], stats: ['sales'], }, fields: [ { key: 'about', label: 'About', aliases: ['Story', 'Description'], limit: 600, unit: 'chars', warnAt: 570, soft: true, multiline: true, slot: 'section', }, ], }, ebay: { slug: 'ebay', label: 'eBay', shell: 'plain', accent: '#0064D2', avatarShape: 'tile', identity: 'store', domainMode: 'inline', chrome: { buttons: [{ label: 'Save this seller' }], stats: ['followers'], }, fields: [ { key: 'description', label: 'Description', aliases: ['About'], limit: 1000, unit: 'chars', warnAt: 950, multiline: true, slot: 'section', }, ], }, review_platform: { slug: 'review_platform', label: 'Review site', shell: 'plain', accent: '#00b67a', avatarShape: 'circle', identity: 'name', domainMode: 'none', chrome: { buttons: [{ label: 'Write a review' }], stats: ['reviews'], }, fields: [ { key: 'blurb', label: 'About', aliases: ['Description', 'Blurb'], limit: 500, unit: 'chars', warnAt: 475, multiline: true, slot: 'section', }, ], }, }; export const GENERIC_PROFILE: ProfilePlatformSpec = { slug: 'generic', label: 'Profile', shell: 'plain', accent: '#6d6d78', avatarShape: 'circle', identity: 'name', domainMode: 'none', fields: [ { key: 'text', label: 'Profile text', limit: 0, unit: 'chars', warnAt: 0, multiline: true, slot: 'section', }, ], }; const TOPIC_PLATFORM_PATTERNS: { slug: ProfilePlatform; re: RegExp }[] = [ { slug: 'linkedin', re: /linkedin/i }, { slug: 'instagram', re: /instagram/i }, { slug: 'facebook', re: /facebook/i }, { slug: 'tiktok', re: /tik\s*tok/i }, { slug: 'youtube', re: /youtube/i }, { slug: 'pinterest', re: /pinterest/i }, { slug: 'whatsapp', re: /whats\s*app/i }, { slug: 'google_business', re: /google\s+(business|my business)/i }, { slug: 'amazon', re: /amazon/i }, { slug: 'etsy', re: /etsy/i }, { slug: 'ebay', re: /ebay/i }, { slug: 'review_platform', re: /review/i }, { slug: 'press', re: /press|boilerplate/i }, { slug: 'x', re: /\bx\b|twitter/i }, ]; export const inferPlatformFromTopic = ( topic: string | null | undefined ): ProfilePlatform | null => { const text = topic || ''; for (const { slug, re } of TOPIC_PLATFORM_PATTERNS) { if (re.test(text)) return slug; } return null; }; export const resolveProfileSpec = ( platform: string | null | undefined, topic?: string | null ): ProfilePlatformSpec => { const slug = (platform || '').trim() as ProfilePlatform; if (PROFILE_PLATFORM_SPECS[slug]) return PROFILE_PLATFORM_SPECS[slug]; const inferred = inferPlatformFromTopic(topic); if (inferred) return PROFILE_PLATFORM_SPECS[inferred]; return GENERIC_PROFILE; }; export type CharMeterState = 'ok' | 'near' | 'over'; export const charMeterState = ( count: number, field: ProfileFieldSpec ): CharMeterState => { if (!field.limit) return 'ok'; if (field.soft) return count >= field.warnAt ? 'near' : 'ok'; if (count > field.limit) return 'over'; if (count >= field.warnAt) return 'near'; return 'ok'; }; export const countForField = ( value: string, field: ProfileFieldSpec ): number => { const text = (value || '').trim(); if (!text) return 0; if (field.unit === 'words') return text.split(/\s+/).filter(Boolean).length; if (field.unit === 'items') return splitItems(text).length; return text.length; }; export const splitItems = (value: string): string[] => (value || '') .split(/\r?\n|,|·|•|;/) .map(item => item.replace(/^[-*]\s*/, '').trim()) .filter(Boolean); const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const cleanFieldBody = (value: string): string => (value || '') .replace(/^[\s>*_-]+/, '') .replace(/[\s]+$/, '') .trim(); export const parseProfileFields = ( markdown: string, spec: ProfilePlatformSpec ): Record => { const source = markdown || ''; if (spec.fields.length <= 1) { const field = spec.fields[0]; let body = source; if (field) { // Strip a leading "Label:" / "## Label" the generator may prepend even // on single-field platforms, so the label never doubles inside the card. const labels = [field.label, ...(field.aliases ?? [])].map(escapeRegExp); const lead = new RegExp( `^[ \\t]*(?:#{1,6}[ \\t]*)?(?:\\*\\*[ \\t]*)?(?:${labels.join('|')})(?:[ \\t]*\\*\\*)?[ \\t]*(?::[ \\t]*|\\r?\\n)`, 'i' ); body = body.replace(lead, ''); } return { [field?.key ?? 'text']: cleanFieldBody(body) }; } const hits: { key: string; start: number; labelEnd: number }[] = []; for (const field of spec.fields) { const labels = [field.label, ...(field.aliases ?? [])].map(escapeRegExp); // A label only counts as a field boundary when it is actually MARKED as a // label - a heading (``## About``), bold (``**About**``), a trailing colon // (``About:``), or alone on its line - not a bare word that merely starts a // sentence. This keeps common alias words (Skills, Services, Description, // Status, ...) from mis-splitting a field mid-prose. const matcher = new RegExp( `(?:^|\\n)[ \\t]*(?:#{1,6}[ \\t]*)?(?:\\*\\*[ \\t]*)?(?:${labels.join('|')})(?:[ \\t]*\\*\\*)?[ \\t]*(?::[ \\t]*|(?=\\r?\\n)|$)`, 'i' ); const match = matcher.exec(source); if (match && match.index !== undefined) { hits.push({ key: field.key, start: match.index, labelEnd: match.index + match[0].length, }); } } if (hits.length === 0) { return { [spec.fields[0].key]: cleanFieldBody(source) }; } hits.sort((a, b) => a.start - b.start); const out: Record = {}; const firstKey = spec.fields[0].key; // Any body text BEFORE the first matched label (e.g. a bare headline line the // generator did not label) belongs to the first field, so it is never dropped. if (hits[0].start > 0 && !hits.some(hit => hit.key === firstKey)) { const lead = cleanFieldBody(source.slice(0, hits[0].start)); if (lead) out[firstKey] = lead; } for (let index = 0; index < hits.length; index += 1) { const end = index + 1 < hits.length ? hits[index + 1].start : source.length; out[hits[index].key] = cleanFieldBody( source.slice(hits[index].labelEnd, end) ); } return out; };