galpal · PRD v2.0 · Bangalore

galpal

A safer friendship surface that takes women's time seriously.

Visit live site galpal.in
Platform: PWA · Next.js 16 · Supabase Version: 2.0 · 17 April 2026 Focus: Bangalore · Women 23–40
🛡️
Women only. Real call with a real human before entry.
💌
No hearts, no swipes, no romantic mechanics. A wave with a reason.
🚫
No feed, no groups, no endless scroll. Matches and chats only.

Product Summary

galpal is a women-only friendship PWA for Bangalore. Members finish a hand-reviewed verification call before they can see matches, then meet each other by sending a "wave" with a short reason. Mutual waves open a 1:1 chat. The product is a matches-and-chats surface — nothing more.

Target Persona

A Bangalore woman, 23–40
NEW TO THE CITY · OR LONG-RESIDENT WITH A FRAYING SOCIAL CIRCLE

On the app to date. Looking for a meditation cohort, a mom-group, or a professional network.

One or two real friends this year. A friendship surface that takes her time seriously.

Safety. Every new platform is an unknown risk. She's been burned by spaces that say "women-only" but don't enforce it.

Genuine, verified safety. A human called her before she could enter. Every match is real. Every profile was reviewed.

What This Is Not

A dating app
A wellness app
A feed product
A voice app
A group product
An events platform
A gamified product

No hearts, no swipes, no streaks-as-shame, no infinite scroll. No Stories strip. No Circles. No group chat. No broadcast. Games and Events exist in the DB but are scheduled for removal and carry no new brand work.

User Journey (End-to-End)

01

Landing Page ( / )

Anonymous view. Wordmark nav, one-sentence hero, three-step "how it works" row, full-bleed Safety Promise band (women only · real call real human · no hearts), pull-quote, FAQ, charcoal CTA band. Primary CTA: "join galpal" → /login.

02

Login ( /login )

Single Google OAuth button. On success, Supabase creates auth.users row → handle_new_user() trigger → matching row in public.users. Redirect to /auth/callback → /onboarding or /home.

03

Onboarding (3 Steps, stacked-deck UI)

Step 1: name, DOB (18+), city tenure, neighborhood, home state. Step 2: work situation, looking_for (≥1), personality type. Step 3: interests (≥3), surprise fact (10–150 chars). Submission of step 3 sets onboarding_completed = true.

04

Unverified Home

Until admin verifies: welcome banner + blurred 2×2 top-4 matches teaser. Cannot open a match, send a wave, or open a chat. API endpoints gate on verification.

05

Admin Verification Call

Allowlisted admin opens /admin, reviews profile at /admin/[id], calls the phone number via click-to-call, clicks Accept or Reject. Reject may include internal 500-char note. User can request another call via /profile → POST /api/me/reschedule.

06

Celebration ( /celebration )

One-time hand-off animation on first post-verification landing. Sets celebration_seen = true, then redirects to /home.

07

Verified Home

Time-of-day greeting, contextual wave-back card (most recent incoming wave), first-week nudges (Days 1–7, Day 4 skipped if no incoming waves), horizontal scroll of top 3 matches.

08

Wave Flow

Tapping match card opens ProfileOverlay. Wave button opens modal — 3 preset reasons + custom 100-char textarea. Sending inserts into waves table with expires_at = now() + 30 days. If mutual: creates connections row + fires two push notifications + MATCHED sticker.

09

1:1 Chat ( /chat/[connectionId] )

Text + image bubbles only. Realtime subscription on mount, removed on unmount. Three-dot menu: Disconnect / Block / Block + Report.

Feature Specifications

Authentication — Google OAuth

Single /login page. Client invokes supabase.auth.signInWithOAuth with Google provider. Middleware gates everything except /, /login, /privacy, /terms, /auth/callback. /admin/* allowed through middleware — auth handled at API layer.

Verification Workflow

User-Side Signals

Unverified home banner → "our team will reach out within 24 hours." If missed call: /profile → "Request another call" → POST /api/me/reschedule. Verification card flips butter-pending → mint-verified on acceptance.

Admin-Side Workflow

/admin lists: reschedule requests first, then unverified newest-first, then verified. Accept/Reject actions via PATCH /api/admin. Reject opens 500-char note sheet. Reset clears all verification columns.

Wave Rules

RuleDetail
Expiry30 days from created_at. Expired waves still count toward the lifetime cap.
Lifetime capMaximum 3 total waves from the same sender to the same recipient — ever. Button disabled at 3, showing "you've waved 3 times."
Rate limit10 waves per hour per user via Upstash Redis. Returns 429 with Retry-After header.
Cannot waveYourself, someone you're already connected to, same recipient while a previous wave is still active.
Mutual detectionOn every wave insert, server checks for any earlier wave from recipient to sender (active OR expired). If present: service-role creates connections, fires 2 push notifications + 2 wave_matched notifications.

Wave States Surfaced to Client

none
waved
incoming
exhausted
expired

1:1 Chat

Message types: text | image | system. GET /api/messages paginates backwards from created_at via cursor, 50 messages per page. Membership checked server-side (defense in depth — RLS exists but not solely relied upon). Rate limit: 60 messages per minute.

Safety Actions

Disconnect

Sets connections.status = 'disconnected'. Inserts "Conversation ended" system message. Soft, reversible from DB layer — no reconnect UI today.

Block (symmetric, silent)

Sets connections.status = 'blocked'. Inserts blocks row. Applied via src/lib/blocks.ts to matches, user-matches, search, and profile-detail endpoints. Blocked user sees "user not found."

Report

Writes to reports table. Rate-limited: 5 per day. No internal review surface yet — v3 scope confirmed.

Verification Gate

Unverified users cannot see match details, send waves, or open chats. Blurred match teaser is the only preview. Enforced at API layer.

Matching System

Match Sources

Weekly Cron

POST /api/cron/weekly-matches (protected by CRON_SECRET). Inserts up to 10 rows per verified user into user_matches with source = 'weekly'. Fires push notification to each affected user.

On-Demand

POST /api/matches/generate. Inserts up to 10 rows with source = 'on_demand', excluding existing matches, connections, and blocks.

Live Fallback

GET /api/matches. Called only when user_matches has no rows. Never writes to DB.

User-Facing

GET /api/user-matches?limit&offset. Reads user_matches, enriches with profiles, interests, wave status, common line. Filters blocks (symmetric) and existing connections.

Scoring Features

FeatureWeight
Shared interests+3 per shared interest
Shared "looking for" tags+4 per shared tag
Same home state+2
Neighborhood proximity (same)+4 or +5
Neighborhood cluster+2 or +3
Same city tenure+1
Same work situation+2
Personality compatibility0–3 via lookup table
Recency boost (live path only)Decays +3 → 0 over 30 days
Diverse pick: same home_state penalty−5 if already picked
Diverse pick: same neighborhood penalty−3 if already picked

Platform & Tech Stack

Runtime
Next.js 16 App Router (src/app), TypeScript, PWA via custom manifest.ts + service worker
Styling
Tailwind CSS v4 with @theme inline tokens in globals.css. No tailwind.config.ts. No shadcn, no MUI.
Fonts
Sora only — Indian Type Foundry, variable 100–800. Loaded via next/font/google under CSS variable --font-sora. Legacy Caveat/Nunito/JetBrains Mono references are stale, scheduled for removal.
Hosting
Vercel. Cron jobs registered on the Vercel scheduler.
Data
Supabase — Postgres + RLS + Realtime + Storage + Auth. One Realtime channel per user (notifications, waves), one per active chat (cleanup on unmount).
Auth
Google OAuth via Supabase Auth. (CLAUDE.md still claims phone OTP — that claim is stale. Actual is Google OAuth.)
Rate Limiting
Upstash Redis via @upstash/ratelimit. Optional — when env vars are missing, limits are disabled and a loud warning is logged.
Push
VAPID + Web Push API + service worker.
Observability
PostHog (analytics), Sentry (errors).
Animation
Framer Motion for signature moments (sticker press, wave sweep). Default CSS transitions use shared --ease-soft curve.

Brand Rules with API-Level Consequences

One sticker per screen maximum. Only two sticker variants ship: WAVE SENT (peach) and MATCHED (marigold). One primary CTA per visible viewport. Primary CTAs are always charcoal — never a saturated accent. Marigold is the earned celebration color, never a member-facing CTA fill.

Core Data Model

Key Tables

TableKey Columns / Notes
usersFull profile: neighborhood, city_tenure, home_state, work_situation, looking_for[], personality_type, surprise_fact, is_verified, verified_at, celebration_seen, nudges_dismissed (JSONB), reschedule_count, rejected_at, rejection_reason
user_interestsMany-to-many: user_id + interest tag. Replaced on each onboarding step 3 save.
wavesfrom_user, to_user, reason_type, custom_reason (nullable), expires_at (now() + 30 days). RLS: select where from_user = auth.uid() OR to_user = auth.uid()
connectionsuser_a (LEAST of sender/recipient), user_b, status (active | disconnected | blocked), connected_via ('wave' | 'event'). Deterministic ordering preserves unique index.
messagesconnection_id, sender_id, content, type (text | image | system), image_url. Legacy enum values voice|poll still exist in DB but no new writes.
notificationstype: wave_received | wave_matched | wave_back_reminder | match_available | verification_approved. Realtime-published per user channel.
blocksblocker_id, blocked_id. Applied symmetrically in src/lib/blocks.ts across all user-facing queries.
user_matchesPersisted scored matches. source: 'weekly' | 'on_demand'. Enriched with wave_status at read time.
waitlistemail, created_at. Written to from landing page for pre-signup users.

RLS Policies (Representative)

users — self-read, self-update, public read of public fields
waves — select where from_user = auth.uid() OR to_user = auth.uid()
connections — select where user_a = auth.uid() OR user_b = auth.uid()
messages — select if a member of the connection, insert where sender_id = auth.uid()
blocks — select + all operations where blocker_id = auth.uid()

RLS audit pending. No end-to-end proof yet that every cross-user query goes through either (a) an RLS policy that restricts appropriately or (b) a defense-in-depth server check. Flagged for a dedicated audit session.

Known Limitations & Tech Debt

1

Streak incrementer has a short-circuit bug

In src/app/api/messages/route.ts, once a streak reaches 1, the || current_streak > 0 branch keeps the streak alive regardless of gap. Streaks effectively never break. Do NOT surface streaks in marketing until this is fixed.

2

Chat images upload to the avatars bucket

Intentional shortcut. Move to dedicated chat-images bucket before scaling storage.

3

Rate limits are optional

When UPSTASH_REDIS_REST_URL / _TOKEN are missing, all limiters silently bypass. In production these env vars MUST be set or wave/message/report endpoints become unlimited.

4

Auth claim in CLAUDE.md is stale

Still says "Phone OTP only via Supabase Auth + Twilio." Actual: Google OAuth. Update on next edit pass.

5

Legacy schema tables still exist

circles, circle_members, stories, story_responses, weekend_plan_joins, user_prompts. No UI writes to them. Drop on a future migration after product signs off.

6

Hard-coded admin emails fallback

src/app/api/admin/route.ts and /admin/users/[id]/route.ts contain 3 hard-coded email addresses as fallback. Move to env before public launch.

7

sessionStorage key queens_matches leaks old name

Rename to galpal_matches as part of the rebrand sweep.

8

Legacy brand tokens still in code

font-display (Caveat), font-nunito, font-mono, terracotta/sage/butter raw hex colors, bg-cream. Rebrand migration is in-progress and not yet complete in code.

Open Questions

OQQuestionStatus
1Events + Games removal cutover date? Keep DB tables for history or drop with UI?Open
2Streak rule — what breaks a streak? Proposed: "both users send at least one message on each of N consecutive local days."Open
3Domain confirmed as galpal.in. VAPID, Supabase redirect URLs, Google OAuth, email sender, and footer copy have been aligned to the new domain.Resolved
4RLS audit — who owns it and when?Open
5Reschedule cap — do we cap reschedule_count? Current: unlimited.Open
6Wordmark SVG with linked-arms ligature — ETA from design team?Open
74 bespoke illustrations — Empty Friends (teacups + envelope), Home unverified (door ajar), Onboarding complete (linked hands). Blocks onboarding complete screen.Open