'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Check, Copy, ExternalLink, Instagram, LineChart, Linkedin, QrCode, Send, X } from 'lucide-react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/Dialog'; import { Button } from '@/components/ui/Button'; import { useCsrf } from '@/hooks/useCsrf'; import { useTranslation } from '@/hooks/useTranslation'; import { showToast } from '@/components/ui/Toast'; import { roundedChartPath } from '@/lib/rounded-chart-path'; type Channel = 'x' | 'instagram'; type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalScans?: number; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null }; type ChannelOption = { channel: Channel; available: boolean; defaultHandle: string; head: string; tail: string; mentionWord: string }; type PostState = { channel: string; status: string; postUrl: string | null; error: string | null }; type Milestone = { id: string; qrTitle: string; threshold: number; shareUrl: string; language: 'en' | 'de'; card: Card; promptStatus: string; channels: ChannelOption[]; posts: PostState[] }; type BrandState = { promptStatus: string; selfSharedAt: string | null; posts: PostState[] }; const CHANNEL_LABELS: Record = { x: 'X', instagram: 'Instagram' }; function Trend({ trend, locale }: { trend: NonNullable; locale: 'en' | 'de' }) { const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25; const ticks = trend.target <= 5 ? [1, 2, 3, 4, 5] : Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4); const first = new Date(trend.points[0].at).getTime(); const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1); const chartPoints = trend.points.map(point => { const x = 60 + ((new Date(point.at).getTime() - first) / (last - first)) * 410; const y = 142 - (point.total / ceiling) * 126; return { x, y }; }); const path = roundedChartPath(chartPoints, 18); const endPoint = chartPoints[chartPoints.length - 1] || { x: 470, y: 142 }; const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 }); return {ticks.map(tick => { const y = 142 - (tick / ceiling) * 126; return {number.format(tick)}; })} {trend.startLabel} {trend.endLabel} ; } async function copyShareText(text: string) { try { await navigator.clipboard.writeText(text); return; } catch { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); const copied = document.execCommand('copy'); textarea.remove(); if (!copied) throw new Error('Copying is blocked by this browser'); } } export function SocialMilestoneDialog() { const { fetchWithCsrf } = useCsrf(); const { locale } = useTranslation(); const [milestone, setMilestone] = useState(null); const [withName, setWithName] = useState(false); const [handles, setHandles] = useState>({}); const [selected, setSelected] = useState([]); const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | 'instagram' | null>(null); const [brand, setBrand] = useState(null); const loadedMilestone = useRef(false); useEffect(() => { if (loadedMilestone.current) return; loadedMilestone.current = true; fetch(`/api/social-milestones?locale=${locale}`).then(async response => { if (response.ok) { const next = (await response.json()).milestone as Milestone | null; setMilestone(next); if (next) setBrand({ promptStatus: next.promptStatus, selfSharedAt: null, posts: next.posts || [] }); } }).catch(() => undefined); }, [locale]); useEffect(() => { if (!milestone) return; setHandles(Object.fromEntries(milestone.channels.map(option => [option.channel, option.defaultHandle]))); // Instagram stays unticked on purpose: consent for one channel is not // consent for the next, so the second one has to be an actual decision. setSelected(milestone.channels.filter(option => option.available && option.channel === 'x').map(option => option.channel)); }, [milestone]); const posts = brand?.posts || []; const postFor = (channel: Channel) => posts.find(post => post.channel === channel); const pending = posts.some(post => ['approved', 'processing'].includes(post.status)); useEffect(() => { if (!milestone || !pending) return; const poll = async () => { const response = await fetch(`/api/social-milestones/${milestone.id}`); if (response.ok) setBrand((await response.json()).milestone); }; const timer = window.setInterval(poll, 3000); void poll(); return () => window.clearInterval(timer); }, [milestone, pending]); const german = milestone?.language === 'de'; const copy = german ? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf den eigenen Kanälen veröffentlichen?', name: 'Meinen Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Von QR Master posten', queued: 'Wird veröffentlicht …', posted: 'Veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' } : { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success on its own channels?', name: 'Mention my handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Publishing …', posted: 'Published', failed: 'Publishing failed' }; const optionFor = (channel: Channel) => milestone?.channels.find(option => option.channel === channel); const mentionOf = (channel: Channel) => { const option = optionFor(channel); const handle = (handles[channel] || '').trim().replace(/^@/, ''); return option && withName && handle ? `\n\n${option.mentionWord} @${handle}.` : ''; }; /** Without the channel suffix - used where the share URL is added by hand. */ const composeBody = (channel: Channel) => { const option = optionFor(channel); return option ? `${option.head}${mentionOf(channel)}` : ''; }; // Head + mention + tail is exactly how the server assembles the consent text. const compose = (channel: Channel) => { const option = optionFor(channel); return option ? `${composeBody(channel)}${option.tail}` : ''; }; const previews = useMemo( () => selected.map(channel => ({ channel, text: compose(channel) })), // eslint-disable-next-line react-hooks/exhaustive-deps [selected, handles, withName, milestone], ); const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out', extra?: Record) => { if (!milestone) return null; const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { method: 'PATCH', body: JSON.stringify({ action, withName, handles, channels: selected, language: milestone.language, ...extra }), }); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Could not save your choice'); return result; }; const prepareSelfShare = async () => { const result = await update('self_share'); const shareUrl = `${result.shareUrl}?v=${result.shareVersion}`; setBrand(result.milestone); return { shareUrl, // 4:5 is the tallest ratio Instagram accepts and the one that keeps the // numbers readable in a phone feed. imageUrl: `${result.shareUrl}/og?format=portrait&v=${result.shareVersion}`, text: `${composeBody('x')}\n\n${shareUrl}`, }; }; const shareSelf = async (network: 'x' | 'linkedin') => { if (!milestone) return; // LinkedIn's public share dialog accepts only a URL. Start copying the // prepared commentary while this click still owns browser focus, then // open the LinkedIn share dialog after public-share consent is persisted. const commentary = composeBody('x'); const linkedinCopy = network === 'linkedin' ? copyShareText(commentary).then(() => true).catch(() => false) : Promise.resolve(true); // Open synchronously from the user gesture. Awaiting the API first can make // LinkedIn treat the new window as a blocked popup. const shareWindow = window.open('about:blank', '_blank'); if (shareWindow) shareWindow.opener = null; setSaving('self'); try { const { shareUrl, text } = await prepareSelfShare(); const copied = await linkedinCopy; const targetUrl = network === 'x' ? `https://x.com/intent/post?text=${encodeURIComponent(text)}` : `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`; if (shareWindow) shareWindow.location.href = targetUrl; else window.location.assign(targetUrl); showToast(network === 'linkedin' ? copied ? (german ? 'LinkedIn-Text kopiert. Jetzt nur noch einfügen.' : 'LinkedIn text copied. Paste it into the composer.') : (german ? 'LinkedIn ist geöffnet. Bitte nutze „Text kopieren“ im QR-Master-Fenster.' : 'LinkedIn is open. Please use “Copy text” in the QR Master window.') : 'X share composer opened.', copied ? 'success' : 'error'); } catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } finally { setSaving(null); } }; // Instagram has no composer a website can prefill: there is no intent URL, // and the story deep links need a native pasteboard the browser cannot // reach. What is left is the system share sheet on a phone, and a download // plus the caption in the clipboard everywhere else. const shareInstagram = async () => { if (!milestone) return; setSaving('instagram'); try { const caption = compose('instagram'); const { imageUrl } = await prepareSelfShare(); const response = await fetch(imageUrl); if (!response.ok) throw new Error(german ? 'Das Meilenstein-Bild konnte nicht geladen werden.' : 'Could not load the milestone image'); const blob = await response.blob(); const file = new File([blob], 'qr-master-milestone.png', { type: blob.type || 'image/png' }); const copied = await copyShareText(caption).then(() => true).catch(() => false); if (navigator.canShare?.({ files: [file] })) { try { await navigator.share({ files: [file], text: caption }); return; } catch (error) { // Sheet dismissed on purpose - do not push a download nobody asked for. if (error instanceof Error && error.name === 'AbortError') return; } } const objectUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = objectUrl; link.download = file.name; link.click(); URL.revokeObjectURL(objectUrl); showToast(copied ? (german ? 'Bild geladen, Text kopiert. Beides in Instagram einfügen.' : 'Image downloaded, caption copied. Add both in Instagram.') : (german ? 'Bild geladen. Bitte „Nur Text kopieren“ für die Bildunterschrift nutzen.' : 'Image downloaded. Use “Copy text only” for the caption.'), copied ? 'success' : 'error'); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } finally { setSaving(null); } }; const copyLinkedInText = async () => { setSaving('copy'); try { const { text } = await prepareSelfShare(); await copyShareText(text); showToast(german ? 'LinkedIn-Text kopiert.' : 'LinkedIn post text copied.', 'success'); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not copy the LinkedIn text', 'error'); } finally { setSaving(null); } }; const approveBrand = async () => { setSaving('brand'); try { const result = await update('approve_brand'); setBrand(result.milestone); showToast(copy.queued, 'success'); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); } finally { setSaving(null); } }; const dismiss = async (action: 'decline' | 'opt_out') => { try { await update(action); setMilestone(null); } catch (error) { showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error'); } }; const optOut = () => { const message = german ? 'Meilenstein-Hinweise dauerhaft ausblenden? Du kannst sie später in den Einstellungen wieder aktivieren.' : 'Turn off milestone prompts? You can enable them again later in Settings.'; if (window.confirm(message)) void dismiss('opt_out'); }; const toggleChannel = (channel: Channel) => { setSelected(current => current.includes(channel) ? current.filter(entry => entry !== channel) : [...current, channel]); }; if (!milestone) return null; const card = milestone.card; const count = card.totalUniqueScans || milestone.threshold; const promptStatus = brand?.promptStatus || milestone.promptStatus; const brandChannels = milestone.channels.filter(option => option.available); // A channel that is already published or in flight cannot be re-approved. const canApprove = selected.length > 0 && !selected.some(channel => ['processing', 'posted'].includes(postFor(channel)?.status || '')); return !open && setMilestone(null)} containerClassName="max-w-[960px]">
{copy.heading}{milestone.qrTitle} {copy.subtitle}
QR MASTERVerified scan milestone
UNIQUE SCANS
{count.toLocaleString(german ? 'de-DE' : 'en-US')}
{german ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}
{(card.totalScans || count).toLocaleString(german ? 'de-DE' : 'en-US')}
{card.trend ? :
{german ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}
}
{card.trend &&
{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}
}
{card.qrTitle}

{copy.consent}

{brandChannels.map(option => ( ))}
{previews.length === 0 ?

{german ? 'Kein Kanal ausgewählt – QR Master veröffentlicht nichts.' : 'No channel selected – QR Master publishes nothing.'}

: previews.map(preview => (
{CHANNEL_LABELS[preview.channel]}
{preview.text}
))}
{withName && selected.map(channel => ( setHandles(current => ({ ...current, [channel]: event.target.value }))} disabled={saving !== null} maxLength={channel === 'instagram' ? 31 : 16} placeholder={channel === 'instagram' ? '@your.instagram' : '@yourhandle'} className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-[#0256ff] focus:ring-2 focus:ring-blue-100" /> ))}
{copy.self}

{german ? 'LinkedIn öffnet den Editor und kopiert den fertigen Text automatisch. Instagram lässt sich nicht vorbefüllen: am Handy öffnet das Teilen-Menü, sonst wird das Bild geladen und der Text kopiert.' : 'LinkedIn opens the composer and copies the finished text automatically. Instagram cannot be prefilled: on a phone the share sheet opens, otherwise the image is downloaded and the caption copied.'}

{posts.filter(post => post.status !== 'revoked').map(post => (
{CHANNEL_LABELS[post.channel as Channel] || post.channel}: {post.status === 'posted' ? copy.posted : post.status === 'failed' ? `${copy.failed}${post.error ? ` – ${post.error}` : ''}` : copy.queued} {post.postUrl && View}
))}
{promptStatus === 'shown' && }
; }