From 11d19873f69d4af1a8de9cfd103cb1b2859ecfa0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 18:01:28 +0000 Subject: [PATCH 1/2] feat: credit codes (plan days) and scarce invites - Credit codes (@profullstack/redeem-credits 0.2, supabaseStore) add days of a paid plan through the new plan_grant_days RPC, the same profiles.plan / plan_expires_at a CoinPay payment extends. Pro by default; the mint can pick Plus or Team. Redeem on the dashboard billing card, POST /api/credits/redeem or `pairux redeem`. Admin mint/list/revoke on /admin and /api/admin/credit-codes. - Invites (@profullstack/affiliates 0.2, supabaseStore): 3 per account from 7 days old, /invites + /i/, accepted at signup or on the confirmation link. Both sides get 7 days of Pro once the new account's email is confirmed; no bonus for the same mailbox (+tags, Gmail dots) or past 10 inviter bonuses in 30 days. Decisions recorded once in invite_bonuses. - ?ref= first-touch in the referral_code cookie, recorded at signup. - Security: grant_plan was executable by anon (SECURITY DEFINER, PUBLIC execute) and users could PATCH their own profiles.plan; both closed. - CLI: `pairux redeem`, `pairux invites [create]`. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/main/agent-cli/api.ts | 39 ++ .../src/main/agent-cli/commands.test.ts | 96 +++ apps/desktop/src/main/agent-cli/commands.ts | 47 ++ apps/desktop/src/main/agent-cli/index.ts | 4 +- apps/installer/scripts/install.ps1 | 4 +- apps/installer/scripts/install.sh | 4 + apps/web/messages/ar.json | 55 ++ apps/web/messages/de.json | 55 ++ apps/web/messages/en.json | 55 ++ apps/web/messages/es.json | 55 ++ apps/web/messages/fr.json | 55 ++ apps/web/messages/hi.json | 55 ++ apps/web/messages/id.json | 55 ++ apps/web/messages/it.json | 55 ++ apps/web/messages/ja.json | 55 ++ apps/web/messages/ko.json | 55 ++ apps/web/messages/nl.json | 55 ++ apps/web/messages/pl.json | 55 ++ apps/web/messages/pt.json | 55 ++ apps/web/messages/ru.json | 55 ++ apps/web/messages/sv.json | 55 ++ apps/web/messages/tr.json | 55 ++ apps/web/messages/uk.json | 55 ++ apps/web/messages/vi.json | 55 ++ apps/web/messages/zh.json | 55 ++ apps/web/package.json | 2 + apps/web/src/app/admin/credit-codes.tsx | 176 +++++ apps/web/src/app/admin/page.tsx | 12 + .../app/api/admin/credit-codes/[id]/route.ts | 21 + .../src/app/api/admin/credit-codes/route.ts | 56 ++ apps/web/src/app/api/auth/signup/route.ts | 13 + .../src/app/api/credits/redeem/route.test.ts | 67 ++ apps/web/src/app/api/credits/redeem/route.ts | 59 ++ apps/web/src/app/api/credits/route.ts | 35 + apps/web/src/app/api/invites/[id]/route.ts | 23 + apps/web/src/app/api/invites/accept/route.ts | 58 ++ apps/web/src/app/api/invites/route.ts | 64 ++ apps/web/src/app/auth/callback/route.ts | 30 +- apps/web/src/app/dashboard/page.tsx | 48 +- apps/web/src/app/i/[code]/page.tsx | 40 ++ apps/web/src/app/invites/page.tsx | 37 + .../src/components/auth/SignupForm.test.tsx | 1 + apps/web/src/components/auth/SignupForm.tsx | 2 + .../src/components/credits/InviteLanding.tsx | 96 +++ .../src/components/credits/InvitesManager.tsx | 216 ++++++ .../components/credits/PlanBillingCard.tsx | 152 +++++ apps/web/src/lib/credits.test.ts | 308 +++++++++ apps/web/src/lib/credits.ts | 639 ++++++++++++++++++ apps/web/src/lib/request-user.ts | 30 + apps/web/src/middleware.ts | 16 + docs/API.md | 21 + pnpm-lock.yaml | 46 +- .../20261026200000_redeem_credits.sql | 200 ++++++ .../migrations/20261026200100_affiliates.sql | 319 +++++++++ ...6200200_credit_plan_grants_and_invites.sql | 105 +++ 55 files changed, 4081 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/app/admin/credit-codes.tsx create mode 100644 apps/web/src/app/api/admin/credit-codes/[id]/route.ts create mode 100644 apps/web/src/app/api/admin/credit-codes/route.ts create mode 100644 apps/web/src/app/api/credits/redeem/route.test.ts create mode 100644 apps/web/src/app/api/credits/redeem/route.ts create mode 100644 apps/web/src/app/api/credits/route.ts create mode 100644 apps/web/src/app/api/invites/[id]/route.ts create mode 100644 apps/web/src/app/api/invites/accept/route.ts create mode 100644 apps/web/src/app/api/invites/route.ts create mode 100644 apps/web/src/app/i/[code]/page.tsx create mode 100644 apps/web/src/app/invites/page.tsx create mode 100644 apps/web/src/components/credits/InviteLanding.tsx create mode 100644 apps/web/src/components/credits/InvitesManager.tsx create mode 100644 apps/web/src/components/credits/PlanBillingCard.tsx create mode 100644 apps/web/src/lib/credits.test.ts create mode 100644 apps/web/src/lib/credits.ts create mode 100644 apps/web/src/lib/request-user.ts create mode 100644 supabase/migrations/20261026200000_redeem_credits.sql create mode 100644 supabase/migrations/20261026200100_affiliates.sql create mode 100644 supabase/migrations/20261026200200_credit_plan_grants_and_invites.sql diff --git a/apps/desktop/src/main/agent-cli/api.ts b/apps/desktop/src/main/agent-cli/api.ts index fa3c9abc..9f2c4408 100644 --- a/apps/desktop/src/main/agent-cli/api.ts +++ b/apps/desktop/src/main/agent-cli/api.ts @@ -115,4 +115,43 @@ export class PairuxClient { method: 'DELETE', }); } + + /** Redeem a credit code (adds days of a paid plan). */ + redeem(code: string) { + return this.request<{ + plan: string; + days: number; + effectivePlan: string; + planExpiresAt: string | null; + message: string; + }>('/api/credits/redeem', { method: 'POST', body: JSON.stringify({ code }) }, true); + } + + invites() { + return this.request('/api/invites', {}, true); + } + + createInvite(note: string) { + return this.request<{ id: string; code: string; url: string; expiresAt: number | null }>( + '/api/invites', + { method: 'POST', body: JSON.stringify({ note }) }, + true + ); + } +} + +export interface InvitesOverview { + quota: { total: number; used: number; left: number }; + invites: { + id: string; + status: string; + note: string; + usedByName?: string | null; + bonus?: string; + expiresAt: number | null; + }[]; + canInvite: boolean; + unlocksAt: string | null; + bonusDays: number; + refLink: string; } diff --git a/apps/desktop/src/main/agent-cli/commands.test.ts b/apps/desktop/src/main/agent-cli/commands.test.ts index 33681862..67ffb3fd 100644 --- a/apps/desktop/src/main/agent-cli/commands.test.ts +++ b/apps/desktop/src/main/agent-cli/commands.test.ts @@ -442,3 +442,99 @@ describe('pairux room', () => { expect(out[1]).not.toBe(out[0]); }); }); + +describe('pairux redeem / invites', () => { + async function signIn(d: Deps) { + await ( + await import('./config') + ).saveConfig( + { + tokens: { + accessToken: 'pux_at_x', + refreshToken: 'pux_rt_x', + expiresAt: Date.now() + 3_600_000, + }, + }, + d.configPath + ); + } + + it('redeems a code with the access token', async () => { + const d = deps((call) => + call.path === '/api/credits/redeem' + ? { + status: 200, + body: { + data: { + plan: 'pro', + days: 30, + effectivePlan: 'pro', + planExpiresAt: '2026-11-25T00:00:00.000Z', + message: 'Code redeemed: 30 days of PairUX Pro.', + }, + }, + } + : { status: 404, body: { error: 'nope' } } + ); + await signIn(d); + expect(await run(['redeem', 'CR-AAAA-BBBB-CCCC-DDDD'], d)).toBe(0); + expect(calls[0].auth).toBe('Bearer pux_at_x'); + expect(JSON.parse(calls[0].body ?? '{}')).toEqual({ code: 'CR-AAAA-BBBB-CCCC-DDDD' }); + expect(out).toEqual([ + 'Code redeemed: 30 days of PairUX Pro.', + 'Plan: pro, active until 2026-11-25.', + ]); + }); + + it('asks to sign in before redeeming', async () => { + const d = deps(() => ({ status: 500, body: {} })); + expect(await run(['redeem', 'CR-1'], d)).not.toBe(0); + expect(calls).toHaveLength(0); + }); + + it('creates an invite and lists invites', async () => { + const d = deps((call) => + call.method === 'POST' + ? { + status: 201, + body: { + data: { + id: 'i1', + code: 'INV-1', + url: 'https://pairux.test/i/INV-1', + expiresAt: null, + }, + }, + } + : { + status: 200, + body: { + data: { + quota: { total: 3, used: 1, left: 2 }, + invites: [ + { + id: 'i1', + status: 'used', + note: 'Ada', + usedByName: '@ada', + bonus: 'granted', + expiresAt: null, + }, + ], + canInvite: true, + unlocksAt: null, + bonusDays: 7, + refLink: 'https://pairux.test/?ref=ABC', + }, + }, + } + ); + await signIn(d); + expect(await run(['invites', 'create', '--note', 'Ada'], d)).toBe(0); + expect(out[0]).toBe('Invite link (shown once): https://pairux.test/i/INV-1'); + expect(await run(['invites'], d)).toBe(0); + expect(out).toContain('2 of 3 invites left.'); + expect(out).toContain(' used by @ada (bonus granted) Ada'); + expect(out.at(-1)).toBe('Referral link: https://pairux.test/?ref=ABC'); + }); +}); diff --git a/apps/desktop/src/main/agent-cli/commands.ts b/apps/desktop/src/main/agent-cli/commands.ts index 1e05fff1..2466fe70 100644 --- a/apps/desktop/src/main/agent-cli/commands.ts +++ b/apps/desktop/src/main/agent-cli/commands.ts @@ -69,6 +69,9 @@ Usage: pairux leave Leave the session pairux room Print a link to a fresh anonymous room: end-to-end encrypted voice/video, no account needed to join + pairux redeem Redeem a credit code (adds days of a paid plan) + pairux invites [--json] Your invites left, invites sent and referral link + pairux invites create [--note ] Create an invite link (3 per account) Environment: PAIRUX_API_URL default https://pairux.com @@ -203,6 +206,50 @@ export async function run(argv: string[], deps: Deps): Promise { return 0; } + case 'redeem': { + const code = rest.join(' ').trim(); + if (!code) throw new UsageError('Usage: pairux redeem '); + if (!config.tokens) throw new UsageError('Sign in first: `pairux login`.'); + const r = await client.redeem(code); + deps.out(r.message); + if (r.planExpiresAt) { + deps.out(`Plan: ${r.effectivePlan}, active until ${r.planExpiresAt.slice(0, 10)}.`); + } + return 0; + } + + case 'invites': { + if (!config.tokens) throw new UsageError('Sign in first: `pairux login`.'); + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { note: { type: 'string' }, json: { type: 'boolean', default: false } }, + }); + if (positionals[0] === 'create') { + const inv = await client.createInvite(values.note ?? ''); + deps.out(values.json ? JSON.stringify(inv) : `Invite link (shown once): ${inv.url}`); + return 0; + } + if (positionals[0]) throw new UsageError('Usage: pairux invites [create [--note ]]'); + const o = await client.invites(); + if (values.json) { + deps.out(JSON.stringify(o)); + return 0; + } + deps.out(`${String(o.quota.left)} of ${String(o.quota.total)} invites left.`); + deps.out( + `Someone new who joins on your invite and confirms their email gets ${String(o.bonusDays)} days of Pro, and so do you.` + ); + if (!o.canInvite && o.unlocksAt) deps.out(`Invites unlock on ${o.unlocksAt.slice(0, 10)}.`); + for (const i of o.invites) { + const who = i.usedByName ? ` by ${i.usedByName}` : ''; + const bonus = i.bonus ? ` (bonus ${i.bonus})` : ''; + deps.out(` ${i.status}${who}${bonus}${i.note ? ` ${i.note}` : ''}`); + } + deps.out(`Referral link: ${o.refLink}`); + return 0; + } + case 'whoami': { if (!config.tokens) { deps.out( diff --git a/apps/desktop/src/main/agent-cli/index.ts b/apps/desktop/src/main/agent-cli/index.ts index 502e5b48..74e5d5f5 100644 --- a/apps/desktop/src/main/agent-cli/index.ts +++ b/apps/desktop/src/main/agent-cli/index.ts @@ -1,5 +1,5 @@ /** - * `pairux login|logout|whoami|join|listen|say|who|status|leave|room` + * `pairux login|logout|whoami|join|listen|say|who|status|leave|room|redeem|invites` * * The installed `pairux` launcher passes any argument it does not handle * itself (update, uninstall, --help, --version) straight to the app, the same @@ -22,6 +22,8 @@ export const AGENT_COMMANDS = new Set([ 'status', 'leave', 'room', + 'redeem', + 'invites', ]); /** diff --git a/apps/installer/scripts/install.ps1 b/apps/installer/scripts/install.ps1 index d8384bcf..81e55d91 100644 --- a/apps/installer/scripts/install.ps1 +++ b/apps/installer/scripts/install.ps1 @@ -164,7 +164,7 @@ if /I "%~1"=="update" goto :update if /I "%~1"=="upgrade" goto :update if /I "%~1"=="uninstall" goto :uninstall if /I "%~1"=="remove" goto :uninstall -for %%C in (login logout whoami join listen say who status leave) do if /I "%~1"=="%%C" goto :agent +for %%C in (login logout whoami join listen say who status leave room redeem invites) do if /I "%~1"=="%%C" goto :agent goto :run :help @@ -184,6 +184,8 @@ echo join CODE Join a session as an agent (--name, --client) echo listen [--json] Follow chat and who is here; keeps the agent present echo say MESSAGE Post in the session chat echo who ^| status ^| leave Roster, this agent, leave the session +echo redeem CODE Redeem a credit code (adds days of a paid plan) +echo invites [create] Your invites and referral link exit /b 0 :version diff --git a/apps/installer/scripts/install.sh b/apps/installer/scripts/install.sh index 2bafe186..dd19458b 100644 --- a/apps/installer/scripts/install.sh +++ b/apps/installer/scripts/install.sh @@ -778,6 +778,8 @@ case "\${1-}" in echo " listen [--json] Follow chat and who is here; keeps the agent present" echo " say Post in the session chat" echo " who | status | leave Roster, this agent, leave the session" + echo " redeem Redeem a credit code (adds days of a paid plan)" + echo " invites [create] Your invites and referral link" exit 0 ;; -v|--version) @@ -989,6 +991,8 @@ case "\${1-}" in echo " listen [--json] Follow chat and who is here; keeps the agent present" echo " say Post in the session chat" echo " who | status | leave Roster, this agent, leave the session" + echo " redeem Redeem a credit code (adds days of a paid plan)" + echo " invites [create] Your invites and referral link" exit 0 ;; -v|--version) diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index aa73c579..75b22024 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -2261,5 +2261,60 @@ "paid": "شكرًا! تُفعَّل خطة {plan} فور تأكيد الدفع، عادةً خلال دقائق.", "toDashboard": "اذهب إلى لوحة التحكم" } + }, + "credits": { + "billingTitle": "الخطة والفوترة", + "planName": "خطة {plan}", + "current": "الحالية", + "activeUntil": "نشطة حتى {date}", + "seePlans": "عرض الخطط", + "haveACode": "لديك رمز؟", + "redeem": "استرداد", + "redeeming": "جارٍ الاسترداد...", + "redeemFailed": "تعذر استرداد الرمز. حاول مرة أخرى.", + "inviteLink": "ادعُ شخصًا (ستحصلان معًا على أيام Pro)", + "invites": { + "title": "الدعوات", + "subtitle": "الدعوات محدودة: لكل حساب ثلاث دعوات. أرسل واحدة لمن تريده معك على PairUX.", + "loading": "جارٍ التحميل...", + "loadFailed": "تعذر تحميل الدعوات.", + "createFailed": "تعذر إنشاء الدعوة.", + "revokeFailed": "تعذر سحب الدعوة.", + "left": "تبقّى {left} من {total} دعوات", + "explain": "عندما ينضم شخص جديد عبر دعوتك ويؤكد بريده الإلكتروني، تحصلان معًا على {days} يومًا من PairUX Pro.", + "notePlaceholder": "لمن هذه الدعوة؟ (أنت فقط ترى هذا)", + "create": "إنشاء دعوة", + "noneLeft": "لقد استخدمت جميع دعواتك.", + "unlocks": "تُفتح الدعوات في {date}، بعد أسبوع من انضمامك.", + "locked": "الدعوات غير متاحة لهذا الحساب بعد.", + "shownOnce": "رابط دعوتك (يظهر مرة واحدة، انسخه الآن):", + "copy": "نسخ", + "copied": "تم النسخ", + "sent": "الدعوات التي أرسلتها", + "noNote": "دعوة", + "expires": "تنتهي في {date}", + "withdraw": "سحب", + "status": { + "open": "مفتوحة", + "used": "انضم", + "expired": "منتهية", + "revoked": "مسحوبة" + }, + "bonus": { + "granted": "{days} يومًا من Pro لكل منكما", + "pending": "المكافأة بانتظار تأكيد بريده الإلكتروني", + "denied": "لا مكافأة" + }, + "refTitle": "رابط الإحالة الخاص بك", + "refExplain": "رابط مفتوح بلا حد. التسجيلات التي تتم عبره تُنسب إليك." + }, + "landing": { + "title": "أنت مدعو إلى PairUX", + "body": "يريدك شخص ما على PairUX: مشاركة الشاشة مع التحكم عن بُعد، والاجتماعات، والبث المباشر. انضم عبر هذه الدعوة وستحصلان معًا على أيام من PairUX Pro.", + "accept": "قبول الدعوة", + "signup": "أنشئ حسابك المجاني", + "login": "لدي حساب بالفعل", + "failed": "تعذر قبول الدعوة." + } } } diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 4b4fad32..8dae7a36 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -2261,5 +2261,60 @@ "paid": "Danke! Dein {plan}-Plan wird aktiv, sobald die Zahlung bestätigt ist, meist innerhalb weniger Minuten.", "toDashboard": "Zum Dashboard" } + }, + "credits": { + "billingTitle": "Tarif und Abrechnung", + "planName": "Tarif {plan}", + "current": "Aktuell", + "activeUntil": "Aktiv bis {date}", + "seePlans": "Tarife ansehen", + "haveACode": "Du hast einen Code?", + "redeem": "Einlösen", + "redeeming": "Wird eingelöst...", + "redeemFailed": "Der Code konnte nicht eingelöst werden. Versuche es erneut.", + "inviteLink": "Lade jemanden ein (ihr beide bekommt Pro-Tage)", + "invites": { + "title": "Einladungen", + "subtitle": "Einladungen sind knapp: Jedes Konto hat drei. Schicke eine an jemanden, den du bei PairUX dabeihaben möchtest.", + "loading": "Wird geladen...", + "loadFailed": "Einladungen konnten nicht geladen werden.", + "createFailed": "Die Einladung konnte nicht erstellt werden.", + "revokeFailed": "Die Einladung konnte nicht zurückgezogen werden.", + "left": "Noch {left} von {total} Einladungen übrig", + "explain": "Wenn jemand Neues über deine Einladung beitritt und die E-Mail-Adresse bestätigt, bekommt ihr beide {days} Tage PairUX Pro.", + "notePlaceholder": "Für wen ist sie? (nur du siehst das)", + "create": "Einladung erstellen", + "noneLeft": "Du hast alle deine Einladungen verbraucht.", + "unlocks": "Einladungen werden am {date} freigeschaltet, eine Woche nach deiner Anmeldung.", + "locked": "Einladungen sind für dieses Konto noch nicht verfügbar.", + "shownOnce": "Dein Einladungslink (wird nur einmal angezeigt, kopiere ihn jetzt):", + "copy": "Kopieren", + "copied": "Kopiert", + "sent": "Von dir gesendete Einladungen", + "noNote": "Einladung", + "expires": "läuft am {date} ab", + "withdraw": "Zurückziehen", + "status": { + "open": "Offen", + "used": "Beigetreten", + "expired": "Abgelaufen", + "revoked": "Zurückgezogen" + }, + "bonus": { + "granted": "je {days} Tage Pro", + "pending": "Bonus wartet auf die Bestätigung der E-Mail-Adresse", + "denied": "kein Bonus" + }, + "refTitle": "Dein Empfehlungslink", + "refExplain": "Ein offener Link ohne Limit. Anmeldungen darüber werden dir gutgeschrieben." + }, + "landing": { + "title": "Du bist zu PairUX eingeladen", + "body": "Jemand möchte dich bei PairUX dabeihaben: Bildschirmfreigabe mit Fernsteuerung, Meetings und Livestreams. Tritt mit dieser Einladung bei und ihr bekommt beide Tage PairUX Pro.", + "accept": "Einladung annehmen", + "signup": "Kostenloses Konto erstellen", + "login": "Ich habe schon ein Konto", + "failed": "Die Einladung konnte nicht angenommen werden." + } } } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 65df0809..d1241b25 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2261,5 +2261,60 @@ "paid": "Thanks! Your {plan} plan turns on as soon as the payment confirms, usually within a few minutes.", "toDashboard": "Go to your dashboard" } + }, + "credits": { + "billingTitle": "Plan & billing", + "planName": "{plan} plan", + "current": "Current", + "activeUntil": "Active until {date}", + "seePlans": "See plans", + "haveACode": "Have a code?", + "redeem": "Redeem", + "redeeming": "Redeeming...", + "redeemFailed": "Could not redeem the code. Try again.", + "inviteLink": "Invite someone (you both get Pro days)", + "invites": { + "title": "Invites", + "subtitle": "Invites are scarce: every account gets three. Send one to someone you want on PairUX.", + "loading": "Loading...", + "loadFailed": "Could not load invites.", + "createFailed": "Could not create the invite.", + "revokeFailed": "Could not withdraw the invite.", + "left": "{left} of {total} invites left", + "explain": "When someone new joins on your invite and confirms their email, you both get {days} days of PairUX Pro.", + "notePlaceholder": "Who is it for? (only you see this)", + "create": "Create invite", + "noneLeft": "You have used all your invites.", + "unlocks": "Invites unlock on {date}, a week after you joined.", + "locked": "Invites are not available for this account yet.", + "shownOnce": "Your invite link (shown once, copy it now):", + "copy": "Copy", + "copied": "Copied", + "sent": "Invites you sent", + "noNote": "Invite", + "expires": "expires {date}", + "withdraw": "Withdraw", + "status": { + "open": "Open", + "used": "Joined", + "expired": "Expired", + "revoked": "Withdrawn" + }, + "bonus": { + "granted": "{days} days of Pro each", + "pending": "bonus waits for their email confirmation", + "denied": "no bonus" + }, + "refTitle": "Your referral link", + "refExplain": "An open link with no limit. Signups that come through it are credited to you." + }, + "landing": { + "title": "You're invited to PairUX", + "body": "Someone wants you on PairUX: screen sharing with remote control, meetings and live streams. Join on this invite and you both get days of PairUX Pro.", + "accept": "Accept invite", + "signup": "Create your free account", + "login": "I already have an account", + "failed": "Could not accept the invite." + } } } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 95201aa2..e8e42965 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -2261,5 +2261,60 @@ "paid": "¡Gracias! Tu plan {plan} se activa en cuanto se confirme el pago, normalmente en unos minutos.", "toDashboard": "Ir a tu panel" } + }, + "credits": { + "billingTitle": "Plan y facturación", + "planName": "Plan {plan}", + "current": "Actual", + "activeUntil": "Activo hasta el {date}", + "seePlans": "Ver planes", + "haveACode": "¿Tienes un código?", + "redeem": "Canjear", + "redeeming": "Canjeando...", + "redeemFailed": "No se pudo canjear el código. Inténtalo de nuevo.", + "inviteLink": "Invita a alguien (ambos obtenéis días de Pro)", + "invites": { + "title": "Invitaciones", + "subtitle": "Las invitaciones son escasas: cada cuenta tiene tres. Envía una a alguien que quieras en PairUX.", + "loading": "Cargando...", + "loadFailed": "No se pudieron cargar las invitaciones.", + "createFailed": "No se pudo crear la invitación.", + "revokeFailed": "No se pudo retirar la invitación.", + "left": "Te quedan {left} de {total} invitaciones", + "explain": "Cuando alguien nuevo se une con tu invitación y confirma su correo, ambos obtenéis {days} días de PairUX Pro.", + "notePlaceholder": "¿Para quién es? (solo tú lo ves)", + "create": "Crear invitación", + "noneLeft": "Has usado todas tus invitaciones.", + "unlocks": "Las invitaciones se desbloquean el {date}, una semana después de que te unieras.", + "locked": "Las invitaciones aún no están disponibles para esta cuenta.", + "shownOnce": "Tu enlace de invitación (se muestra una sola vez, cópialo ahora):", + "copy": "Copiar", + "copied": "Copiado", + "sent": "Invitaciones que enviaste", + "noNote": "Invitación", + "expires": "caduca el {date}", + "withdraw": "Retirar", + "status": { + "open": "Abierta", + "used": "Se unió", + "expired": "Caducada", + "revoked": "Retirada" + }, + "bonus": { + "granted": "{days} días de Pro cada uno", + "pending": "el bono espera la confirmación de su correo", + "denied": "sin bono" + }, + "refTitle": "Tu enlace de referido", + "refExplain": "Un enlace abierto y sin límite. Los registros que lleguen por él se te acreditan." + }, + "landing": { + "title": "Te han invitado a PairUX", + "body": "Alguien quiere verte en PairUX: pantalla compartida con control remoto, reuniones y transmisiones en vivo. Únete con esta invitación y ambos obtenéis días de PairUX Pro.", + "accept": "Aceptar invitación", + "signup": "Crea tu cuenta gratis", + "login": "Ya tengo una cuenta", + "failed": "No se pudo aceptar la invitación." + } } } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 6d1d8bb4..736121b7 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -2261,5 +2261,60 @@ "paid": "Merci ! Votre forfait {plan} s'active dès que le paiement est confirmé, en général en quelques minutes.", "toDashboard": "Aller au tableau de bord" } + }, + "credits": { + "billingTitle": "Forfait et facturation", + "planName": "Forfait {plan}", + "current": "Actuel", + "activeUntil": "Actif jusqu'au {date}", + "seePlans": "Voir les forfaits", + "haveACode": "Vous avez un code ?", + "redeem": "Utiliser", + "redeeming": "Validation...", + "redeemFailed": "Impossible d'utiliser le code. Réessayez.", + "inviteLink": "Invitez quelqu'un (vous gagnez tous les deux des jours Pro)", + "invites": { + "title": "Invitations", + "subtitle": "Les invitations sont rares : chaque compte en a trois. Envoyez-en une à quelqu'un que vous voulez voir sur PairUX.", + "loading": "Chargement...", + "loadFailed": "Impossible de charger les invitations.", + "createFailed": "Impossible de créer l'invitation.", + "revokeFailed": "Impossible de retirer l'invitation.", + "left": "{left} invitations restantes sur {total}", + "explain": "Quand une nouvelle personne rejoint grâce à votre invitation et confirme son e-mail, vous obtenez tous les deux {days} jours de PairUX Pro.", + "notePlaceholder": "Pour qui ? (vous seul le voyez)", + "create": "Créer une invitation", + "noneLeft": "Vous avez utilisé toutes vos invitations.", + "unlocks": "Les invitations se débloquent le {date}, une semaine après votre inscription.", + "locked": "Les invitations ne sont pas encore disponibles pour ce compte.", + "shownOnce": "Votre lien d'invitation (affiché une seule fois, copiez-le maintenant) :", + "copy": "Copier", + "copied": "Copié", + "sent": "Invitations envoyées", + "noNote": "Invitation", + "expires": "expire le {date}", + "withdraw": "Retirer", + "status": { + "open": "Ouverte", + "used": "Inscrit", + "expired": "Expirée", + "revoked": "Retirée" + }, + "bonus": { + "granted": "{days} jours de Pro chacun", + "pending": "le bonus attend la confirmation de son e-mail", + "denied": "pas de bonus" + }, + "refTitle": "Votre lien de parrainage", + "refExplain": "Un lien ouvert et sans limite. Les inscriptions qui passent par lui vous sont créditées." + }, + "landing": { + "title": "Vous êtes invité sur PairUX", + "body": "Quelqu'un vous attend sur PairUX : partage d'écran avec contrôle à distance, réunions et diffusions en direct. Rejoignez-nous avec cette invitation et vous gagnez tous les deux des jours de PairUX Pro.", + "accept": "Accepter l'invitation", + "signup": "Créer votre compte gratuit", + "login": "J'ai déjà un compte", + "failed": "Impossible d'accepter l'invitation." + } } } diff --git a/apps/web/messages/hi.json b/apps/web/messages/hi.json index 86494c3d..4fb5c0c7 100644 --- a/apps/web/messages/hi.json +++ b/apps/web/messages/hi.json @@ -2261,5 +2261,60 @@ "paid": "धन्यवाद! भुगतान की पुष्टि होते ही आपका {plan} प्लान चालू हो जाएगा, आमतौर पर कुछ मिनटों में।", "toDashboard": "अपने डैशबोर्ड पर जाएं" } + }, + "credits": { + "billingTitle": "प्लान और बिलिंग", + "planName": "{plan} प्लान", + "current": "मौजूदा", + "activeUntil": "{date} तक सक्रिय", + "seePlans": "प्लान देखें", + "haveACode": "कोई कोड है?", + "redeem": "रिडीम करें", + "redeeming": "रिडीम हो रहा है...", + "redeemFailed": "कोड रिडीम नहीं हो सका। फिर से कोशिश करें।", + "inviteLink": "किसी को आमंत्रित करें (आप दोनों को Pro के दिन मिलेंगे)", + "invites": { + "title": "आमंत्रण", + "subtitle": "आमंत्रण सीमित हैं: हर खाते को तीन मिलते हैं। जिसे आप PairUX पर चाहते हैं, उसे एक भेजें।", + "loading": "लोड हो रहा है...", + "loadFailed": "आमंत्रण लोड नहीं हो सके।", + "createFailed": "आमंत्रण नहीं बनाया जा सका।", + "revokeFailed": "आमंत्रण वापस नहीं लिया जा सका।", + "left": "{total} में से {left} आमंत्रण बाकी", + "explain": "जब कोई नया व्यक्ति आपके आमंत्रण से जुड़ता है और अपना ईमेल पक्का करता है, तो आप दोनों को {days} दिन का PairUX Pro मिलता है।", + "notePlaceholder": "यह किसके लिए है? (सिर्फ़ आपको दिखता है)", + "create": "आमंत्रण बनाएँ", + "noneLeft": "आपने अपने सभी आमंत्रण इस्तेमाल कर लिए हैं।", + "unlocks": "आमंत्रण {date} को खुलेंगे, आपके जुड़ने के एक हफ़्ते बाद।", + "locked": "इस खाते के लिए आमंत्रण अभी उपलब्ध नहीं हैं।", + "shownOnce": "आपका आमंत्रण लिंक (सिर्फ़ एक बार दिखता है, अभी कॉपी करें):", + "copy": "कॉपी करें", + "copied": "कॉपी हो गया", + "sent": "आपके भेजे आमंत्रण", + "noNote": "आमंत्रण", + "expires": "{date} को समाप्त", + "withdraw": "वापस लें", + "status": { + "open": "खुला", + "used": "जुड़ गया", + "expired": "समाप्त", + "revoked": "वापस लिया गया" + }, + "bonus": { + "granted": "हर एक को {days} दिन का Pro", + "pending": "बोनस उनके ईमेल की पुष्टि का इंतज़ार कर रहा है", + "denied": "कोई बोनस नहीं" + }, + "refTitle": "आपका रेफ़रल लिंक", + "refExplain": "बिना सीमा वाला खुला लिंक। इसके ज़रिए हुए साइनअप आपके खाते में गिने जाते हैं।" + }, + "landing": { + "title": "आपको PairUX पर आमंत्रित किया गया है", + "body": "कोई आपको PairUX पर चाहता है: रिमोट कंट्रोल के साथ स्क्रीन शेयरिंग, मीटिंग और लाइव स्ट्रीम। इस आमंत्रण से जुड़ें और आप दोनों को PairUX Pro के दिन मिलेंगे।", + "accept": "आमंत्रण स्वीकार करें", + "signup": "अपना मुफ़्त खाता बनाएँ", + "login": "मेरा खाता पहले से है", + "failed": "आमंत्रण स्वीकार नहीं किया जा सका।" + } } } diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index b98aebf4..7287bb37 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -2261,5 +2261,60 @@ "paid": "Terima kasih! Paket {plan} Anda aktif begitu pembayaran terkonfirmasi, biasanya dalam beberapa menit.", "toDashboard": "Ke dasbor Anda" } + }, + "credits": { + "billingTitle": "Paket dan penagihan", + "planName": "Paket {plan}", + "current": "Saat ini", + "activeUntil": "Aktif hingga {date}", + "seePlans": "Lihat paket", + "haveACode": "Punya kode?", + "redeem": "Tukar", + "redeeming": "Menukar...", + "redeemFailed": "Kode tidak dapat ditukar. Coba lagi.", + "inviteLink": "Undang seseorang (kalian berdua dapat hari Pro)", + "invites": { + "title": "Undangan", + "subtitle": "Undangan terbatas: setiap akun mendapat tiga. Kirim satu ke orang yang ingin kamu ajak ke PairUX.", + "loading": "Memuat...", + "loadFailed": "Undangan tidak dapat dimuat.", + "createFailed": "Undangan tidak dapat dibuat.", + "revokeFailed": "Undangan tidak dapat ditarik.", + "left": "Sisa {left} dari {total} undangan", + "explain": "Saat orang baru bergabung lewat undanganmu dan mengonfirmasi email, kalian berdua mendapat {days} hari PairUX Pro.", + "notePlaceholder": "Untuk siapa? (hanya kamu yang melihat)", + "create": "Buat undangan", + "noneLeft": "Kamu sudah memakai semua undanganmu.", + "unlocks": "Undangan terbuka pada {date}, seminggu setelah kamu bergabung.", + "locked": "Undangan belum tersedia untuk akun ini.", + "shownOnce": "Tautan undanganmu (hanya tampil sekali, salin sekarang):", + "copy": "Salin", + "copied": "Tersalin", + "sent": "Undangan yang kamu kirim", + "noNote": "Undangan", + "expires": "kedaluwarsa {date}", + "withdraw": "Tarik", + "status": { + "open": "Terbuka", + "used": "Bergabung", + "expired": "Kedaluwarsa", + "revoked": "Ditarik" + }, + "bonus": { + "granted": "masing-masing {days} hari Pro", + "pending": "bonus menunggu konfirmasi email mereka", + "denied": "tanpa bonus" + }, + "refTitle": "Tautan referralmu", + "refExplain": "Tautan terbuka tanpa batas. Pendaftaran lewat tautan ini dikreditkan kepadamu." + }, + "landing": { + "title": "Kamu diundang ke PairUX", + "body": "Seseorang ingin kamu ada di PairUX: berbagi layar dengan kontrol jarak jauh, rapat, dan siaran langsung. Bergabunglah lewat undangan ini dan kalian berdua dapat hari PairUX Pro.", + "accept": "Terima undangan", + "signup": "Buat akun gratismu", + "login": "Saya sudah punya akun", + "failed": "Undangan tidak dapat diterima." + } } } diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index f7685a96..42ea0e87 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -2261,5 +2261,60 @@ "paid": "Grazie! Il tuo piano {plan} si attiva appena il pagamento è confermato, di solito in pochi minuti.", "toDashboard": "Vai alla dashboard" } + }, + "credits": { + "billingTitle": "Piano e fatturazione", + "planName": "Piano {plan}", + "current": "Attuale", + "activeUntil": "Attivo fino al {date}", + "seePlans": "Vedi i piani", + "haveACode": "Hai un codice?", + "redeem": "Riscatta", + "redeeming": "Riscatto in corso...", + "redeemFailed": "Impossibile riscattare il codice. Riprova.", + "inviteLink": "Invita qualcuno (entrambi ottenete giorni Pro)", + "invites": { + "title": "Inviti", + "subtitle": "Gli inviti sono pochi: ogni account ne ha tre. Mandane uno a chi vorresti su PairUX.", + "loading": "Caricamento...", + "loadFailed": "Impossibile caricare gli inviti.", + "createFailed": "Impossibile creare l'invito.", + "revokeFailed": "Impossibile ritirare l'invito.", + "left": "{left} inviti su {total} rimasti", + "explain": "Quando una persona nuova si iscrive con il tuo invito e conferma la sua email, entrambi ottenete {days} giorni di PairUX Pro.", + "notePlaceholder": "Per chi è? (lo vedi solo tu)", + "create": "Crea invito", + "noneLeft": "Hai usato tutti i tuoi inviti.", + "unlocks": "Gli inviti si sbloccano il {date}, una settimana dopo la tua iscrizione.", + "locked": "Gli inviti non sono ancora disponibili per questo account.", + "shownOnce": "Il tuo link di invito (mostrato una sola volta, copialo ora):", + "copy": "Copia", + "copied": "Copiato", + "sent": "Inviti inviati", + "noNote": "Invito", + "expires": "scade il {date}", + "withdraw": "Ritira", + "status": { + "open": "Aperto", + "used": "Iscritto", + "expired": "Scaduto", + "revoked": "Ritirato" + }, + "bonus": { + "granted": "{days} giorni di Pro a testa", + "pending": "il bonus attende la conferma della sua email", + "denied": "nessun bonus" + }, + "refTitle": "Il tuo link di riferimento", + "refExplain": "Un link aperto e senza limiti. Le iscrizioni che arrivano da qui vengono accreditate a te." + }, + "landing": { + "title": "Sei stato invitato su PairUX", + "body": "Qualcuno ti vuole su PairUX: condivisione dello schermo con controllo remoto, riunioni e dirette. Iscriviti con questo invito e otterrete entrambi giorni di PairUX Pro.", + "accept": "Accetta l'invito", + "signup": "Crea il tuo account gratuito", + "login": "Ho già un account", + "failed": "Impossibile accettare l'invito." + } } } diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 732e2307..aa47816c 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -2261,5 +2261,60 @@ "paid": "ありがとうございます。支払いが確認されると {plan} プランが有効になります。通常は数分です。", "toDashboard": "ダッシュボードへ" } + }, + "credits": { + "billingTitle": "プランと請求", + "planName": "{plan}プラン", + "current": "現在のプラン", + "activeUntil": "{date}まで有効", + "seePlans": "プランを見る", + "haveACode": "コードをお持ちですか?", + "redeem": "使う", + "redeeming": "適用中...", + "redeemFailed": "コードを適用できませんでした。もう一度お試しください。", + "inviteLink": "友だちを招待(お互いにProの日数がもらえます)", + "invites": { + "title": "招待", + "subtitle": "招待は数に限りがあり、各アカウントに3件です。PairUXに誘いたい人に送りましょう。", + "loading": "読み込み中...", + "loadFailed": "招待を読み込めませんでした。", + "createFailed": "招待を作成できませんでした。", + "revokeFailed": "招待を取り消せませんでした。", + "left": "招待の残り:{left}/{total}件", + "explain": "新しい人があなたの招待で参加してメールを確認すると、お互いに{days}日分のPairUX Proがもらえます。", + "notePlaceholder": "誰への招待ですか?(あなただけに表示されます)", + "create": "招待を作成", + "noneLeft": "招待をすべて使い切りました。", + "unlocks": "招待は{date}に利用可能になります(参加から1週間後)。", + "locked": "このアカウントではまだ招待を利用できません。", + "shownOnce": "あなたの招待リンク(表示は1回のみです。今すぐコピーしてください):", + "copy": "コピー", + "copied": "コピーしました", + "sent": "送信した招待", + "noNote": "招待", + "expires": "{date}に期限切れ", + "withdraw": "取り消す", + "status": { + "open": "未使用", + "used": "参加済み", + "expired": "期限切れ", + "revoked": "取り消し済み" + }, + "bonus": { + "granted": "それぞれ{days}日分のPro", + "pending": "メール確認後にボーナスが付与されます", + "denied": "ボーナスなし" + }, + "refTitle": "あなたの紹介リンク", + "refExplain": "回数無制限の公開リンクです。このリンク経由の登録はあなたの実績になります。" + }, + "landing": { + "title": "PairUXに招待されました", + "body": "誰かがあなたをPairUXに誘っています。リモート操作つきの画面共有、ミーティング、ライブ配信が使えます。この招待から参加すると、お互いにPairUX Proの日数がもらえます。", + "accept": "招待を受ける", + "signup": "無料アカウントを作成", + "login": "アカウントをお持ちの方", + "failed": "招待を受けられませんでした。" + } } } diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index c00e446a..9ba98e9a 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -2261,5 +2261,60 @@ "paid": "감사합니다! 결제가 확인되면 {plan} 플랜이 바로 켜집니다. 보통 몇 분이면 됩니다.", "toDashboard": "대시보드로 이동" } + }, + "credits": { + "billingTitle": "요금제 및 결제", + "planName": "{plan} 요금제", + "current": "현재", + "activeUntil": "{date}까지 이용 가능", + "seePlans": "요금제 보기", + "haveACode": "코드가 있나요?", + "redeem": "등록", + "redeeming": "등록 중...", + "redeemFailed": "코드를 등록하지 못했습니다. 다시 시도해 주세요.", + "inviteLink": "친구 초대하기 (둘 다 Pro 기간을 받아요)", + "invites": { + "title": "초대", + "subtitle": "초대장은 수량이 한정되어 있어요. 계정당 3장씩 주어집니다. PairUX에서 함께하고 싶은 사람에게 보내 보세요.", + "loading": "불러오는 중...", + "loadFailed": "초대를 불러오지 못했습니다.", + "createFailed": "초대를 만들지 못했습니다.", + "revokeFailed": "초대를 취소하지 못했습니다.", + "left": "초대장 {total}장 중 {left}장 남음", + "explain": "새로운 사람이 내 초대로 가입하고 이메일을 인증하면, 둘 다 PairUX Pro {days}일을 받아요.", + "notePlaceholder": "누구에게 보내나요? (나만 볼 수 있어요)", + "create": "초대 만들기", + "noneLeft": "초대장을 모두 사용했습니다.", + "unlocks": "초대는 {date}에 열립니다. 가입 후 일주일 뒤예요.", + "locked": "이 계정은 아직 초대를 사용할 수 없습니다.", + "shownOnce": "내 초대 링크 (한 번만 표시되니 지금 복사하세요):", + "copy": "복사", + "copied": "복사됨", + "sent": "보낸 초대", + "noNote": "초대", + "expires": "{date} 만료", + "withdraw": "취소", + "status": { + "open": "대기 중", + "used": "가입함", + "expired": "만료됨", + "revoked": "취소됨" + }, + "bonus": { + "granted": "각각 Pro {days}일", + "pending": "이메일 인증을 기다리는 중이에요", + "denied": "보너스 없음" + }, + "refTitle": "내 추천 링크", + "refExplain": "횟수 제한 없는 공개 링크입니다. 이 링크로 가입한 사람은 내 실적으로 집계돼요." + }, + "landing": { + "title": "PairUX에 초대받으셨어요", + "body": "누군가 PairUX에서 함께하길 원해요. 원격 제어가 되는 화면 공유, 미팅, 라이브 스트림을 사용할 수 있어요. 이 초대로 가입하면 둘 다 PairUX Pro 기간을 받아요.", + "accept": "초대 수락", + "signup": "무료 계정 만들기", + "login": "이미 계정이 있어요", + "failed": "초대를 수락하지 못했습니다." + } } } diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index 7227ee8d..878d4ef9 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -2261,5 +2261,60 @@ "paid": "Bedankt! Je {plan}-abonnement wordt actief zodra de betaling bevestigd is, meestal binnen een paar minuten.", "toDashboard": "Naar je dashboard" } + }, + "credits": { + "billingTitle": "Abonnement en facturatie", + "planName": "{plan}-abonnement", + "current": "Huidig", + "activeUntil": "Actief tot {date}", + "seePlans": "Abonnementen bekijken", + "haveACode": "Heb je een code?", + "redeem": "Inwisselen", + "redeeming": "Bezig met inwisselen...", + "redeemFailed": "De code kon niet worden ingewisseld. Probeer het opnieuw.", + "inviteLink": "Nodig iemand uit (jullie krijgen allebei Pro-dagen)", + "invites": { + "title": "Uitnodigingen", + "subtitle": "Uitnodigingen zijn schaars: elk account heeft er drie. Stuur er een naar iemand die je op PairUX wilt hebben.", + "loading": "Laden...", + "loadFailed": "Uitnodigingen konden niet worden geladen.", + "createFailed": "De uitnodiging kon niet worden gemaakt.", + "revokeFailed": "De uitnodiging kon niet worden ingetrokken.", + "left": "Nog {left} van {total} uitnodigingen over", + "explain": "Als iemand nieuw via jouw uitnodiging lid wordt en het e-mailadres bevestigt, krijgen jullie allebei {days} dagen PairUX Pro.", + "notePlaceholder": "Voor wie is het? (alleen jij ziet dit)", + "create": "Uitnodiging maken", + "noneLeft": "Je hebt al je uitnodigingen gebruikt.", + "unlocks": "Uitnodigingen worden op {date} ontgrendeld, een week nadat je lid werd.", + "locked": "Uitnodigingen zijn nog niet beschikbaar voor dit account.", + "shownOnce": "Je uitnodigingslink (wordt één keer getoond, kopieer hem nu):", + "copy": "Kopiëren", + "copied": "Gekopieerd", + "sent": "Verstuurde uitnodigingen", + "noNote": "Uitnodiging", + "expires": "verloopt op {date}", + "withdraw": "Intrekken", + "status": { + "open": "Open", + "used": "Lid geworden", + "expired": "Verlopen", + "revoked": "Ingetrokken" + }, + "bonus": { + "granted": "elk {days} dagen Pro", + "pending": "bonus wacht op bevestiging van het e-mailadres", + "denied": "geen bonus" + }, + "refTitle": "Je verwijzingslink", + "refExplain": "Een open link zonder limiet. Aanmeldingen via deze link worden aan jou toegeschreven." + }, + "landing": { + "title": "Je bent uitgenodigd voor PairUX", + "body": "Iemand wil je op PairUX hebben: schermdelen met afstandsbediening, vergaderingen en livestreams. Word lid met deze uitnodiging en jullie krijgen allebei dagen PairUX Pro.", + "accept": "Uitnodiging accepteren", + "signup": "Maak je gratis account", + "login": "Ik heb al een account", + "failed": "De uitnodiging kon niet worden geaccepteerd." + } } } diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index 51b41593..1e44dfd7 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -2261,5 +2261,60 @@ "paid": "Dziękujemy! Plan {plan} włączy się, gdy płatność zostanie potwierdzona, zwykle w ciągu kilku minut.", "toDashboard": "Przejdź do panelu" } + }, + "credits": { + "billingTitle": "Plan i płatności", + "planName": "Plan {plan}", + "current": "Obecny", + "activeUntil": "Aktywny do {date}", + "seePlans": "Zobacz plany", + "haveACode": "Masz kod?", + "redeem": "Zrealizuj", + "redeeming": "Realizowanie...", + "redeemFailed": "Nie udało się zrealizować kodu. Spróbuj ponownie.", + "inviteLink": "Zaproś kogoś (oboje dostaniecie dni Pro)", + "invites": { + "title": "Zaproszenia", + "subtitle": "Zaproszeń jest mało: każde konto ma trzy. Wyślij jedno komuś, kogo chcesz zobaczyć w PairUX.", + "loading": "Ładowanie...", + "loadFailed": "Nie udało się wczytać zaproszeń.", + "createFailed": "Nie udało się utworzyć zaproszenia.", + "revokeFailed": "Nie udało się wycofać zaproszenia.", + "left": "Pozostało zaproszeń: {left} z {total}", + "explain": "Gdy ktoś nowy dołączy z Twojego zaproszenia i potwierdzi e-mail, oboje dostaniecie {days} dni PairUX Pro.", + "notePlaceholder": "Dla kogo? (widzisz to tylko Ty)", + "create": "Utwórz zaproszenie", + "noneLeft": "Wykorzystano wszystkie zaproszenia.", + "unlocks": "Zaproszenia odblokują się {date}, tydzień po dołączeniu.", + "locked": "Zaproszenia nie są jeszcze dostępne dla tego konta.", + "shownOnce": "Twój link z zaproszeniem (pokazany raz, skopiuj go teraz):", + "copy": "Kopiuj", + "copied": "Skopiowano", + "sent": "Wysłane zaproszenia", + "noNote": "Zaproszenie", + "expires": "wygasa {date}", + "withdraw": "Wycofaj", + "status": { + "open": "Otwarte", + "used": "Dołączył(a)", + "expired": "Wygasło", + "revoked": "Wycofane" + }, + "bonus": { + "granted": "po {days} dni Pro", + "pending": "bonus czeka na potwierdzenie e-maila", + "denied": "brak bonusu" + }, + "refTitle": "Twój link polecający", + "refExplain": "Otwarty link bez limitu. Rejestracje z tego linku są zaliczane Tobie." + }, + "landing": { + "title": "Masz zaproszenie do PairUX", + "body": "Ktoś chce Cię w PairUX: udostępnianie ekranu ze zdalnym sterowaniem, spotkania i transmisje na żywo. Dołącz z tym zaproszeniem, a oboje dostaniecie dni PairUX Pro.", + "accept": "Przyjmij zaproszenie", + "signup": "Utwórz darmowe konto", + "login": "Mam już konto", + "failed": "Nie udało się przyjąć zaproszenia." + } } } diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index 5ec98d83..04c9f37f 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -2261,5 +2261,60 @@ "paid": "Obrigado! Seu plano {plan} é ativado assim que o pagamento for confirmado, normalmente em poucos minutos.", "toDashboard": "Ir para o painel" } + }, + "credits": { + "billingTitle": "Plano e cobrança", + "planName": "Plano {plan}", + "current": "Atual", + "activeUntil": "Ativo até {date}", + "seePlans": "Ver planos", + "haveACode": "Tem um código?", + "redeem": "Resgatar", + "redeeming": "Resgatando...", + "redeemFailed": "Não foi possível resgatar o código. Tente de novo.", + "inviteLink": "Convide alguém (vocês dois ganham dias de Pro)", + "invites": { + "title": "Convites", + "subtitle": "Os convites são raros: cada conta tem três. Envie um para alguém que você quer no PairUX.", + "loading": "Carregando...", + "loadFailed": "Não foi possível carregar os convites.", + "createFailed": "Não foi possível criar o convite.", + "revokeFailed": "Não foi possível cancelar o convite.", + "left": "{left} de {total} convites restantes", + "explain": "Quando uma pessoa nova entra pelo seu convite e confirma o e-mail, vocês dois ganham {days} dias de PairUX Pro.", + "notePlaceholder": "Para quem é? (só você vê isso)", + "create": "Criar convite", + "noneLeft": "Você usou todos os seus convites.", + "unlocks": "Os convites são liberados em {date}, uma semana depois que você entrou.", + "locked": "Os convites ainda não estão disponíveis para esta conta.", + "shownOnce": "Seu link de convite (exibido uma vez, copie agora):", + "copy": "Copiar", + "copied": "Copiado", + "sent": "Convites que você enviou", + "noNote": "Convite", + "expires": "expira em {date}", + "withdraw": "Cancelar", + "status": { + "open": "Aberto", + "used": "Entrou", + "expired": "Expirado", + "revoked": "Cancelado" + }, + "bonus": { + "granted": "{days} dias de Pro para cada um", + "pending": "o bônus aguarda a confirmação do e-mail", + "denied": "sem bônus" + }, + "refTitle": "Seu link de indicação", + "refExplain": "Um link aberto e sem limite. Os cadastros feitos por ele são creditados a você." + }, + "landing": { + "title": "Você foi convidado para o PairUX", + "body": "Alguém quer você no PairUX: compartilhamento de tela com controle remoto, reuniões e transmissões ao vivo. Entre com este convite e vocês dois ganham dias de PairUX Pro.", + "accept": "Aceitar convite", + "signup": "Crie sua conta grátis", + "login": "Já tenho uma conta", + "failed": "Não foi possível aceitar o convite." + } } } diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index f04e0d74..326d20c3 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -2261,5 +2261,60 @@ "paid": "Спасибо! План {plan} включится, как только платёж подтвердится, обычно за несколько минут.", "toDashboard": "Перейти в панель" } + }, + "credits": { + "billingTitle": "Тариф и оплата", + "planName": "Тариф {plan}", + "current": "Текущий", + "activeUntil": "Действует до {date}", + "seePlans": "Смотреть тарифы", + "haveACode": "Есть код?", + "redeem": "Активировать", + "redeeming": "Активация...", + "redeemFailed": "Не удалось активировать код. Попробуйте ещё раз.", + "inviteLink": "Пригласите друга (вы оба получите дни Pro)", + "invites": { + "title": "Приглашения", + "subtitle": "Приглашений мало: у каждого аккаунта их три. Отправьте одно тому, кого хотите видеть в PairUX.", + "loading": "Загрузка...", + "loadFailed": "Не удалось загрузить приглашения.", + "createFailed": "Не удалось создать приглашение.", + "revokeFailed": "Не удалось отозвать приглашение.", + "left": "Осталось приглашений: {left} из {total}", + "explain": "Когда новый человек присоединится по вашему приглашению и подтвердит почту, вы оба получите {days} дн. PairUX Pro.", + "notePlaceholder": "Для кого? (видите только вы)", + "create": "Создать приглашение", + "noneLeft": "Вы использовали все приглашения.", + "unlocks": "Приглашения откроются {date}, через неделю после вашей регистрации.", + "locked": "Для этого аккаунта приглашения пока недоступны.", + "shownOnce": "Ваша ссылка-приглашение (показывается один раз, скопируйте сейчас):", + "copy": "Копировать", + "copied": "Скопировано", + "sent": "Отправленные вами приглашения", + "noNote": "Приглашение", + "expires": "истекает {date}", + "withdraw": "Отозвать", + "status": { + "open": "Открыто", + "used": "Присоединился", + "expired": "Истекло", + "revoked": "Отозвано" + }, + "bonus": { + "granted": "по {days} дн. Pro каждому", + "pending": "бонус ждёт подтверждения почты", + "denied": "без бонуса" + }, + "refTitle": "Ваша реферальная ссылка", + "refExplain": "Открытая ссылка без ограничений. Регистрации по ней засчитываются вам." + }, + "landing": { + "title": "Вас пригласили в PairUX", + "body": "Кто-то хочет видеть вас в PairUX: показ экрана с удалённым управлением, встречи и прямые трансляции. Присоединяйтесь по этому приглашению, и вы оба получите дни PairUX Pro.", + "accept": "Принять приглашение", + "signup": "Создать бесплатный аккаунт", + "login": "У меня уже есть аккаунт", + "failed": "Не удалось принять приглашение." + } } } diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index bcf35abe..28cff9ec 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -2261,5 +2261,60 @@ "paid": "Tack! Din {plan}-plan aktiveras så snart betalningen bekräftas, oftast inom några minuter.", "toDashboard": "Till din instrumentpanel" } + }, + "credits": { + "billingTitle": "Plan och fakturering", + "planName": "{plan}-plan", + "current": "Nuvarande", + "activeUntil": "Aktiv till {date}", + "seePlans": "Se planer", + "haveACode": "Har du en kod?", + "redeem": "Lös in", + "redeeming": "Löser in...", + "redeemFailed": "Det gick inte att lösa in koden. Försök igen.", + "inviteLink": "Bjud in någon (ni får båda Pro-dagar)", + "invites": { + "title": "Inbjudningar", + "subtitle": "Inbjudningar är sällsynta: varje konto får tre. Skicka en till någon du vill ha på PairUX.", + "loading": "Laddar...", + "loadFailed": "Det gick inte att ladda inbjudningarna.", + "createFailed": "Det gick inte att skapa inbjudan.", + "revokeFailed": "Det gick inte att dra tillbaka inbjudan.", + "left": "{left} av {total} inbjudningar kvar", + "explain": "När någon ny går med via din inbjudan och bekräftar sin e-post får ni båda {days} dagar PairUX Pro.", + "notePlaceholder": "Vem gäller det? (bara du ser detta)", + "create": "Skapa inbjudan", + "noneLeft": "Du har använt alla dina inbjudningar.", + "unlocks": "Inbjudningar låses upp {date}, en vecka efter att du gick med.", + "locked": "Inbjudningar är inte tillgängliga för det här kontot ännu.", + "shownOnce": "Din inbjudningslänk (visas en gång, kopiera den nu):", + "copy": "Kopiera", + "copied": "Kopierad", + "sent": "Inbjudningar du skickat", + "noNote": "Inbjudan", + "expires": "går ut {date}", + "withdraw": "Dra tillbaka", + "status": { + "open": "Öppen", + "used": "Gick med", + "expired": "Utgången", + "revoked": "Tillbakadragen" + }, + "bonus": { + "granted": "{days} dagar Pro var", + "pending": "bonusen väntar på bekräftelse av e-posten", + "denied": "ingen bonus" + }, + "refTitle": "Din värvningslänk", + "refExplain": "En öppen länk utan gräns. Registreringar via den krediteras dig." + }, + "landing": { + "title": "Du är inbjuden till PairUX", + "body": "Någon vill ha dig på PairUX: skärmdelning med fjärrstyrning, möten och livesändningar. Gå med via den här inbjudan så får ni båda dagar med PairUX Pro.", + "accept": "Acceptera inbjudan", + "signup": "Skapa ditt kostnadsfria konto", + "login": "Jag har redan ett konto", + "failed": "Det gick inte att acceptera inbjudan." + } } } diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index e5c39ef8..a0bf0195 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -2261,5 +2261,60 @@ "paid": "Teşekkürler! {plan} planın ödeme onaylanır onaylanmaz, genellikle birkaç dakika içinde açılır.", "toDashboard": "Panele git" } + }, + "credits": { + "billingTitle": "Plan ve faturalandırma", + "planName": "{plan} planı", + "current": "Geçerli", + "activeUntil": "{date} tarihine kadar aktif", + "seePlans": "Planları gör", + "haveACode": "Kodun var mı?", + "redeem": "Kullan", + "redeeming": "Kullanılıyor...", + "redeemFailed": "Kod kullanılamadı. Tekrar dene.", + "inviteLink": "Birini davet et (ikiniz de Pro günleri kazanırsınız)", + "invites": { + "title": "Davetler", + "subtitle": "Davetler kısıtlı: her hesapta üç tane var. PairUX'te görmek istediğin birine gönder.", + "loading": "Yükleniyor...", + "loadFailed": "Davetler yüklenemedi.", + "createFailed": "Davet oluşturulamadı.", + "revokeFailed": "Davet geri çekilemedi.", + "left": "{total} davetten {left} tanesi kaldı", + "explain": "Yeni biri davetinle katılıp e-postasını doğruladığında ikiniz de {days} gün PairUX Pro kazanırsınız.", + "notePlaceholder": "Kimin için? (sadece sen görürsün)", + "create": "Davet oluştur", + "noneLeft": "Tüm davetlerini kullandın.", + "unlocks": "Davetler {date} tarihinde açılır, katılmandan bir hafta sonra.", + "locked": "Bu hesap için davetler henüz kullanılamıyor.", + "shownOnce": "Davet bağlantın (bir kez gösterilir, şimdi kopyala):", + "copy": "Kopyala", + "copied": "Kopyalandı", + "sent": "Gönderdiğin davetler", + "noNote": "Davet", + "expires": "{date} tarihinde sona erer", + "withdraw": "Geri çek", + "status": { + "open": "Açık", + "used": "Katıldı", + "expired": "Süresi doldu", + "revoked": "Geri çekildi" + }, + "bonus": { + "granted": "her birine {days} gün Pro", + "pending": "bonus, e-postanın doğrulanmasını bekliyor", + "denied": "bonus yok" + }, + "refTitle": "Yönlendirme bağlantın", + "refExplain": "Sınırsız açık bir bağlantı. Bu bağlantıdan gelen kayıtlar sana yazılır." + }, + "landing": { + "title": "PairUX'e davetlisin", + "body": "Biri seni PairUX'te görmek istiyor: uzaktan kontrollü ekran paylaşımı, toplantılar ve canlı yayınlar. Bu davetle katıl, ikiniz de PairUX Pro günleri kazanın.", + "accept": "Daveti kabul et", + "signup": "Ücretsiz hesabını oluştur", + "login": "Zaten hesabım var", + "failed": "Davet kabul edilemedi." + } } } diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index 364ae011..8c6ce4eb 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -2261,5 +2261,60 @@ "paid": "Дякуємо! План {plan} увімкнеться, щойно платіж підтвердиться, зазвичай за кілька хвилин.", "toDashboard": "Перейти до панелі" } + }, + "credits": { + "billingTitle": "Тариф і оплата", + "planName": "Тариф {plan}", + "current": "Поточний", + "activeUntil": "Діє до {date}", + "seePlans": "Переглянути тарифи", + "haveACode": "Маєте код?", + "redeem": "Активувати", + "redeeming": "Активація...", + "redeemFailed": "Не вдалося активувати код. Спробуйте ще раз.", + "inviteLink": "Запросіть друга (ви обоє отримаєте дні Pro)", + "invites": { + "title": "Запрошення", + "subtitle": "Запрошень мало: кожен акаунт має три. Надішліть одне тому, кого хочете бачити в PairUX.", + "loading": "Завантаження...", + "loadFailed": "Не вдалося завантажити запрошення.", + "createFailed": "Не вдалося створити запрошення.", + "revokeFailed": "Не вдалося відкликати запрошення.", + "left": "Залишилося запрошень: {left} із {total}", + "explain": "Коли нова людина приєднається за вашим запрошенням і підтвердить пошту, ви обоє отримаєте {days} дн. PairUX Pro.", + "notePlaceholder": "Для кого? (бачите лише ви)", + "create": "Створити запрошення", + "noneLeft": "Ви використали всі запрошення.", + "unlocks": "Запрошення відкриються {date}, через тиждень після вашої реєстрації.", + "locked": "Для цього акаунта запрошення поки недоступні.", + "shownOnce": "Ваше посилання-запрошення (показується один раз, скопіюйте зараз):", + "copy": "Копіювати", + "copied": "Скопійовано", + "sent": "Надіслані вами запрошення", + "noNote": "Запрошення", + "expires": "спливає {date}", + "withdraw": "Відкликати", + "status": { + "open": "Відкрите", + "used": "Приєднався", + "expired": "Прострочене", + "revoked": "Відкликане" + }, + "bonus": { + "granted": "по {days} дн. Pro кожному", + "pending": "бонус чекає на підтвердження пошти", + "denied": "без бонусу" + }, + "refTitle": "Ваше реферальне посилання", + "refExplain": "Відкрите посилання без обмежень. Реєстрації через нього зараховуються вам." + }, + "landing": { + "title": "Вас запросили до PairUX", + "body": "Хтось хоче бачити вас у PairUX: демонстрація екрана з віддаленим керуванням, зустрічі та прямі трансляції. Приєднуйтеся за цим запрошенням, і ви обоє отримаєте дні PairUX Pro.", + "accept": "Прийняти запрошення", + "signup": "Створити безкоштовний акаунт", + "login": "У мене вже є акаунт", + "failed": "Не вдалося прийняти запрошення." + } } } diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index aa5ca498..a0fc3028 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -2261,5 +2261,60 @@ "paid": "Cảm ơn bạn! Gói {plan} sẽ bật ngay khi thanh toán được xác nhận, thường trong vài phút.", "toDashboard": "Đến bảng điều khiển" } + }, + "credits": { + "billingTitle": "Gói và thanh toán", + "planName": "Gói {plan}", + "current": "Hiện tại", + "activeUntil": "Có hiệu lực đến {date}", + "seePlans": "Xem các gói", + "haveACode": "Bạn có mã không?", + "redeem": "Đổi mã", + "redeeming": "Đang đổi mã...", + "redeemFailed": "Không thể đổi mã. Vui lòng thử lại.", + "inviteLink": "Mời bạn bè (cả hai cùng nhận ngày Pro)", + "invites": { + "title": "Lời mời", + "subtitle": "Lời mời có hạn: mỗi tài khoản có ba lượt. Hãy gửi cho người bạn muốn cùng dùng PairUX.", + "loading": "Đang tải...", + "loadFailed": "Không thể tải lời mời.", + "createFailed": "Không thể tạo lời mời.", + "revokeFailed": "Không thể thu hồi lời mời.", + "left": "Còn {left} trên {total} lời mời", + "explain": "Khi người mới tham gia bằng lời mời của bạn và xác nhận email, cả hai cùng nhận {days} ngày PairUX Pro.", + "notePlaceholder": "Gửi cho ai? (chỉ bạn thấy)", + "create": "Tạo lời mời", + "noneLeft": "Bạn đã dùng hết lời mời.", + "unlocks": "Lời mời sẽ mở vào {date}, một tuần sau khi bạn tham gia.", + "locked": "Tài khoản này chưa thể dùng lời mời.", + "shownOnce": "Liên kết mời của bạn (chỉ hiện một lần, hãy sao chép ngay):", + "copy": "Sao chép", + "copied": "Đã sao chép", + "sent": "Lời mời bạn đã gửi", + "noNote": "Lời mời", + "expires": "hết hạn {date}", + "withdraw": "Thu hồi", + "status": { + "open": "Đang mở", + "used": "Đã tham gia", + "expired": "Hết hạn", + "revoked": "Đã thu hồi" + }, + "bonus": { + "granted": "mỗi người {days} ngày Pro", + "pending": "thưởng đang chờ xác nhận email của họ", + "denied": "không có thưởng" + }, + "refTitle": "Liên kết giới thiệu của bạn", + "refExplain": "Một liên kết mở, không giới hạn. Các lượt đăng ký qua liên kết này được tính cho bạn." + }, + "landing": { + "title": "Bạn được mời dùng PairUX", + "body": "Có người muốn bạn dùng PairUX: chia sẻ màn hình có điều khiển từ xa, cuộc họp và phát trực tiếp. Tham gia bằng lời mời này để cả hai cùng nhận ngày PairUX Pro.", + "accept": "Chấp nhận lời mời", + "signup": "Tạo tài khoản miễn phí", + "login": "Tôi đã có tài khoản", + "failed": "Không thể chấp nhận lời mời." + } } } diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index dd987284..755b0bc3 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -2261,5 +2261,60 @@ "paid": "谢谢!付款确认后你的 {plan} 套餐会立即开通,通常只需几分钟。", "toDashboard": "前往控制台" } + }, + "credits": { + "billingTitle": "套餐与账单", + "planName": "{plan}套餐", + "current": "当前", + "activeUntil": "有效期至 {date}", + "seePlans": "查看套餐", + "haveACode": "有兑换码吗?", + "redeem": "兑换", + "redeeming": "兑换中...", + "redeemFailed": "无法兑换该兑换码,请重试。", + "inviteLink": "邀请好友(双方都获得 Pro 天数)", + "invites": { + "title": "邀请", + "subtitle": "邀请名额有限:每个账号有三个。发给你想拉进 PairUX 的人吧。", + "loading": "加载中...", + "loadFailed": "无法加载邀请。", + "createFailed": "无法创建邀请。", + "revokeFailed": "无法撤回邀请。", + "left": "剩余 {left} / {total} 个邀请", + "explain": "新用户通过你的邀请加入并确认邮箱后,你们双方都将获得 {days} 天 PairUX Pro。", + "notePlaceholder": "发给谁?(仅你可见)", + "create": "创建邀请", + "noneLeft": "你已用完所有邀请。", + "unlocks": "邀请将于 {date} 解锁,即你加入一周后。", + "locked": "此账号暂时无法使用邀请。", + "shownOnce": "你的邀请链接(仅显示一次,请立即复制):", + "copy": "复制", + "copied": "已复制", + "sent": "你发出的邀请", + "noNote": "邀请", + "expires": "{date} 到期", + "withdraw": "撤回", + "status": { + "open": "未使用", + "used": "已加入", + "expired": "已过期", + "revoked": "已撤回" + }, + "bonus": { + "granted": "双方各 {days} 天 Pro", + "pending": "奖励等待对方确认邮箱", + "denied": "无奖励" + }, + "refTitle": "你的推荐链接", + "refExplain": "不限次数的公开链接。通过它注册的用户会记在你名下。" + }, + "landing": { + "title": "你收到了 PairUX 邀请", + "body": "有人想邀请你使用 PairUX:支持远程控制的屏幕共享、会议和直播。通过此邀请加入,你们双方都将获得 PairUX Pro 天数。", + "accept": "接受邀请", + "signup": "创建免费账号", + "login": "我已有账号", + "failed": "无法接受邀请。" + } } } diff --git a/apps/web/package.json b/apps/web/package.json index 2ac1296e..46647813 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "dependencies": { "@anthropic-ai/sdk": "^1.3.0", "@pairux/meeting-translation": "workspace:*", + "@profullstack/affiliates": "^0.2.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", "@profullstack/emailer": "^1.0.3", "@profullstack/footer": "latest", @@ -25,6 +26,7 @@ "@profullstack/notifications": "0.1.4", "@profullstack/pairux-embed": "workspace:*", "@profullstack/profile": "0.1.6", + "@profullstack/redeem-credits": "^0.2.0", "@profullstack/stack": "^0.5.0", "@profullstack/x402-gateway": "^0.13.1", "@serwist/next": "^9.5.13", diff --git a/apps/web/src/app/admin/credit-codes.tsx b/apps/web/src/app/admin/credit-codes.tsx new file mode 100644 index 00000000..ed365229 --- /dev/null +++ b/apps/web/src/app/admin/credit-codes.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +interface CodeRow { + id: string; + label: string; + plan: string | null; + days: number; + maxRedemptions: number; + redemptions: number; + expiresAt: number | null; + createdAt: number; + revokedAt: number | null; +} + +/** /admin: mint, list and revoke credit codes (plan days). English only, admin-only. */ +export function CreditCodesAdmin() { + const [codes, setCodes] = useState([]); + const [plan, setPlan] = useState('pro'); + const [days, setDays] = useState('30'); + const [label, setLabel] = useState(''); + const [max, setMax] = useState('1'); + const [expires, setExpires] = useState(''); + const [minted, setMinted] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + const res = await fetch('/api/admin/credit-codes', { cache: 'no-store' }); + const body = (await res.json().catch(() => ({}))) as { data?: CodeRow[] }; + setCodes(body.data ?? []); + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function mint(e: React.SyntheticEvent) { + e.preventDefault(); + setError(null); + setMinted(null); + const res = await fetch('/api/admin/credit-codes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + plan, + days: Number(days), + label, + maxRedemptions: Number(max), + expiresInDays: Number(expires || 0), + }), + }); + const body = (await res.json().catch(() => ({}))) as { + data?: { code: string }; + error?: string; + }; + if (body.data) { + setMinted(body.data.code); + setLabel(''); + await load(); + } else setError(body.error ?? 'Mint failed'); + } + + async function revoke(id: string) { + await fetch(`/api/admin/credit-codes/${encodeURIComponent(id)}`, { method: 'DELETE' }); + await load(); + } + + const input = 'rounded-lg border border-gray-300 px-3 py-2 text-sm'; + return ( +
+
void mint(e)} className="grid gap-2 sm:grid-cols-6"> + + { + setDays(e.target.value); + }} + className={input} + aria-label="Days" + placeholder="Days" + inputMode="numeric" + /> + { + setLabel(e.target.value); + }} + className={`${input} sm:col-span-2`} + placeholder="Label (who it is for)" + /> + { + setMax(e.target.value); + }} + className={input} + aria-label="Max redemptions" + placeholder="Max uses" + inputMode="numeric" + /> + { + setExpires(e.target.value); + }} + className={input} + aria-label="Expires in days" + placeholder="Expires (days)" + inputMode="numeric" + /> + +
+ {minted && ( +

+ {minted}{' '} + + (shown once; only its hash is stored) + +

+ )} + {error &&

{error}

} + {codes.length > 0 && ( + + + + + + + + + + {codes.map((c) => ( + + + + + + + ))} + +
LabelGrantUsed +
{c.label} + {c.days}d {c.plan ?? '?'} + + {c.redemptions}/{c.maxRedemptions} + + {!c.revokedAt && ( + + )} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index 0901b148..0939a19f 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -9,6 +9,7 @@ import { createClient } from '@/lib/supabase/server'; import { serviceClient } from '@/lib/supabase/service'; import { IntegrationsManager } from './integrations-form'; import { EmailComposer } from './email-composer'; +import { CreditCodesAdmin } from './credit-codes'; export async function generateMetadata(): Promise { const t = await getTranslations('admin.meta'); @@ -98,6 +99,17 @@ export default async function AdminPage() { +
+
+

Credit codes

+

+ A code adds days of a paid plan when someone redeems it on their dashboard (or with{' '} + pairux redeem). +

+
+ +
+

{t('email.title')}

diff --git a/apps/web/src/app/api/admin/credit-codes/[id]/route.ts b/apps/web/src/app/api/admin/credit-codes/[id]/route.ts new file mode 100644 index 00000000..e45f0a2c --- /dev/null +++ b/apps/web/src/app/api/admin/credit-codes/[id]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { isAdminUser, requestUser } from '@/lib/request-user'; +import { getCreditDeps } from '@/lib/credits'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** DELETE /api/admin/credit-codes/: revoke a code (redemptions already made stand). */ +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const user = await requestUser(request); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (!(await isAdminUser(user.id))) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const { id } = await params; + try { + await getCreditDeps().credits.revoke(id); + return NextResponse.json({ data: { ok: true } }); + } catch (err) { + return NextResponse.json({ error: (err as Error).message || 'Unknown code' }, { status: 404 }); + } +} diff --git a/apps/web/src/app/api/admin/credit-codes/route.ts b/apps/web/src/app/api/admin/credit-codes/route.ts new file mode 100644 index 00000000..b20c854e --- /dev/null +++ b/apps/web/src/app/api/admin/credit-codes/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { isAdminUser, requestUser } from '@/lib/request-user'; +import { getCreditDeps, mintCreditCode, planForUnit, type PaidPlan } from '@/lib/credits'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +async function admin(request: Request) { + const user = await requestUser(request); + if (!user) return { res: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }; + if (!(await isAdminUser(user.id))) { + return { res: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }; + } + return { user }; +} + +/** GET /api/admin/credit-codes: every minted code (never the code itself, only its hash is stored). */ +export async function GET(request: Request) { + const a = await admin(request); + if (a.res) return a.res; + const codes = await getCreditDeps().credits.list(); + return NextResponse.json({ + data: codes.map((c) => ({ ...c, plan: planForUnit(c.grant.unit), days: c.grant.amount })), + }); +} + +/** + * POST /api/admin/credit-codes { days, plan?: plus|pro|team (default pro), label?, maxRedemptions?, expiresInDays? } + * Returns the plaintext code once. + */ +export async function POST(request: Request) { + const a = await admin(request); + if (a.res) return a.res; + const body = (await request.json().catch(() => null)) as Record | null; + if (!body) return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + const plan = (typeof body.plan === 'string' ? body.plan : 'pro') as PaidPlan; + const expiresInDays = Number(body.expiresInDays ?? 0); + try { + const minted = await mintCreditCode({ + days: Number(body.days), + plan, + label: typeof body.label === 'string' ? body.label : undefined, + maxRedemptions: body.maxRedemptions === undefined ? 1 : Number(body.maxRedemptions), + expiresAt: expiresInDays > 0 ? Date.now() + expiresInDays * 86_400_000 : null, + createdBy: a.user.id, + }); + return NextResponse.json( + { data: { ...minted, plan, days: minted.grant.amount } }, + { status: 201 } + ); + } catch (err) { + if (err instanceof TypeError) return NextResponse.json({ error: err.message }, { status: 400 }); + console.error('[admin/credit-codes] mint failed', err); + return NextResponse.json({ error: 'Could not mint the code.' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/auth/signup/route.ts b/apps/web/src/app/api/auth/signup/route.ts index 330cf436..9784288d 100644 --- a/apps/web/src/app/api/auth/signup/route.ts +++ b/apps/web/src/app/api/auth/signup/route.ts @@ -4,6 +4,7 @@ import { successResponse, errorResponse, handleApiError } from '@/lib/api'; import { NextResponse } from 'next/server'; import { validateProfile, saveSignupProfile } from '@profullstack/profile'; import { profileStore } from '@/lib/profile'; +import { INVITE_COOKIE, cookieFrom, onSignedIn, refFromRequest } from '@/lib/credits'; function optional(body: unknown, key: string): string { const v = (body as Record | null)?.[key]; @@ -77,6 +78,18 @@ export async function POST(request: Request) { } catch (e) { console.error('[signup] profile save failed', e); } + // An invite from /i/ (cookie, or `invite` in the body) is spent on + // the new account now; both bonuses wait for the email confirmation + // (settled in /auth/callback). ?ref= is recorded first-touch. + try { + const fromBody = optional(body, 'invite'); + await onSignedIn(data.user.id, { + inviteCode: fromBody !== '' ? fromBody : cookieFrom(request, INVITE_COOKIE), + refCode: refFromRequest(request), + }); + } catch (e) { + console.error('[signup] invite/ref failed', e); + } } // Check if user needs email confirmation or is already confirmed diff --git a/apps/web/src/app/api/credits/redeem/route.test.ts b/apps/web/src/app/api/credits/redeem/route.test.ts new file mode 100644 index 00000000..b9682a45 --- /dev/null +++ b/apps/web/src/app/api/credits/redeem/route.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const user = { current: null as { id: string } | null }; +vi.mock('@/lib/request-user', () => ({ + requestUser: () => Promise.resolve(user.current), + isAdminUser: () => Promise.resolve(false), +})); +const redeem = vi.fn(); +vi.mock('@/lib/credits', () => ({ + redeemCreditCode: (...a: unknown[]) => redeem(...a) as unknown, +})); + +import { POST } from './route'; + +function req(body: unknown) { + return new Request('https://pairux.com/api/credits/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + user.current = { id: 'u1' }; + redeem.mockReset(); +}); + +describe('POST /api/credits/redeem', () => { + it('needs a signed-in user', async () => { + user.current = null; + expect((await POST(req({ code: 'CR-1' }))).status).toBe(401); + }); + + it('rejects an empty code without calling the store', async () => { + expect((await POST(req({ code: ' ' }))).status).toBe(400); + expect(redeem).not.toHaveBeenCalled(); + }); + + it('returns the friendly reason for a bad code', async () => { + redeem.mockResolvedValue({ + ok: false, + reason: 'used_up', + message: 'That code has already been used.', + }); + const res = await POST(req({ code: 'CR-AAAA-BBBB-CCCC-DDDD' })); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + reason: 'used_up', + error: 'That code has already been used.', + }); + }); + + it('reports the plan and its new end date', async () => { + redeem.mockResolvedValue({ + ok: true, + plan: 'pro', + days: 30, + view: { plan: 'pro', effectivePlan: 'pro', planExpiresAt: '2026-11-25T00:00:00Z' }, + }); + const res = await POST(req({ code: 'CR-AAAA-BBBB-CCCC-DDDD' })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { message: string; effectivePlan: string } }; + expect(body.data.effectivePlan).toBe('pro'); + expect(body.data.message).toBe('Code redeemed: 30 days of PairUX Pro.'); + expect(redeem).toHaveBeenCalledWith('u1', 'CR-AAAA-BBBB-CCCC-DDDD'); + }); +}); diff --git a/apps/web/src/app/api/credits/redeem/route.ts b/apps/web/src/app/api/credits/redeem/route.ts new file mode 100644 index 00000000..b12a3a73 --- /dev/null +++ b/apps/web/src/app/api/credits/redeem/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server'; +import { FixedWindowRateLimiter } from '@/lib/rate-limit'; +import { requestUser } from '@/lib/request-user'; +import { redeemCreditCode } from '@/lib/credits'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// Codes carry 80 random bits, but there is no reason to let anyone guess fast. +const attemptsByUser = new FixedWindowRateLimiter(10, 10 * 60_000); + +/** + * POST /api/credits/redeem { code: "CR-XXXX-XXXX-XXXX-XXXX" } + * + * Redeem a credit code: it adds days of a paid plan (Pro unless the code says + * otherwise) to profiles.plan / plan_expires_at, the same columns a payment + * extends. Web cookie, desktop/mobile Bearer or `pairux redeem`. + */ +export async function POST(request: Request) { + const user = await requestUser(request); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const rl = attemptsByUser.check(user.id); + if (!rl.success) { + return NextResponse.json( + { error: 'Too many attempts. Try again later.' }, + { status: 429, headers: { 'Retry-After': String(rl.retryAfterSeconds) } } + ); + } + + let code: unknown; + try { + code = ((await request.json()) as { code?: unknown } | null)?.code; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (typeof code !== 'string' || !code.trim() || code.length > 64) { + return NextResponse.json({ error: 'Enter a code.', reason: 'invalid' }, { status: 400 }); + } + + try { + const r = await redeemCreditCode(user.id, code); + if (!r.ok) return NextResponse.json({ error: r.message, reason: r.reason }, { status: 400 }); + const name = `${r.plan.charAt(0).toUpperCase()}${r.plan.slice(1)}`; + return NextResponse.json({ + data: { + ok: true, + plan: r.plan, + days: r.days, + effectivePlan: r.view.effectivePlan, + planExpiresAt: r.view.planExpiresAt, + message: `Code redeemed: ${String(r.days)} days of PairUX ${name}.`, + }, + }); + } catch (err) { + console.error('[credits/redeem] failed:', err); + return NextResponse.json({ error: 'Could not redeem the code. Try again.' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/credits/route.ts b/apps/web/src/app/api/credits/route.ts new file mode 100644 index 00000000..c5cad5f4 --- /dev/null +++ b/apps/web/src/app/api/credits/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { requestUser } from '@/lib/request-user'; +import { getCreditDeps, planView } from '@/lib/credits'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** GET /api/credits: the caller's plan, its end date and the grants codes/invites gave. */ +export async function GET(request: Request) { + const user = await requestUser(request); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + try { + const d = getCreditDeps(); + const [view, history] = await Promise.all([ + planView(d.db, user.id), + d.credits.history(user.id), + ]); + return NextResponse.json({ + data: { + ...view, + grants: history + .filter((e) => e.kind === 'grant') + .map((e) => ({ + unit: e.unit, + amount: e.amount, + source: e.source, + at: new Date(e.at).toISOString(), + })), + }, + }); + } catch (err) { + console.error('[credits] overview failed', err); + return NextResponse.json({ error: 'Could not load your plan.' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/invites/[id]/route.ts b/apps/web/src/app/api/invites/[id]/route.ts new file mode 100644 index 00000000..13de6c59 --- /dev/null +++ b/apps/web/src/app/api/invites/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { requestUser } from '@/lib/request-user'; +import { InviteError, revokeInvite } from '@/lib/credits'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** DELETE /api/invites/: withdraw an unused invite (refunds the slot). */ +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const user = await requestUser(request); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { id } = await params; + try { + await revokeInvite(user.id, id); + return NextResponse.json({ data: { ok: true } }); + } catch (err) { + if (err instanceof InviteError) { + return NextResponse.json({ error: err.message, reason: err.reason }, { status: err.status }); + } + console.error('[invites] revoke failed', err); + return NextResponse.json({ error: 'Could not withdraw the invite.' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/invites/accept/route.ts b/apps/web/src/app/api/invites/accept/route.ts new file mode 100644 index 00000000..a75ceefa --- /dev/null +++ b/apps/web/src/app/api/invites/accept/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from 'next/server'; +import { FixedWindowRateLimiter } from '@/lib/rate-limit'; +import { requestUser } from '@/lib/request-user'; +import { INVITE_BONUS_PRO_DAYS, INVITE_COOKIE, acceptInvite } from '@/lib/credits'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const attemptsByUser = new FixedWindowRateLimiter(10, 10 * 60_000); + +/** + * POST /api/invites/accept { code: "INV-XXXX-XXXX-XXXX" } + * + * Join on an invite (accounts up to a week old, once). Both people get + * INVITE_BONUS_PRO_DAYS of Pro once the new account's email is confirmed: + * `bonus` is "granted", "pending" or "denied". + */ +export async function POST(request: Request) { + const user = await requestUser(request); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const rl = attemptsByUser.check(user.id); + if (!rl.success) { + return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 }); + } + + let code: unknown; + try { + code = ((await request.json()) as { code?: unknown } | null)?.code; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (typeof code !== 'string' || !code.trim() || code.length > 64) { + return NextResponse.json( + { error: 'Enter an invite code.', reason: 'invalid' }, + { status: 400 } + ); + } + + try { + const r = await acceptInvite(user.id, code); + if (!r.ok) return NextResponse.json({ error: r.message, reason: r.reason }, { status: 400 }); + const message = + r.bonus === 'granted' + ? `Invite accepted. You both got ${String(INVITE_BONUS_PRO_DAYS)} days of Pro.` + : r.bonus === 'pending' + ? 'Invite accepted. Your Pro days arrive once your email is confirmed.' + : 'Invite accepted.'; + const res = NextResponse.json({ + data: { ok: true, bonus: r.bonus, bonusDays: INVITE_BONUS_PRO_DAYS, message }, + }); + res.cookies.delete(INVITE_COOKIE); + return res; + } catch (err) { + console.error('[invites/accept] failed:', err); + return NextResponse.json({ error: 'Could not accept the invite. Try again.' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/invites/route.ts b/apps/web/src/app/api/invites/route.ts new file mode 100644 index 00000000..7dd602f1 --- /dev/null +++ b/apps/web/src/app/api/invites/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server'; +import { requestUser } from '@/lib/request-user'; +import { + InviteError, + createInvite, + inviteOverview, + inviteUrl, + publicOrigin, + refUrl, + settleInvitesSentBy, +} from '@/lib/credits'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** GET /api/invites: quota, invites sent (with bonus state) and the ?ref= link. */ +export async function GET(request: Request) { + const user = await requestUser(request); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + try { + // Bonuses held for an unconfirmed email settle as soon as either side looks. + await settleInvitesSentBy(user.id); + const o = await inviteOverview(user.id); + const origin = publicOrigin(request); + return NextResponse.json({ data: { ...o, refLink: refUrl(origin, o.affiliateCode) } }); + } catch (err) { + console.error('[invites] overview failed', err); + return NextResponse.json({ error: 'Could not load invites.' }, { status: 500 }); + } +} + +/** POST /api/invites { note? }: create one invite. The code is shown once. */ +export async function POST(request: Request) { + const user = await requestUser(request); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + let note = ''; + try { + const body = (await request.json().catch(() => ({}))) as { note?: unknown } | null; + if (typeof body?.note === 'string') note = body.note; + } catch { + /* empty body is fine */ + } + try { + const inv = await createInvite(user.id, note); + return NextResponse.json( + { + data: { + id: inv.id, + code: inv.code, + url: inviteUrl(publicOrigin(request), inv.code), + expiresAt: inv.expiresAt, + note: inv.note, + }, + }, + { status: 201 } + ); + } catch (err) { + if (err instanceof InviteError) { + return NextResponse.json({ error: err.message, reason: err.reason }, { status: err.status }); + } + console.error('[invites] create failed', err); + return NextResponse.json({ error: 'Could not create the invite.' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/auth/callback/route.ts b/apps/web/src/app/auth/callback/route.ts index 346e3c2a..a7749e3f 100644 --- a/apps/web/src/app/auth/callback/route.ts +++ b/apps/web/src/app/auth/callback/route.ts @@ -2,6 +2,29 @@ import { createServerClient, type CookieOptions } from '@supabase/ssr'; import { NextResponse, type NextRequest } from 'next/server'; import { exchangeCodeAndPersist } from '@/lib/supabase/exchange-code'; +import { INVITE_COOKIE, cookieFrom, onSignedIn, refFromRequest } from '@/lib/credits'; + +/** + * A confirmed sign-in: accept a pending /i/ invite, record a ?ref= + * attribution and settle any invite bonus held for email confirmation. + * Never blocks the redirect on failure. + */ +async function afterSignIn( + request: NextRequest, + response: NextResponse, + userId: string | undefined +) { + if (!userId) return; + try { + await onSignedIn(userId, { + inviteCode: cookieFrom(request, INVITE_COOKIE), + refCode: refFromRequest(request), + }); + if (request.cookies.get(INVITE_COOKIE)) response.cookies.delete(INVITE_COOKIE); + } catch (err) { + console.error('[auth/callback] invite settle failed', err); + } +} export function safeNextPath(next: string | null): string { if (!next || !next.startsWith('/') || next.startsWith('//')) { @@ -46,7 +69,7 @@ export async function GET(request: NextRequest) { // Handle token_hash from direct email links if (tokenHash && type) { const otpType = type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email'; - const { error } = await supabase.auth.verifyOtp({ + const { data, error } = await supabase.auth.verifyOtp({ token_hash: tokenHash, type: otpType, }); @@ -56,17 +79,20 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(`${origin}/login?error=auth_callback_error`); } + await afterSignIn(request, response, data.user?.id); return response; } // Handle code from PKCE flow if (code) { - const { error } = await exchangeCodeAndPersist(supabase, code); + const { session, error } = await exchangeCodeAndPersist(supabase, code); if (error) { console.error('Auth callback code exchange error:', error); return NextResponse.redirect(`${origin}/login?error=auth_callback_error`); } + + await afterSignIn(request, response, session?.user.id); } return response; diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index ab84fa4d..2e730f44 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,23 +1,13 @@ import type { Metadata } from 'next'; import Link from 'next/link'; -import { - Check, - Clock, - Monitor, - Users, - CreditCard, - BarChart3, - Settings, - Bell, - Radio, -} from 'lucide-react'; +import { Check, Clock, Monitor, Users, BarChart3, Settings, Bell, Radio } from 'lucide-react'; import { getTranslations } from 'next-intl/server'; import { useTranslations } from 'next-intl'; -import { maxListeners } from '@pairux/shared-types'; import { Header } from '@/components/header'; import { Footer } from '@/components/footer'; import { DashboardClient } from './components/DashboardClient'; import { ChannelsManager } from './ChannelsManager'; +import { PlanBillingCard } from '@/components/credits/PlanBillingCard'; export async function generateMetadata(): Promise { const t = await getTranslations('dashboard.metadata'); @@ -38,8 +28,6 @@ const stats = [ export default function DashboardPage() { const t = useTranslations('dashboard'); - // The free plan line is the /pricing one, with the enforced caps. - const tFree = useTranslations('pricing.tiers.free.features'); return (
@@ -98,21 +86,8 @@ export default function DashboardPage() { {/* Right Column - Plan & Settings */}
- {/* Current Plan */} -
-

{t('yourPlan')}

-
-
- {t('freePlan')} - - {t('current')} - -
-

- {tFree('participants', { count: maxListeners('free') })} -

-
-
+ {/* Plan, end date, "Have a code?" and invites */} + {/* Upgrade Options (Greyed Out) */}
@@ -158,21 +133,6 @@ export default function DashboardPage() {
- {/* Billing (Greyed Out) */} -
-
- -

{t('billing')}

-
-

{t('noPaymentMethod')}

- -
- {/* Settings Links */}

{t('settings')}

diff --git a/apps/web/src/app/i/[code]/page.tsx b/apps/web/src/app/i/[code]/page.tsx new file mode 100644 index 00000000..ee3f9401 --- /dev/null +++ b/apps/web/src/app/i/[code]/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; +import { Header } from '@/components/header'; +import { Footer } from '@/components/footer'; +import { InviteLanding } from '@/components/credits/InviteLanding'; +import { createClient } from '@/lib/supabase/server'; +import { INVITE_COOKIE } from '@/lib/credits'; + +export const dynamic = 'force-dynamic'; + +export async function generateMetadata(): Promise { + const t = await getTranslations('credits.landing'); + return { title: `${t('title')} - PairUX`, description: t('body'), robots: { index: false } }; +} + +/** /i/: an invite link. Visiting it uses nothing up; joining does. */ +export default async function InvitePage({ params }: { params: Promise<{ code: string }> }) { + const { code: raw } = await params; + const code = decodeURIComponent(raw).trim().slice(0, 64); + let signedIn = false; + try { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + signedIn = Boolean(user); + } catch { + signedIn = false; + } + + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/invites/page.tsx b/apps/web/src/app/invites/page.tsx new file mode 100644 index 00000000..0772e8dd --- /dev/null +++ b/apps/web/src/app/invites/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; +import { Header } from '@/components/header'; +import { Footer } from '@/components/footer'; +import { InvitesManager } from '@/components/credits/InvitesManager'; +import { createClient } from '@/lib/supabase/server'; + +export const dynamic = 'force-dynamic'; + +export async function generateMetadata(): Promise { + const t = await getTranslations('credits.invites'); + return { title: `${t('title')} - PairUX`, robots: { index: false, follow: false } }; +} + +export default async function InvitesPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login?redirect=/invites'); + const t = await getTranslations('credits.invites'); + + return ( +
+
+
+
+

{t('title')}

+

{t('subtitle')}

+ +
+
+
+
+ ); +} diff --git a/apps/web/src/components/auth/SignupForm.test.tsx b/apps/web/src/components/auth/SignupForm.test.tsx index e034c5c2..dd815477 100644 --- a/apps/web/src/components/auth/SignupForm.test.tsx +++ b/apps/web/src/components/auth/SignupForm.test.tsx @@ -58,6 +58,7 @@ describe('SignupForm', () => { company_domain: '', job_title: '', linkedin_url: '', + invite: '', }), }); }); diff --git a/apps/web/src/components/auth/SignupForm.tsx b/apps/web/src/components/auth/SignupForm.tsx index 73bfe824..c229691c 100644 --- a/apps/web/src/components/auth/SignupForm.tsx +++ b/apps/web/src/components/auth/SignupForm.tsx @@ -49,6 +49,8 @@ export function SignupForm() { company_domain: profile.company_domain ?? '', job_title: profile.job_title ?? '', linkedin_url: profile.linkedin_url ?? '', + // /i/ sends people here with ?invite= (the cookie carries it too). + invite: new URLSearchParams(window.location.search).get('invite') ?? '', }), }); diff --git a/apps/web/src/components/credits/InviteLanding.tsx b/apps/web/src/components/credits/InviteLanding.tsx new file mode 100644 index 00000000..8bc032fb --- /dev/null +++ b/apps/web/src/components/credits/InviteLanding.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; + +/** + * /i/: keep the invite in a cookie for 30 days so it survives signup + * and the email confirmation link, then offer signup (or one-click accept + * for a signed-in, new account). + */ +export function InviteLanding({ + code, + signedIn, + cookieName, +}: { + code: string; + signedIn: boolean; + cookieName: string; +}) { + const t = useTranslations('credits.landing'); + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + useEffect(() => { + const secure = window.location.protocol === 'https:' ? '; secure' : ''; + document.cookie = `${cookieName}=${encodeURIComponent(code)}; path=/; max-age=${String(30 * 86400)}; samesite=lax${secure}`; + }, [code, cookieName]); + + async function accept() { + setBusy(true); + try { + const res = await fetch('/api/invites/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + const body = (await res.json().catch(() => ({}))) as { + data?: { message: string }; + error?: string; + }; + setMsg( + body.data + ? { ok: true, text: body.data.message } + : { ok: false, text: body.error ?? t('failed') } + ); + if (body.data) { + document.cookie = `${cookieName}=; path=/; max-age=0`; + setTimeout(() => { + router.push('/dashboard#billing'); + }, 1500); + } + } finally { + setBusy(false); + } + } + + return ( +
+

{t('title')}

+

{t('body')}

+

{code}

+ {signedIn ? ( + + ) : ( +
+ + {t('signup')} + + + {t('login')} + +
+ )} + {msg && ( +

+ {msg.text} +

+ )} +
+ ); +} diff --git a/apps/web/src/components/credits/InvitesManager.tsx b/apps/web/src/components/credits/InvitesManager.tsx new file mode 100644 index 00000000..fab3fbb1 --- /dev/null +++ b/apps/web/src/components/credits/InvitesManager.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Copy, Gift, Link2 } from 'lucide-react'; + +interface InviteRow { + id: string; + createdAt: number; + expiresAt: number | null; + status: 'open' | 'used' | 'expired' | 'revoked'; + note: string; + usedByName?: string | null; + bonus?: 'granted' | 'denied' | 'pending'; +} + +interface Overview { + quota: { total: number; used: number; left: number }; + invites: InviteRow[]; + canInvite: boolean; + unlocksAt: string | null; + bonusDays: number; + refLink: string; +} + +function day(ms: number | string) { + return new Date(ms).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +/** /invites: three scarce invites per account, plus the open ?ref= link. */ +export function InvitesManager() { + const t = useTranslations('credits.invites'); + const [o, setO] = useState(null); + const [note, setNote] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [fresh, setFresh] = useState<{ code: string; url: string } | null>(null); + const [copied, setCopied] = useState(null); + + const load = useCallback(async () => { + const res = await fetch('/api/invites', { cache: 'no-store' }); + const body = (await res.json().catch(() => ({}))) as { data?: Overview; error?: string }; + if (body.data) setO(body.data); + else setError(body.error ?? t('loadFailed')); + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + async function create() { + setBusy(true); + setError(null); + try { + const res = await fetch('/api/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note }), + }); + const body = (await res.json().catch(() => ({}))) as { + data?: { code: string; url: string }; + error?: string; + }; + if (body.data) { + setFresh(body.data); + setNote(''); + await load(); + } else setError(body.error ?? t('createFailed')); + } finally { + setBusy(false); + } + } + + async function revoke(id: string) { + const res = await fetch(`/api/invites/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + setError(body.error ?? t('revokeFailed')); + } + await load(); + } + + function copy(text: string) { + void navigator.clipboard.writeText(text).then(() => { + setCopied(text); + setTimeout(() => { + setCopied(null); + }, 1500); + }); + } + + if (!o) { + return

{error ?? t('loading')}

; + } + + return ( +
+
+
+ +

+ {t('left', { left: o.quota.left, total: o.quota.total })} +

+
+

{t('explain', { days: o.bonusDays })}

+ + {o.canInvite ? ( + o.quota.left > 0 ? ( +
+ { + setNote(e.target.value); + }} + maxLength={200} + placeholder={t('notePlaceholder')} + className="min-w-0 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + +
+ ) : ( +

{t('noneLeft')}

+ ) + ) : ( +

+ {o.unlocksAt ? t('unlocks', { date: day(o.unlocksAt) }) : t('locked')} +

+ )} + + {fresh && ( +
+

{t('shownOnce')}

+
+ {fresh.url} + +
+

{fresh.code}

+
+ )} + {error &&

{error}

} +
+ + {o.invites.length > 0 && ( +
+

{t('sent')}

+
    + {o.invites.map((i) => ( +
  • +
    +

    {i.note || t('noNote')}

    +

    + {t(`status.${i.status}`)} + {i.status === 'used' && i.usedByName ? ` · ${i.usedByName}` : ''} + {i.bonus ? ` · ${t(`bonus.${i.bonus}`, { days: o.bonusDays })}` : ''} + {i.status === 'open' && i.expiresAt + ? ` · ${t('expires', { date: day(i.expiresAt) })}` + : ''} +

    +
    + {i.status === 'open' && ( + + )} +
  • + ))} +
+
+ )} + +
+
+ +

{t('refTitle')}

+
+

{t('refExplain')}

+
+ + {o.refLink} + + +
+ {copied &&

{t('copied')}

} +
+
+ ); +} diff --git a/apps/web/src/components/credits/PlanBillingCard.tsx b/apps/web/src/components/credits/PlanBillingCard.tsx new file mode 100644 index 00000000..0de7c1e2 --- /dev/null +++ b/apps/web/src/components/credits/PlanBillingCard.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { CreditCard, Gift, Ticket } from 'lucide-react'; + +interface PlanData { + plan: string; + effectivePlan: string; + planExpiresAt: string | null; +} + +const PLAN_NAME: Record = { free: 'Free', plus: 'Plus', pro: 'Pro', team: 'Team' }; + +/** + * Dashboard billing card: the plan in effect, when it ends, a "Have a code?" + * redeem form (credit codes add plan days) and a link to /invites. + */ +export function PlanBillingCard() { + const t = useTranslations('credits'); + const [plan, setPlan] = useState(null); + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + const load = useCallback(async () => { + try { + const res = await fetch('/api/credits', { cache: 'no-store' }); + if (!res.ok) return; + const body = (await res.json()) as { data?: PlanData }; + if (body.data) setPlan(body.data); + } catch { + /* keep the last value */ + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function redeem(e: React.SyntheticEvent) { + e.preventDefault(); + if (!code.trim()) return; + setBusy(true); + setMsg(null); + try { + const res = await fetch('/api/credits/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + const body = (await res.json().catch(() => ({}))) as { + data?: { message: string }; + error?: string; + }; + if (res.ok && body.data) { + setMsg({ ok: true, text: body.data.message }); + setCode(''); + await load(); + } else { + setMsg({ ok: false, text: body.error ?? t('redeemFailed') }); + } + } catch { + setMsg({ ok: false, text: t('redeemFailed') }); + } finally { + setBusy(false); + } + } + + const active = plan?.effectivePlan ?? 'free'; + const ends = + active !== 'free' && plan?.planExpiresAt + ? new Date(plan.planExpiresAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : null; + + return ( +
+
+ +

{t('billingTitle')}

+
+ +
+
+ + {t('planName', { plan: PLAN_NAME[active] ?? active })} + + + {t('current')} + +
+ {ends && ( +

{t('activeUntil', { date: ends })}

+ )} + {active === 'free' && ( + + {t('seePlans')} + + )} +
+ +
void redeem(e)} className="mt-5"> + +
+ { + setCode(e.target.value); + }} + placeholder="CR-XXXX-XXXX-XXXX-XXXX" + autoComplete="off" + spellCheck={false} + maxLength={64} + className="focus:border-primary-500 focus:ring-primary-500 min-w-0 flex-1 rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm uppercase" + /> + +
+ {msg && ( +

+ {msg.text} +

+ )} +
+ + + + {t('inviteLink')} + +
+ ); +} diff --git a/apps/web/src/lib/credits.test.ts b/apps/web/src/lib/credits.test.ts new file mode 100644 index 00000000..b756ab01 --- /dev/null +++ b/apps/web/src/lib/credits.test.ts @@ -0,0 +1,308 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Credits, memoryStore as creditMemory } from '@profullstack/redeem-credits'; +import { Affiliates, memoryStore as affMemory } from '@profullstack/affiliates'; +import { + INVITE_BONUS_MONTHLY_CAP, + INVITE_BONUS_PRO_DAYS, + acceptInvite, + applyGrant, + createInvite, + inviteOverview, + mailboxKey, + mintCreditCode, + onSignedIn, + redeemCreditCode, + revokeInvite, + settleInviteBonus, + type CreditDeps, +} from './credits'; + +const DAY = 24 * 60 * 60 * 1000; +const NOW = Date.parse('2026-10-26T12:00:00Z'); +const RANK = ['free', 'plus', 'pro', 'team']; + +type Row = Record; + +/** Just enough of supabase-js (and the plan_grant_days RPC) for credits.ts. */ +function fakeDb() { + const tables: Record = { profiles: [], invite_bonuses: [] }; + const users = new Map(); + + function query(table: string) { + const filters: ((r: Row) => boolean)[] = []; + let op: 'select' | 'delete' | 'upsert' = 'select'; + let pending: Row | null = null; + let opts: { onConflict: string; ignoreDuplicates?: boolean } | null = null; + const t = () => (tables[table] ??= []); + const rows = () => t().filter((r) => filters.every((f) => f(r))); + const run = () => { + if (op === 'delete') { + tables[table] = t().filter((r) => !filters.every((f) => f(r))); + return { data: null, error: null }; + } + if (op === 'upsert' && pending && opts) { + const keys = opts.onConflict.split(','); + const hit = t().find((r) => keys.every((k) => r[k] === pending?.[k])); + if (hit) return { data: [], error: null }; + const row = { created_at: new Date(NOW).toISOString(), ...pending }; + t().push(row); + return { data: [row], error: null }; + } + return { data: rows(), error: null }; + }; + const q: any = { + select: () => q, + eq: (k: string, v: unknown) => (filters.push((r) => r[k] === v), q), + in: (k: string, v: unknown[]) => (filters.push((r) => v.includes(r[k])), q), + gte: (k: string, v: string) => (filters.push((r) => String(r[k]) >= v), q), + delete: () => ((op = 'delete'), q), + upsert: (row: Row, o: { onConflict: string }) => ( + (op = 'upsert'), + (pending = row), + (opts = o), + q + ), + maybeSingle: async () => ({ data: rows()[0] ?? null, error: null }), + then: (res: (v: unknown) => void, rej?: (e: unknown) => void) => { + try { + res(run()); + } catch (e) { + rej?.(e); + } + }, + }; + return q; + } + + const db: any = { + from: query, + rpc: async (fn: string, a: any) => { + if (fn !== 'plan_grant_days') return { data: null, error: { message: `no rpc ${fn}` } }; + const p = tables.profiles?.find((r) => r.id === a.p_user_id); + if (!p) return { data: null, error: { message: 'no profile' } }; + const running = p.plan_expires_at && Date.parse(p.plan_expires_at) > Date.now(); + if (!(running && RANK.indexOf(p.plan) > RANK.indexOf(a.p_plan))) p.plan = a.p_plan; + const base = Math.max(Date.now(), p.plan_expires_at ? Date.parse(p.plan_expires_at) : 0); + p.plan_expires_at = new Date(base + a.p_days * DAY).toISOString(); + return { data: p.plan_expires_at, error: null }; + }, + auth: { + admin: { + getUserById: async (id: string) => { + const u = users.get(id); + return u + ? { data: { user: { id, ...u } }, error: null } + : { data: { user: null }, error: { message: 'nope' } }; + }, + }, + }, + }; + return { db, tables, users }; +} + +let env: ReturnType; +let deps: CreditDeps; + +function addUser(id: string, o: { ageDays: number; confirmed?: boolean; email?: string }) { + env.tables.profiles?.push({ + id, + username: id, + display_name: id, + email: o.email ?? `${id}@example.com`, + created_at: new Date(NOW - o.ageDays * DAY).toISOString(), + plan: 'free', + plan_expires_at: null, + }); + env.users.set(id, { + email: o.email ?? `${id}@example.com`, + email_confirmed_at: o.confirmed === false ? null : new Date(NOW).toISOString(), + }); +} + +const profile = (id: string) => env.tables.profiles!.find((p) => p.id === id)!; +const daysLeft = (id: string) => (Date.parse(profile(id).plan_expires_at) - Date.now()) / DAY; + +beforeEach(() => { + env = fakeDb(); + const now = () => NOW; + deps = { + db: env.db, + now, + credits: new Credits(creditMemory(), { + now, + onGrant: (u, g, s) => applyGrant(env.db, u, g, s), + }), + affiliates: new Affiliates(affMemory(), { now, defaultQuota: 3, inviteTtlMs: 30 * DAY }), + }; + addUser('alice', { ageDays: 60 }); +}); + +describe('credit codes', () => { + it('grants days of Pro through profiles.plan / plan_expires_at', async () => { + const { code } = await mintCreditCode({ days: 30 }, deps); + const r = await redeemCreditCode('alice', code.toLowerCase().replace(/-/g, ' '), deps); + expect(r).toMatchObject({ ok: true, plan: 'pro', days: 30 }); + expect(profile('alice').plan).toBe('pro'); + expect(daysLeft('alice')).toBeCloseTo(30, 0); + if (r.ok) expect(r.view.effectivePlan).toBe('pro'); + }); + + it('lets the mint choose the plan', async () => { + const { code } = await mintCreditCode({ days: 10, plan: 'team' }, deps); + expect(await redeemCreditCode('alice', code, deps)).toMatchObject({ ok: true, plan: 'team' }); + expect(profile('alice').plan).toBe('team'); + }); + + it('extends a running period and keeps a bigger running plan', async () => { + const p = profile('alice'); + p.plan = 'team'; + p.plan_expires_at = new Date(Date.now() + 10 * DAY).toISOString(); + const { code } = await mintCreditCode({ days: 5 }, deps); + await redeemCreditCode('alice', code, deps); + expect(p.plan).toBe('team'); + expect(daysLeft('alice')).toBeCloseTo(15, 0); + }); + + it('refuses bad, reused and used-up codes with friendly messages', async () => { + expect(await redeemCreditCode('alice', 'CR-NOPE', deps)).toMatchObject({ + ok: false, + reason: 'invalid', + }); + const { code } = await mintCreditCode({ days: 5 }, deps); + await redeemCreditCode('alice', code, deps); + expect(await redeemCreditCode('alice', code, deps)).toMatchObject({ + ok: false, + reason: 'already_redeemed', + }); + addUser('bob', { ageDays: 1 }); + const r = await redeemCreditCode('bob', code, deps); + expect(r).toMatchObject({ ok: false, reason: 'used_up' }); + if (!r.ok) expect(r.message).toMatch(/already been used/); + }); + + it('validates mint input', async () => { + await expect(mintCreditCode({ days: 0 }, deps)).rejects.toThrow(); + await expect(mintCreditCode({ days: 1.5 }, deps)).rejects.toThrow(); + await expect(mintCreditCode({ days: 5, plan: 'gold' as never }, deps)).rejects.toThrow(); + }); +}); + +describe('mailboxKey', () => { + it('treats +tags, case and Gmail dots as one mailbox', () => { + expect(mailboxKey('Ann+pairux@Example.com')).toBe('ann@example.com'); + expect(mailboxKey('a.n.n@googlemail.com')).toBe(mailboxKey('ann+x@gmail.com')); + expect(mailboxKey('a.nn@example.com')).not.toBe(mailboxKey('ann@example.com')); + expect(mailboxKey(null)).toBeNull(); + }); +}); + +describe('invites', () => { + it('needs a week-old account to invite', async () => { + addUser('newbie', { ageDays: 2 }); + await expect(createInvite('newbie', '', deps)).rejects.toMatchObject({ + reason: 'too_new', + status: 403, + }); + const o = await inviteOverview('newbie', deps); + expect(o.canInvite).toBe(false); + expect(o.unlocksAt).not.toBeNull(); + }); + + it('caps invites at 3 and refunds a withdrawn one', async () => { + const a = await createInvite('alice', 'one', deps); + await createInvite('alice', 'two', deps); + await createInvite('alice', 'three', deps); + await expect(createInvite('alice', 'four', deps)).rejects.toMatchObject({ + reason: 'no_invites_left', + status: 409, + }); + await revokeInvite('alice', a.id, deps); + expect((await inviteOverview('alice', deps)).quota.left).toBe(1); + }); + + it('gives both sides Pro days once a confirmed new account accepts', async () => { + const inv = await createInvite('alice', 'for Carol', deps); + addUser('carol', { ageDays: 0 }); + expect(await acceptInvite('carol', inv.code, deps)).toMatchObject({ + ok: true, + bonus: 'granted', + }); + for (const u of ['alice', 'carol']) { + expect(profile(u).plan).toBe('pro'); + expect(daysLeft(u)).toBeCloseTo(INVITE_BONUS_PRO_DAYS, 0); + } + const end = profile('carol').plan_expires_at; + expect((await settleInviteBonus('carol', deps)).status).toBe('already'); + expect(profile('carol').plan_expires_at).toBe(end); + const o = await inviteOverview('alice', deps); + expect(o.invites[0]).toMatchObject({ status: 'used', usedByName: '@carol', bonus: 'granted' }); + }); + + it('holds the bonus until the email is confirmed, then pays once', async () => { + const inv = await createInvite('alice', '', deps); + addUser('dan', { ageDays: 0, confirmed: false }); + expect(await acceptInvite('dan', inv.code, deps)).toMatchObject({ ok: true, bonus: 'pending' }); + expect(profile('dan').plan).toBe('free'); + env.users.get('dan')!.email_confirmed_at = new Date(NOW).toISOString(); + expect((await onSignedIn('dan', {}, deps)).settled?.status).toBe('granted'); + expect(profile('dan').plan).toBe('pro'); + expect((await settleInviteBonus('dan', deps)).status).toBe('already'); + expect(daysLeft('alice')).toBeCloseTo(INVITE_BONUS_PRO_DAYS, 0); + }); + + it('pays nothing when both addresses are the same mailbox', async () => { + env.users.get('alice')!.email = 'alice@gmail.com'; + const inv = await createInvite('alice', '', deps); + addUser('alt', { ageDays: 0, email: 'a.lice+alt@gmail.com' }); + expect(await acceptInvite('alt', inv.code, deps)).toMatchObject({ ok: true, bonus: 'denied' }); + expect(profile('alt').plan).toBe('free'); + expect(profile('alice').plan).toBe('free'); + expect(env.tables.invite_bonuses?.every((b) => b.reason === 'same_mailbox')).toBe(true); + }); + + it('refuses old accounts, second invites and your own invite', async () => { + const inv = await createInvite('alice', '', deps); + addUser('old', { ageDays: 30 }); + expect(await acceptInvite('old', inv.code, deps)).toMatchObject({ + ok: false, + reason: 'not_new', + }); + expect(await acceptInvite('alice', inv.code, deps)).toMatchObject({ + ok: false, + reason: 'not_new', + }); + addUser('erin', { ageDays: 0 }); + await acceptInvite('erin', inv.code, deps); + const inv2 = await createInvite('alice', '', deps); + expect(await acceptInvite('erin', inv2.code, deps)).toMatchObject({ + ok: false, + reason: 'already_invited', + }); + }); + + it('stops paying an inviter past the monthly cap', async () => { + await deps.affiliates.setQuota('alice', INVITE_BONUS_MONTHLY_CAP + 1); + for (let i = 0; i < INVITE_BONUS_MONTHLY_CAP; i++) { + const inv = await createInvite('alice', '', deps); + addUser(`u${String(i)}`, { ageDays: 0 }); + expect(await acceptInvite(`u${String(i)}`, inv.code, deps)).toMatchObject({ + bonus: 'granted', + }); + } + const inv = await createInvite('alice', '', deps); + addUser('late', { ageDays: 0 }); + expect(await acceptInvite('late', inv.code, deps)).toMatchObject({ ok: true, bonus: 'denied' }); + expect(profile('late').plan).toBe('free'); + expect(daysLeft('alice')).toBeCloseTo(INVITE_BONUS_PRO_DAYS * INVITE_BONUS_MONTHLY_CAP, 0); + }); + + it('records a ?ref= signup first-touch and accepts a cookie invite on sign-in', async () => { + const ref = await deps.affiliates.affiliateCode('alice'); + const inv = await createInvite('alice', '', deps); + addUser('fay', { ageDays: 0 }); + const r = await onSignedIn('fay', { inviteCode: inv.code, refCode: ref }, deps); + expect(r.invite).toMatchObject({ ok: true, bonus: 'granted' }); + expect(await deps.affiliates.affiliateOf('fay')).toBe('alice'); + }); +}); diff --git a/apps/web/src/lib/credits.ts b/apps/web/src/lib/credits.ts new file mode 100644 index 00000000..83d19bce --- /dev/null +++ b/apps/web/src/lib/credits.ts @@ -0,0 +1,639 @@ +/** + * Redeemable credit codes and scarce invites (server only). + * + * - A credit code (@profullstack/redeem-credits, `CR-XXXX-XXXX-XXXX-XXXX`) + * grants days of a paid plan. The unit names the plan: `pro_days` (the + * default), `plus_days` or `team_days`. It is minted by an admin (/admin, + * POST /api/admin/credit-codes) and redeemed on the dashboard billing card, + * via POST /api/credits/redeem or `pairux redeem `. + * - An invite (@profullstack/affiliates, `INV-XXXX-XXXX-XXXX`) is scarce: + * INVITE_QUOTA per account. When a NEW account joins on one, both people get + * INVITE_BONUS_PRO_DAYS of Pro, once the new account has confirmed its email, + * unless the two addresses are the same mailbox or the inviter has had + * INVITE_BONUS_MONTHLY_CAP bonuses in the last 30 days. + * + * The plan is never tracked in a second place: every grant goes through the + * plan_grant_days RPC, which extends profiles.plan / plan_expires_at exactly + * like a CoinPay payment (a smaller plan while a bigger one runs adds time and + * keeps the bigger plan). Web, desktop and mobile already read that. The + * credit_ledger table is the audit trail of grants; invite_bonuses records + * each bonus decision once (claimed before the grant). + * + * The open affiliate link is aff.affiliateCode() as `?ref=`; the middleware + * keeps it in the `referral_code` cookie (@profullstack/referrals' name) and + * signup records it with aff.attribute(). PairUX has no other referral + * program, so nothing is counted twice. + * + * Clients are built lazily so env vars are read at runtime, not inlined at build. + */ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { Credits, supabaseStore as creditStore, type Grant } from '@profullstack/redeem-credits'; +import { Affiliates, supabaseStore as affiliateStore } from '@profullstack/affiliates'; +import type { Plan } from '@pairux/shared-types'; +import { effectivePlan } from '@pairux/shared-types'; +import { serviceClient } from '@/lib/supabase/service'; + +export type PaidPlan = Exclude; + +/** Unit a code grants: `_days`. pro_days is the default. */ +export const PLAN_DAY_UNITS: Record = { + plus: 'plus_days', + pro: 'pro_days', + team: 'team_days', +}; +export const PRO_DAYS_UNIT = PLAN_DAY_UNITS.pro; + +/** Invites every account may create (admins can raise one user's with setQuota). */ +export const INVITE_QUOTA = 3; +/** Pro days each side gets when an invited account qualifies. */ +export const INVITE_BONUS_PRO_DAYS = 7; +/** An account can hand out invites once it is this old (stops sock-puppet chains). */ +export const INVITE_MIN_INVITER_AGE_DAYS = 7; +/** Only accounts at most this old can accept an invite: invites are for new people. */ +export const INVITE_ACCEPT_MAX_ACCOUNT_AGE_DAYS = 7; +/** Most inviter-side bonuses one account gets in 30 days (both sides are refused past it). */ +export const INVITE_BONUS_MONTHLY_CAP = 10; +/** Invite codes expire after this many days. */ +export const INVITE_TTL_DAYS = 30; +/** Cookie /i/ sets so the code survives the trip through signup. */ +export const INVITE_COOKIE = 'pairux_invite'; +/** @profullstack/referrals' cookie name; the middleware stores ?ref= in it. */ +export { REF_COOKIE, refFromRequest } from '@profullstack/affiliates'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Db = SupabaseClient; + +export interface CreditDeps { + db: Db; + credits: Credits; + affiliates: Affiliates; + now: () => number; +} + +/** Build the services over a supabase-js service-role client. */ +export function buildCreditDeps(db: Db, now: () => number = Date.now): CreditDeps { + const credits = new Credits(creditStore(db as never), { + now, + onGrant: async (userId: string, grant: Grant, source: string) => { + await applyGrant(db, userId, grant, source); + }, + }); + const affiliates = new Affiliates(affiliateStore(db as never), { + now, + defaultQuota: INVITE_QUOTA, + inviteTtlMs: INVITE_TTL_DAYS * DAY_MS, + }); + return { db, credits, affiliates, now }; +} + +let deps: CreditDeps | undefined; +export function getCreditDeps(): CreditDeps { + return (deps ??= buildCreditDeps(serviceClient() as unknown as Db)); +} + +/** Test hook. */ +export function setCreditDepsForTest(next: CreditDeps | undefined): void { + deps = next; +} + +export function planForUnit(unit: string): PaidPlan | null { + for (const [plan, u] of Object.entries(PLAN_DAY_UNITS) as [PaidPlan, string][]) { + if (u === unit) return plan; + } + return null; +} + +/** Mirror one committed grant into profiles.plan / plan_expires_at. */ +export async function applyGrant(db: Db, userId: string, grant: Grant, source: string) { + const plan = planForUnit(grant.unit); + if (!plan) { + console.error('[credits] grant with an unknown unit ignored', { + userId, + unit: grant.unit, + source, + }); + return; + } + const { error } = await db.rpc('plan_grant_days', { + p_user_id: userId, + p_plan: plan, + p_days: Math.round(grant.amount), + }); + if (error) throw new Error(`plan_grant_days: ${error.message}`); +} + +// ── Plan view ───────────────────────────────────────────────────────── + +export interface PlanView { + plan: Plan; + effectivePlan: Plan; + planExpiresAt: string | null; +} + +export async function planView(db: Db, userId: string): Promise { + const { data } = (await db + .from('profiles') + .select('plan, plan_expires_at') + .eq('id', userId) + .maybeSingle()) as { data: { plan: Plan; plan_expires_at: string | null } | null }; + const plan = data?.plan ?? 'free'; + const planExpiresAt = data?.plan_expires_at ?? null; + return { plan, planExpiresAt, effectivePlan: effectivePlan(plan, planExpiresAt) }; +} + +// ── Credit codes ────────────────────────────────────────────────────── + +export type RedeemFailure = 'invalid' | 'expired' | 'used_up' | 'already_redeemed' | 'revoked'; + +export const REDEEM_MESSAGES: Record = { + invalid: 'That code is not valid. Check it and try again.', + expired: 'That code has expired.', + used_up: 'That code has already been used.', + already_redeemed: 'You have already redeemed that code.', + revoked: 'That code is no longer valid.', +}; + +export async function redeemCreditCode( + userId: string, + code: string, + d: CreditDeps = getCreditDeps() +): Promise< + | { ok: true; plan: PaidPlan; days: number; view: PlanView } + | { ok: false; reason: RedeemFailure; message: string } +> { + const r = await d.credits.redeem(code, userId); + if (!r.ok) return { ok: false, reason: r.reason, message: REDEEM_MESSAGES[r.reason] }; + return { + ok: true, + plan: planForUnit(r.grant.unit) ?? 'pro', + days: r.grant.amount, + view: await planView(d.db, userId), + }; +} + +export interface MintInput { + days: number; + plan?: PaidPlan; + label?: string | undefined; + maxRedemptions?: number; + expiresAt?: number | null; + createdBy?: string; +} + +export async function mintCreditCode(o: MintInput, d: CreditDeps = getCreditDeps()) { + const plan = o.plan ?? 'pro'; + if (!(plan in PLAN_DAY_UNITS)) throw new TypeError('plan must be plus, pro or team'); + if (!Number.isInteger(o.days) || o.days < 1 || o.days > 3660) { + throw new TypeError('days must be a whole number from 1 to 3660'); + } + const max = o.maxRedemptions ?? 1; + if (!Number.isInteger(max) || max < 1 || max > 10_000) { + throw new TypeError('maxRedemptions must be a whole number from 1 to 10000'); + } + const label = o.label?.trim() ? o.label.trim().slice(0, 200) : undefined; + return d.credits.mint({ + grant: { unit: PLAN_DAY_UNITS[plan], amount: o.days }, + label: + label ?? `${String(o.days)} days of PairUX ${plan[0]?.toUpperCase() ?? ''}${plan.slice(1)}`, + maxRedemptions: max, + expiresAt: o.expiresAt ?? null, + ...(o.createdBy ? { createdBy: o.createdBy } : {}), + }); +} + +// ── Invites ─────────────────────────────────────────────────────────── + +interface ProfileRow { + id: string; + username: string | null; + display_name: string | null; + email: string | null; + created_at: string | null; +} + +async function getProfile(db: Db, userId: string): Promise { + const { data } = (await db + .from('profiles') + .select('id, username, display_name, email, created_at') + .eq('id', userId) + .maybeSingle()) as { data: ProfileRow | null }; + return data ?? null; +} + +function ageDays(createdAt: string | null | undefined, now: number): number { + if (!createdAt) return 0; + return (now - new Date(createdAt).getTime()) / DAY_MS; +} + +/** + * One mailbox, however it is spelled: lowercased, `+tag` dropped, and for + * Gmail the dots too (googlemail.com is gmail.com). + */ +export function mailboxKey(email: string | null | undefined): string | null { + if (!email) return null; + const at = email.trim().toLowerCase().lastIndexOf('@'); + if (at <= 0) return null; + const e = email.trim().toLowerCase(); + let local = e.slice(0, at); + let domain = e.slice(at + 1); + const plus = local.indexOf('+'); + if (plus >= 0) local = local.slice(0, plus); + if (domain === 'googlemail.com') domain = 'gmail.com'; + if (domain === 'gmail.com') local = local.replace(/\./g, ''); + return `${local}@${domain}`; +} + +export interface InviteRow { + id: string; + createdAt: number; + expiresAt: number | null; + usedBy: string | null; + usedAt: number | null; + note: string; + revokedAt: number | null; + status: 'open' | 'used' | 'expired' | 'revoked'; + usedByName?: string | null; + bonus?: 'granted' | 'denied' | 'pending'; +} + +interface BonusRow { + invite_id: string; + side: 'inviter' | 'invitee'; + user_id: string; + outcome: 'granted' | 'denied'; + reason: string | null; + created_at: string; +} + +async function bonusRows(db: Db, inviteIds: string[]): Promise { + if (!inviteIds.length) return []; + const { data } = (await db + .from('invite_bonuses') + .select('invite_id, side, user_id, outcome, reason, created_at') + .in('invite_id', inviteIds)) as { data: BonusRow[] | null }; + return data ?? []; +} + +export async function inviteOverview(userId: string, d: CreditDeps = getCreditDeps()) { + const now = d.now(); + const [quota, raw, profile] = await Promise.all([ + d.affiliates.quota(userId), + d.affiliates.invites(userId), + getProfile(d.db, userId), + ]); + const usedIds = raw.map((i) => i.usedBy).filter((u): u is string => !!u); + const names = new Map(); + if (usedIds.length) { + const { data } = (await d.db + .from('profiles') + .select('id, username, display_name') + .in('id', usedIds)) as { + data: { id: string; username: string | null; display_name: string | null }[] | null; + }; + for (const p of data ?? []) names.set(p.id, p.username ? `@${p.username}` : p.display_name); + } + const bonuses = await bonusRows( + d.db, + raw.filter((i) => i.usedBy).map((i) => i.id) + ); + const invites: InviteRow[] = raw.map((i) => { + const mine = bonuses.find((b) => b.invite_id === i.id && b.side === 'inviter'); + return { + id: i.id, + createdAt: i.createdAt, + expiresAt: i.expiresAt, + usedBy: i.usedBy, + usedAt: i.usedAt, + note: i.note, + revokedAt: i.revokedAt ?? null, + status: i.usedBy + ? 'used' + : i.revokedAt + ? 'revoked' + : i.expiresAt !== null && now >= i.expiresAt + ? 'expired' + : 'open', + ...(i.usedBy + ? { usedByName: names.get(i.usedBy) ?? null, bonus: mine ? mine.outcome : 'pending' } + : {}), + }; + }); + const canInvite = ageDays(profile?.created_at, now) >= INVITE_MIN_INVITER_AGE_DAYS; + return { + quota, + invites, + canInvite, + unlocksAt: + !canInvite && profile?.created_at + ? new Date( + new Date(profile.created_at).getTime() + INVITE_MIN_INVITER_AGE_DAYS * DAY_MS + ).toISOString() + : null, + bonusDays: INVITE_BONUS_PRO_DAYS, + affiliateCode: await d.affiliates.affiliateCode(userId), + }; +} + +export class InviteError extends Error { + constructor( + message: string, + readonly status: number, + readonly reason: string + ) { + super(message); + } +} + +export async function createInvite(userId: string, note = '', d: CreditDeps = getCreditDeps()) { + const profile = await getProfile(d.db, userId); + if (!profile) throw new InviteError('Profile not found.', 404, 'no_profile'); + if (ageDays(profile.created_at, d.now()) < INVITE_MIN_INVITER_AGE_DAYS) { + throw new InviteError( + `Invites unlock once your account is ${String(INVITE_MIN_INVITER_AGE_DAYS)} days old.`, + 403, + 'too_new' + ); + } + try { + return await d.affiliates.createInvite(userId, note.slice(0, 200)); + } catch (err) { + if (/no invites left/i.test((err as Error).message)) { + throw new InviteError('You have no invites left.', 409, 'no_invites_left'); + } + throw err; + } +} + +export async function revokeInvite( + userId: string, + inviteId: string, + d: CreditDeps = getCreditDeps() +) { + const mine = (await d.affiliates.invites(userId)).find((i) => i.id === inviteId); + if (!mine) throw new InviteError('Invite not found.', 404, 'not_found'); + if (mine.usedBy) throw new InviteError('That invite has already been used.', 409, 'used'); + await d.affiliates.revokeInvite(inviteId); +} + +export const ACCEPT_MESSAGES: Record = { + invalid: 'That invite code is not valid.', + expired: 'That invite has expired.', + used: 'That invite has already been used.', + self: 'You cannot use your own invite.', + revoked: 'That invite was withdrawn.', + already_invited: 'Your account has already joined on an invite.', + not_new: `Invites are for new accounts (${String(INVITE_ACCEPT_MAX_ACCOUNT_AGE_DAYS)} days old or less).`, + no_profile: 'Profile not found.', +}; + +export type AcceptResult = + | { ok: true; inviterId: string; inviteId: string; bonus: 'granted' | 'pending' | 'denied' } + | { ok: false; reason: string; message: string }; + +/** + * Spend an invite on a new account and record who invited whom, then settle + * the bonus (granted now if the email is confirmed, otherwise on confirmation). + */ +export async function acceptInvite( + userId: string, + code: string, + d: CreditDeps = getCreditDeps() +): Promise { + const fail = (reason: string) => ({ + ok: false as const, + reason, + message: ACCEPT_MESSAGES[reason] ?? reason, + }); + const profile = await getProfile(d.db, userId); + if (!profile) return fail('no_profile'); + if (ageDays(profile.created_at, d.now()) > INVITE_ACCEPT_MAX_ACCOUNT_AGE_DAYS) + return fail('not_new'); + if (await d.affiliates.invitedBy(userId)) return fail('already_invited'); + + const r = await d.affiliates.redeemInvite(code, userId); + if (!r.ok) return fail(r.reason); + + const settled = await settleInviteBonus(userId, d); + return { + ok: true, + inviterId: r.inviterId, + inviteId: r.inviteId, + bonus: + settled.status === 'granted' || settled.status === 'already' + ? 'granted' + : settled.status === 'denied' + ? 'denied' + : 'pending', + }; +} + +async function emailOf( + db: Db, + userId: string +): Promise<{ email: string | null; confirmed: boolean }> { + const { data, error } = await db.auth.admin.getUserById(userId); + if (error) return { email: null, confirmed: false }; + const u = data.user as { email?: string | null; email_confirmed_at?: string | null } | null; + if (!u) return { email: null, confirmed: false }; + return { email: u.email ?? null, confirmed: Boolean(u.email_confirmed_at) }; +} + +/** Claim a (invite, side) decision; false when one was already recorded. */ +async function claim( + db: Db, + row: Omit & { days: number } +): Promise { + const { data, error } = (await db + .from('invite_bonuses') + .upsert(row, { onConflict: 'invite_id,side', ignoreDuplicates: true }) + .select('invite_id')) as { data: unknown[] | null; error: { message: string } | null }; + if (error) throw new Error(`invite_bonuses: ${error.message}`); + return (data ?? []).length > 0; +} + +async function unclaim(db: Db, inviteId: string, side: string) { + await db.from('invite_bonuses').delete().eq('invite_id', inviteId).eq('side', side); +} + +async function grantSide( + d: CreditDeps, + inviteId: string, + side: 'inviter' | 'invitee', + userId: string +) { + const won = await claim(d.db, { + invite_id: inviteId, + side, + user_id: userId, + outcome: 'granted', + reason: null, + days: INVITE_BONUS_PRO_DAYS, + }); + if (!won) return false; + try { + await d.credits.grant( + userId, + { unit: PRO_DAYS_UNIT, amount: INVITE_BONUS_PRO_DAYS }, + `invite:${inviteId}:${side}` + ); + } catch (err) { + // Let a later settle retry: nothing was granted. + await unclaim(d.db, inviteId, side); + throw err; + } + return true; +} + +async function inviterBonusesLast30Days(d: CreditDeps, inviterId: string): Promise { + const since = new Date(d.now() - 30 * DAY_MS).toISOString(); + const { data } = (await d.db + .from('invite_bonuses') + .select('invite_id') + .eq('user_id', inviterId) + .eq('side', 'inviter') + .eq('outcome', 'granted') + .gte('created_at', since)) as { data: unknown[] | null }; + return (data ?? []).length; +} + +export type SettleResult = + | { status: 'none' } + | { status: 'pending'; reason: string } + | { status: 'denied'; reason: string } + | { status: 'granted' | 'already'; inviteId: string; inviterId: string }; + +/** + * Grant the invite bonus to both sides if the invited account qualifies now. + * Idempotent: each side is decided once per invite. Safe to call often. + */ +export async function settleInviteBonus( + inviteeId: string, + d: CreditDeps = getCreditDeps() +): Promise { + const inviterId = await d.affiliates.invitedBy(inviteeId); + if (!inviterId) return { status: 'none' }; + const invite = (await d.affiliates.invites(inviterId)).find((i) => i.usedBy === inviteeId); + if (!invite) return { status: 'none' }; + + const existing = await bonusRows(d.db, [invite.id]); + if (existing.length >= 2) { + const denied = existing.find((b) => b.outcome === 'denied'); + return denied + ? { status: 'denied', reason: denied.reason ?? 'denied' } + : { status: 'already', inviteId: invite.id, inviterId }; + } + + const [invitee, inviter] = await Promise.all([ + emailOf(d.db, inviteeId), + emailOf(d.db, inviterId), + ]); + if (!invitee.confirmed) return { status: 'pending', reason: 'email not confirmed' }; + + let deny: string | null = null; + const a = mailboxKey(invitee.email); + if (a && a === mailboxKey(inviter.email)) deny = 'same_mailbox'; + else if ((await inviterBonusesLast30Days(d, inviterId)) >= INVITE_BONUS_MONTHLY_CAP) + deny = 'monthly_cap'; + if (deny) { + for (const [side, uid] of [ + ['invitee', inviteeId], + ['inviter', inviterId], + ] as const) { + await claim(d.db, { + invite_id: invite.id, + side, + user_id: uid, + outcome: 'denied', + reason: deny, + days: 0, + }); + } + console.warn('[invites] bonus denied', { inviteId: invite.id, inviteeId, inviterId, deny }); + return { status: 'denied', reason: deny }; + } + + const gotInvitee = await grantSide(d, invite.id, 'invitee', inviteeId); + const gotInviter = await grantSide(d, invite.id, 'inviter', inviterId); + return { + status: gotInvitee || gotInviter ? 'granted' : 'already', + inviteId: invite.id, + inviterId, + }; +} + +/** Settle every used invite this user sent (cheap: at most the quota). */ +export async function settleInvitesSentBy(inviterId: string, d: CreditDeps = getCreditDeps()) { + for (const inv of await d.affiliates.invites(inviterId)) { + if (!inv.usedBy) continue; + try { + await settleInviteBonus(inv.usedBy, d); + } catch (err) { + console.error('[invites] settle failed', { inviteId: inv.id, err }); + } + } +} + +/** + * After a sign-in or signup: accept a pending invite code (from the /i/ + * cookie) and record a ?ref= attribution, then settle any bonus. Never throws: + * signing in must not fail on an invite. + */ +export async function onSignedIn( + userId: string, + opts: { inviteCode?: string | null; refCode?: string | null }, + d: CreditDeps = getCreditDeps() +): Promise<{ invite?: AcceptResult; settled?: SettleResult }> { + const out: { invite?: AcceptResult; settled?: SettleResult } = {}; + try { + if (opts.refCode) await d.affiliates.attribute(opts.refCode, userId); + } catch (err) { + console.error('[affiliates] attribute failed', err); + } + try { + if (opts.inviteCode && !(await d.affiliates.invitedBy(userId))) { + out.invite = await acceptInvite(userId, opts.inviteCode, d); + } else { + out.settled = await settleInviteBonus(userId, d); + } + } catch (err) { + console.error('[invites] sign-in settle failed', err); + } + return out; +} + +/** The public origin for links: NEXT_PUBLIC_APP_URL, else the request's host. */ +export function publicOrigin(request: Request): string { + const configured = process.env.NEXT_PUBLIC_APP_URL; + if (configured && !/localhost|127\.0\.0\.1/.test(configured)) + return configured.replace(/\/$/, ''); + const h = request.headers; + const host = h.get('x-forwarded-host') ?? h.get('host'); + const proto = h.get('x-forwarded-proto') ?? 'https'; + return host ? `${proto}://${host}` : 'https://pairux.com'; +} + +export function inviteUrl(origin: string, code: string) { + return `${origin.replace(/\/$/, '')}/i/${encodeURIComponent(code)}`; +} + +export function refUrl(origin: string, code: string) { + return `${origin.replace(/\/$/, '')}/?ref=${encodeURIComponent(code)}`; +} + +/** Read one cookie from a raw Cookie header. */ +export function cookieFrom(request: Request, name: string): string | null { + const raw = request.headers.get('cookie') ?? ''; + for (const part of raw.split(';')) { + const [k, ...v] = part.trim().split('='); + if (k === name) { + try { + return decodeURIComponent(v.join('=')); + } catch { + return v.join('='); + } + } + } + return null; +} diff --git a/apps/web/src/lib/request-user.ts b/apps/web/src/lib/request-user.ts new file mode 100644 index 00000000..ec47357a --- /dev/null +++ b/apps/web/src/lib/request-user.ts @@ -0,0 +1,30 @@ +import { serviceClient } from '@/lib/supabase/service'; +import { getCliUser } from '@/lib/cli-auth'; +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +/** + * The signed-in user behind a request, however it arrived: a `pairux` CLI + * token (Bearer pux_at_...), a Supabase access token (desktop/mobile Bearer) + * or the web session cookie. + */ +export async function requestUser(request: Request): Promise<{ id: string } | null> { + const header = request.headers.get('authorization') ?? ''; + if (/^Bearer\s+pux_at_/.test(header)) { + const cli = await getCliUser(serviceClient() as unknown as SupabaseClient, request); + return cli ? { id: cli.userId } : null; + } + const supabase = await createClient(); + const { user } = await getAuthenticatedUser(supabase); + return user ? { id: user.id } : null; +} + +/** Whether a user is a PairUX admin (admin_users, as /admin checks). */ +export async function isAdminUser(userId: string): Promise { + const { data } = (await (serviceClient() as unknown as SupabaseClient) + .from('admin_users') + .select('user_id') + .eq('user_id', userId) + .maybeSingle()) as { data: unknown }; + return Boolean(data); +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 7d7b444d..a038fdeb 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -3,6 +3,9 @@ import { NextResponse, type NextRequest } from 'next/server'; import { updateSession } from '@/lib/supabase/middleware'; import { CORS_HEADERS } from '@/lib/cors'; +/** @profullstack/affiliates REF_COOKIE (the @profullstack/referrals cookie). */ +const REF_COOKIE_NAME = 'referral_code'; + // Content-Security-Policy is built per-request here (not in next.config) so it // can carry a fresh script nonce — that lets us drop 'unsafe-inline' from // script-src. Next.js reads the nonce from the request's CSP header and applies @@ -86,6 +89,19 @@ export async function middleware(request: NextRequest) { const response = await updateSession(request, requestHeaders); response.headers.set('content-security-policy', csp); + // ?ref=: first touch wins, kept in @profullstack/referrals' + // cookie name so a signup is attributed once (lib/credits.ts onSignedIn). + const ref = request.nextUrl.searchParams.get('ref'); + if (ref && /^[A-Za-z0-9_-]{4,40}$/.test(ref) && !request.cookies.get(REF_COOKIE_NAME)) { + response.cookies.set(REF_COOKIE_NAME, ref, { + path: '/', + maxAge: 30 * 24 * 60 * 60, + sameSite: 'lax', + secure: request.nextUrl.protocol === 'https:', + httpOnly: true, + }); + } + // Add CORS headers for API routes if (pathname.startsWith('/api/')) { for (const [key, value] of Object.entries(CORS_HEADERS)) { diff --git a/docs/API.md b/docs/API.md index d2e4dc79..091fd271 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1513,6 +1513,27 @@ export interface ControlRevokeMessage { --- +### Credit Codes and Invites API + +Every route takes the web session cookie, a Supabase access token (`Authorization: Bearer `, desktop and mobile) or a `pairux login` CLI token (`Bearer pux_at_...`). Responses are `{ data }` or `{ error, reason? }`. + +A **credit code** (`CR-XXXX-XXXX-XXXX-XXXX`) adds days of a paid plan to `profiles.plan` / `plan_expires_at`, the same columns a CoinPay payment extends. Codes grant Pro unless they were minted for Plus or Team. A smaller plan while a bigger one is running adds the days and keeps the bigger plan. Codes are stored only as sha256 hashes. + +An **invite** (`INV-XXXX-XXXX-XXXX`): every account gets 3, from 7 days old. Each works once and expires after 30 days. When an account at most 7 days old joins on one and its email is confirmed, both people get 7 days of Pro. There is no bonus when both addresses are the same mailbox (case, `+tag` and Gmail dots ignored) or once the inviter has had 10 bonuses in 30 days. + +| Method | Path | Body | What it does | +| -------- | ------------------------------ | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| GET | `/api/credits` | | Plan, effective plan, end date and the grants codes/invites gave | +| POST | `/api/credits/redeem` | `{ code }` | Redeem a code; 400 with `reason` = `invalid`, `expired`, `used_up`, `already_redeemed` or `revoked` | +| GET | `/api/invites` | | Quota, invites sent (status and bonus), `refLink` (`/?ref=`) | +| POST | `/api/invites` | `{ note? }` | Create an invite; the code and its `/i/` URL are returned once | +| DELETE | `/api/invites/{id}` | | Withdraw an unused invite (refunds the slot) | +| POST | `/api/invites/accept` | `{ code }` | Join on an invite; `bonus` = `granted`, `pending` (email not confirmed) or `denied` | +| GET/POST | `/api/admin/credit-codes` | `{ days, plan?, label?, maxRedemptions?, expiresInDays? }` | Admins: list or mint codes | +| DELETE | `/api/admin/credit-codes/{id}` | | Admins: revoke a code | + +CLI: `pairux redeem `, `pairux invites`, `pairux invites create [--note ]`. + ## Error Handling ### Error Codes diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2115ed84..f0edaa9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -379,6 +379,9 @@ importers: '@pairux/meeting-translation': specifier: workspace:* version: link:../../packages/meeting-translation + '@profullstack/affiliates': + specifier: ^0.2.0 + version: 0.2.0(react@19.3.1) '@profullstack/autoblog': specifier: github:profullstack/autoblog#75e54af version: https://codeload.github.com/profullstack/autoblog/tar.gz/75e54af @@ -387,7 +390,7 @@ importers: version: 1.0.3 '@profullstack/footer': specifier: latest - version: 0.2.6(react@19.3.1) + version: 0.3.1(react@19.3.1) '@profullstack/live-translate': specifier: ^0.3.0 version: 0.3.0 @@ -400,6 +403,9 @@ importers: '@profullstack/profile': specifier: 0.1.6 version: 0.1.6(react@19.3.1) + '@profullstack/redeem-credits': + specifier: ^0.2.0 + version: 0.2.0 '@profullstack/stack': specifier: ^0.5.0 version: 0.5.0(@supabase/ssr@0.12.7(@supabase/supabase-js@2.118.0(@opentelemetry/api@1.10.0)))(next@16.4.3(@opentelemetry/api@1.10.0)(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react@19.3.1) @@ -2110,7 +2116,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -2783,16 +2789,19 @@ packages: '@nut-tree-fork/libnut-darwin@2.7.5': resolution: {integrity: sha512-LbqtPtMPTJUcg4XoPP2jsU1wc8flBcGyKTerKsIfK9cD7nBHROnO0QksbrsbSWEpLym8T8fRtuU7XEY83l6Z2Q==} engines: {node: '>=10.15.3'} + cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut-linux@2.7.5': resolution: {integrity: sha512-uxaXEcRKnFObAljsoR6tLOBUU1dJ2sctloG6gFgCBGN7+k6Jdv6jZfOuNjd/fpdq2C5WPMm0rtn9EE7h5J3Jcg==} engines: {node: '>=10.15.3'} + cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut-win32@2.7.5': resolution: {integrity: sha512-yqC87zvmFcDPwFrRU40DYhN0xmEVM3aSkOuyF0IX+y1x+HWSu/i0PNklATpPBhGid3QVb/TOHuVoaraMrUFCNw==} engines: {node: '>=10.15.3'} + cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut@4.2.6': @@ -2806,6 +2815,7 @@ packages: '@nut-tree-fork/nut-js@4.2.6': resolution: {integrity: sha512-aI/WCX7gE1HFGPH3EZP/UWqpNMM1NMoM/EkXqp7pKMgXFCi8e5+o5p+jd/QOYpmALv9bQg7+s69nI7FONbMqDg==} engines: {node: '>=16'} + cpu: [x64, arm64] os: [linux, darwin, win32] '@nut-tree-fork/provider-interfaces@4.2.6': @@ -2926,6 +2936,11 @@ packages: '@posthog/types@1.416.1': resolution: {integrity: sha512-PG06HE2axHNyyZ9CKSSHKSW4pWfQwEtPA1dewFqIwggcIgk1BsQ+tHSNqvZz+IvE2WnBZ8OcOkF9SWI55VxQfA==} + '@profullstack/affiliates@0.2.0': + resolution: {integrity: sha512-fbP5uiS8Pa5g8mk5WFD79U4Cj7BUPH6Qy0tQP7ZXE9gfxHOHirHASmZ/rAk8MqnmzGOZTuwq9WwOHbmoFV2TSA==} + engines: {node: '>=22.13'} + hasBin: true + '@profullstack/autoblog@https://codeload.github.com/profullstack/autoblog/tar.gz/75e54af': resolution: {tarball: https://codeload.github.com/profullstack/autoblog/tar.gz/75e54af} version: 0.4.0 @@ -2944,6 +2959,16 @@ packages: react: optional: true + '@profullstack/footer@0.3.1': + resolution: {integrity: sha512-uiiCgEuOZAFd4UKpOUIhAseVLTXp+tbGF9kosYDuW2M/+/A3QcWtUCu9lYGSNfOUnvnlscqrLjJC2uYZlJmufQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + react: '>=18' + peerDependenciesMeta: + react: + optional: true + '@profullstack/form-guard@0.1.1': resolution: {integrity: sha512-RGLGfQhjq40f+JqEhSmsJnS0I5sFWjYdrYx4VYtkUqz8xvioN2yyR+6uFK0mnDAStKrYytlngg3meUsrChm/OQ==} engines: {node: '>=20'} @@ -2976,6 +3001,11 @@ packages: react: optional: true + '@profullstack/redeem-credits@0.2.0': + resolution: {integrity: sha512-JLowb7mWYfouIK7O522tuPPhvLjQjhdEfm6g3xEYrlNLGzLhrIADDhrYldBlGHfiVBWUFX7wefLuCSLF9fnyog==} + engines: {node: '>=20'} + hasBin: true + '@profullstack/referrals@0.1.0': resolution: {integrity: sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg==} engines: {node: '>=18'} @@ -12264,6 +12294,12 @@ snapshots: '@posthog/types@1.416.1': {} + '@profullstack/affiliates@0.2.0(react@19.3.1)': + dependencies: + '@profullstack/referrals': 0.1.0(react@19.3.1) + transitivePeerDependencies: + - react + '@profullstack/autoblog@https://codeload.github.com/profullstack/autoblog/tar.gz/75e54af': {} '@profullstack/emailer@1.0.3': {} @@ -12272,6 +12308,10 @@ snapshots: optionalDependencies: react: 19.3.1 + '@profullstack/footer@0.3.1(react@19.3.1)': + optionalDependencies: + react: 19.3.1 + '@profullstack/form-guard@0.1.1': {} '@profullstack/keys@0.1.0': {} @@ -12284,6 +12324,8 @@ snapshots: optionalDependencies: react: 19.3.1 + '@profullstack/redeem-credits@0.2.0': {} + '@profullstack/referrals@0.1.0(react@19.3.1)': optionalDependencies: react: 19.3.1 diff --git a/supabase/migrations/20261026200000_redeem_credits.sql b/supabase/migrations/20261026200000_redeem_credits.sql new file mode 100644 index 00000000..ce61a951 --- /dev/null +++ b/supabase/migrations/20261026200000_redeem_credits.sql @@ -0,0 +1,200 @@ +-- @profullstack/redeem-credits: Supabase schema (table prefix "credit_") +-- Apply as a migration (supabase/migrations/_redeem_credits.sql). +-- Tables have RLS enabled and NO policies: only the service role (which +-- bypasses RLS) can read or write them. The functions are SECURITY DEFINER +-- with a pinned search_path, and only service_role may execute them. + +CREATE TABLE IF NOT EXISTS public.credit_codes ( + id TEXT PRIMARY KEY, + hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + unit TEXT NOT NULL, + amount DOUBLE PRECISION NOT NULL, + recur TEXT, + months INTEGER, + max_redemptions INTEGER NOT NULL, + redemptions INTEGER NOT NULL DEFAULT 0, + expires_at BIGINT, + created_at BIGINT NOT NULL, + revoked_at BIGINT, + created_by TEXT +); + +CREATE TABLE IF NOT EXISTS public.credit_redemptions ( + code_id TEXT NOT NULL, + user_id TEXT NOT NULL, + at BIGINT NOT NULL, + PRIMARY KEY (code_id, user_id) +); + +CREATE TABLE IF NOT EXISTS public.credit_ledger ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + unit TEXT NOT NULL, + amount DOUBLE PRECISION NOT NULL, + kind TEXT NOT NULL, + recur TEXT, + months INTEGER, + source TEXT NOT NULL, + at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS credit_ledger_user_unit ON public.credit_ledger (user_id, unit, at); + +CREATE TABLE IF NOT EXISTS public.credit_accounts ( + user_id TEXT NOT NULL, + unit TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, unit) +); + +ALTER TABLE public.credit_codes ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.credit_redemptions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.credit_ledger ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.credit_accounts ENABLE ROW LEVEL SECURITY; + +-- Redeem a code (by sha256 hash) for a user, atomically. The row lock on the +-- code serializes concurrent redeemers, so max_redemptions and one redemption +-- per (code, user) hold. Returns {"ok":true,"code":{...}} or {"ok":false,"reason":"..."}. +CREATE OR REPLACE FUNCTION public.credit_redeem(p_hash text, p_user_id text, p_at bigint) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $fn$ +DECLARE + c public.credit_codes%ROWTYPE; +BEGIN + SELECT * INTO c FROM public.credit_codes WHERE hash = p_hash FOR UPDATE; + IF NOT FOUND THEN + RETURN jsonb_build_object('ok', false, 'reason', 'invalid'); + END IF; + IF c.revoked_at IS NOT NULL THEN + RETURN jsonb_build_object('ok', false, 'reason', 'revoked'); + END IF; + IF c.expires_at IS NOT NULL AND p_at >= c.expires_at THEN + RETURN jsonb_build_object('ok', false, 'reason', 'expired'); + END IF; + IF EXISTS (SELECT 1 FROM public.credit_redemptions r WHERE r.code_id = c.id AND r.user_id = p_user_id) THEN + RETURN jsonb_build_object('ok', false, 'reason', 'already_redeemed'); + END IF; + IF c.redemptions >= c.max_redemptions THEN + RETURN jsonb_build_object('ok', false, 'reason', 'used_up'); + END IF; + UPDATE public.credit_codes SET redemptions = redemptions + 1 WHERE id = c.id; + INSERT INTO public.credit_redemptions (code_id, user_id, at) VALUES (c.id, p_user_id, p_at); + INSERT INTO public.credit_ledger (user_id, unit, amount, kind, recur, months, source, at) + VALUES (p_user_id, c.unit, c.amount, 'grant', c.recur, c.months, 'code:' || c.id, p_at); + c.redemptions := c.redemptions + 1; + RETURN jsonb_build_object('ok', true, 'code', to_jsonb(c)); +END; +$fn$; + +-- Compare-and-swap append: bump the (user, unit) version and append the entry +-- only if the version still equals p_version. Spends go through this, so two +-- concurrent spends can never both pass the balance check. +CREATE OR REPLACE FUNCTION public.credit_append_if_version( + p_user_id text, p_unit text, p_amount double precision, p_kind text, p_recur text, + p_months integer, p_source text, p_at bigint, p_version integer +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $fn$ +BEGIN + INSERT INTO public.credit_accounts (user_id, unit, version) VALUES (p_user_id, p_unit, 0) ON CONFLICT DO NOTHING; + UPDATE public.credit_accounts SET version = version + 1 + WHERE user_id = p_user_id AND unit = p_unit AND version = p_version; + IF NOT FOUND THEN + RETURN false; + END IF; + INSERT INTO public.credit_ledger (user_id, unit, amount, kind, recur, months, source, at) + VALUES (p_user_id, p_unit, p_amount, p_kind, p_recur, p_months, p_source, p_at); + RETURN true; +END; +$fn$; + +-- One user's version and ledger for one unit, from a single snapshot, as one +-- JSON value (so PostgREST's max-rows never truncates it). +CREATE OR REPLACE FUNCTION public.credit_account(p_user_id text, p_unit text) +RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $fn$ + SELECT jsonb_build_object( + 'version', COALESCE((SELECT a.version FROM public.credit_accounts a WHERE a.user_id = p_user_id AND a.unit = p_unit), 0), + 'entries', COALESCE((SELECT jsonb_agg(to_jsonb(l) ORDER BY l.at, l.id) FROM public.credit_ledger l + WHERE l.user_id = p_user_id AND l.unit = p_unit), '[]'::jsonb) + ); +$fn$; + +-- A user's ledger, optionally for one unit (p_unit null = all units). +CREATE OR REPLACE FUNCTION public.credit_history(p_user_id text, p_unit text DEFAULT NULL) +RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $fn$ + SELECT COALESCE(jsonb_agg(to_jsonb(l) ORDER BY l.at, l.id), '[]'::jsonb) + FROM public.credit_ledger l + WHERE l.user_id = p_user_id AND (p_unit IS NULL OR l.unit = p_unit); +$fn$; + +-- Revoke a code by id (idempotent: keeps the first revoked_at). False if no such id. +CREATE OR REPLACE FUNCTION public.credit_revoke(p_id text, p_at bigint) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +SET search_path = '' +AS $fn$ + WITH u AS ( + UPDATE public.credit_codes SET revoked_at = COALESCE(revoked_at, p_at) WHERE id = p_id RETURNING 1 + ) + SELECT EXISTS (SELECT 1 FROM u); +$fn$; + +REVOKE ALL ON FUNCTION public.credit_redeem(text, text, bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.credit_append_if_version(text, text, double precision, text, text, integer, text, bigint, integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.credit_account(text, text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.credit_history(text, text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.credit_revoke(text, bigint) FROM PUBLIC; + +-- Supabase roles: anon and authenticated get nothing (Supabase's default +-- privileges would otherwise grant them table and function access); +-- service_role gets what supabaseStore needs. Skipped on plain Postgres. +DO $grants$ +DECLARE + r text; +BEGIN + FOREACH r IN ARRAY ARRAY['anon', 'authenticated'] LOOP + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN + EXECUTE format('REVOKE ALL ON TABLE public.credit_codes FROM %I', r); + EXECUTE format('REVOKE ALL ON TABLE public.credit_redemptions FROM %I', r); + EXECUTE format('REVOKE ALL ON TABLE public.credit_ledger FROM %I', r); + EXECUTE format('REVOKE ALL ON TABLE public.credit_accounts FROM %I', r); + EXECUTE format('REVOKE ALL ON SEQUENCE public.credit_ledger_id_seq FROM %I', r); + EXECUTE format('REVOKE ALL ON FUNCTION public.credit_redeem(text, text, bigint) FROM %I', r); + EXECUTE format('REVOKE ALL ON FUNCTION public.credit_append_if_version(text, text, double precision, text, text, integer, text, bigint, integer) FROM %I', r); + EXECUTE format('REVOKE ALL ON FUNCTION public.credit_account(text, text) FROM %I', r); + EXECUTE format('REVOKE ALL ON FUNCTION public.credit_history(text, text) FROM %I', r); + EXECUTE format('REVOKE ALL ON FUNCTION public.credit_revoke(text, bigint) FROM %I', r); + END IF; + END LOOP; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.credit_codes TO service_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.credit_redemptions TO service_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.credit_ledger TO service_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.credit_accounts TO service_role; + GRANT USAGE, SELECT ON SEQUENCE public.credit_ledger_id_seq TO service_role; + GRANT EXECUTE ON FUNCTION public.credit_redeem(text, text, bigint) TO service_role; + GRANT EXECUTE ON FUNCTION public.credit_append_if_version(text, text, double precision, text, text, integer, text, bigint, integer) TO service_role; + GRANT EXECUTE ON FUNCTION public.credit_account(text, text) TO service_role; + GRANT EXECUTE ON FUNCTION public.credit_history(text, text) TO service_role; + GRANT EXECUTE ON FUNCTION public.credit_revoke(text, bigint) TO service_role; + END IF; +END +$grants$; diff --git a/supabase/migrations/20261026200100_affiliates.sql b/supabase/migrations/20261026200100_affiliates.sql new file mode 100644 index 00000000..afa5947c --- /dev/null +++ b/supabase/migrations/20261026200100_affiliates.sql @@ -0,0 +1,319 @@ +-- @profullstack/affiliates: Supabase migration (prefix aff_) +-- Generated by supabaseMigrationSql('aff_'). Same tables as postgresStore, so both stores can share them. +-- Use with supabaseStore(serviceRoleClient). Idempotent. + +CREATE TABLE IF NOT EXISTS public.aff_invites ( + id TEXT PRIMARY KEY, + code_hash TEXT NOT NULL UNIQUE, + inviter_id TEXT NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT, + used_by TEXT, + used_at BIGINT, + note TEXT NOT NULL DEFAULT '', + revoked_at BIGINT +); +CREATE INDEX IF NOT EXISTS aff_invites_inviter_idx ON public.aff_invites (inviter_id); +CREATE INDEX IF NOT EXISTS aff_invites_used_by_idx ON public.aff_invites (used_by); +CREATE TABLE IF NOT EXISTS public.aff_quotas ( + user_id TEXT PRIMARY KEY, + total INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS public.aff_codes ( + user_id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + created_at BIGINT NOT NULL +); +CREATE TABLE IF NOT EXISTS public.aff_attributions ( + user_id TEXT PRIMARY KEY, + affiliate_id TEXT NOT NULL, + code TEXT NOT NULL, + at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS aff_attributions_affiliate_idx ON public.aff_attributions (affiliate_id); + +-- RLS on with no policies: anon and authenticated keys see nothing. Access goes only through the +-- SECURITY DEFINER functions below, which only service_role may execute. +ALTER TABLE public.aff_invites ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.aff_quotas ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.aff_codes ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.aff_attributions ENABLE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION public.aff_get_quota(p_user TEXT) +RETURNS INTEGER +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT total FROM public.aff_quotas WHERE user_id = p_user +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_set_quota(p_user TEXT, p_total INTEGER) +RETURNS VOID +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +INSERT INTO public.aff_quotas (user_id, total) VALUES (p_user, p_total) + ON CONFLICT (user_id) DO UPDATE SET total = excluded.total +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_count_invites(p_inviter TEXT) +RETURNS INTEGER +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT count(*)::INTEGER FROM public.aff_invites WHERE inviter_id = p_inviter AND (revoked_at IS NULL OR used_by IS NOT NULL) +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_insert_invite(p_id TEXT, p_hash TEXT, p_inviter TEXT, p_created BIGINT, p_expires BIGINT, p_note TEXT, p_limit INTEGER) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +BEGIN + -- Per-inviter lock; plpgsql takes a fresh snapshot per statement, so the count sees every committed invite. + PERFORM pg_advisory_xact_lock(hashtext('aff_invites'), hashtext(p_inviter)); + IF (SELECT count(*) FROM public.aff_invites WHERE inviter_id = p_inviter AND (revoked_at IS NULL OR used_by IS NOT NULL)) >= p_limit THEN + RETURN FALSE; + END IF; + INSERT INTO public.aff_invites (id, code_hash, inviter_id, created_at, expires_at, note) + VALUES (p_id, p_hash, p_inviter, p_created, p_expires, coalesce(p_note, '')); + RETURN TRUE; +END +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_get_invite_by_hash(p_hash TEXT) +RETURNS SETOF public.aff_invites +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT * FROM public.aff_invites WHERE code_hash = p_hash +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_get_invite(p_id TEXT) +RETURNS SETOF public.aff_invites +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT * FROM public.aff_invites WHERE id = p_id +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_claim_invite(p_id TEXT, p_user TEXT, p_at BIGINT) +RETURNS BOOLEAN +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +WITH won AS ( + UPDATE public.aff_invites SET used_by = p_user, used_at = p_at + WHERE id = p_id AND used_by IS NULL AND revoked_at IS NULL + RETURNING 1 +) +SELECT EXISTS (SELECT 1 FROM won) +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_revoke_invite(p_id TEXT, p_at BIGINT) +RETURNS VOID +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +UPDATE public.aff_invites SET revoked_at = p_at WHERE id = p_id AND revoked_at IS NULL +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_list_invites(p_inviter TEXT) +RETURNS SETOF public.aff_invites +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT * FROM public.aff_invites WHERE inviter_id = p_inviter ORDER BY created_at DESC, id +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_invited_by(p_user TEXT) +RETURNS TEXT +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT inviter_id FROM public.aff_invites WHERE used_by = p_user ORDER BY used_at ASC LIMIT 1 +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_invitees(p_inviter TEXT) +RETURNS TABLE (user_id TEXT, at BIGINT) +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT used_by, used_at FROM public.aff_invites + WHERE inviter_id = p_inviter AND used_by IS NOT NULL ORDER BY used_at ASC +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_get_affiliate_code(p_user TEXT) +RETURNS TEXT +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT code FROM public.aff_codes WHERE user_id = p_user +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_put_affiliate_code(p_user TEXT, p_code TEXT, p_at BIGINT) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +BEGIN + INSERT INTO public.aff_codes (user_id, code, created_at) VALUES (p_user, p_code, p_at) ON CONFLICT DO NOTHING; + RETURN (SELECT code FROM public.aff_codes WHERE user_id = p_user); +END +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_affiliate_by_code(p_code TEXT) +RETURNS TEXT +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT user_id FROM public.aff_codes WHERE code = p_code +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_attribute(p_user TEXT, p_affiliate TEXT, p_code TEXT, p_at BIGINT) +RETURNS BOOLEAN +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +WITH ins AS ( + INSERT INTO public.aff_attributions (user_id, affiliate_id, code, at) VALUES (p_user, p_affiliate, p_code, p_at) + ON CONFLICT (user_id) DO NOTHING + RETURNING 1 +) +SELECT EXISTS (SELECT 1 FROM ins) +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_get_attribution(p_user TEXT) +RETURNS SETOF public.aff_attributions +LANGUAGE sql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +SELECT * FROM public.aff_attributions WHERE user_id = p_user +$fn$; + +CREATE OR REPLACE FUNCTION public.aff_is_external_code(p_code TEXT) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +DECLARE found BOOLEAN := FALSE; +BEGIN + IF to_regclass('public.referral_codes') IS NULL THEN + RETURN FALSE; + END IF; + EXECUTE 'SELECT EXISTS (SELECT 1 FROM public.referral_codes WHERE code = $1)' + INTO found USING upper(btrim(p_code)); + RETURN found; +END +$fn$; + +REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM PUBLIC; + +DO $grants$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM anon; + REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM anon; + REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM anon; + REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM authenticated; + REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM authenticated; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN + GRANT EXECUTE ON FUNCTION public.aff_get_quota(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_set_quota(TEXT, INTEGER) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_count_invites(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_get_invite_by_hash(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_get_invite(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_list_invites(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_invited_by(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_invitees(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_get_affiliate_code(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_affiliate_by_code(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_get_attribution(TEXT) TO service_role; + GRANT EXECUTE ON FUNCTION public.aff_is_external_code(TEXT) TO service_role; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public.aff_invites FROM anon; + REVOKE ALL ON TABLE public.aff_quotas FROM anon; + REVOKE ALL ON TABLE public.aff_codes FROM anon; + REVOKE ALL ON TABLE public.aff_attributions FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public.aff_invites FROM authenticated; + REVOKE ALL ON TABLE public.aff_quotas FROM authenticated; + REVOKE ALL ON TABLE public.aff_codes FROM authenticated; + REVOKE ALL ON TABLE public.aff_attributions FROM authenticated; + END IF; +END +$grants$; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20261026200200_credit_plan_grants_and_invites.sql b/supabase/migrations/20261026200200_credit_plan_grants_and_invites.sql new file mode 100644 index 00000000..4d131953 --- /dev/null +++ b/supabase/migrations/20261026200200_credit_plan_grants_and_invites.sql @@ -0,0 +1,105 @@ +-- Credit codes and invites grant plan days, and the plan columns become +-- server-only. +-- +-- 1. plan_grant_days: a redeemed code (@profullstack/redeem-credits) or an +-- invite bonus extends profiles.plan / plan_expires_at, the same columns a +-- CoinPay payment extends (plan_credit_payment), with the same rule: a +-- smaller plan while a bigger one is running adds time and keeps the bigger +-- plan. Service role only. +-- 2. invite_bonuses: one row per (invite, side) once the bonus is decided +-- (granted, or denied with a reason). The row is claimed before the grant, +-- so a retry or a race never pays twice. +-- 3. grant_plan was executable by anon and authenticated (SECURITY DEFINER, +-- default PUBLIC execute): anyone with the public anon key could give any +-- account any plan. Nothing in the app calls it. Service role only now. +-- 4. "Users can update own profile" let a signed-in user PATCH their own +-- plan / plan_expires_at through PostgREST. A trigger now keeps those two +-- columns unchanged for the anon and authenticated roles (and forces a new +-- row to free), so only the service role (webhook, credits) can grant a plan. + +CREATE OR REPLACE FUNCTION public.plan_grant_days( + p_user_id UUID, + p_plan TEXT, + p_days INTEGER +) RETURNS TIMESTAMPTZ +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + new_expiry TIMESTAMPTZ; +BEGIN + IF p_plan NOT IN ('plus', 'pro', 'team') THEN + RAISE EXCEPTION 'plan_grant_days: unknown plan %', p_plan; + END IF; + IF p_days IS NULL OR p_days < 1 OR p_days > 3660 THEN + RAISE EXCEPTION 'plan_grant_days: days must be 1..3660, got %', p_days; + END IF; + + UPDATE public.profiles + SET plan = CASE + WHEN plan_expires_at > NOW() + AND array_position(ARRAY['free','plus','pro','team'], plan) + > array_position(ARRAY['free','plus','pro','team'], p_plan) + THEN plan + ELSE p_plan + END, + plan_expires_at = GREATEST(NOW(), COALESCE(plan_expires_at, NOW())) + + (p_days || ' days')::INTERVAL, + updated_at = NOW() + WHERE id = p_user_id + RETURNING plan_expires_at INTO new_expiry; + + IF new_expiry IS NULL THEN + RAISE EXCEPTION 'plan_grant_days: no profile %', p_user_id; + END IF; + RETURN new_expiry; +END; +$$; + +REVOKE ALL ON FUNCTION public.plan_grant_days(UUID, TEXT, INTEGER) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.plan_grant_days(UUID, TEXT, INTEGER) TO service_role; + +CREATE TABLE IF NOT EXISTS public.invite_bonuses ( + invite_id TEXT NOT NULL, + side TEXT NOT NULL CHECK (side IN ('inviter', 'invitee')), + user_id UUID NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('granted', 'denied')), + reason TEXT, + days INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (invite_id, side) +); +CREATE INDEX IF NOT EXISTS invite_bonuses_user_idx ON public.invite_bonuses (user_id, created_at); + +ALTER TABLE public.invite_bonuses ENABLE ROW LEVEL SECURITY; +-- No policies: only the service role reads or writes it. +REVOKE ALL ON public.invite_bonuses FROM anon, authenticated; + +REVOKE ALL ON FUNCTION public.grant_plan(UUID, TEXT, INTEGER) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.grant_plan(UUID, TEXT, INTEGER) TO service_role; + +CREATE OR REPLACE FUNCTION public.profiles_protect_plan() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = public +AS $$ +BEGIN + IF current_user IN ('anon', 'authenticated') THEN + IF TG_OP = 'INSERT' THEN + NEW.plan := 'free'; + NEW.plan_expires_at := NULL; + ELSE + NEW.plan := OLD.plan; + NEW.plan_expires_at := OLD.plan_expires_at; + END IF; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS profiles_protect_plan ON public.profiles; +CREATE TRIGGER profiles_protect_plan + BEFORE INSERT OR UPDATE ON public.profiles + FOR EACH ROW + EXECUTE FUNCTION public.profiles_protect_plan(); From f1c4128c2a7616a97c4c4dafccc2904c4e8faabe Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 18:05:56 +0000 Subject: [PATCH 2/2] fix(auth/callback): settle invites only for the id GoTrue verified Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/auth/callback/route.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/auth/callback/route.ts b/apps/web/src/app/auth/callback/route.ts index a7749e3f..1c3a6bfd 100644 --- a/apps/web/src/app/auth/callback/route.ts +++ b/apps/web/src/app/auth/callback/route.ts @@ -66,6 +66,10 @@ export async function GET(request: NextRequest) { }, }); + // The account GoTrue verified, from either flow below. Only a verified id + // reaches afterSignIn. + let verifiedUserId: string | undefined; + // Handle token_hash from direct email links if (tokenHash && type) { const otpType = type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email'; @@ -79,12 +83,9 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(`${origin}/login?error=auth_callback_error`); } - await afterSignIn(request, response, data.user?.id); - return response; - } - - // Handle code from PKCE flow - if (code) { + verifiedUserId = data.user?.id; + } else if (code) { + // Handle code from PKCE flow const { session, error } = await exchangeCodeAndPersist(supabase, code); if (error) { @@ -92,8 +93,10 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(`${origin}/login?error=auth_callback_error`); } - await afterSignIn(request, response, session?.user.id); + verifiedUserId = session?.user.id; } + if (verifiedUserId) await afterSignIn(request, response, verifiedUserId); + return response; }