import { CARD, drawShareCard, stampAiGeneratedWatermark, type CardSize } from './shareCard'; import { withShareLandingParam } from './shareLanding'; export type ShareChannel = 'instagram_stories' | 'instagram_feed' | 'whatsapp_status' | 'whatsapp_message'; export type ShareCapability = 'file_share' | 'url_share' | 'download_clipboard' | 'download_only' | 'unavailable'; export type ShareOutcome = 'share_sheet_closed' | 'cancelled' | 'fallback_ready' | 'failed'; export type SharePayloadMode = 'file_text' | 'file_only' | 'link_only' | 'none'; export interface ShareMetadata { itemName: string; brandName?: string | null; productUrl: string | null; productImage?: string | null; } export interface ShareSheetResult { capability: ShareCapability; outcome: ShareOutcome; payloadMode: SharePayloadMode } function loadImage(source: string): Promise { return new Promise((resolve, reject) => { const image = new Image(); image.decoding = 'async'; const resolved = new URL(source, location.href); if (resolved.protocol !== 'data:') image.crossOrigin = 'anonymous'; image.onload = () => resolve(image); image.onerror = () => reject(new Error('share image could not be decoded')); image.src = resolved.href; }); } /** Compose a separate card; never export the raw result, even on failure. */ export async function composeShareImage(source: string, metadata: ShareMetadata, size: CardSize = CARD): Promise { const photo = await loadImage(source); const thumb = metadata.productImage ? await loadImage(metadata.productImage).catch(() => null) : null; const render = (thumbnail: HTMLImageElement | null) => { const canvas = document.createElement('canvas'); canvas.width = size.width; canvas.height = size.height; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('canvas unavailable'); drawShareCard(ctx, photo, thumbnail, metadata); stampAiGeneratedWatermark(ctx); return new Promise((resolve, reject) => { try { canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('share image export failed')), 'image/png'); } catch (error) { reject(error); } }); }; try { return await render(thumb); } catch (error) { if (error instanceof DOMException && error.name === 'SecurityError') return render(null); throw error; } } export function shareFile(blob: Blob): File { return new File([blob], 'aisthetix-try-on-story.png', { type: 'image/png' }); } export function buildShareMessage(metadata: ShareMetadata, channel: ShareChannel = 'whatsapp_message'): string { const norm = (value: string) => value.trim().replace(/\s+/g, ' '); const item = norm(metadata.itemName), brand = norm(metadata.brandName || ''); const identity = item ? item + (brand ? ` by ${brand}` : '') : ''; const url = withShareLandingParam(metadata.productUrl); const ask = 'Should I get it? ❤️ yes · 😢 no'; // Legacy analytics enums share one Instagram caption, including the link. if (channel === 'instagram_feed' || channel === 'instagram_stories') { const line = url ? (identity ? `${identity} · try it on your own photo: ${url}` : `Try it on your own photo: ${url}`) : identity; return [ask, line].filter(Boolean).join('\n'); } return [ask, identity, url ? `Try it on your own photo 👉 ${url}` : ''].filter(Boolean).join('\n'); } export function shareClipboardText(metadata: ShareMetadata, channel: ShareChannel): string { return buildShareMessage(metadata, channel); } function canShare(nav: Navigator, data: ShareData): boolean { try { return typeof nav.canShare === 'function' && nav.canShare(data); } catch { return false; } } /** Device hint for callers; sharing itself depends only on file capability. */ export function isMobileShareDevice(nav: Navigator = navigator): boolean { const hint = (nav as Navigator & { userAgentData?: { mobile?: boolean } }).userAgentData?.mobile; if (typeof hint === 'boolean') return hint; return /Android|iPhone|iPad|iPod/i.test(nav.userAgent || '') || (/Macintosh/i.test(nav.userAgent || '') && nav.maxTouchPoints > 1); } export function detectSharePayloadMode(file: File, metadata: ShareMetadata, nav: Navigator = navigator, channel: ShareChannel = 'whatsapp_message'): SharePayloadMode { // Validate the file by itself first: browsers can ignore unknown properties // and otherwise report true for a text payload with unsupported files. if (typeof nav.share !== 'function' || !canShare(nav, { files: [file] })) return 'none'; const common = { text: buildShareMessage(metadata, channel) }; return canShare(nav, { ...common, files: [file] }) ? 'file_text' : 'file_only'; } export function detectShareCapability(file: File, nav: Navigator = navigator, metadata: ShareMetadata = { itemName: '', productUrl: null }): ShareCapability { const mode = detectSharePayloadMode(file, metadata, nav); if (mode === 'file_text' || mode === 'file_only') return 'file_share'; if (typeof document !== 'undefined') return typeof nav.clipboard?.writeText === 'function' ? 'download_clipboard' : 'download_only'; return 'unavailable'; } export async function openShareSheet(file: File, metadata: ShareMetadata, nav: Navigator = navigator, channel: ShareChannel = 'whatsapp_message', fileOnly = false): Promise { const detected = detectSharePayloadMode(file, metadata, nav, channel); const payloadMode = detected !== 'none' && fileOnly ? 'file_only' : detected; if (payloadMode === 'none' || typeof nav.share !== 'function') { return { capability: detectShareCapability(file, nav, metadata), outcome: 'fallback_ready', payloadMode: 'none' }; } const data: ShareData = payloadMode === 'file_text' ? { text: buildShareMessage(metadata, channel), files: [file] } : { files: [file] }; try { await nav.share(data); return { capability: 'file_share', outcome: 'share_sheet_closed', payloadMode }; } catch (error) { return { capability: 'file_share', outcome: error instanceof DOMException && error.name === 'AbortError' ? 'cancelled' : 'failed', payloadMode }; } } export function downloadShareImage(blob: Blob): void { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'aisthetix-try-on-story.png'; anchor.rel = 'noopener'; document.body.appendChild(anchor); try { anchor.click(); } finally { anchor.remove(); // WebKit and embedded browsers can consume the URL after this task ends. setTimeout(() => URL.revokeObjectURL(url), 30_000); } } export async function copyText(text: string, nav: Navigator = navigator): Promise { let timer: ReturnType | undefined; try { if (!nav.clipboard?.writeText) return false; // Clipboard permission may stay pending after focus changes. // Keep the downloaded image and controls usable in that case. return await Promise.race([ nav.clipboard.writeText(text).then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), 2000); }), ]); } catch { return false; } finally { if (timer !== undefined) clearTimeout(timer); } }