334 lines
24 KiB
TypeScript
334 lines
24 KiB
TypeScript
'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>;
|
||
}
|