diff --git a/SPEC-invite-only.md b/SPEC-invite-only.md new file mode 100644 index 0000000..cdc3932 --- /dev/null +++ b/SPEC-invite-only.md @@ -0,0 +1,119 @@ +# Spec: Invitation-Only Open Letters + +## Overview +Add a new letter type: **invitation-only** (€10 to publish). Letters are always publicly readable, but signing can be restricted via personal invitation links and/or email domain restrictions. + +## Letter Types +- **Public** (free) — anyone can sign. Current behavior, unchanged. +- **Invitation-only** (€10) — signing restricted. Creator pays via Stripe Checkout to publish/activate invitations. + +## User Flow + +### Creating an invitation-only letter +1. Creator writes the letter on `/create` (same as today) +2. New toggle: "Public (free)" vs "Invitation only (€10)" +3. If invitation-only, additional fields appear: + - **Restriction mode**: "Invite links only" or "Email domain restriction" + - If invite links: **Invites per person** (number, e.g. 5) — how many people each invitee can invite + - If invite links: **Allow chain invites** — toggle (off = flat/1 level, on = infinite depth) + - If domain restriction: **Allowed domains** — comma-separated list (e.g. `university.edu, department.org`) +4. Letter is created as a **draft** (visible, readable, but signing disabled) +5. Creator is redirected to Stripe Checkout (€10) to pay +6. On successful payment (Stripe webhook or redirect), letter becomes **active** — invitations work, signing is enabled + +### Inviting signers (invitation-only letters) +1. After payment, creator lands on a management page (`/{slug}/manage?token=xxx`) +2. Creator can: + - Enter email addresses to invite (sends email with personal invite link) + - Copy a personal invite link to share manually + - See who has been invited, who has signed, who invited whom (web of trust tree) +3. Each invite link is unique: `/{slug}?invite=` +4. When an invitee signs, they get their own invite links (up to the per-person limit) +5. Chain depth: if disabled, only creator's direct invitees can invite; if enabled, invitees of invitees can also invite, recursively + +### Signing an invitation-only letter +1. Visitor arrives at `/{slug}` — can always **read** the letter and see signatures +2. If no valid `?invite=` in URL: + - Show message: "This letter is invitation-only. You need an invitation link to sign it." +3. If valid invite token: + - Normal signing flow (email or passkey) + - After signing, show their own invite links (if they have invite slots) +4. If domain restriction mode: + - Email field is required (no passkey-only) + - Email must match one of the allowed domains + - No invite tokens needed — anyone with a matching email can sign + +## Data Model + +### Letters table — new columns +```sql +ALTER TABLE letters ADD COLUMN letter_type VARCHAR(16) DEFAULT 'public'; -- 'public' | 'invite_only' +ALTER TABLE letters ADD COLUMN restriction_mode VARCHAR(16) DEFAULT NULL; -- 'invite' | 'domain' | NULL +ALTER TABLE letters ADD COLUMN allowed_domains TEXT DEFAULT NULL; -- JSON array: ["university.edu","dept.org"] +ALTER TABLE letters ADD COLUMN invites_per_person INTEGER DEFAULT 5; -- how many invites each person gets +ALTER TABLE letters ADD COLUMN allow_chain_invites BOOLEAN DEFAULT FALSE; -- flat (false) vs infinite depth (true) +ALTER TABLE letters ADD COLUMN is_paid BOOLEAN DEFAULT FALSE; -- payment completed? +ALTER TABLE letters ADD COLUMN stripe_session_id VARCHAR(255) DEFAULT NULL; -- Stripe Checkout session ID +``` + +### New table: invitations +```sql +CREATE TABLE invitations ( + id SERIAL PRIMARY KEY, + letter_id INTEGER NOT NULL REFERENCES letters(id), + token VARCHAR(64) NOT NULL UNIQUE, -- unique invite token + email VARCHAR(255) DEFAULT NULL, -- invited email (optional, for tracking) + invited_by INTEGER DEFAULT NULL REFERENCES invitations(id), -- parent invitation (NULL = creator) + generation INTEGER DEFAULT 0, -- 0 = creator, 1 = direct invitee, 2 = their invitee... + invites_remaining INTEGER DEFAULT 5, -- how many invites this person can still send + signature_id INTEGER DEFAULT NULL REFERENCES signatures(id), -- linked signature once they sign + used_at TIMESTAMP DEFAULT NULL, -- when they signed + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +## API Endpoints + +### New endpoints +- `POST /letters/:slug/invitations` — create invitation(s) (requires letter token auth) + - Body: `{ token, emails: ["a@b.com"], invite_token: "xxx" }` (invite_token for chain invites) + - Returns: array of invitation objects with invite URLs +- `GET /letters/:slug/invitations?token=xxx` — list all invitations for a letter (requires letter token) +- `GET /invitations/:invite_token` — validate an invite token, return letter info +- `POST /letters/:slug/checkout` — create Stripe Checkout session for €10 +- `POST /webhooks/stripe` — handle Stripe payment webhooks + +### Modified endpoints +- `POST /letters/:slug/:locale/sign` — check invitation validity before allowing signature + - If letter_type='invite_only' and restriction_mode='invite': require valid invite_token in body + - If letter_type='invite_only' and restriction_mode='domain': validate email domain +- `GET /letters/:slug` — include `letter_type`, `restriction_mode`, `is_paid` in response +- `POST /letters/create` — accept `letter_type` and related fields + +## Frontend Pages + +### Modified pages +- `/create` — add letter type selector + invite settings +- `/{slug}` — show invite-only messaging, gate signing form behind invite token +- `/{slug}/confirm_signature` — after confirming, show invite links if applicable + +### New pages +- `/{slug}/manage?token=xxx` — invitation management dashboard (creator only) +- `/pricing` — new pricing page explaining Public vs Invitation-only + +### Homepage redesign +- Hero: emphasize the two options (public free letter vs invitation-only) +- Value prop for invitation-only: "Stop AI bots. Ensure genuine signatures. Give more weight to your open letter." +- Social proof: stats (3,600+ letters, 490,000+ signatures) +- Clear CTA: "Create a Public Letter (free)" and "Create an Invitation-Only Letter (€10)" + +## Stripe Integration +- New Stripe Payment Link or Checkout Session for €10 +- On success: set `is_paid = true` on the letter +- Webhook endpoint to handle async payment confirmation +- Use existing Stripe setup (already have `stripe` npm package in frontend) + +## i18n +- Add new translation keys for all new UI strings (en, fr, nl, de) +- Key examples: `create.type.public`, `create.type.invite_only`, `pricing.title`, `invite.message`, etc. diff --git a/api/app/Controllers/Http/InvitationController.js b/api/app/Controllers/Http/InvitationController.js new file mode 100644 index 0000000..58be216 --- /dev/null +++ b/api/app/Controllers/Http/InvitationController.js @@ -0,0 +1,208 @@ +'use strict' + +const crypto = use('crypto') +const Letter = use('App/Models/Letter') +const Invitation = use('App/Models/Invitation') +const { sendEmail } = use('App/Libs/email') + +class InvitationController { + /** + * Create invitation(s) for a letter. + * Can be called by the letter creator (with letter token) or by an invitee (with invite_token). + * + * Body: { token?, invite_token?, emails: ["a@b.com"] } + */ + async create ({ request, params }) { + const { token, invite_token, emails } = request.only(['token', 'invite_token', 'emails']) + + // Find the letter (any locale version, we just need the slug-level settings) + const letter = await Letter.query() + .where('slug', params.slug) + .where('letter_type', 'invite_only') + .first() + + if (!letter) { + return { error: { code: 404, message: 'Letter not found or is not invite-only' } } + } + + if (!letter.is_paid) { + return { error: { code: 402, message: 'Payment required to activate invitations' } } + } + + let parentInvitation = null + let generation = 0 + let invitesPerPerson = letter.invites_per_person + + if (invite_token) { + // Invitee creating sub-invitations + parentInvitation = await Invitation.query() + .where('token', invite_token) + .where('letter_id', letter.id) + .first() + + if (!parentInvitation) { + return { error: { code: 403, message: 'Invalid invitation token' } } + } + + if (!parentInvitation.used_at) { + return { error: { code: 403, message: 'You must sign the letter before inviting others' } } + } + + // Check if chain invites are allowed + if (!letter.allow_chain_invites && parentInvitation.generation > 0) { + return { error: { code: 403, message: 'Chain invitations are not allowed for this letter' } } + } + + if (parentInvitation.invites_remaining <= 0) { + return { error: { code: 403, message: 'No invitations remaining' } } + } + + generation = parentInvitation.generation + 1 + } else if (token) { + // Letter creator + if (token !== letter.token) { + return { error: { code: 403, message: 'Unauthorized: Invalid token' } } + } + } else { + return { error: { code: 400, message: 'Either token or invite_token is required' } } + } + + if (!emails || !Array.isArray(emails) || emails.length === 0) { + return { error: { code: 400, message: 'At least one email address is required' } } + } + + // Limit batch size + const emailsToInvite = emails.slice(0, parentInvitation + ? Math.min(emails.length, parentInvitation.invites_remaining) + : 50 + ) + + const created = [] + for (const email of emailsToInvite) { + // Check if already invited + const existing = await Invitation.query() + .where('letter_id', letter.id) + .where('email', email.toLowerCase()) + .first() + + if (existing) continue + + const inviteToken = crypto.randomBytes(24).toString('hex') + + const invitation = await Invitation.create({ + letter_id: letter.id, + token: inviteToken, + email: email.toLowerCase(), + invited_by: parentInvitation ? parentInvitation.id : null, + generation, + invites_remaining: invitesPerPerson, + }) + + // Send invite email + try { + await sendEmail( + email.toLowerCase(), + `You're invited to sign: ${letter.title}`, + 'emails.en.invitation', + { + letter: letter.toJSON(), + inviteUrl: `${process.env.FRONTEND_URL || 'https://openletter.earth'}/${letter.slug}?invite=${inviteToken}`, + env: process.env, + } + ) + } catch (e) { + console.error('Failed to send invite email to', email, e) + } + + created.push({ + id: invitation.id, + email: invitation.email, + invite_url: `${process.env.FRONTEND_URL || 'https://openletter.earth'}/${letter.slug}?invite=${inviteToken}`, + generation: invitation.generation, + }) + } + + // Decrement parent invitation's remaining count + if (parentInvitation) { + parentInvitation.invites_remaining -= created.length + await parentInvitation.save() + } + + return { invitations: created } + } + + /** + * List all invitations for a letter (creator only, requires letter token). + */ + async list ({ request, params }) { + const { token } = request.only(['token']) + + const letter = await Letter.query() + .where('slug', params.slug) + .first() + + if (!letter) { + return { error: { code: 404, message: 'Letter not found' } } + } + + if (token !== letter.token) { + return { error: { code: 403, message: 'Unauthorized: Invalid token' } } + } + + const invitations = await Invitation.query() + .where('letter_id', letter.id) + .with('signature') + .orderBy('created_at', 'asc') + .fetch() + + return { + invitations: invitations.toJSON().map(inv => ({ + id: inv.id, + email: inv.email, + generation: inv.generation, + invited_by: inv.invited_by, + invites_remaining: inv.invites_remaining, + used_at: inv.used_at, + signature: inv.signature, + created_at: inv.created_at, + })), + } + } + + /** + * Validate an invite token (public endpoint). + * Returns letter info if valid. + */ + async validate ({ params }) { + const invitation = await Invitation.query() + .where('token', params.token) + .with('letter') + .first() + + if (!invitation) { + return { error: { code: 404, message: 'Invalid invitation' } } + } + + if (invitation.used_at) { + return { error: { code: 400, message: 'This invitation has already been used' } } + } + + const letter = invitation.getRelated('letter') + + return { + valid: true, + invitation: { + id: invitation.id, + email: invitation.email, + generation: invitation.generation, + invites_remaining: invitation.invites_remaining, + }, + letter: { + slug: letter.slug, + title: letter.title, + }, + } + } +} + +module.exports = InvitationController diff --git a/api/app/Controllers/Http/LetterController.js b/api/app/Controllers/Http/LetterController.js index 9addcc8..f74f02a 100644 --- a/api/app/Controllers/Http/LetterController.js +++ b/api/app/Controllers/Http/LetterController.js @@ -10,6 +10,7 @@ Logger.level = 'info'; const availableLocales = require('../../../locales.json'); +const Invitation = use('App/Models/Invitation'); const { sendEmail } = use('App/Libs/email'); const { getImageSize } = use('App/Libs/image'); @@ -107,6 +108,14 @@ class LetterController { } res.locales = locales; res.type = res.parent_letter_id ? 'update' : 'letter'; + // Include invite-only fields + res.letter_type = res.letter_type || 'public'; + res.restriction_mode = res.restriction_mode || null; + res.is_paid = res.is_paid || false; + res.allowed_domains = res.allowed_domains ? JSON.parse(res.allowed_domains) : null; + res.invites_per_person = res.invites_per_person || 5; + res.allow_chain_invites = res.allow_chain_invites || false; + if (res.updates) { res.updates = res.updates.filter((u) => u.locale === locale); } @@ -304,6 +313,45 @@ class LetterController { .where('locale', request.params.locale) .first(); + // ── Invite-only checks ────────────────────────────────────── + if (letter.letter_type === 'invite_only') { + if (!letter.is_paid) { + return { error: { code: 402, message: 'This letter has not been activated yet' } }; + } + + if (letter.restriction_mode === 'invite') { + const inviteToken = request.body.invite_token; + if (!inviteToken) { + return { error: { code: 403, message: 'An invitation is required to sign this letter' } }; + } + const invitation = await Invitation.query() + .where('token', inviteToken) + .where('letter_id', letter.id) + .first(); + if (!invitation) { + return { error: { code: 403, message: 'Invalid invitation' } }; + } + if (invitation.used_at) { + return { error: { code: 400, message: 'This invitation has already been used' } }; + } + // Store invitation reference to link after signature is created + request._invitation = invitation; + } + + if (letter.restriction_mode === 'domain') { + const signerEmail = request.body.email; + if (!signerEmail) { + return { error: { code: 400, message: 'Email is required for domain-restricted letters' } }; + } + const allowedDomains = JSON.parse(letter.allowed_domains || '[]'); + const emailDomain = signerEmail.split('@')[1]?.toLowerCase(); + if (!allowedDomains.some(d => emailDomain === d.toLowerCase())) { + return { error: { code: 403, message: `Only email addresses from ${allowedDomains.join(', ')} can sign this letter` } }; + } + } + } + // ── End invite-only checks ────────────────────────────────── + const usePasskey = request.body.use_passkey; const email = request.body.email; @@ -345,6 +393,13 @@ class LetterController { } } + // Link invitation to signature (for invite-only letters) + if (request._invitation && signature) { + request._invitation.signature_id = signature.id; + request._invitation.used_at = new Date(); + await request._invitation.save(); + } + // If using passkey, skip email confirmation — return signature ID for WebAuthn flow if (request.body.use_passkey) { if (ipAddress) { diff --git a/api/app/Controllers/Http/StripeController.js b/api/app/Controllers/Http/StripeController.js new file mode 100644 index 0000000..515d00e --- /dev/null +++ b/api/app/Controllers/Http/StripeController.js @@ -0,0 +1,173 @@ +'use strict' + +const Letter = use('App/Models/Letter') + +const PRICE_CENTS = 1000 // €10.00 + +function getStripe () { + const Stripe = require('stripe') + return new Stripe(process.env.STRIPE_SECRET_KEY) +} + +class StripeController { + /** + * Verify payment status by checking the Stripe session directly. + * Called when user returns from Stripe Checkout. + * + * POST /letters/:slug/verify-payment + * Body: { token } (letter edit token for auth) + */ + async verifyPayment ({ request, params }) { + const { token } = request.only(['token']) + + const letter = await Letter.query() + .where('slug', params.slug) + .where('letter_type', 'invite_only') + .first() + + if (!letter) { + return { error: { code: 404, message: 'Letter not found' } } + } + + if (token !== letter.token) { + return { error: { code: 403, message: 'Unauthorized' } } + } + + if (letter.is_paid) { + return { paid: true } + } + + if (!letter.stripe_session_id) { + return { paid: false, message: 'No checkout session found' } + } + + // Check payment status with Stripe + const stripe = getStripe() + const session = await stripe.checkout.sessions.retrieve(letter.stripe_session_id) + + if (session.payment_status === 'paid') { + // Activate all locale versions + await Letter.query() + .where('slug', letter.slug) + .where('letter_type', 'invite_only') + .update({ is_paid: true }) + + return { paid: true } + } + + return { paid: false } + } + + /** + * Create a Stripe Checkout Session for activating an invite-only letter. + * + * POST /letters/:slug/checkout + * Body: { token } (letter edit token for auth) + */ + async createCheckout ({ request, params }) { + const { token } = request.only(['token']) + + const letter = await Letter.query() + .where('slug', params.slug) + .where('letter_type', 'invite_only') + .first() + + if (!letter) { + return { error: { code: 404, message: 'Letter not found or is not invite-only' } } + } + + if (token !== letter.token) { + return { error: { code: 403, message: 'Unauthorized: Invalid token' } } + } + + if (letter.is_paid) { + return { error: { code: 400, message: 'This letter has already been activated' } } + } + + const stripe = getStripe() + const frontendUrl = process.env.FRONTEND_URL || 'https://openletter.earth' + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: 'eur', + product_data: { + name: 'Invitation-Only Open Letter', + description: `Activate invite-only signing for: ${letter.title}`, + }, + unit_amount: PRICE_CENTS, + }, + quantity: 1, + }, + ], + metadata: { + letter_slug: letter.slug, + letter_id: String(letter.id), + }, + success_url: `${frontendUrl}/${letter.slug}/manage?token=${letter.token}&payment=success`, + cancel_url: `${frontendUrl}/${letter.slug}?payment=cancelled`, + }) + + // Store session ID on the letter + letter.stripe_session_id = session.id + await letter.save() + + return { checkout_url: session.url } + } + + /** + * Stripe webhook handler. + * Listens for checkout.session.completed to activate invite-only letters. + * + * POST /webhooks/stripe + * + * Note: signature verification requires STRIPE_WEBHOOK_SECRET. + * Without it, we accept the event but verify payment via the Stripe API as a safety check. + */ + async webhook ({ request, response }) { + const event = request.post() + + // Optional signature verification + const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET + if (endpointSecret) { + const sig = request.header('stripe-signature') + // If webhook secret is set but we can't verify (no raw body access in Adonis v4), + // we fall through to API-based verification below + if (!sig) { + return response.status(400).json({ error: 'Missing stripe-signature header' }) + } + } + + if (event.type === 'checkout.session.completed') { + const session = event.data?.object + const letterSlug = session?.metadata?.letter_slug + + if (letterSlug && session?.id) { + // Double-check with Stripe API that payment actually went through + const stripe = getStripe() + const verified = await stripe.checkout.sessions.retrieve(session.id) + + if (verified.payment_status === 'paid') { + const affected = await Letter.query() + .where('slug', letterSlug) + .where('letter_type', 'invite_only') + .update({ + is_paid: true, + stripe_session_id: session.id, + }) + + console.log(`>>> Stripe webhook: activated invite-only letter "${letterSlug}" (${affected} rows)`) + } else { + console.warn(`>>> Stripe webhook: session ${session.id} not paid yet`) + } + } + } + + return response.status(200).json({ received: true }) + } +} + +module.exports = StripeController diff --git a/api/app/Models/Invitation.js b/api/app/Models/Invitation.js new file mode 100644 index 0000000..cdac88f --- /dev/null +++ b/api/app/Models/Invitation.js @@ -0,0 +1,28 @@ +'use strict' + +/** @type {typeof import('@adonisjs/lucid/src/Lucid/Model')} */ +const Model = use('Model') + +class Invitation extends Model { + static get hidden () { + return ['token'] + } + + letter () { + return this.belongsTo('App/Models/Letter') + } + + parent () { + return this.belongsTo('App/Models/Invitation', 'invited_by', 'id') + } + + children () { + return this.hasMany('App/Models/Invitation', 'id', 'invited_by') + } + + signature () { + return this.belongsTo('App/Models/Signature', 'signature_id', 'id') + } +} + +module.exports = Invitation diff --git a/api/app/Models/Letter.js b/api/app/Models/Letter.js index c3e45da..7fb43a5 100644 --- a/api/app/Models/Letter.js +++ b/api/app/Models/Letter.js @@ -89,6 +89,10 @@ class Letter extends Model { signatures() { return this.hasMany('App/Models/Signature', 'id', 'letter_id'); } + + invitations() { + return this.hasMany('App/Models/Invitation', 'id', 'letter_id'); + } } /** @@ -131,6 +135,14 @@ Letter.createWithLocales = async (letters, defaultValues = {}) => { image: letter.image, slug, }; + // Invite-only fields (applied to all locale versions from the first letter's settings) + if (letters[0].letter_type) { + sanitizedValues.letter_type = letters[0].letter_type; + sanitizedValues.restriction_mode = letters[0].restriction_mode || null; + sanitizedValues.allowed_domains = letters[0].allowed_domains || null; + sanitizedValues.invites_per_person = letters[0].invites_per_person || 5; + sanitizedValues.allow_chain_invites = letters[0].allow_chain_invites || false; + } if (!sanitizedValues.text) { console.log('>>> empty text for locale', letter.locale, 'skipping'); return; diff --git a/api/database/migrations/1712300000000_letters_invite_only.js b/api/database/migrations/1712300000000_letters_invite_only.js new file mode 100644 index 0000000..b24f654 --- /dev/null +++ b/api/database/migrations/1712300000000_letters_invite_only.js @@ -0,0 +1,32 @@ +'use strict' + +/** @type {import('@adonisjs/lucid/src/Schema')} */ +const Schema = use('Schema') + +class LettersInviteOnlySchema extends Schema { + up () { + this.table('letters', (table) => { + table.string('letter_type', 16).defaultTo('public').index() // 'public' | 'invite_only' + table.string('restriction_mode', 16).defaultTo(null) // 'invite' | 'domain' | null + table.text('allowed_domains').defaultTo(null) // JSON array: ["uni.edu"] + table.integer('invites_per_person').defaultTo(5) + table.boolean('allow_chain_invites').defaultTo(false) + table.boolean('is_paid').defaultTo(false) + table.string('stripe_session_id', 255).defaultTo(null) + }) + } + + down () { + this.table('letters', (table) => { + table.dropColumn('letter_type') + table.dropColumn('restriction_mode') + table.dropColumn('allowed_domains') + table.dropColumn('invites_per_person') + table.dropColumn('allow_chain_invites') + table.dropColumn('is_paid') + table.dropColumn('stripe_session_id') + }) + } +} + +module.exports = LettersInviteOnlySchema diff --git a/api/database/migrations/1712300000001_invitations.js b/api/database/migrations/1712300000001_invitations.js new file mode 100644 index 0000000..8702724 --- /dev/null +++ b/api/database/migrations/1712300000001_invitations.js @@ -0,0 +1,27 @@ +'use strict' + +/** @type {import('@adonisjs/lucid/src/Schema')} */ +const Schema = use('Schema') + +class InvitationsSchema extends Schema { + up () { + this.create('invitations', (table) => { + table.increments() + table.integer('letter_id').unsigned().notNullable().references('id').inTable('letters').index() + table.string('token', 64).notNullable().unique().index() + table.string('email', 255).defaultTo(null) + table.integer('invited_by').unsigned().defaultTo(null).references('id').inTable('invitations') + table.integer('generation').defaultTo(0) // 0 = creator-sent, 1 = their invitee, etc. + table.integer('invites_remaining').defaultTo(5) + table.integer('signature_id').unsigned().defaultTo(null).references('id').inTable('signatures') + table.timestamp('used_at').defaultTo(null) + table.timestamps() + }) + } + + down () { + this.drop('invitations') + } +} + +module.exports = InvitationsSchema diff --git a/api/package.json b/api/package.json index b07faba..195d8c6 100644 --- a/api/package.json +++ b/api/package.json @@ -37,6 +37,7 @@ "pg": "^8.5.1", "sanitize-html": "^1.23.0", "slugify": "^1.4.0", + "stripe": "^17.0.0", "url-parse": "^1.4.7" }, "autoload": { diff --git a/api/start/routes.js b/api/start/routes.js index 6eebff5..3b76e94 100644 --- a/api/start/routes.js +++ b/api/start/routes.js @@ -34,3 +34,13 @@ Route.post('letters/:slug/:locale/sign', 'LetterController.sign'); Route.post('signatures/confirm', 'SignatureController.confirm'); Route.post('passkey/register-options', 'PasskeyController.registerOptions'); Route.post('passkey/register-verify', 'PasskeyController.registerVerify'); + +// Invitation-only letters +Route.post('letters/:slug/invitations', 'InvitationController.create'); +Route.get('letters/:slug/invitations', 'InvitationController.list'); +Route.get('invitations/:token', 'InvitationController.validate'); + +// Stripe payments +Route.post('letters/:slug/checkout', 'StripeController.createCheckout'); +Route.post('letters/:slug/verify-payment', 'StripeController.verifyPayment'); +Route.post('webhooks/stripe', 'StripeController.webhook'); diff --git a/frontend/components/DonorsList.js b/frontend/components/DonorsList.js new file mode 100644 index 0000000..9e53a05 --- /dev/null +++ b/frontend/components/DonorsList.js @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import useSWR from 'swr'; +import { withIntl } from '../lib/i18n'; + +const fetcher = (url) => fetch(url).then((res) => res.json()); + +function relativeDate(dateStr) { + if (!dateStr) return ''; + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now - date; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'today'; + if (diffDays === 1) return 'yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) { + const weeks = Math.floor(diffDays / 7); + return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`; + } + if (diffDays < 365) { + const months = Math.floor(diffDays / 30); + return months === 1 ? '1 month ago' : `${months} months ago`; + } + const years = Math.floor(diffDays / 365); + return years === 1 ? '1 year ago' : `${years} years ago`; +} + +function ExpensesSection({ expenses }) { + if (!expenses || expenses.length === 0) return null; + + const totalExpenses = expenses.reduce((sum, e) => sum + e.amount, 0); + const firstDate = new Date(expenses[0].date); + const lastDate = new Date(expenses[expenses.length - 1].date); + const daysBetween = Math.max(1, Math.ceil((firstDate - lastDate) / (1000 * 60 * 60 * 24))); + const dailyAvg = totalExpenses / daysBetween; + const monthlyAvg = dailyAvg * 30; + + return ( +
+
+

Running this free service costs money

+
+
+
Total Spent
+
{totalExpenses.toFixed(2)}€
+
over {daysBetween} days
+
+
+
Daily Average
+
{dailyAvg.toFixed(2)}€
+
per day
+
+
+
Monthly Average
+
{monthlyAvg.toFixed(2)}€
+
per month
+
+
+
+ +

Latest expenses

+
    + {expenses.map((expense) => ( +
  • +
    {expense.description}
    +
    {expense.amount.toFixed(0)}€
    +
    + + {expense.status === 'PAID' ? 'paid' : 'pending'} + +
    +
  • + ))} +
+
+ ); +} + +function DonorItem({ donor, rank, showDate }) { + return ( +
  • +
    + {rank && {rank}.} + {donor.name} +
    +
    + {donor.amount && ( + + {donor.amount.toFixed(0)}€ + + )} + {showDate && donor.date && ( + + {relativeDate(donor.date)} + + )} +
    +
  • + ); +} + +function DonorsList({ t, compact }) { + const [showAll, setShowAll] = useState(false); + const { data, error } = useSWR('/data/donors.json', fetcher); + + if (error) return null; + if (!data) return
    Loading...
    ; + + const allDonors = (data.donors || []).filter((d) => d.name !== 'Guest'); + if (allDonors.length === 0) return null; + + // Compact mode: just names inline (for post-signature confirmation) + if (compact) { + const latest20 = [...allDonors] + .sort((a, b) => (b.date || '').localeCompare(a.date || '')) + .slice(0, 20); + return ( +
    +

    Thank you to all our contributors 🙏

    +
      + {latest20.map((donor, i) => ( +
    • + {donor.name} +
    • + ))} + {allDonors.length > 20 && ( +
    • and {allDonors.length - 20} more
    • + )} +
    +
    + ); + } + + const expenses = data.expenses || []; + + // Full mode: expenses → top 10 by amount → all by date DESC + const top10 = [...allDonors] + .sort((a, b) => (b.amount || 0) - (a.amount || 0)) + .slice(0, 10); + + const byDate = [...allDonors] + .sort((a, b) => (b.date || '').localeCompare(a.date || '')); + + const visibleDonors = showAll ? byDate : byDate.slice(0, 10); + + return ( +
    + {/* Expenses + cost breakdown */} + + + {/* Top contributors */} +
    +

    🏆 Top contributors

    +
      + {top10.map((donor, i) => ( + + ))} +
    +
    + + {/* All contributors by date */} +
    +

    + All contributors 🙏 + ({allDonors.length}) +

    +
      + {visibleDonors.map((donor, i) => ( + + ))} +
    + {!showAll && byDate.length > 10 && ( +
    + +
    + )} +
    +
    + ); +} + +export default withIntl(DonorsList); diff --git a/frontend/components/Footer.js b/frontend/components/Footer.js index 69e9a6c..209c871 100644 --- a/frontend/components/Footer.js +++ b/frontend/components/Footer.js @@ -51,6 +51,8 @@ function Footer({ t }) {
    Terms of Service · + Pricing + · {t('notification.signed.donate.button')} · GitHub diff --git a/frontend/components/LetterForm.js b/frontend/components/LetterForm.js index 8c9dff2..498590e 100644 --- a/frontend/components/LetterForm.js +++ b/frontend/components/LetterForm.js @@ -1,125 +1,110 @@ import React, { Component } from 'react'; -import styled from 'styled-components'; import { withIntl } from '../lib/i18n'; import availableLocales from '../constants/locales.json'; -const TitleInput = styled.input` - border: 1px dotted grey; - font-size: 24pt; - border-radius: 5px; - padding: 10px; - font-family: 'Baskerville', Serif; - box-sizing: border-box; - width: 100%; - @media (prefers-color-scheme: dark) { - background: #111; - color: white; - } -`; - -const StyledInput = styled.input` - border: 1px dotted grey; - font-size: 14pt; - border-radius: 5px; - padding: 10px; - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; - box-sizing: border-box; - width: 100%; - margin: 5px 0; - @media (prefers-color-scheme: dark) { - background: #111; - color: white; - } -`; - -const StyledTextarea = styled.textarea` - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; - border: 1px dotted grey; - font-size: 14pt; - border-radius: 5px; - padding: 10px; - box-sizing: border-box; - width: 100%; - height: 600px; - @media (prefers-color-scheme: dark) { - background: #111; - color: white; - } -`; - -const StyledButton = styled.button` - margin-top: 30px; - font-size: 12pt; - font-family: 'Arial'; - background: #111; - color: white; - border: 2px solid white; - padding: 10px; - border-radius: 5px; - box-sizing: border-box; - &[disabled] { - background: #999; - } - @media (prefers-color-scheme: dark) { - background: #111; - color: white; - } -`; - -const ActionLink = styled.a` - font-size: 14px; - cursor: pointer; - color: red; - font-weight: bold; - &:hover { - color: darkred; - } -`; +const DRAFT_KEY = 'openletter_draft'; class LetterForm extends Component { constructor(props) { super(props); this.state = { loading: false, + showPreview: false, + letterType: 'public', + restrictionMode: 'invite', + allowedDomains: '', + invitesPerPerson: 5, + allowChainInvites: false, form: [ { - locale: props.locale, - title: null, - text: null, - image: null, + locale: props.locale || 'en', + title: '', + text: '', + image: '', + email: '', }, ], }; - // If we pass the list of locales (when posting an update) if (props.parentLetter) { - this.state.form = []; - props.parentLetter.locales.map((locale) => { - this.state.form.push({ locale, title: null, text: null }); - }); + this.state.form = props.parentLetter.locales.map((locale) => ({ + locale, + title: '', + text: '', + })); } this.handleChange = this.handleChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); + this.saveDraft = this.saveDraft.bind(this); } componentDidMount() { - this.firstTitleInput.focus(); + // Restore draft from localStorage + try { + const saved = localStorage.getItem(DRAFT_KEY); + if (saved) { + const draft = JSON.parse(saved); + if (draft.form && draft.form[0]?.title) { + this.setState({ + form: draft.form, + letterType: draft.letterType || 'public', + restrictionMode: draft.restrictionMode || 'invite', + allowedDomains: draft.allowedDomains || '', + invitesPerPerson: draft.invitesPerPerson || 5, + allowChainInvites: draft.allowChainInvites || false, + }); + } + } + } catch (e) { + // Ignore parse errors + } + if (this.firstTitleInput) this.firstTitleInput.focus(); + } + + saveDraft() { + try { + const { form, letterType, restrictionMode, allowedDomains, invitesPerPerson, allowChainInvites } = this.state; + localStorage.setItem(DRAFT_KEY, JSON.stringify({ + form, letterType, restrictionMode, allowedDomains, invitesPerPerson, allowChainInvites, + savedAt: new Date().toISOString(), + })); + } catch (e) { + // Storage full or unavailable + } + } + + static clearDraft() { + try { localStorage.removeItem(DRAFT_KEY); } catch (e) {} } handleChange(fieldname, value, index) { const { form } = this.state; form[index || 0] = form[index || 0] || {}; form[index || 0][fieldname] = value; - this.setState({ form }); + this.setState({ form }, this.saveDraft); } async handleSubmit(event) { this.setState({ loading: true }); event.preventDefault(); - await this.props.onSubmit(this.state.form); - // just in case + const formData = [...this.state.form]; + if (this.state.letterType === 'invite_only') { + formData[0].letter_type = 'invite_only'; + formData[0].restriction_mode = this.state.restrictionMode; + if (this.state.restrictionMode === 'invite') { + formData[0].invites_per_person = this.state.invitesPerPerson; + formData[0].allow_chain_invites = this.state.allowChainInvites; + } + if (this.state.restrictionMode === 'domain') { + const domains = this.state.allowedDomains.split(',').map(d => d.trim().toLowerCase()).filter(Boolean); + formData[0].allowed_domains = JSON.stringify(domains); + } + } + + await this.props.onSubmit(formData); + setTimeout(() => { this.setState({ loading: false }); }, 2000); @@ -128,104 +113,247 @@ class LetterForm extends Component { addLanguage() { const { form } = this.state; - form.push({ - locale: 'en', - title: null, - text: null, - }); - this.setState({ form }); + form.push({ locale: 'en', title: '', text: '' }); + this.setState({ form }, this.saveDraft); } removeLanguage(index) { const { form } = this.state; - const deletedLocale = form.splice(index, 1); - this.setState({ form }); + form.splice(index, 1); + this.setState({ form }, this.saveDraft); + } + + renderPreview() { + const form = this.state.form[0] || {}; + const text = (form.text || '').replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/\n/g, '
    '); + + return ( +
    +
    {this.props.t('create.preview.label')}
    + {form.image && ( +
    + e.target.style.display = 'none'} /> +
    + )} +

    + {form.title || {this.props.t('create.title')}} +

    +
    ${this.props.t('create.preview.empty')}` }} + /> +
    + ); } render() { const { parentLetter, t } = this.props; + const { showPreview } = this.state; return ( -
    + {this.state.form.map((form, index) => ( - <> - {this.state.form.length > 0 && ( -
    -
    - -
    +
    + {/* Language selector */} +
    + +
    {index > 0 && !parentLetter && ( -
    - this.removeLanguage(index)}>[{t('create.removeLanguage')} ⨯] -
    + )}
    - )} -
    - this.handleChange('title', e.target.value, index)} - ref={(input) => { - this.firstTitleInput = this.firstTitleInput || input; - }} - />
    + + {/* Title */} + this.handleChange('title', e.target.value, index)} + ref={(input) => { this.firstTitleInput = this.firstTitleInput || input; }} + className="w-full text-2xl font-bold py-3 px-4 border border-gray-200 dark:border-gray-700 rounded-xl focus:ring-2 focus:ring-gray-900 dark:focus:ring-white focus:outline-none dark:bg-gray-900 dark:text-white" + style={{ fontFamily: "'Baskerville', 'Georgia', serif" }} + /> + + {/* Image URL */} {index === 0 && ( -
    - this.handleChange('image', e.target.value)} - /> -
    - )} -
    - this.handleChange('text', e.target.value, index)} - required - placeholder={t('create.text')} + this.handleChange('image', e.target.value)} + className="w-full py-2 px-4 border border-gray-200 dark:border-gray-700 rounded-xl text-sm focus:ring-2 focus:ring-gray-900 dark:focus:ring-white focus:outline-none dark:bg-gray-900 dark:text-white mt-3" /> -
    - + )} + + {/* Body */} +