Consent is bound to the channel it was given for: approving a post on X says nothing about Instagram. Publishing state moves from the SocialMilestone row into SocialMilestonePost, one row per channel, where a missing row means no consent. The dialog asks per channel, shows the text each one will publish and keeps a separate handle for each; Instagram captions end in hashtags because a link there is not clickable. Also fixes three problems in the existing X path: - A QR code already past several thresholds produced one prompt per threshold, and since the post quotes the current scan count, every one of them would have published the same number. Only the highest threshold is announced now. - Detection ran after every unique scan and re-read the QR code's full scan history just to hit skipDuplicates. Known milestones are filtered first. - A failed post stayed failed forever because the consent dialog only opens once. The queue now retries three times on its own, spaces first attempts by SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per channel with restart and revoke. The worker no longer renders the card itself; it downloads the image the app renders at /s/m/<token>/og, which also serves the new square and portrait formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and SOCIAL_WORKER_CHANNELS both name it. Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before deploying this version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
553 lines
22 KiB
TypeScript
553 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
import { useCsrf } from '@/hooks/useCsrf';
|
|
import { showToast } from '@/components/ui/Toast';
|
|
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
|
|
|
|
type TabType = 'profile' | 'subscription';
|
|
|
|
type MilestonePost = {
|
|
channel: string;
|
|
status: string;
|
|
postUrl: string | null;
|
|
error: string | null;
|
|
postedAt: string | null;
|
|
};
|
|
|
|
type MilestoneHistoryItem = {
|
|
id: string;
|
|
qrTitle: string;
|
|
uniqueScans: number;
|
|
detectedAt: string;
|
|
promptStatus: string;
|
|
selfSharedAt: string | null;
|
|
shareUrl: string | null;
|
|
posts: MilestonePost[];
|
|
};
|
|
|
|
const CHANNEL_LABELS: Record<string, string> = { x: 'X', instagram: 'Instagram' };
|
|
|
|
function milestoneStateLabel(milestone: MilestoneHistoryItem) {
|
|
if (milestone.posts.some(post => post.status !== 'revoked')) return 'Approved for publishing';
|
|
if (milestone.selfSharedAt) return 'Shared by you';
|
|
if (milestone.promptStatus === 'declined') return 'Declined';
|
|
return 'Waiting for your decision';
|
|
}
|
|
|
|
function postStateLabel(post: MilestonePost) {
|
|
if (post.status === 'posted') return 'published';
|
|
if (post.status === 'failed') return 'publishing failed';
|
|
if (post.status === 'revoked') return 'revoked before publishing';
|
|
return 'queued';
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
const { fetchWithCsrf } = useCsrf();
|
|
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
|
const [loading, setLoading] = useState(false);
|
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
|
const [socialPromptsEnabled, setSocialPromptsEnabled] = useState(true);
|
|
const [socialTestResetAvailable, setSocialTestResetAvailable] = useState(false);
|
|
const [socialSaving, setSocialSaving] = useState(false);
|
|
const [milestones, setMilestones] = useState<MilestoneHistoryItem[]>([]);
|
|
const [milestoneBusy, setMilestoneBusy] = useState<string | null>(null);
|
|
|
|
// Profile states
|
|
const [name, setName] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
|
|
// Subscription states
|
|
const [plan, setPlan] = useState('FREE');
|
|
const [usageStats, setUsageStats] = useState({
|
|
dynamicUsed: 0,
|
|
dynamicLimit: 3,
|
|
staticUsed: 0,
|
|
});
|
|
|
|
// Load user data
|
|
useEffect(() => {
|
|
const fetchUserData = async () => {
|
|
try {
|
|
// Load from localStorage
|
|
const userStr = localStorage.getItem('user');
|
|
if (userStr) {
|
|
const user = JSON.parse(userStr);
|
|
setName(user.name || '');
|
|
setEmail(user.email || '');
|
|
}
|
|
|
|
// Fetch plan from API
|
|
const planResponse = await fetch('/api/user/plan');
|
|
if (planResponse.ok) {
|
|
const data = await planResponse.json();
|
|
setPlan(data.plan || 'FREE');
|
|
}
|
|
|
|
// Fetch usage stats from API
|
|
const statsResponse = await fetch('/api/user/stats');
|
|
if (statsResponse.ok) {
|
|
const data = await statsResponse.json();
|
|
setUsageStats(data);
|
|
}
|
|
|
|
const socialResponse = await fetch('/api/social-milestones/preferences');
|
|
if (socialResponse.ok) {
|
|
const data = await socialResponse.json();
|
|
setSocialPromptsEnabled(data.promptsEnabled !== false);
|
|
setSocialTestResetAvailable(data.testResetAvailable === true);
|
|
}
|
|
|
|
const historyResponse = await fetch('/api/social-milestones/history');
|
|
if (historyResponse.ok) {
|
|
const data = await historyResponse.json();
|
|
setMilestones(Array.isArray(data.milestones) ? data.milestones : []);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load user data:', e);
|
|
}
|
|
};
|
|
|
|
fetchUserData();
|
|
}, []);
|
|
|
|
const handleSaveProfile = async () => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
// Save to backend API
|
|
const response = await fetchWithCsrf('/api/user/profile', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to update profile');
|
|
}
|
|
|
|
// Update user data in localStorage
|
|
const userStr = localStorage.getItem('user');
|
|
if (userStr) {
|
|
const user = JSON.parse(userStr);
|
|
user.name = name;
|
|
localStorage.setItem('user', JSON.stringify(user));
|
|
}
|
|
|
|
showToast('Profile updated successfully!', 'success');
|
|
} catch (error: any) {
|
|
console.error('Error saving profile:', error);
|
|
showToast(error.message || 'Failed to update profile', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const updateSocialPrompts = async (action: 'enable' | 'disable' | 'reset_test') => {
|
|
setSocialSaving(true);
|
|
try {
|
|
const response = await fetchWithCsrf('/api/social-milestones/preferences', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ action }),
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || 'Could not update milestone prompts');
|
|
setSocialPromptsEnabled(data.promptsEnabled !== false);
|
|
showToast(action === 'reset_test' ? 'Milestone test reset. Open the dashboard to test it again.' : 'Milestone preference updated.', 'success');
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : 'Could not update milestone prompts', 'error');
|
|
} finally {
|
|
setSocialSaving(false);
|
|
}
|
|
};
|
|
|
|
const updateMilestone = async (id: string, action: 'retry' | 'revoke', channel: string) => {
|
|
setMilestoneBusy(`${id}:${channel}`);
|
|
try {
|
|
const response = await fetchWithCsrf(`/api/social-milestones/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ action, channel }),
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || 'Could not update this milestone');
|
|
setMilestones(current => current.map(milestone => milestone.id === id ? {
|
|
...milestone,
|
|
posts: milestone.posts.map(post => {
|
|
const next = (data.milestone?.posts || []).find((entry: MilestonePost) => entry.channel === post.channel);
|
|
return next ? { ...post, status: next.status, postUrl: next.postUrl, error: next.error } : post;
|
|
}),
|
|
} : milestone));
|
|
showToast(action === 'retry' ? 'Post queued again.' : 'Post revoked. Nothing will be published.', 'success');
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : 'Could not update this milestone', 'error');
|
|
} finally {
|
|
setMilestoneBusy(null);
|
|
}
|
|
};
|
|
|
|
const handleManageSubscription = async () => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
const response = await fetchWithCsrf('/api/stripe/portal', {
|
|
method: 'POST',
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to open subscription management');
|
|
}
|
|
|
|
// Redirect to Stripe Customer Portal
|
|
window.location.href = data.url;
|
|
} catch (error: any) {
|
|
console.error('Error opening portal:', error);
|
|
showToast(error.message || 'Failed to open subscription management', 'error');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAccount = async () => {
|
|
const confirmed = window.confirm(
|
|
'Are you sure you want to delete your account? This will permanently delete all your data, including all QR codes and analytics. This action cannot be undone.'
|
|
);
|
|
|
|
if (!confirmed) return;
|
|
|
|
// Double confirmation for safety
|
|
const doubleConfirmed = window.confirm(
|
|
'This is your last warning. Are you absolutely sure you want to permanently delete your account?'
|
|
);
|
|
|
|
if (!doubleConfirmed) return;
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const response = await fetchWithCsrf('/api/user/delete', {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to delete account');
|
|
}
|
|
|
|
// Clear local storage and redirect to login
|
|
localStorage.clear();
|
|
showToast('Account deleted successfully', 'success');
|
|
|
|
// Redirect to home page after a short delay
|
|
setTimeout(() => {
|
|
window.location.href = '/';
|
|
}, 1500);
|
|
} catch (error: any) {
|
|
console.error('Error deleting account:', error);
|
|
showToast(error.message || 'Failed to delete account', 'error');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getPlanLimits = () => {
|
|
switch (plan) {
|
|
case 'PRO':
|
|
return { dynamic: 50, price: '€9', period: 'per month' };
|
|
case 'BUSINESS':
|
|
return { dynamic: 500, price: '€29', period: 'per month' };
|
|
case 'ENTERPRISE':
|
|
return { dynamic: 99999, price: 'Custom', period: 'per month' };
|
|
default:
|
|
return { dynamic: 3, price: '€0', period: 'forever' };
|
|
}
|
|
};
|
|
|
|
const planLimits = getPlanLimits();
|
|
const usagePercentage = (usageStats.dynamicUsed / usageStats.dynamicLimit) * 100;
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto">
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
|
|
<p className="text-gray-600 mt-2">Manage your account settings and preferences</p>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-gray-200 mb-6">
|
|
<nav className="-mb-px flex space-x-8">
|
|
<button
|
|
onClick={() => setActiveTab('profile')}
|
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === 'profile'
|
|
? 'border-primary-500 text-primary-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
Profile
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('subscription')}
|
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === 'subscription'
|
|
? 'border-primary-500 text-primary-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
Subscription
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{activeTab === 'profile' && (
|
|
<div className="space-y-6">
|
|
{/* Profile Information */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Profile Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
placeholder="Enter your name"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Email
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
disabled
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Email cannot be changed
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Milestone sharing</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="max-w-xl">
|
|
<h3 className="text-sm font-medium text-gray-900">Show scan milestone prompts</h3>
|
|
<p className="mt-1 text-sm text-gray-500">Choose whether QR Master may ask you to share verified scan achievements. Nothing is published without your confirmation.</p>
|
|
</div>
|
|
<Button variant="outline" disabled={socialSaving} onClick={() => updateSocialPrompts(socialPromptsEnabled ? 'disable' : 'enable')}>
|
|
{socialPromptsEnabled ? 'Turn off' : 'Turn on'}
|
|
</Button>
|
|
</div>
|
|
{milestones.length > 0 && <div className="border-t border-gray-100 pt-4">
|
|
<h3 className="text-sm font-medium text-gray-900">Your milestones</h3>
|
|
<p className="mt-1 text-sm text-gray-500">Every scan milestone we detected and what happened to it.</p>
|
|
<ul className="mt-3 divide-y divide-gray-100">
|
|
{milestones.map(milestone => (
|
|
<li key={milestone.id} className="py-3">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium text-gray-900">{milestone.qrTitle}</p>
|
|
<p className="mt-0.5 text-xs text-gray-500">
|
|
{milestone.uniqueScans.toLocaleString('en-US')} unique scans · {new Date(milestone.detectedAt).toLocaleDateString('en-US')} · {milestoneStateLabel(milestone)}
|
|
</p>
|
|
</div>
|
|
{milestone.shareUrl && (
|
|
<a href={milestone.shareUrl} target="_blank" rel="noreferrer" className="shrink-0 text-sm font-medium text-blue-600 hover:underline">Open card</a>
|
|
)}
|
|
</div>
|
|
{/* One line per channel: consent, and everything that can be
|
|
withdrawn or restarted, is per channel. */}
|
|
{milestone.posts.map(post => (
|
|
<div key={post.channel} className="mt-2 flex flex-col gap-2 rounded-md bg-gray-50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="min-w-0">
|
|
<p className="text-xs text-gray-600">
|
|
<span className="font-medium text-gray-900">{CHANNEL_LABELS[post.channel] || post.channel}</span> — {postStateLabel(post)}
|
|
</p>
|
|
{post.status === 'failed' && post.error && (
|
|
<p className="mt-0.5 text-xs text-rose-600">{post.error}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
|
{post.postUrl && (
|
|
<a href={post.postUrl} target="_blank" rel="noreferrer" className="text-sm font-medium text-blue-600 hover:underline">View post</a>
|
|
)}
|
|
{post.status === 'failed' && (
|
|
<Button variant="outline" disabled={milestoneBusy === `${milestone.id}:${post.channel}`} onClick={() => updateMilestone(milestone.id, 'retry', post.channel)}>Try again</Button>
|
|
)}
|
|
{['approved', 'failed'].includes(post.status) && (
|
|
<Button variant="outline" disabled={milestoneBusy === `${milestone.id}:${post.channel}`} onClick={() => updateMilestone(milestone.id, 'revoke', post.channel)}>Cancel</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>}
|
|
{socialTestResetAvailable && <div className="border-t border-gray-100 pt-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-sm text-gray-500">Test environment: reopen the latest milestone and clear its publishing state.</p>
|
|
<Button variant="outline" disabled={socialSaving} onClick={() => updateSocialPrompts('reset_test')}>Reset milestone test</Button>
|
|
</div>
|
|
</div>}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Security */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Security</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-900">Password</h3>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Update your password to keep your account secure
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowPasswordModal(true)}
|
|
>
|
|
Change Password
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Account Deletion */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-red-600">Delete Account</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-900">Delete your account</h3>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Permanently delete your account and all data. This action cannot be undone.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
className="border-red-600 text-red-600 hover:bg-red-50"
|
|
onClick={handleDeleteAccount}
|
|
>
|
|
Delete Account
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Save Button */}
|
|
<div className="flex justify-end">
|
|
<Button
|
|
onClick={handleSaveProfile}
|
|
disabled={loading}
|
|
size="lg"
|
|
variant="primary"
|
|
>
|
|
{loading ? 'Saving...' : 'Save Changes'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'subscription' && (
|
|
<div className="space-y-6">
|
|
{/* Current Plan */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Current Plan</CardTitle>
|
|
<Badge variant={plan === 'FREE' ? 'default' : plan === 'PRO' ? 'info' : 'warning'}>
|
|
{plan}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-baseline">
|
|
<span className="text-4xl font-bold">{planLimits.price}</span>
|
|
<span className="text-gray-600 ml-2">{planLimits.period}</span>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-gray-600">Dynamic QR Codes</span>
|
|
<span className="font-medium">
|
|
{usageStats.dynamicUsed} of {usageStats.dynamicLimit} used
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className="bg-primary-600 h-2 rounded-full transition-all"
|
|
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-gray-600">Static QR Codes</span>
|
|
<span className="font-medium">Unlimited ∞</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
<div className="bg-success-600 h-2 rounded-full" style={{ width: '100%' }} />
|
|
</div>
|
|
</div>
|
|
|
|
{plan !== 'FREE' && (
|
|
<div className="pt-4 border-t">
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => window.location.href = '/upgrade?from=/settings'}
|
|
>
|
|
Manage Subscription
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{plan === 'FREE' && (
|
|
<div className="pt-4 border-t">
|
|
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/upgrade?reason=limit&from=/settings'}>
|
|
Upgrade Plan
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Change Password Modal */}
|
|
<ChangePasswordModal
|
|
isOpen={showPasswordModal}
|
|
onClose={() => setShowPasswordModal(false)}
|
|
onSuccess={() => {
|
|
setShowPasswordModal(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|