diff --git a/REFERRAL_FEATURES_ROADMAP.md b/REFERRAL_FEATURES_ROADMAP.md new file mode 100644 index 000000000..c4ab23f69 --- /dev/null +++ b/REFERRAL_FEATURES_ROADMAP.md @@ -0,0 +1,259 @@ +# Referral System — Feature Roadmap + +This document lists candidate features for the referral system, split into two tiers. Everything already shipped is +marked ✅. Everything below is a candidate — decide which ones to build next. + +--- + +## What's already live (summary) + +| Feature | Notes | +| -------------------------------------------- | ------------------------------------------------------------------------ | +| ✅ First-touch attribution | localStorage, 90-day expiry | +| ✅ Click tracking | IP, user-agent, UTM, referer → D1 | +| ✅ WAE analytics | Click counts by country / code / day | +| ✅ Conversion tracking | pending → completed on signup | +| ✅ User-managed referral username | Set once, change limited by `REFERRAL_SYSTEM_USERNAME_CHANGE` | +| ✅ Auto-generate referral username on signup | Derived from email prefix with uniqueness suffix loop | +| ✅ Duplicate-click deduplication | KV `ref:dedup:{ip}:{code}`, 20-min TTL (`REFERRAL_DEDUP_WINDOW_MINUTES`) | +| ✅ CSV export | `GET /api/referrals/export?format=csv` + Download button in dashboard | +| ✅ `/r/{username}` vanity URL | HTML + OG meta + JS redirect — registered in `cloudflare-worker.ts` | +| ✅ Referral dashboard | Stats, activity feed, copy link, Download CSV | +| ✅ Admin tracking page | All-user conversion list | +| ✅ RESTful API | `/api/referrals/*` | + +--- + +## Tier 1 — Simple, Good-to-Have (10 ideas) + +These are self-contained, low-risk additions that fit naturally into the existing architecture. Each one can be built in +a single PR. + +--- + +### 1. Auto-generate referral username on signup + +**What:** When a new user registers and no referral username is set, automatically derive a username from their display +name or email prefix (`john.doe@` → `johndoe`) and save it. + +**Why:** Users get a share-ready link immediately; zero friction. + +**Where:** `processReferralAttribution` / Auth.js sign-in callback. New helper `generateReferralUsername(user)` in +`@ottabase/referrals/validation`. Add a uniqueness-suffix loop (`johndoe2`, `johndoe3` …) if taken. + +--- + +### 2. Conversion rate display in the dashboard + +**What:** Add a "Conversion rate" stat card next to Total / Conversions / Pending: + +``` +Conversion rate = completed / (completed + pending) × 100 +``` + +**Why:** The most useful KPI for any referral programme — it's one arithmetic expression on data that's already returned +by `/api/referrals/user`. + +**Where:** Pure UI change in `ReferralDashboard.tsx`. No schema or API change needed. + +--- + +### 3. One-click social sharing buttons + +**What:** Pre-formatted share URLs for Twitter/X, LinkedIn, and WhatsApp directly in the dashboard, next to the "Copy" +button. + +``` +Twitter: https://twitter.com/intent/tweet?text=Join+via+my+link:+{link} +LinkedIn: https://www.linkedin.com/shareArticle?url={link} +WhatsApp: https://wa.me/?text={link} +``` + +**Why:** Dramatically lowers the effort to share. No backend work; pure UI. + +**Where:** `ReferralDashboard.tsx` — Referral Link card. + +--- + +### 4. Referral source label in the activity feed + +**What:** Parse the stored `referer` header into a human-readable label ("Twitter", "Facebook", "Reddit", "Direct", +"Other") and show it in the tracking table. + +**Why:** Users want to know _where_ their clicks came from without decoding raw URLs. + +**Where:** Pure display utility in `ReferralDashboard.tsx` / `ReferralTracking.getBrowserInfo()` style helper. No schema +change. + +--- + +### 5. QR code for the referral link + +**What:** A "Show QR Code" button in the Referral Link card that renders a QR code using the browser-native +`window.QRCode` API or a tiny canvas-based lib (e.g. `qrcode` npm, ~7 KB). + +**Why:** Great for offline use, printed materials, and conference name-badges. + +**Where:** `ReferralDashboard.tsx` — Referral Link card. Optional dep added only to the app, not shared packages. + +--- + +### 6. Referred-by display on the user's own profile/settings + +**What:** If `referredById` is set on the user, show a small "Referred by: @username" note on the user's settings or +profile page. + +**Why:** Nice social acknowledgement; confirms the attribution is working. + +**Where:** Add `GET /api/referrals/referrer` (returns `{ referralUsername }` of the referrer), then display in the +profile UI. + +--- + +### 7. Referral milestone badges / in-app notifications + +**What:** When a user crosses a referral count milestone (1st, 5th, 10th, 25th, 50th conversion), show a toast/banner in +the dashboard celebrating it. + +**Why:** Gamification keeps top referrers engaged. Pure client-side calculation on data already loaded. + +**Where:** `ReferralDashboard.tsx` — compute `milestoneMessage` from `stats.completed` on mount, pop a +`toast.success()`. + +--- + +### 8. Duplicate-click deduplication (basic fraud prevention) + +**What:** In `handleReferralTrack`, skip creating a new WAE event if the same IP has already fired for the same +`referralCode` within the last N minutes (tracked in KV with a TTL). + +**Why:** Prevents a single user from inflating click counts by refreshing the page. + +**Where:** `worker/routes/referrals.ts` — `handleReferralTrack`. Use `OBCF_KV` (already bound) with key +`ref_dedup:{ip}:{code}` and 15-min TTL. Config flag `REFERRAL_DEDUP_WINDOW_MINUTES` (default `15`, `0` = disabled). + +--- + +### 9. Export referral data as CSV + +**What:** A "Download CSV" button in the activity feed that calls `GET /api/referrals/export?format=csv` and downloads +the user's tracking records as a comma-separated file. + +**Why:** Power users want their data. Requested feature in many SaaS products. + +**Where:** New route handler `handleReferralExport` in `worker/routes/referrals.ts`. Generates CSV in memory from +`ReferralTracking.forUser(userId)`. + +--- + +### 10. Referral link preview / custom `/r/{username}` vanity URL + +**What:** Add a route `/r/:username` that redirects to `/?ref=:username` with a proper `302` and injects OG meta tags +(`og:title`, `og:description`, `og:image`) so link previews on social media show a personalised card rather than the +generic homepage preview. + +**Why:** `?ref=` params look spammy; `/r/johndoe` is clean and memorable. + +**Where:** New catch-all worker route `/r/:username` → read user record → redirect with meta-injected HTML (reuse the +existing `brand-html-inject` pattern). + +--- + +## Tier 2 — High-Level / Larger Features (5 ideas) + +These require more planning (schema changes, multi-step flows, or new packages) but would significantly elevate the +referral programme. + +--- + +### A. Rewards & Incentives Engine + +**Vision:** Define configurable rewards that are automatically granted when a referral converts — account credits, +coupon codes, feature unlocks, or custom callback webhooks. Both the referrer _and_ the new user can receive rewards +(double-sided referral). + +**Key pieces:** + +- `rewards` config table: `{ trigger: 'conversion', grantType: 'credit', amount: 10 }` +- `referral_rewards` table: `{ userId, trackingId, grantType, amount, status, grantedAt }` +- Queue job `referral.reward.grant` dispatched on conversion +- Dashboard: "You earned $10 credit" banner + +--- + +### B. Multi-Tier / Chain Referrals + +**Vision:** Support referral chains where A referred B who referred C, so A gets a partial reward for C's conversion +(configurable depth and split percentages). + +**Key pieces:** + +- `referralChain` JSON column on `referral_tracking`: `['userId-A', 'userId-B']` +- Attribution walker that climbs the chain up to `REFERRAL_MAX_DEPTH` levels +- Per-tier reward config: `[{ depth: 1, pct: 100 }, { depth: 2, pct: 20 }]` + +--- + +### C. Campaign Management + +**Vision:** Admins create named referral campaigns (e.g. "Black Friday 2025") with custom expiry dates, unique +campaign-scoped tracking URLs, per-campaign conversion goals, and campaign-specific reward overrides. + +**Key pieces:** + +- New `referral_campaigns` table: `{ id, name, startsAt, endsAt, goal, rewardConfig }` +- Campaign-scoped referral links: `/?ref=johndoe&campaign=blackfriday` +- Admin campaign CRUD page +- Dashboard: campaign selector + per-campaign stats + +--- + +### D. Fraud Detection & Risk Scoring + +**Vision:** Automatically flag suspicious referral activity with a risk score per tracking record — VPN/datacenter IP +detection, velocity checks (too many conversions from the same /24 subnet in 24 h), disposable email detection on the +referred user. + +**Key pieces:** + +- `riskScore` integer column on `referral_tracking` (0–100) +- `status: 'suspicious'` in addition to existing `pending/completed/invalid` +- Background queue job `referral.risk.score` runs after each conversion +- Admin UI: filter by status=suspicious, one-click approve/invalidate + +--- + +### E. White-Label Public Invite Page (`/invite/{username}`) + +**Vision:** A fully branded, publicly accessible landing page at `/invite/{username}` that shows the inviter's name, +avatar, a personalised headline ("John Doe invites you to join!"), and a sign-up CTA — all themed with the app's brand +engine. Ideal for email campaigns and direct links. + +**Key pieces:** + +- Worker SSR route `/invite/:username` → fetches user record → renders branded HTML +- Extend `brand-html-inject` to accept per-page OG meta overrides +- Optional: `referralBio` text field on the User model for a custom tagline +- Optional: integration with `@ottabase/ui-shadcn` for the client-side component after hydration + +--- + +## Decision Matrix + +| # | Feature | Effort | Impact | Dependencies | Status | +| --- | -------------------------- | -------- | --------- | --------------------- | ------- | +| 1 | Auto-generate username | Low | High | None | ✅ Done | +| 2 | Conversion rate display | Very Low | Medium | None | | +| 3 | Social sharing buttons | Very Low | High | None | | +| 4 | Source label | Very Low | Medium | None | | +| 5 | QR code | Low | Medium | Small npm dep | | +| 6 | Referred-by on profile | Low | Low | New API endpoint | | +| 7 | Milestone badges | Very Low | Medium | None | | +| 8 | Dedup / fraud prevention | Low | High | KV (already bound) | ✅ Done | +| 9 | CSV export | Low | Medium | None | ✅ Done | +| 10 | `/r/{username}` vanity URL | Medium | High | Worker route | ✅ Done | +| A | Rewards engine | High | Very High | Schema + Queue | | +| B | Multi-tier referrals | High | High | Schema changes | | +| C | Campaign management | High | High | New tables + Admin UI | | +| D | Fraud detection | Medium | High | Queue + scoring logic | | +| E | White-label invite page | Medium | High | Worker SSR | | diff --git a/REFERRAL_SYSTEM.md b/REFERRAL_SYSTEM.md index 69c743eb6..86735f819 100644 --- a/REFERRAL_SYSTEM.md +++ b/REFERRAL_SYSTEM.md @@ -85,6 +85,7 @@ Added to `packages/ottaorm/src/models/User.ts`: { referralUsername: text("referral_username").unique(), referredById: text("referred_by_id"), + referralUsernameChanges: integer("referral_username_changes").default(0).notNull(), } ``` @@ -216,6 +217,8 @@ Response: 200 - Letters, numbers, underscores only - Must be unique - Returns 400 with error if validation fails +- Returns 400 with `USERNAME_CHANGE_LIMIT_REACHED` code if the user has already changed their username the maximum + number of times (configurable via `REFERRAL_SYSTEM_USERNAME_CHANGE` env var, default: 1) ### Register with Referral Attribution @@ -309,6 +312,16 @@ features: { - **Behavior:** Expired codes are automatically cleared from localStorage - **Common values:** 30, 60, 90, 180, 365 +### Environment Variables + +#### `REFERRAL_SYSTEM_USERNAME_CHANGE` (default: `1`) + +- **Type:** `string` (parsed as integer) +- **Description:** How many times a user can change their referral username **after initial setup** +- **Default:** `"1"` — users may set the username once and change it one more time +- **`"0"`** — username is locked after initial setup (no changes allowed) +- **Set in:** `wrangler.jsonc` `vars` section or as a Worker secret + ### Example Configurations **Minimal tracking (conversions only):** @@ -547,9 +560,9 @@ Referral usernames must follow these rules (enforced in `@ottabase/referrals/val Example validation: ```typescript -import { validateReferralUsername } from '@ottabase/referrals'; +import { validateUsername } from '@ottabase/utils/user'; -const result = validateReferralUsername('john_doe123'); +const result = validateUsername('john_doe123'); if (!result.valid) { console.error(result.error); } @@ -604,6 +617,7 @@ When a user changes their referral username: - Pending referrals with old code may not convert - A warning is shown in the UI - Completed conversions remain linked +- **Change limit is enforced** (configurable via `REFERRAL_SYSTEM_USERNAME_CHANGE` env var, default: 1) ## Testing Checklist @@ -700,15 +714,26 @@ When a user changes their referral username: ## Future Enhancements -- [ ] Email notifications for conversions -- [ ] Reward/incentive system -- [ ] Admin analytics dashboard -- [ ] Referral leaderboard -- [ ] Custom referral link URLs (e.g., `/r/{username}`) -- [ ] Multi-level referrals (referral of referral) -- [ ] Export referral data (CSV/JSON) -- [ ] Webhook notifications for conversions -- [ ] A/B testing for referral campaigns +See **[REFERRAL_FEATURES_ROADMAP.md](./REFERRAL_FEATURES_ROADMAP.md)** for a full list of candidate features, split +into: + +- **Tier 1 — Simple, good-to-have** (10 ideas, each buildable in a single PR) +- **Tier 2 — High-level / larger features** (5 strategic ideas) + +Quick reference of items not yet started: + +| Tier 1 (simple) | Tier 2 (high-level) | +| ----------------------------------------- | ------------------------------------- | +| Auto-generate referral username on signup | Rewards & incentives engine | +| Conversion rate stat in dashboard | Multi-tier / chain referrals | +| One-click social sharing buttons | Campaign management | +| Source label in activity feed | Fraud detection & risk scoring | +| QR code for referral link | White-label `/invite/{username}` page | +| Referred-by on user profile | | +| Milestone badges / in-app notifications | | +| Duplicate-click deduplication | | +| CSV export | | +| `/r/{username}` vanity URL | | ## License diff --git a/apps/ottabase-template-app-tanstack/.env.example b/apps/ottabase-template-app-tanstack/.env.example index dd85ab90e..466d0e505 100644 --- a/apps/ottabase-template-app-tanstack/.env.example +++ b/apps/ottabase-template-app-tanstack/.env.example @@ -127,3 +127,12 @@ KILLSWITCH_LOCKDOWN=false # By default destructive migrations are disabled. Set to '1' or 'true' to enable. MIGRATION_ALLOW_DESTRUCTIVE=0 +# Referral system: number of times a user can change their referral username after initial setup. +# Set to '0' to disallow any changes after first set. Default is 1. +REFERRAL_SYSTEM_USERNAME_CHANGE=1 + +# Referral click deduplication window (minutes). Within this window a second click from the +# same IP+referral-code pair is silently ignored (not counted in analytics). +# Set to '0' to disable deduplication. Default is 20. +REFERRAL_DEDUP_WINDOW_MINUTES=20 + diff --git a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts index cb205b13d..45260d905 100644 --- a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts +++ b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts @@ -6,6 +6,7 @@ import { handleBootstrapRoute, interceptIfNotReady, resolvePlatformState } from import { injectBrandCriticalCSS } from './worker/lib/brand-html-inject'; import { initDbConnection } from './worker/lib/db-utils'; import { checkKillSwitches } from './worker/lib/killswitch'; +import { handleReferralVanityRedirect } from './worker/routes/referrals'; import { resolveApiRoute } from './worker/routes/router'; import { handleShortlinkFallback } from './worker/routes/shortlinks'; @@ -118,6 +119,16 @@ export default { return shortlinkFallbackResponse; } + // /r/{username} vanity referral redirect + const vanityMatch = normalizedPathname.match(/^\/r\/([^/]+)$/); + if (vanityMatch) { + const vanityRes = await handleReferralVanityRedirect( + { request, env, url }, + decodeURIComponent(vanityMatch[1]), + ); + if (vanityRes) return vanityRes; + } + if (!env.OBCF_ASSETS) { return errorResponse('Assets binding not configured', 500, { code: 'CONFIG_ERROR', diff --git a/apps/ottabase-template-app-tanstack/src/components/ProfilePhotoUploader.tsx b/apps/ottabase-template-app-tanstack/src/components/ProfilePhotoUploader.tsx new file mode 100644 index 000000000..3f8af8b88 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/components/ProfilePhotoUploader.tsx @@ -0,0 +1,238 @@ +/** + * ProfilePhotoUploader + * + * Lets the user pick an image, crop it to a circle (1:1), and upload the result + * to the configured storage backend via POST /api/upload. + * + * Uses: + * - @ottabase/cropper — zero-React vanilla image cropper + * - POST /api/upload — existing upload endpoint (R2 / Cloudflare Images) + */ + +import { Avatar, AvatarFallback, AvatarImage, Button } from '@ottabase/ui-shadcn'; +import type { Cropper } from '@ottabase/cropper'; +import { Loader2, Pencil, Upload, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface ProfilePhotoUploaderProps { + /** Current avatar URL shown before any upload. */ + currentImageUrl?: string | null; + /** User initials for the fallback avatar. */ + initials?: string; + /** Called with the uploaded image URL on success. */ + onUploaded: (url: string) => void; + /** Optionally override the upload endpoint (default: /api/upload). */ + uploadEndpoint?: string; + /** Disable the component. */ + disabled?: boolean; +} + +type Stage = 'idle' | 'cropping' | 'uploading'; + +export function ProfilePhotoUploader({ + currentImageUrl, + initials = '?', + onUploaded, + uploadEndpoint = '/api/upload', + disabled = false, +}: ProfilePhotoUploaderProps) { + const fileInputRef = useRef(null); + const cropContainerRef = useRef(null); + const cropperRef = useRef(null); + // Pending file waiting for cropContainerRef to mount + const pendingFileRef = useRef(null); + + const [stage, setStage] = useState('idle'); + const [error, setError] = useState(null); + + // Destroy cropper on unmount + useEffect(() => { + return () => { + cropperRef.current?.destroy(); + cropperRef.current = null; + }; + }, []); + + const openFilePicker = () => { + setError(null); + fileInputRef.current?.click(); + }; + + const handleFileChange = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Reset input so picking the same file again triggers the event + e.target.value = ''; + + if (!file.type.startsWith('image/')) { + setError('Please select an image file (PNG, JPEG, WebP).'); + return; + } + if (file.size > 10 * 1024 * 1024) { + setError('Image must be smaller than 10 MB.'); + return; + } + + // Destroy any previous cropper instance + cropperRef.current?.destroy(); + cropperRef.current = null; + + // Store the file; the ref callback on the crop container will initialise + // the cropper once the div is in the DOM. + pendingFileRef.current = file; + setStage('cropping'); + }, []); + + /** + * Ref callback for the crop container div. + * Called with the element when it mounts (stage === 'cropping') and with null on unmount. + */ + const handleCropContainerMount = useCallback(async (el: HTMLDivElement | null) => { + // Store for later use (confirm/cancel) + (cropContainerRef as React.MutableRefObject).current = el; + + if (!el || !pendingFileRef.current) return; + + const { Cropper: CropperClass } = await import('@ottabase/cropper'); + + // Guard: component might have been unmounted by the time the import resolves + if (!pendingFileRef.current) return; + + const file = pendingFileRef.current; + pendingFileRef.current = null; + + const cropper = new CropperClass(el, { + aspectRatio: 1, + shape: 'circle', + maxHeight: 320, + }); + cropperRef.current = cropper; + await cropper.loadFile(file); + }, []); + + const handleCancelCrop = () => { + cropperRef.current?.destroy(); + cropperRef.current = null; + setStage('idle'); + setError(null); + }; + + const handleConfirmCrop = useCallback(async () => { + const cropper = cropperRef.current; + if (!cropper) return; + + setError(null); + setStage('uploading'); + + try { + const blob = await cropper.getBlob('image/jpeg', 0.9); + + const formData = new FormData(); + formData.append('file', blob, 'avatar.jpg'); + + const res = await fetch(uploadEndpoint, { method: 'POST', body: formData }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(text || `Upload failed (${res.status})`); + } + + const json = (await res.json()) as { url?: string; key?: string; success?: boolean }; + const url = json.url; + + if (!url) throw new Error('Upload succeeded but no URL was returned.'); + + cropper.destroy(); + cropperRef.current = null; + + onUploaded(url); + setStage('idle'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Upload failed. Please try again.'); + setStage('cropping'); // Return to crop stage so user can retry + } + }, [onUploaded, uploadEndpoint]); + + return ( +
+ {/* Avatar preview + edit button */} +
+ + + {initials} + + + {stage === 'idle' && !disabled && ( + + )} +
+ + {/* Hidden file input */} + + + {/* Cropper stage */} + {stage === 'cropping' && ( +
+

+ Drag to reposition · scroll to zoom · use handles to resize +

+ + {/* The cropper mounts here; ref callback initialises it once in DOM */} +
+ +
+ + +
+
+ )} + + {/* Uploading stage */} + {stage === 'uploading' && ( +
+ + Uploading… +
+ )} + + {/* Idle change-photo link */} + {stage === 'idle' && !disabled && ( + + )} + + {/* Error */} + {error &&

{error}

} +
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx b/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx index c8440e07a..b420d0d77 100644 --- a/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx +++ b/apps/ottabase-template-app-tanstack/src/components/ReferralDashboard.tsx @@ -6,7 +6,7 @@ import { api } from '@/lib/api'; import { clearStoredReferralCode, getReferralExpiryInfo, getStoredReferralCode } from '@/lib/referrals'; -import { validateReferralUsername } from '@ottabase/referrals'; +import { validateUsername } from '@ottabase/utils/user'; import { AlertDialog, AlertDialogAction, @@ -26,7 +26,7 @@ import { CardTitle, Input, } from '@ottabase/ui-shadcn'; -import { Copy, X } from 'lucide-react'; +import { Copy, Download, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; @@ -42,11 +42,13 @@ interface ReferralUser { email?: string; referralUsername?: string; referredById?: string; + referralUsernameChanges?: number; } interface ReferralData { user: ReferralUser; stats: ReferralStats; + usernameChangeLimit?: number; } interface TrackingData { @@ -132,7 +134,7 @@ export function ReferralDashboard({ userId }: ReferralDashboardProps) { const handleUpdateUsername = async () => { const trimmed = newUsername.trim(); // Validate - const validation = validateReferralUsername(trimmed); + const validation = validateUsername(trimmed); if (!validation.valid) { setUsernameError(validation.error || 'Invalid username'); return; @@ -172,6 +174,22 @@ export function ReferralDashboard({ userId }: ReferralDashboardProps) { window.location.reload(); }; + const handleDownloadCsv = async () => { + try { + const res = await fetch('/api/referrals/export?format=csv', { credentials: 'include' }); + if (!res.ok) throw new Error('Export failed'); + const blob = await res.blob(); + const today = new Date().toISOString().slice(0, 10); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `referrals-${today}.csv`; + a.click(); + URL.revokeObjectURL(a.href); + } catch { + toast.error('Failed to download CSV'); + } + }; + if (loading) { return (
@@ -242,35 +260,72 @@ export function ReferralDashboard({ userId }: ReferralDashboardProps) { Choose a unique username for your referral links -
-
- setNewUsername(e.target.value)} - placeholder="e.g., johndoe" - className="flex-1" - /> - -
- {usernameError &&

{usernameError}

} -

- 3-20 characters, letters/numbers/underscore only -

-
+ {(() => { + // usernameChangeLimit is always included in the API response; + // the fallback of 1 matches the server-side default (REFERRAL_SYSTEM_USERNAME_CHANGE). + const maxChanges = data.usernameChangeLimit ?? 1; + const changesMade = data.user.referralUsernameChanges ?? 0; + const hasUsername = !!data.user.referralUsername; + const changesRemaining = hasUsername ? Math.max(0, maxChanges - changesMade) : null; + const atLimit = hasUsername && changesRemaining === 0; + + return ( + <> +
+
+ setNewUsername(e.target.value)} + placeholder="e.g., johndoe" + className="flex-1" + disabled={atLimit} + /> + +
+ {usernameError &&

{usernameError}

} +

+ 3-20 characters, letters/numbers/underscore only +

+ {hasUsername && changesRemaining !== null && ( +

+ {atLimit ? ( + + Username change limit reached. You cannot change your username + again. + + ) : ( + <> + You have{' '} + + {changesRemaining} change + {changesRemaining !== 1 ? 's' : ''} + {' '} + remaining. + + )} +

+ )} +
- {data.user.referralUsername && ( - - -

- Warning: Changing your username will invalidate your old referral - links and may affect pending conversions. -

-
-
- )} + {hasUsername && !atLimit && ( + + +

+ Warning: Changing your username will invalidate your + old referral links and may affect pending conversions. +

+
+
+ )} + + ); + })()}
@@ -343,8 +398,16 @@ export function ReferralDashboard({ userId }: ReferralDashboardProps) { {/* Recent Tracking with Pagination */} - Recent Activity - Your referral click and conversion history +
+
+ Recent Activity + Your referral click and conversion history +
+ +
{trackingLoading ? ( diff --git a/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx b/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx index bad617215..df3bd4b5f 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx @@ -10,9 +10,6 @@ import { api } from '@/lib/api'; import { useSession } from '@/lib/auth'; import { requestEmailVerification } from '@/lib/auth-api'; import { - Avatar, - AvatarFallback, - AvatarImage, Badge, Button, Card, @@ -24,8 +21,9 @@ import { Label, Separator, } from '@ottabase/ui-shadcn'; -import { Calendar, Check, Loader2, Mail, User } from 'lucide-react'; +import { AtSign, Calendar, Check, Loader2, Mail, User } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; +import { ProfilePhotoUploader } from '@/components/ProfilePhotoUploader'; interface LinkedAccountRecord { provider: string; @@ -37,11 +35,20 @@ export function UserProfilePage() { const { user, updateUser, refreshSession } = useSession({ skipAutoSync: true }); const toast = useRBACToast(); + // Convenience accessor — the auth User type uses an index signature so extra + // properties like `username` are present at runtime but not statically typed. + const userUsername: string = user?.username ?? ''; + const [formData, setFormData] = useState({ name: user?.name || '', email: user?.email || '', + username: userUsername, }); + const [usernameError, setUsernameError] = useState(null); + const [currentImage, setCurrentImage] = useState(user?.image ?? null); + const [isPhotoUploading, setIsPhotoUploading] = useState(false); + const [linkedAccounts, setLinkedAccounts] = useState([]); const [isAccountsLoading, setIsAccountsLoading] = useState(true); @@ -53,21 +60,28 @@ export function UserProfilePage() { const normalize = useCallback((value: string) => value.trim(), []); const computeHasChanges = useCallback( - (next: { name: string; email: string }) => { + (next: { name: string; email: string; username: string }) => { const currentName = normalize(user?.name ?? ''); const currentEmail = normalize(user?.email ?? ''); - return normalize(next.name) !== currentName || normalize(next.email) !== currentEmail; + const currentUsername = normalize(userUsername ?? ''); + return ( + normalize(next.name) !== currentName || + normalize(next.email) !== currentEmail || + normalize(next.username) !== currentUsername + ); }, - [normalize, user?.email, user?.name], + [normalize, user?.email, user?.name, userUsername], ); useEffect(() => { setFormData({ name: user?.name || '', email: user?.email || '', + username: userUsername || '', }); setHasChanges(false); - }, [user?.name, user?.email]); + setUsernameError(null); + }, [user?.name, user?.email, userUsername]); useEffect(() => { let cancelled = false; @@ -104,12 +118,29 @@ export function UserProfilePage() { .toUpperCase() : user?.email?.[0]?.toUpperCase() || '?'; - const handleChange = (field: 'name' | 'email', value: string) => { + const handleChange = (field: 'name' | 'email' | 'username', value: string) => { setFormData((prev) => { const next = { ...prev, [field]: value }; setHasChanges(computeHasChanges(next)); return next; }); + if (field === 'username') setUsernameError(null); + }; + + const handlePhotoUploaded = async (url: string) => { + setIsPhotoUploading(true); + setCurrentImage(url); + // Persist to the user profile immediately; this only updates `image`, + // which is separate from the name/username save below. + try { + await api('/api/users/me', { method: 'PATCH', body: { image: url } }); + updateUser({ image: url }); + toast.success('Photo updated', 'Your profile photo has been updated successfully.'); + } catch { + toast.error('Photo update failed', 'Could not save the new photo to your profile.'); + } finally { + setIsPhotoUploading(false); + } }; const handleSave = async () => { @@ -122,12 +153,19 @@ export function UserProfilePage() { const trimmedName = normalize(formData.name); const trimmedEmail = normalize(formData.email); + const trimmedUsername = normalize(formData.username); if (!trimmedName) { toast.error('Name is required', 'Please enter your full name.'); return; } + // Basic client-side username validation (same rules as server) + if (trimmedUsername && !/^[a-zA-Z0-9_]{3,20}$/.test(trimmedUsername)) { + setUsernameError('Username must be 3-20 characters: letters, numbers, underscores only'); + return; + } + const updates: Record = {}; if (trimmedName !== normalize(user.name ?? '')) { @@ -137,10 +175,16 @@ export function UserProfilePage() { if (trimmedEmail !== normalize(user.email ?? '')) { toast.warning('Email changes are disabled', 'Contact support to update your login email.'); setFormData((prev) => ({ ...prev, email: user.email ?? '' })); - setHasChanges(computeHasChanges({ name: trimmedName, email: user.email ?? '' })); + setHasChanges( + computeHasChanges({ name: trimmedName, email: user.email ?? '', username: trimmedUsername }), + ); return; } + if (trimmedUsername !== normalize(userUsername ?? '')) { + updates.username = trimmedUsername; + } + if (Object.keys(updates).length === 0) { toast.info('No changes to save'); setHasChanges(false); @@ -165,14 +209,19 @@ export function UserProfilePage() { if (updatedUser?.emailVerified !== undefined) safeUpdates.emailVerified = updatedUser.emailVerified; if (updatedUser?.createdAt !== undefined) safeUpdates.createdAt = updatedUser.createdAt; if (updatedUser?.updatedAt !== undefined) safeUpdates.updatedAt = updatedUser.updatedAt; + if (updatedUser?.username !== undefined) safeUpdates.username = updatedUser.username; if (Object.keys(safeUpdates).length > 0) { updateUser(safeUpdates); } + if (updatedUser?.image !== undefined) { + setCurrentImage(updatedUser.image ?? null); + } setFormData({ name: updatedUser?.name ?? user.name ?? '', email: updatedUser?.email ?? user.email ?? '', + username: updatedUser?.username ?? userUsername ?? '', }); if (updatedUser?.linkedAccounts) { setLinkedAccounts(updatedUser.linkedAccounts); @@ -182,8 +231,13 @@ export function UserProfilePage() { } setHasChanges(false); toast.success('Profile updated', 'Your profile has been updated successfully'); - } catch (error) { - toast.error('Update failed', 'Failed to update profile'); + } catch (error: any) { + const fieldErrors = error?.fieldErrors; + if (fieldErrors?.username) { + setUsernameError(fieldErrors.username[0]); + } else { + toast.error('Update failed', 'Failed to update profile'); + } } finally { setIsSaving(false); } @@ -235,13 +289,15 @@ export function UserProfilePage() { Your profile information visible to others - {/* Avatar */} -
- - - {userInitials} - -
+ {/* Avatar + photo uploader */} +
+ +

{formData.name || 'No name set'}

@@ -264,6 +320,25 @@ export function UserProfilePage() { />
+ {/* Username */} +
+ + handleChange('username', e.target.value)} + placeholder="e.g. johndoe" + disabled={isSaving} + /> + {usernameError &&

{usernameError}

} +

+ 3–20 characters: letters, numbers and underscores only. Can be changed at any time. +

+
+ {/* Email */}
@@ -284,7 +359,11 @@ export function UserProfilePage() { {/* Save Button */} {hasChanges && (
-