Add consented social milestone posting
This commit is contained in:
104
src/components/dashboard/SocialMilestoneDialog.tsx
Normal file
104
src/components/dashboard/SocialMilestoneDialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { QrCode, Sparkles } 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 };
|
||||
};
|
||||
|
||||
export function SocialMilestoneDialog() {
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const { locale } = useTranslation();
|
||||
const [milestone, setMilestone] = useState<Milestone | null>(null);
|
||||
const [withName, setWithName] = useState(false);
|
||||
const [xHandle, setXHandle] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/social-milestones?locale=${locale}`)
|
||||
.then(async (response) => response.ok && setMilestone((await response.json()).milestone))
|
||||
.catch(() => undefined);
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => setXHandle(milestone?.defaultXHandle || ''), [milestone]);
|
||||
|
||||
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.' };
|
||||
|
||||
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') => {
|
||||
if (!milestone) return;
|
||||
setSaving(true);
|
||||
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');
|
||||
}
|
||||
setMilestone(null);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Could not save your choice', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
Reference in New Issue
Block a user