Files
QR-master/src/components/dashboard/SocialMilestoneDialog.tsx

334 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<Channel, string> = { x: 'X', instagram: 'Instagram' };
function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; 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 <svg viewBox="0 0 480 184" className="w-full" role="img" aria-label="Cumulative unique scan trend">
{ticks.map(tick => {
const y = 142 - (tick / ceiling) * 126;
return <g key={tick}><text x="48" y={y + 4} textAnchor="end" fontSize="11" fontWeight={tick === trend.target ? 700 : 500} fill={tick === trend.target ? '#0256ff' : '#64748b'}>{number.format(tick)}</text><line x1="60" x2="470" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? 1.6 : 1} /></g>;
})}
<path d={path} fill="none" stroke="#0256ff" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
<circle cx={endPoint.x} cy={endPoint.y} r="4.5" fill="#ffffff" stroke="#0256ff" strokeWidth="3" />
<text x="60" y="176" fontSize="11" fontWeight="600" fill="#45617f">{trend.startLabel}</text>
<text x="470" y="176" textAnchor="end" fontSize="11" fontWeight="600" fill="#45617f">{trend.endLabel}</text>
</svg>;
}
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<Milestone | null>(null);
const [withName, setWithName] = useState(false);
const [handles, setHandles] = useState<Record<string, string>>({});
const [selected, setSelected] = useState<Channel[]>([]);
const [saving, setSaving] = useState<'brand' | 'self' | 'copy' | 'instagram' | null>(null);
const [brand, setBrand] = useState<BrandState | null>(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<string, unknown>) => {
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 <Dialog open onOpenChange={open => !open && setMilestone(null)} containerClassName="max-w-[960px]">
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-full max-w-none flex-col overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)] sm:max-h-[calc(100dvh-1.5rem)]">
<div className="shrink-0 px-4 py-4 sm:px-6">
<DialogHeader className="flex-row items-center space-y-0 text-left">
<div className="mr-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-[#0256ff] shadow-sm"><QrCode className="h-5 w-5" /></div>
<div className="min-w-0 flex-1"><DialogTitle className="text-xl font-semibold tracking-[-0.03em] text-[#061b31] sm:text-[22px]">{copy.heading}</DialogTitle><DialogDescription className="truncate pt-1 text-sm text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></div>
<button type="button" aria-label={german ? 'Schließen' : 'Close'} onClick={() => setMilestone(null)} className="ml-3 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus:ring-2 focus:ring-[#0256ff]"><X className="h-5 w-5" /></button>
</DialogHeader>
</div>
<div className="min-h-0 space-y-4 overflow-y-auto border-y border-slate-100 px-4 py-4 overscroll-contain sm:px-6 md:grid md:grid-cols-[minmax(0,1.12fr)_minmax(320px,0.88fr)] md:gap-6 md:space-y-0">
<section className="rounded-xl border border-slate-200 bg-white p-4 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
<div className="flex items-center justify-between border-b border-slate-100 pb-3 text-xs"><span className="flex items-center gap-2 font-semibold tracking-wide text-[#061b31]"><img src="/favicon.ico" alt="" className="h-5 w-5" />QR MASTER</span><span className="rounded bg-emerald-50 px-2 py-1 font-medium text-emerald-700"><Check className="mr-1 inline h-3 w-3" />Verified scan milestone</span></div>
<div className="mt-4 flex items-end justify-between gap-6"><div><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">UNIQUE SCANS</div><div className="mt-1 text-5xl font-normal tracking-[-0.04em] tabular-nums text-[#061b31]">{count.toLocaleString(german ? 'de-DE' : 'en-US')}</div></div><div className="pb-1 text-right"><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">{german ? 'SCANS INSGESAMT' : 'TOTAL SCANS'}</div><div className="mt-1 text-2xl font-normal tabular-nums text-[#061b31]">{(card.totalScans || count).toLocaleString(german ? 'de-DE' : 'en-US')}</div></div></div>
<div className="mt-3 border-t border-slate-100 pt-3">{card.trend ? <Trend trend={card.trend} locale={milestone.language} /> : <div className="py-8 text-center text-xs text-[#45617f]">{german ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}</div>}</div>
{card.trend && <div className="mt-1 flex items-center justify-end gap-2 text-[11px] text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{german ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}</div>}
<div className="mt-3 truncate border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
</section>
<div className="min-w-0 space-y-4 md:pt-1">
<div>
<p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p>
<div className="mt-2 flex flex-wrap gap-3">
{brandChannels.map(option => (
<label key={option.channel} className="flex cursor-pointer items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:border-[#0256ff]">
<input type="checkbox" checked={selected.includes(option.channel)} onChange={() => toggleChannel(option.channel)} disabled={saving !== null} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />
{option.channel === 'instagram' ? <Instagram className="h-3.5 w-3.5" /> : <X className="h-3.5 w-3.5" />}
{CHANNEL_LABELS[option.channel]}
</label>
))}
</div>
{previews.length === 0
? <p className="mt-3 text-xs text-slate-500">{german ? 'Kein Kanal ausgewählt QR Master veröffentlicht nichts.' : 'No channel selected QR Master publishes nothing.'}</p>
: previews.map(preview => (
<div key={preview.channel} className="mt-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">{CHANNEL_LABELS[preview.channel]}</div>
<blockquote className="mt-1 max-h-40 overflow-y-auto whitespace-pre-line break-words border-l border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview.text}</blockquote>
</div>
))}
</div>
<div className="space-y-2">
<label className="flex cursor-pointer items-center gap-3 text-sm font-medium text-slate-700"><input type="checkbox" checked={withName} onChange={event => setWithName(event.target.checked)} disabled={saving !== null} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
{withName && selected.map(channel => (
<input
key={channel}
aria-label={`${CHANNEL_LABELS[channel]} handle`}
value={handles[channel] || ''}
onChange={event => 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"
/>
))}
</div>
<div className="space-y-2"><div className="text-xs font-medium text-slate-500">{copy.self}</div><div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2"><Button variant="outline" size="sm" className="w-full" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" className="w-full" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />{german ? 'Kopieren & LinkedIn' : 'Copy & open LinkedIn'}</Button><Button variant="outline" size="sm" className="w-full" onClick={shareInstagram} disabled={saving !== null}><Instagram className="mr-1.5 h-3.5 w-3.5" />Instagram</Button></div><button type="button" onClick={copyLinkedInText} disabled={saving !== null} className="inline-flex items-center gap-1.5 text-xs font-medium text-[#45617f] underline underline-offset-2 hover:text-[#0256ff] disabled:opacity-50"><Copy className="h-3.5 w-3.5" />{german ? 'Nur Text kopieren' : 'Copy text only'}</button><p className="text-[11px] leading-4 text-slate-500">{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.'}</p></div>
{posts.filter(post => post.status !== 'revoked').map(post => (
<div key={post.channel} className={`flex items-start justify-between gap-3 rounded-md px-3 py-2 text-sm ${post.status === 'posted' ? 'bg-emerald-50 text-emerald-800' : post.status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}>
<span>{CHANNEL_LABELS[post.channel as Channel] || post.channel}: {post.status === 'posted' ? copy.posted : post.status === 'failed' ? `${copy.failed}${post.error ? ` ${post.error}` : ''}` : copy.queued}</span>
{post.postUrl && <a href={post.postUrl} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}
</div>
))}
</div>
</div>
<DialogFooter className="shrink-0 bg-slate-50 px-4 py-3 sm:px-6"><div className="grid w-full grid-cols-2 gap-2 sm:ml-auto sm:flex sm:w-auto sm:flex-wrap sm:justify-end"><Button variant="outline" onClick={() => promptStatus === 'shown' ? dismiss('decline') : setMilestone(null)} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || !canApprove}><Send className="mr-1.5 h-4 w-4" />{pending ? copy.queued : copy.approve}</Button>{promptStatus === 'shown' && <button type="button" className="col-span-2 pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700 sm:w-full" onClick={optOut} disabled={saving !== null}>{german ? 'Nicht mehr anzeigen' : 'Do not show again'}</button>}</div></DialogFooter>
</DialogContent>
</Dialog>;
}