diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 22fbe13..0ff5ab6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(grep:*)", "Bash(npm run build:*)", - "mcp__ide__getDiagnostics" + "mcp__ide__getDiagnostics", + "Bash(gh pr checks:*)" ], "deny": [], "ask": [] diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2d215be..59111c4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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": { diff --git a/frontend/messages/sv.json b/frontend/messages/sv.json index 355444d..0aef288 100644 --- a/frontend/messages/sv.json +++ b/frontend/messages/sv.json @@ -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": { diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/frontend/package.json b/frontend/package.json index 58f0f66..4f887b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/admin/beta-invites/page.tsx b/frontend/src/app/admin/beta-invites/page.tsx index e366e95..70b0da8 100644 --- a/frontend/src/app/admin/beta-invites/page.tsx +++ b/frontend/src/app/admin/beta-invites/page.tsx @@ -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(); @@ -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(); }, []); @@ -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 (
@@ -111,12 +207,26 @@ export default function BetaInvitesPage() { {t('subtitle')}

- +
+ + +
{/* Success/Error Messages */} @@ -243,6 +353,102 @@ export default function BetaInvitesPage() { )} + {/* Marketing Email Form */} + {showMarketingForm && ( +
+

+ {t('marketingEmail.title')} +

+
+
+ + 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')} + /> +
+ +
+ + +

+ {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`} +

+
+ +
+ +