Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"allow": [
"Bash(grep:*)",
"Bash(npm run build:*)",
"mcp__ide__getDiagnostics"
"mcp__ide__getDiagnostics",
"Bash(gh pr checks:*)"
],
"deny": [],
"ask": []
Expand Down
22 changes: 21 additions & 1 deletion frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,27 @@
"errorLoad": "Failed to load beta invites. Please try again.",
"confirmDelete": "Are you sure you want to delete this beta invite?",
"confirmApprove": "Are you sure you want to approve this beta invite? The user will be able to sign up.",
"mustBeLoggedIn": "You must be logged in to approve beta invites."
"mustBeLoggedIn": "You must be logged in to approve beta invites.",
"marketingEmail": {
"title": "Send Marketing Email",
"toggleButton": "📧 Send Marketing Email",
"subjectLabel": "Email Subject",
"subjectPlaceholder": "Enter email subject...",
"htmlLabel": "Email Content (HTML)",
"htmlPlaceholder": "Enter email HTML content...",
"recipientFilterLabel": "Send To",
"recipientFilterAll": "All Beta Users",
"recipientFilterApproved": "All Approved Users",
"recipientFilterApprovedNotUsed": "Approved (Not Signed Up Yet)",
"recipientFilterUsed": "Users Who Signed Up",
"recipientFilterPending": "Pending Approval",
"sendButton": "Send Email",
"sending": "Sending...",
"successSent": "Email sent successfully to {count} recipients!",
"errorSend": "Failed to send email. Please try again.",
"confirmSend": "Are you sure you want to send this email to {count} recipients?",
"noRecipientsError": "No recipients found for the selected filter."
}
}
},
"vatternrundan": {
Expand Down
22 changes: 21 additions & 1 deletion frontend/messages/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,27 @@
"errorLoad": "Kunde inte ladda beta-inbjudningar. Försök igen.",
"confirmDelete": "Är du säker på att du vill ta bort denna beta-inbjudan?",
"confirmApprove": "Är du säker på att du vill godkänna denna beta-inbjudan? Användaren kommer att kunna registrera sig.",
"mustBeLoggedIn": "Du måste vara inloggad för att godkänna beta-inbjudningar."
"mustBeLoggedIn": "Du måste vara inloggad för att godkänna beta-inbjudningar.",
"marketingEmail": {
"title": "Skicka marknadsförings-e-post",
"toggleButton": "📧 Skicka marknadsförings-e-post",
"subjectLabel": "E-postämne",
"subjectPlaceholder": "Ange e-postämne...",
"htmlLabel": "E-postinnehåll (HTML)",
"htmlPlaceholder": "Ange HTML-innehåll för e-post...",
"recipientFilterLabel": "Skicka till",
"recipientFilterAll": "Alla beta-användare",
"recipientFilterApproved": "Alla godkända användare",
"recipientFilterApprovedNotUsed": "Godkända (inte registrerat sig än)",
"recipientFilterUsed": "Användare som registrerat sig",
"recipientFilterPending": "Väntar på godkännande",
"sendButton": "Skicka e-post",
"sending": "Skickar...",
"successSent": "E-post skickades till {count} mottagare!",
"errorSend": "Kunde inte skicka e-post. Försök igen.",
"confirmSend": "Är du säker på att du vill skicka detta e-postmeddelande till {count} mottagare?",
"noRecipientsError": "Inga mottagare hittades för det valda filtret."
}
}
},
"vatternrundan": {
Expand Down
2 changes: 1 addition & 1 deletion frontend/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"dependencies": {
"@heroicons/react": "^2.2.0",
"@marsidev/react-turnstile": "^1.3.1",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.39.0",
"isomorphic-dompurify": "^2.35.0",
"leaflet": "^1.9.4",
"next": "^16.0.5",
"next-intl": "^4.5.6",
Expand Down
218 changes: 212 additions & 6 deletions frontend/src/app/admin/beta-invites/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@/lib/admin';
import { useAuth } from '@/components/AuthProvider';
import { formatDateTime } from '@/lib/dateFormat';
import { supabase } from '@/lib/supabase';

export default function BetaInvitesPage() {
const { user } = useAuth();
Expand All @@ -27,6 +28,13 @@ export default function BetaInvitesPage() {
const [invitedBy, setInvitedBy] = useState('');
const [notes, setNotes] = useState('');

// Marketing email state
const [showMarketingForm, setShowMarketingForm] = useState(false);
const [emailSubject, setEmailSubject] = useState('');
const [emailHtml, setEmailHtml] = useState('');
const [recipientFilter, setRecipientFilter] = useState('all');
const [sendingEmail, setSendingEmail] = useState(false);

useEffect(() => {
loadInvites();
}, []);
Expand Down Expand Up @@ -102,6 +110,94 @@ export default function BetaInvitesPage() {
}
}

async function handleSendMarketingEmail(e: React.FormEvent) {
e.preventDefault();

if (!user?.id) {
setError(t('mustBeLoggedIn'));
return;
}

if (!emailSubject || !emailHtml) {
setError(t('marketingEmail.errorSend'));
return;
}

// Calculate recipient count for confirmation
let recipientCount = 0;
switch (recipientFilter) {
case 'all':
recipientCount = invites.length;
break;
case 'approved':
recipientCount = invites.filter((i) => i.approved).length;
break;
case 'approved_not_used':
recipientCount = invites.filter((i) => i.approved && !i.used).length;
break;
case 'used':
recipientCount = invites.filter((i) => i.used).length;
break;
case 'pending':
recipientCount = invites.filter((i) => !i.approved && !i.used).length;
break;
}

if (recipientCount === 0) {
setError(t('marketingEmail.noRecipientsError'));
return;
}

const confirmMessage = t('marketingEmail.confirmSend', { count: recipientCount });
if (!confirm(confirmMessage)) {
return;
}

setSendingEmail(true);
setError(null);
setSuccess(null);

try {
// Get the current session token
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
setError('You must be logged in to send marketing emails');
setSendingEmail(false);
return;
}

const response = await fetch('/api/send-marketing-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({
subject: emailSubject,
html: emailHtml,
recipientFilter,
}),
});

const data = await response.json();

if (!response.ok) {
throw new Error(data.error || t('marketingEmail.errorSend'));
}

setSuccess(t('marketingEmail.successSent', { count: data.recipientCount }));
setEmailSubject('');
setEmailHtml('');
setRecipientFilter('all');
setShowMarketingForm(false);
} catch (err: any) {
setError(err.message || t('marketingEmail.errorSend'));
console.error('Error sending marketing email:', err);
} finally {
setSendingEmail(false);
}
}

return (
<div className="space-y-4 sm:space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
Expand All @@ -111,12 +207,26 @@ export default function BetaInvitesPage() {
{t('subtitle')}
</p>
</div>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-border-focus focus:ring-offset-2 transition-colors whitespace-nowrap"
>
{showAddForm ? t('cancel') : t('addInvite')}
</button>
<div className="flex flex-col sm:flex-row gap-2">
<button
onClick={() => {
setShowMarketingForm(!showMarketingForm);
setShowAddForm(false);
}}
className="px-4 py-2 bg-info text-white rounded-md hover:bg-info-hover focus:outline-none focus:ring-2 focus:ring-border-focus focus:ring-offset-2 transition-colors whitespace-nowrap"
>
{showMarketingForm ? t('cancel') : t('marketingEmail.toggleButton')}
</button>
<button
onClick={() => {
setShowAddForm(!showAddForm);
setShowMarketingForm(false);
}}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-border-focus focus:ring-offset-2 transition-colors whitespace-nowrap"
>
{showAddForm ? t('cancel') : t('addInvite')}
</button>
</div>
</div>

{/* Success/Error Messages */}
Expand Down Expand Up @@ -243,6 +353,102 @@ export default function BetaInvitesPage() {
</div>
)}

{/* Marketing Email Form */}
{showMarketingForm && (
<div className="bg-surface-background rounded-lg shadow-sm p-6 border border-border">
<h3 className="text-lg font-semibold text-text-primary mb-4">
{t('marketingEmail.title')}
</h3>
<form onSubmit={handleSendMarketingEmail} className="space-y-4">
<div>
<label
htmlFor="emailSubject"
className="block text-sm font-medium text-text-secondary mb-2"
>
{t('marketingEmail.subjectLabel')} *
</label>
<input
type="text"
id="emailSubject"
value={emailSubject}
onChange={(e) => setEmailSubject(e.target.value)}
required
className="w-full px-4 py-2 border border-border rounded-md focus:ring-2 focus:ring-border-focus focus:border-transparent text-text-primary bg-surface-background"
placeholder={t('marketingEmail.subjectPlaceholder')}
/>
</div>

<div>
<label
htmlFor="recipientFilter"
className="block text-sm font-medium text-text-secondary mb-2"
>
{t('marketingEmail.recipientFilterLabel')} *
</label>
<select
id="recipientFilter"
value={recipientFilter}
onChange={(e) => setRecipientFilter(e.target.value)}
className="w-full px-4 py-2 border border-border rounded-md focus:ring-2 focus:ring-border-focus focus:border-transparent text-text-primary bg-surface-background"
>
<option value="all">{t('marketingEmail.recipientFilterAll')}</option>
<option value="approved">{t('marketingEmail.recipientFilterApproved')}</option>
<option value="approved_not_used">{t('marketingEmail.recipientFilterApprovedNotUsed')}</option>
<option value="used">{t('marketingEmail.recipientFilterUsed')}</option>
<option value="pending">{t('marketingEmail.recipientFilterPending')}</option>
</select>
<p className="mt-1 text-xs text-text-muted">
{recipientFilter === 'all' && `${invites.length} recipients`}
{recipientFilter === 'approved' && `${invites.filter((i) => i.approved).length} recipients`}
{recipientFilter === 'approved_not_used' && `${invites.filter((i) => i.approved && !i.used).length} recipients`}
{recipientFilter === 'used' && `${invites.filter((i) => i.used).length} recipients`}
{recipientFilter === 'pending' && `${invites.filter((i) => !i.approved && !i.used).length} recipients`}
</p>
</div>

<div>
<label
htmlFor="emailHtml"
className="block text-sm font-medium text-text-secondary mb-2"
>
{t('marketingEmail.htmlLabel')} *
</label>
<textarea
id="emailHtml"
value={emailHtml}
onChange={(e) => setEmailHtml(e.target.value)}
required
rows={12}
className="w-full px-4 py-2 border border-border rounded-md focus:ring-2 focus:ring-border-focus focus:border-transparent text-text-primary bg-surface-background font-mono text-sm"
placeholder={t('marketingEmail.htmlPlaceholder')}
/>
</div>

<div className="flex flex-col sm:flex-row gap-3">
<button
type="submit"
disabled={sendingEmail}
className="w-full sm:w-auto px-4 py-2 bg-info text-white rounded-md hover:bg-info-hover focus:outline-none focus:ring-2 focus:ring-border-focus focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{sendingEmail ? t('marketingEmail.sending') : t('marketingEmail.sendButton')}
</button>
<button
type="button"
onClick={() => {
setShowMarketingForm(false);
setEmailSubject('');
setEmailHtml('');
setRecipientFilter('all');
}}
className="w-full sm:w-auto px-4 py-2 bg-surface-2 text-text-secondary rounded-md hover:bg-surface-3 focus:outline-none focus:ring-2 focus:ring-border-focus focus:ring-offset-2 transition-colors"
>
{t('cancel')}
</button>
</div>
</form>
</div>
)}

{/* Invites Table */}
<div className="bg-surface-background rounded-lg shadow-sm overflow-hidden border border-border">
{loading ? (
Expand Down
Loading