add new GDPR page and handle account creation email

This commit is contained in:
JCEEE
2026-09-08 15:20:34 +01:00
parent 9117c0ec70
commit afc463a0f0
8 changed files with 406 additions and 14 deletions
+29 -4
View File
@@ -11,11 +11,18 @@
<div class="footer-inner px-8 py-16" class:sidebar> <div class="footer-inner px-8 py-16" class:sidebar>
<div class="footer-row flex items-center justify-between gap-2"> <div class="footer-row flex items-center justify-between gap-2">
<span class="footer-star">✦</span> <span class="footer-star">✦</span>
<span class="footer-copy" <div class="footer-center">
>&copy; {new Date().getFullYear()} <span class="footer-copy"
<span class="footer-text font-bold text-xl"><FamDone /></span> All rights reserved.</span >&copy; {new Date().getFullYear()}
<span class="footer-text font-bold text-xl"><FamDone /></span> All rights reserved.</span
>
<span class="footer-links">
<a href="/privacy">Privacy Policy</a>
</span>
</div>
<aside
class="footer-aside text-text-200 text-wash-300 flex flex-col justify-end text-right text-sm"
> >
<aside class="footer-aside text-text-200 text-wash-300 flex flex-col justify-end text-right text-sm">
<span>Built by humans</span> <span>Built by humans</span>
<a href="https://threejjjs.xyz"> <a href="https://threejjjs.xyz">
<h5 class="m-0 text-right font-tertiary text-xl md:text-3xl"> <h5 class="m-0 text-right font-tertiary text-xl md:text-3xl">
@@ -53,6 +60,24 @@
text-align: center; text-align: center;
padding: 0 1rem; padding: 0 1rem;
} }
.footer-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
text-align: center;
}
.footer-links {
font-size: 0.8rem;
}
.footer-links a {
color: #4c1d95;
text-decoration: none;
}
.footer-links a:hover {
text-decoration: underline;
}
.footer-row { .footer-row {
position: relative; position: relative;
} }
@@ -0,0 +1,46 @@
// Code-specific email templates for successful access-code redemption.
// Key = the raw access-code value a family enters (e.g. "copppermill03").
// Add an entry here and its custom email is sent when that code is used.
// If no entry matches, `defaultAccessEmailTemplate` is used.
export interface AccessEmailVars {
famName: string;
codeName: string;
dashboardUrl: string;
}
export interface AccessEmailTemplate {
subject: (vars: AccessEmailVars) => string;
html: (vars: AccessEmailVars) => string;
}
const DEFAULT_TEMPLATE: AccessEmailTemplate = {
subject: ({ famName }) => `Your ${famName} access is unlocked`,
html: ({ famName, codeName, dashboardUrl }) => `
<p>Hi${famName ? ` ${famName}` : ' there'}! 🎉</p>
<p>Your access code (<strong>${codeName}</strong>) has been applied successfully.</p>
<p>Your <strong>${famName}</strong> family on <strong>FamDone</strong> is now unlocked.</p>
<p><a href="${dashboardUrl}">Open your family dashboard</a> to add kids, set up chores, and get going.</p>
<p>If you have any questions, just reply to this email.</p>
<p>Talk soon,<br />The FamDone team</p>
`
};
export const accessEmailTemplates: Record<string, AccessEmailTemplate> = {
copppermill03: {
subject: ({ famName }) => `Save a spot for ${famName} — access unlocked`,
html: ({ famName, codeName, dashboardUrl }) => `
<p>Hi${famName ? ` ${famName}` : ' there'}! 🎉</p>
<p>Your access code (<strong>${codeName}</strong>) has been applied successfully.</p>
<p>Your <strong>${famName}</strong> family on <strong>FamDone</strong> is now unlocked.</p>
<p><a href="${dashboardUrl}">Open your family dashboard</a> to add kids, set up chores, and get going.</p>
<p>If you have any questions, just reply to this email.</p>
<p>Talk soon,<br />The FamDone team</p>
`
}
};
// Resolve the template for a redeemed code, falling back to the default.
export function getAccessEmailTemplate(codeValue: string): AccessEmailTemplate {
return accessEmailTemplates[codeValue] || DEFAULT_TEMPLATE;
}
+51
View File
@@ -1,5 +1,6 @@
import { Resend } from 'resend'; import { Resend } from 'resend';
import { RESEND_API } from '$app/env/private'; import { RESEND_API } from '$app/env/private';
import { getAccessEmailTemplate } from './email-templates';
const resend = new Resend(String(RESEND_API)); const resend = new Resend(String(RESEND_API));
@@ -43,3 +44,53 @@ export async function sendParentInviteEmail(opts: {
` `
}); });
} }
export async function sendAccessUnlockedEmail(opts: {
to: string;
famName: string;
codeName: string;
codeValue: string;
dashboardUrl: string;
}) {
const { to, famName, codeName, codeValue, dashboardUrl } = opts;
const template = getAccessEmailTemplate(codeValue);
const vars = { famName, codeName, dashboardUrl };
await resend.emails.send({
from: 'no-reply@famchamp.ai',
to,
subject: template.subject(vars),
html: template.html(vars)
});
}
export async function sendWelcomeEmail(opts: {
to: string;
famName: string;
dashboardUrl: string;
}) {
const { to, famName, dashboardUrl } = opts;
await resend.emails.send({
from: 'no-reply@famchamp.ai',
to,
subject: `Welcome to FamDone${famName ? `, ${famName}` : ''}!`,
html: `
<p>Hi${famName ? ` ${famName}` : ' there'}! 👋</p>
<p>Welcome to <strong>FamDone</strong> — your family chore and pocket-money app.</p>
<p>Your family is all set up. Here's how to get going:</p>
<ul>
<li>Open your <a href="${dashboardUrl}">family dashboard</a>.</li>
<li>Add your kids and create join codes from <strong>Family Settings</strong>.</li>
<li>Set up chores, rewards, and allowances.</li>
</ul>
<p>
If you haven't already, you can pick a plan and pay from your dashboard to
unlock your family.
</p>
<p>If you have any questions, just reply to this email — we're happy to help.</p>
<p>Talk soon,<br />The FamDone team</p>
`
});
}
@@ -141,10 +141,6 @@
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 }); qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
} }
function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
}
function handleInviteResult(result: any) { function handleInviteResult(result: any) {
if (result.type === 'success') { if (result.type === 'success') {
const d = result.data as { ok?: boolean; error?: string } | undefined; const d = result.data as { ok?: boolean; error?: string } | undefined;
+20 -5
View File
@@ -34,11 +34,7 @@
} }
</script> </script>
<AuthShell <AuthShell title="Log in" subtitle="Welcome back to FamDone." secondary={secondaryChild}>
title="Log in"
subtitle="Welcome back to FamDone."
secondary={secondaryChild}
>
{#if showForgot} {#if showForgot}
{#if forgotSuccess} {#if forgotSuccess}
<p class="form-ok"> <p class="form-ok">
@@ -93,6 +89,10 @@
Forgot password? Forgot password?
</button> </button>
<button type="submit">Log in</button> <button type="submit">Log in</button>
<p class="privacy-note">
We use a cookie to keep you signed in and collect only what we need to run your account. See
our <a href="/privacy">Privacy Policy</a>.
</p>
</form> </form>
<p class="alt"> <p class="alt">
@@ -194,6 +194,21 @@
color: #6b7280; color: #6b7280;
text-align: center; text-align: center;
} }
.privacy-note {
margin: 0.25rem 0 0;
font-size: 0.78rem;
color: #6b7280;
line-height: 1.5;
text-align: center;
}
.privacy-note a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
.privacy-note a:hover {
text-decoration: underline;
}
.alt a { .alt a {
color: #4338ca; color: #4338ca;
text-decoration: none; text-decoration: none;
+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
import Footer from '$lib/components/Footer.svelte';
import FamDone from '$lib/components/FamDone.svelte';
</script>
<svelte:head>
<title>Privacy Policy — FamDone</title>
<meta
name="description"
content="How FamDone collects, uses, and protects your personal data — in plain, GDPR-friendly language."
/>
</svelte:head>
<main class="privacy">
<a class="brand" href="/"><FamDone /></a>
<div class="card">
<h1>Privacy Policy</h1>
<p class="updated">Last updated: September 2026</p>
<section>
<h2>1. Who we are (the controller)</h2>
<p>
<FamDone /> is provided by <strong>threejjjs / Interactive Tech</strong>. We are the "data
controller" for the personal data you give us. If you have any questions about this policy
or your data, you can reach us at
<a href="mailto:privacy@threejjjs.xyz">privacy@threejjjs.xyz</a>.
</p>
</section>
<section>
<h2>2. What data we collect</h2>
<p>We keep things minimal. When you create an account we collect:</p>
<ul>
<li><strong>Email address</strong> — used only as your login identity.</li>
<li>
<strong>Usernames and family/chore names</strong> — so your account and your family's chores
work.
</li>
</ul>
<p>
We do <strong>not</strong> collect any sensitive data (race, health, religion, biometrics, etc.),
and we do not use your data for profiling, advertising, or predictions about you.
</p>
</section>
<section>
<h2>3. Why we collect it (the purpose &amp; legal basis)</h2>
<p>
We process your data solely to provide the service you asked for: to create and maintain
your account, and to authenticate you when you log in. The legal basis for this is <strong
>contract necessity</strong
> (Article 6(1)(b) GDPR) — we process the data needed to run the account you signed up for.
</p>
</section>
<section>
<h2>4. How long we keep it</h2>
<p>
We keep your data for as long as your account is active. If you delete your account, (see
"Your rights" below) we delete or anonymise your personal data in a reasonable timeframe,
unless we're legally required to keep it longer.
</p>
</section>
<section>
<h2>5. Do we share or sell your data?</h2>
<p>
<strong>No.</strong> We do not sell, rent, or trade your personal data, and we do not share it
with third parties for their own marketing purposes. We only share the data that's strictly necessary
to run the service (for example, processing a payment you make), and we use your email only to
sign you in.
</p>
</section>
<section>
<h2>6. Cookies and login</h2>
<p>
We use a strictly-necessary session/login cookie so you can stay signed in. This is
essential for the service to work and does not require separate consent. We do not use
cookies for advertising, analytics, or marketing pixels.
</p>
</section>
<section>
<h2>7. Your rights</h2>
<p>Under the GDPR you have the right to:</p>
<ul>
<li><strong>Access</strong> the personal data we hold about you.</li>
<li><strong>Rectify</strong> inaccurate data.</li>
<li>Request <strong>deletion</strong> ("right to be forgotten") of your data.</li>
<li><strong>Portability</strong> — receive your data in a machine-readable format.</li>
<li>Withdraw consent or object to processing where it applies.</li>
</ul>
<p>
To exercise any of these rights, email
<a href="mailto:privacy@threejjjs.xyz">privacy@threejjjs.xyz</a>.
</p>
</section>
<section>
<h2>8. International transfers</h2>
<p>
We store your data on our secure hosting providers. If your data is ever transferred outside
the European Economic Area (EEA), we rely on recognised safeguards (such as the relevant
adequacy decisions or the EU's Standard Contractual Clauses) to protect it.
</p>
</section>
<section>
<h2>9. Complaints</h2>
<p>
If you believe we've handled your data incorrectly, please contact us first at
<a href="mailto:privacy@threejjjs.xyz">privacy@threejjjs.xyz</a>. You also have the right to
lodge a complaint with your local supervisory authority at any time.
</p>
</section>
<section>
<h2>10. Children's data</h2>
<p>
A family can add child members under a parent's account. We only keep the child's chosen
username so their chores and progress work. As a parent you control this data and can change
or delete it from your family settings or by contacting us.
</p>
</section>
<section>
<h2>11. Contact</h2>
<p>
Questions about this policy or your data? Email
<a href="mailto:privacy@threejjjs.xyz">privacy@threejjjs.xyz</a>.
</p>
</section>
</div>
<Footer />
</main>
<style>
main {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem 0;
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
font-family:
system-ui,
-apple-system,
sans-serif;
color: #1f2937;
}
.brand {
font-size: 1.15rem;
font-weight: 700;
color: #4338ca;
text-decoration: none;
margin-bottom: 1.5rem;
}
.card {
width: 100%;
max-width: 720px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 16px;
box-shadow: 0 12px 30px rgba(67, 56, 202, 0.08);
padding: 2.5rem;
}
.card h1 {
margin: 0 0 0.25rem;
font-size: 1.75rem;
}
.updated {
margin: 0 0 2rem;
color: #6b7280;
font-size: 0.85rem;
}
.card section {
margin: 0 0 1.75rem;
}
.card section h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
color: #111827;
}
.card p {
margin: 0 0 0.6rem;
line-height: 1.6;
color: #374151;
font-size: 0.95rem;
}
.card ul {
margin: 0 0 0.6rem;
padding-left: 1.25rem;
line-height: 1.6;
color: #374151;
font-size: 0.95rem;
}
.card li {
margin-bottom: 0.35rem;
}
.card a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
.card a:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.card {
padding: 1.5rem;
}
}
</style>
+27 -1
View File
@@ -6,6 +6,7 @@ import { setSessionCookie } from '$lib/server/session';
import { issueAccess } from '$lib/server/member-otp'; import { issueAccess } from '$lib/server/member-otp';
import { slugify, handle, famUsername, handleOf } from '@shared/slugify'; import { slugify, handle, famUsername, handleOf } from '@shared/slugify';
import { applyAccessCode } from '$lib/server/access'; import { applyAccessCode } from '$lib/server/access';
import { sendWelcomeEmail, sendAccessUnlockedEmail } from '$lib/server/email';
import { createEmbeddedCheckoutSession, PLAN_IDS } from '$lib/server/stripe'; import { createEmbeddedCheckoutSession, PLAN_IDS } from '$lib/server/stripe';
import type { PlanId } from '$lib/server/stripe'; import type { PlanId } from '$lib/server/stripe';
@@ -70,6 +71,10 @@ export const actions = {
.catch(() => null); .catch(() => null);
if (authResult?.token) setSessionCookie(event.cookies, authResult.token); if (authResult?.token) setSessionCookie(event.cookies, authResult.token);
// Welcome email — a best-effort send. Failures must not block signup.
const dashboardUrl = `${event.url.origin}/${slug}`;
sendWelcomeEmail({ to: email, famName, dashboardUrl }).catch(() => {});
return { success: true, famSlug: slug, username: handleName }; return { success: true, famSlug: slug, username: handleName };
}, },
@@ -111,6 +116,27 @@ export const actions = {
const result = await applyAccessCode(user.famId, code); const result = await applyAccessCode(user.famId, code);
if (result.error) return fail(400, { error: result.error }); if (result.error) return fail(400, { error: result.error });
// Access code applied successfully — confirm by email (best-effort).
// Failure must never block the user continuing to the plan step.
if (result.ok) {
const fam = await pbAdmin
.getOne('fams', user.famId)
.catch(() => ({ name: '', slug: user.famId }));
const parents = await pbAdmin
.getList('users', `famId = '${user.famId}' && role = 'parent'`)
.catch(() => []);
const parentEmail = (parents[0] as { email?: string } | undefined | null)?.email;
if (parentEmail) {
sendAccessUnlockedEmail({
to: parentEmail,
famName: fam.name || '',
codeName: result.code?.name || code,
codeValue: code,
dashboardUrl: `${event.url.origin}/${fam.slug || user.famId}`
}).catch(() => {});
}
}
return { ok: true, ...result }; return { ok: true, ...result };
}, },
@@ -148,4 +174,4 @@ export const actions = {
function requireUser(event: RequestEvent) { function requireUser(event: RequestEvent) {
if (!event.locals.user) throw redirect(303, '/signup'); if (!event.locals.user) throw redirect(303, '/signup');
return event.locals.user; return event.locals.user;
} }
+19
View File
@@ -189,6 +189,10 @@
/> />
</label> </label>
<button type="submit" disabled={submitting}>Create my family</button> <button type="submit" disabled={submitting}>Create my family</button>
<p class="privacy-note">
By signing up you agree to our <a href="/privacy">Privacy Policy</a>. We collect your email
only to create and log into your account, never sell or share your data.
</p>
</form> </form>
<p class="alt">Already have a family? <a href="/login">Log in</a></p> <p class="alt">Already have a family? <a href="/login">Log in</a></p>
{:else if step === 'child'} {:else if step === 'child'}
@@ -381,6 +385,21 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #059669; color: #059669;
} }
.privacy-note {
margin: 0.25rem 0 0;
font-size: 0.78rem;
color: #6b7280;
line-height: 1.5;
text-align: center;
}
.privacy-note a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
.privacy-note a:hover {
text-decoration: underline;
}
.actions { .actions {
margin-top: 1.25rem; margin-top: 1.25rem;
} }