Improve social milestone sharing flow
This commit is contained in:
@@ -1,22 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { QrCode, Sparkles } from 'lucide-react';
|
||||
import { Check, ExternalLink, LineChart, Linkedin, Send, Sparkles, 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';
|
||||
|
||||
type Milestone = {
|
||||
id: string;
|
||||
qrTitle: string;
|
||||
threshold: number;
|
||||
defaultXHandle: string | null;
|
||||
preview: string;
|
||||
language: 'en' | 'de';
|
||||
card: { title: string; label: string };
|
||||
};
|
||||
type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalUniqueScans: number; milestoneThreshold: number; trend: { periodDays: number; series: number[]; recentTotal: number } | null };
|
||||
type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; language: 'en' | 'de'; card: Card; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null };
|
||||
type BrandState = { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
|
||||
|
||||
function Trend({ series }: { series: number[] }) {
|
||||
const max = Math.max(...series, 1);
|
||||
const points = series.map((value, index) => `${(index / Math.max(series.length - 1, 1)) * 100},${92 - (value / max) * 70}`).join(' ');
|
||||
return <svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-20 w-full overflow-visible" aria-label="Real scan trend"><polyline points={points} fill="none" stroke="currentColor" strokeWidth="3" vectorEffect="non-scaling-stroke" /></svg>;
|
||||
}
|
||||
|
||||
export function SocialMilestoneDialog() {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
@@ -24,81 +24,95 @@ export function SocialMilestoneDialog() {
|
||||
const [milestone, setMilestone] = useState<Milestone | null>(null);
|
||||
const [withName, setWithName] = useState(false);
|
||||
const [xHandle, setXHandle] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saving, setSaving] = useState<'brand' | 'self' | null>(null);
|
||||
const [brand, setBrand] = useState<BrandState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/social-milestones?locale=${locale}`)
|
||||
.then(async (response) => response.ok && setMilestone((await response.json()).milestone))
|
||||
.catch(() => undefined);
|
||||
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({ brandStatus: next.brandStatus, brandPostUrl: next.brandPostUrl, brandPostError: next.brandPostError, selfSharedAt: null });
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]);
|
||||
useEffect(() => {
|
||||
if (!milestone || !['approved', 'processing'].includes(brand?.brandStatus || '')) 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, brand?.brandStatus]);
|
||||
|
||||
const copy = milestone?.language === 'de'
|
||||
? { heading: 'Ein echter Erfolg', subtitle: 'hat gerade einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg mit der unten stehenden Karte auf dem eigenen X-Account teilen?', name: 'Meinen X-Handle nennen', decline: 'Nein, danke', optOut: 'Nicht mehr anzeigen', self: 'Selbst teilen', approve: 'Auf QR Master posten', published: 'Der Beitrag wird jetzt auf dem QR Master X-Account veroeffentlicht.' }
|
||||
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master share this success, including the card below, from our X account?', name: 'Mention my X handle', decline: 'No thanks', optOut: 'Do not show again', self: 'Share myself', approve: 'Post from QR Master', published: 'This will now be published from the QR Master X account.' };
|
||||
|
||||
? { heading: 'Ein echter Erfolg', subtitle: 'hat einen Scan-Meilenstein erreicht.', consent: 'Darf QR Master diesen Erfolg auf dem eigenen X-Account veröffentlichen?', name: 'Meinen X-Handle nennen', decline: 'Nicht jetzt', self: 'Selbst teilen', approve: 'Auf QR Master posten', queued: 'Wird auf X veröffentlicht …', posted: 'Auf X veröffentlicht', failed: 'Veröffentlichung fehlgeschlagen' }
|
||||
: { heading: 'A real milestone', subtitle: 'just reached a scan milestone.', consent: 'May QR Master publish this success from our X account?', name: 'Mention my X handle', decline: 'Not now', self: 'Share myself', approve: 'Post from QR Master', queued: 'Publishing to X …', posted: 'Published on X', failed: 'Publishing failed' };
|
||||
const preview = useMemo(() => {
|
||||
if (!milestone || !withName || !xHandle.trim()) return milestone?.preview || '';
|
||||
return `${milestone.preview} By @${xHandle.trim().replace(/^@/, '')}.`;
|
||||
}, [milestone, withName, xHandle]);
|
||||
|
||||
const respond = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
|
||||
const update = async (action: 'approve_brand' | 'self_share' | 'decline' | 'opt_out') => {
|
||||
if (!milestone) return null;
|
||||
const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, { method: 'PATCH', body: JSON.stringify({ action, withName, xHandle, language: milestone.language }) });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Could not save your choice');
|
||||
return result;
|
||||
};
|
||||
const shareSelf = async (network: 'x' | 'linkedin') => {
|
||||
if (!milestone) return;
|
||||
setSaving(true);
|
||||
setSaving('self');
|
||||
try {
|
||||
const response = await fetchWithCsrf(`/api/social-milestones/${milestone.id}`, {
|
||||
method: 'PATCH', body: JSON.stringify({ action, withName, xHandle, language: milestone.language }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Could not save your choice');
|
||||
if (action === 'self_share') {
|
||||
await navigator.clipboard?.writeText(preview);
|
||||
window.open(`https://x.com/intent/post?text=${encodeURIComponent(preview)}`, '_blank', 'noopener,noreferrer');
|
||||
window.open('https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.qrmaster.net', '_blank', 'noopener,noreferrer');
|
||||
showToast('Post text copied for LinkedIn.', 'success');
|
||||
} else if (action === 'approve_brand') {
|
||||
showToast(copy.published, 'success');
|
||||
const result = await update('self_share');
|
||||
const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`;
|
||||
const text = `${preview} ${shareUrl}`;
|
||||
if (network === 'x') window.open(`https://x.com/intent/post?text=${encodeURIComponent(text)}`, '_blank', 'noopener,noreferrer');
|
||||
else {
|
||||
await navigator.clipboard?.writeText(text);
|
||||
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
setMilestone(null);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setBrand(result.milestone);
|
||||
showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success');
|
||||
} catch (error) { showToast(error instanceof Error ? error.message : 'Could not prepare this share', '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'); }
|
||||
};
|
||||
|
||||
if (!milestone) return null;
|
||||
|
||||
return <Dialog open onOpenChange={(open) => !open && setMilestone(null)}>
|
||||
<DialogContent className="max-w-lg overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.4)]">
|
||||
<div className="border-b border-slate-100 px-6 pb-5 pt-6">
|
||||
<DialogHeader>
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-violet-50 text-violet-700"><Sparkles className="h-5 w-5" /></div>
|
||||
<DialogTitle className="text-2xl font-semibold tracking-[-0.03em] text-[#061b31]">{copy.heading}</DialogTitle>
|
||||
<DialogDescription className="pt-1 text-sm leading-6 text-slate-600"><strong className="font-medium text-slate-900">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription>
|
||||
</DialogHeader>
|
||||
const card = milestone.card;
|
||||
const count = card.totalUniqueScans || milestone.threshold;
|
||||
const status = brand?.brandStatus || milestone.brandStatus || 'pending';
|
||||
return <Dialog open onOpenChange={open => !open && setMilestone(null)}>
|
||||
<DialogContent className="max-w-xl overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)]">
|
||||
<div className="px-7 pb-5 pt-7"><DialogHeader><div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-5 w-5" /></div><DialogTitle className="text-2xl font-semibold tracking-[-0.03em] text-[#061b31]">{copy.heading}</DialogTitle><DialogDescription className="pt-1 text-sm leading-6 text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></DialogHeader></div>
|
||||
<div className="space-y-5 border-y border-slate-100 px-7 py-6">
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-4 text-xs"><span className="font-semibold tracking-wide text-[#061b31]">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-5 text-[11px] font-semibold tracking-[0.12em] text-slate-400">TOTAL UNIQUE SCANS</div><div className="mt-1 text-5xl font-semibold tracking-[-0.05em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div><div className="mt-1 text-sm text-slate-500">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div>
|
||||
{card.trend ? <div className="mt-5 text-[#0256ff]"><Trend series={card.trend.series} /><div className="mt-2 flex items-center gap-2 text-xs text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{card.trend.recentTotal} {milestone.language === 'de' ? 'eindeutige Scans in den letzten' : 'unique scans in the last'} {card.trend.periodDays} days</div></div> : <div className="mt-5 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-xs font-medium text-blue-700"><LineChart className="h-3.5 w-3.5" />{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}</div>}
|
||||
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
|
||||
</section>
|
||||
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 border-l-2 border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>
|
||||
<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={status !== 'pending'} className="h-4 w-4 rounded border-slate-300 text-[#0256ff] focus:ring-[#0256ff]" />{copy.name}</label>
|
||||
{withName && <input aria-label="X handle" value={xHandle} onChange={event => setXHandle(event.target.value)} disabled={status !== 'pending'} placeholder="@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 className="flex flex-wrap items-center gap-2"><span className="mr-1 text-xs font-medium text-slate-500">{copy.self}</span><Button variant="outline" size="sm" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />LinkedIn</Button></div>
|
||||
{status !== 'pending' && <div className={`flex items-center justify-between rounded-md px-3 py-2 text-sm ${status === 'posted' ? 'bg-emerald-50 text-emerald-800' : status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}><span>{status === 'posted' ? copy.posted : status === 'failed' ? copy.failed : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
|
||||
</div>
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
<div className="rounded-xl bg-[#061b31] p-5 text-white shadow-[0_18px_36px_-20px_rgba(50,50,93,0.65)]">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300"><span className="flex items-center gap-2 font-medium tracking-wide"><QrCode className="h-4 w-4" />QR MASTER</span><span>Verified</span></div>
|
||||
<div className="mt-7 text-5xl font-semibold tracking-[-0.04em] tabular-nums">{milestone.threshold.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div>
|
||||
<div className="mt-1 text-sm text-slate-300">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div>
|
||||
<div className="mt-7 border-t border-white/15 pt-3 text-xs text-slate-300">{milestone.card.label} · {milestone.card.title}</div>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-slate-600">{copy.consent}</p>
|
||||
<blockquote className="border-l-2 border-violet-500 pl-3 text-sm leading-6 text-slate-700">{preview}</blockquote>
|
||||
<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)} className="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" />{copy.name}</label>
|
||||
{withName && <input aria-label="X handle" value={xHandle} onChange={(event) => setXHandle(event.target.value)} placeholder="@yourhandle" className="w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-2 focus:ring-violet-100" />}
|
||||
</div>
|
||||
<DialogFooter className="border-t border-slate-100 bg-slate-50 px-6 py-4">
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => respond('decline')} disabled={saving}>{copy.decline}</Button>
|
||||
<Button variant="outline" onClick={() => respond('self_share')} disabled={saving}>{copy.self}</Button>
|
||||
<Button variant="primary" onClick={() => respond('approve_brand')} disabled={saving}>{copy.approve}</Button>
|
||||
<button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={() => respond('opt_out')} disabled={saving}>{copy.optOut}</button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="bg-slate-50 px-7 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => dismiss('decline')} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || status !== 'pending'}><Send className="mr-1.5 h-4 w-4" />{copy.approve}</Button><button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={() => dismiss('opt_out')} disabled={saving !== null}>Do not show again</button></div></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user