ExposedGays: map, shop, auth, Supabase schema

This commit is contained in:
Ryan Salazar
2026-06-26 21:32:17 +00:00
commit fd12fb4100
54 changed files with 4424 additions and 0 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Supabase — create project at https://supabase.com or self-host
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# App
NEXT_PUBLIC_APP_URL=https://exposedgays.com
NEXT_PUBLIC_DEFAULT_CITY=Fort Lauderdale
NEXT_PUBLIC_DEFAULT_LAT=26.1224
NEXT_PUBLIC_DEFAULT_LNG=-80.1373
# Optional: third-party age verification (Veriff, Yoti, etc.)
NEXT_PUBLIC_AGE_VERIFY_PROVIDER=placeholder

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:22-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

163
README.md Normal file
View File

@@ -0,0 +1,163 @@
# ExposedGays
100% free gay cruising map + chat + spots directory + sex shop. No subscriptions ever.
**Domain:** exposedgays.com
## Quick Start
```bash
npm install
cp .env.example .env.local
# Edit .env.local with your Supabase keys (optional for local demo — mock data works offline)
npm run dev
```
Open [http://localhost:3000](http://localhost:3000)
## Features
| Feature | Status |
|---------|--------|
| Real-time cruising map (Leaflet + OSM) | ✅ Mock data + Supabase-ready |
| User clustering + position badges | ✅ |
| Map filters (age, position, kinks, PrEP) | ✅ |
| Cruiser Cards + Profile popup | ✅ |
| Instant chat + city chat + DMs + groups | ✅ Mock + Supabase schema |
| Cruising spot directory | ✅ |
| **Shop** (affiliate links, local cart) | ✅ |
| Age verification gate (Florida-compliant) | ✅ |
| Vanilla mode blur toggle | ✅ |
| Dark mode, mobile-first, PWA manifest | ✅ |
| Anonymous browsing + optional profile | ✅ |
## Tech Stack
- **Next.js 15** App Router + TypeScript
- **Tailwind CSS** + shadcn/ui components
- **React-Leaflet** + OpenStreetMap tiles
- **Supabase** — Auth, Postgres + PostGIS, Realtime, Storage
- **Zustand** — localStorage cart
## Project Structure
```
exposedgays/
├── public/
│ └── manifest.json # PWA manifest
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout + nav
│ │ ├── page.tsx # Map (home)
│ │ ├── shop/page.tsx # Sex shop
│ │ ├── chat/page.tsx # City chat + DMs
│ │ ├── spots/page.tsx # Spot directory
│ │ └── profile/page.tsx # User profile editor
│ ├── components/
│ │ ├── AgeGate.tsx # 18+ DOB verification
│ │ ├── Map.tsx # Interactive cruising map
│ │ ├── Shop.tsx # Product grid + cart
│ │ ├── Nav.tsx # Top/bottom nav with Shop link
│ │ ├── ChatPanel.tsx # Real-time chat UI
│ │ ├── CruiserCard.tsx # Squirt-style cruiser cards
│ │ ├── ProfilePopup.tsx # Tap icon → profile modal
│ │ ├── SpotDirectory.tsx # Spot listings + search
│ │ └── ui/ # shadcn components
│ ├── lib/
│ │ ├── products.ts # Dummy shop inventory
│ │ ├── cart.ts # Zustand localStorage cart
│ │ ├── mock-data.ts # Demo cruisers + spots
│ │ └── supabase/ # Supabase client helpers
│ └── types/index.ts
├── supabase/
│ └── schema.sql # Full Postgres + PostGIS + RLS
├── .env.example
└── package.json
```
## Supabase Setup
### 1. Create project
Go to [supabase.com](https://supabase.com) → New Project (free tier).
### 2. Run schema
SQL Editor → paste contents of `supabase/schema.sql` → Run.
### 3. Enable Realtime
Dashboard → Database → Replication → enable `chat_messages` and `profiles`.
### 4. Create Storage bucket
```sql
INSERT INTO storage.buckets (id, name, public) VALUES ('gallery', 'gallery', true);
INSERT INTO storage.buckets (id, name, public) VALUES ('shop', 'shop', true);
```
### 5. Copy keys to `.env.local`
```
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
```
### CLI alternative
```bash
npx supabase init
npx supabase link --project-ref YOUR_PROJECT_REF
npx supabase db push
```
## 5-Minute Deployment (Vercel + Supabase + DNS)
### Step 1 — Push to GitHub (1 min)
```bash
git init
git add .
git commit -m "ExposedGays starter"
gh repo create exposedgays --private --push
```
### Step 2 — Vercel deploy (2 min)
1. [vercel.com](https://vercel.com) → Import Git repo
2. Framework: **Next.js** (auto-detected)
3. Add environment variables from `.env.example`
4. Deploy → get `exposedgays.vercel.app`
### Step 3 — Custom domain (1 min)
1. Vercel → Project → Settings → Domains → Add `exposedgays.com` + `www.exposedgays.com`
2. Vercel shows required DNS records
### Step 4 — DNS at registrar (1 min)
| Type | Name | Value |
|------|------|-------|
| A | @ | `76.76.21.21` |
| CNAME | www | `cname.vercel-dns.com` |
(Or use Cloudflare CNAME flattening if proxied.)
### Step 5 — Verify
- Visit `https://exposedgays.com` — age gate appears
- Map loads with cruiser icons
- Shop nav works with affiliate buttons
- PWA installable on mobile
## Shop Affiliate Notes
- Cart is **localStorage only** — no checkout on-site
- "Buy Now" opens affiliate URLs in new tab (`rel="sponsored"`)
- Replace placeholder URLs with real Amazon Associates / AdamMale affiliate links
- Products defined in `src/lib/products.ts` (static) or `shop_products` table (DB)
## License
Private — exposedgays.com © 2026

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

237
deploy/setup-infra.py Normal file
View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Push ExposedGays to Gitea, create Coolify app, apply Supabase schema."""
import json
import os
import subprocess
import sys
import urllib.request
import urllib.error
try:
import paramiko
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "paramiko", "-q"])
import paramiko
HOST = "10.10.0.10"
SUPA_HOST = "10.10.0.11"
USER = "localadministrator"
PASS = os.environ.get("COOLIFY_SSH_PASS", "Bbt9115xty9176!")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "dbf033cee4b0e3be5699f374a15f3fa240e2896a")
COOLIFY_TOKEN = os.environ.get("COOLIFY_TOKEN", "1|zALRdpUD3VkejNVlEV5UuRzTeoYVhshZtke5VkCNa6e9262d")
REPO_NAME = "exposedgays"
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GIT_AUTH = f"https://{USER}:{GITEA_TOKEN}@gitea.onsethost.com/localadministrator/{REPO_NAME}.git"
def ssh_run(host, cmd, timeout=120):
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(host, username=USER, password=PASS, timeout=20, allow_agent=False, look_for_keys=False)
print(f"[{host}] $ {cmd[:120]}")
_, o, e = c.exec_command(cmd, timeout=timeout)
code = o.channel.recv_exit_status()
out = (o.read() + e.read()).decode(errors="replace")
if out.strip():
print(out[-8000:])
print(f"exit={code}")
c.close()
return code, out
def gitea_api(method, path, data=None):
url = f"https://gitea.onsethost.com/api/v1{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Authorization", f"token {GITEA_TOKEN}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as ex:
body = ex.read().decode()
try:
return ex.code, json.loads(body)
except Exception:
return ex.code, {"message": body}
def coolify_api(method, path, data=None):
url = f"https://coolify.onsethost.com/api/v1{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Authorization", f"Bearer {COOLIFY_TOKEN}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read().decode()
return resp.status, json.loads(raw) if raw else {}
except urllib.error.HTTPError as ex:
body = ex.read().decode()
try:
return ex.code, json.loads(body)
except Exception:
return ex.code, {"message": body}
def push_gitea():
print("\n=== 1. Create Gitea repo (if missing) ===")
code, resp = gitea_api("POST", "/user/repos", {
"name": REPO_NAME,
"private": True,
"auto_init": False,
"description": "ExposedGays — free gay cruising + shop",
})
if code in (201, 409):
print(f"Repo ready (HTTP {code})")
else:
print(f"Repo create: {code} {resp}")
print("\n=== 2. Git commit + push from COOLIFY_01 ===")
schema_sql = open(os.path.join(PROJECT_ROOT, "supabase", "schema.sql"), encoding="utf-8").read()
functions_sql = open(os.path.join(PROJECT_ROOT, "supabase", "functions.sql"), encoding="utf-8").read()
# Tar project and upload via SSH
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASS, timeout=20, allow_agent=False, look_for_keys=False)
sftp = c.open_sftp()
import tarfile
import io
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for root, dirs, files in os.walk(PROJECT_ROOT):
dirs[:] = [d for d in dirs if d not in (".next", "node_modules", ".git")]
for f in files:
if f in (".env.local",):
continue
full = os.path.join(root, f)
arc = os.path.relpath(full, PROJECT_ROOT)
tar.add(full, arcname=arc)
buf.seek(0)
sftp.putfo(buf, "/tmp/exposedgays.tar.gz")
sftp.close()
cmds = [
"rm -rf /tmp/exposedgays && mkdir -p /tmp/exposedgays && tar -xzf /tmp/exposedgays.tar.gz -C /tmp/exposedgays",
f"cd /tmp/exposedgays && git init -b main && git config user.email 'ryan.salazar@justtworoommates.com' && git config user.name 'Ryan Salazar'",
"cd /tmp/exposedgays && git add -A && git commit -m 'ExposedGays: map, shop, auth, Supabase schema' || true",
f"cd /tmp/exposedgays && git remote remove origin 2>/dev/null; git remote add origin {GIT_AUTH}",
f"cd /tmp/exposedgays && git push -u origin main --force",
]
for cmd in cmds:
_, o, e = c.exec_command(cmd, timeout=180)
code = o.channel.recv_exit_status()
out = (o.read() + e.read()).decode(errors="replace")
print(out[-3000:])
print(f"exit={code}")
c.close()
return GIT_AUTH.replace(GITEA_TOKEN, "***")
def apply_supabase_schema():
print("\n=== 3. Apply Supabase schema on SUPABASE_01 ===")
schema_path = os.path.join(PROJECT_ROOT, "supabase", "schema.sql")
func_path = os.path.join(PROJECT_ROOT, "supabase", "functions.sql")
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(SUPA_HOST, username=USER, password=PASS, timeout=20, allow_agent=False, look_for_keys=False)
sftp = c.open_sftp()
sftp.put(schema_path, "/tmp/eg_schema.sql")
sftp.put(func_path, "/tmp/eg_functions.sql")
sftp.close()
cmd = (
"docker exec -i supabase-db psql -U postgres -d postgres "
"-f /dev/stdin < /tmp/eg_schema.sql 2>&1; "
"docker exec -i supabase-db psql -U postgres -d postgres "
"-f /dev/stdin < /tmp/eg_functions.sql 2>&1"
)
# Copy into container first
for f in ("eg_schema.sql", "eg_functions.sql"):
c.exec_command(f"docker cp /tmp/{f} supabase-db:/tmp/{f}")
import time
time.sleep(2)
_, o, e = c.exec_command(
"docker exec supabase-db psql -U postgres -d postgres -f /tmp/eg_schema.sql 2>&1; "
"docker exec supabase-db psql -U postgres -d postgres -f /tmp/eg_functions.sql 2>&1",
timeout=120,
)
out = (o.read() + e.read()).decode(errors="replace")
print(out[-5000:])
c.close()
def create_coolify_app(git_url_auth):
print("\n=== 4. Create Coolify application ===")
# List projects
code, projects = coolify_api("GET", "/projects")
print(f"Projects: {code}")
project_uuid = None
if code == 200 and projects:
items = projects if isinstance(projects, list) else projects.get("data", projects)
if items:
project_uuid = items[0].get("uuid") or items[0].get("id")
print(f"Using project: {project_uuid}")
payload = {
"project_uuid": project_uuid,
"server_uuid": None,
"environment_name": "production",
"git_repository": f"https://gitea.onsethost.com/localadministrator/{REPO_NAME}.git",
"git_branch": "main",
"build_pack": "dockerfile",
"dockerfile_location": "/Dockerfile",
"ports_exposes": "3000",
"name": "exposedgays",
"description": "ExposedGays — free cruising + shop",
"domains": "https://exposedgays.com,https://www.exposedgays.com",
"instant_deploy": True,
}
code, resp = coolify_api("POST", "/applications/public", payload)
print(f"Create app: HTTP {code}")
print(json.dumps(resp, indent=2)[:3000])
app_uuid = resp.get("uuid") if isinstance(resp, dict) else None
if not app_uuid and isinstance(resp, dict):
app_uuid = resp.get("id")
if app_uuid:
print(f"\n=== 5. Set env vars on {app_uuid} ===")
envs = [
("NEXT_PUBLIC_SUPABASE_URL", "https://supabase.onsethost.com"),
("NEXT_PUBLIC_SUPABASE_ANON_KEY", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc2NzEzOTczLCJleHAiOjIwOTIwNzM5NzN9.oRPm6uJt4Gj-ASOixznas7-IC1pCtlZtEFew-SmY9wo"),
("NEXT_PUBLIC_APP_URL", "https://exposedgays.com"),
("NEXT_PUBLIC_DEFAULT_CITY", "Fort Lauderdale"),
("NEXT_PUBLIC_DEFAULT_LAT", "26.1224"),
("NEXT_PUBLIC_DEFAULT_LNG", "-80.1373"),
]
for key, value in envs:
c, r = coolify_api("POST", f"/applications/{app_uuid}/envs", {"key": key, "value": value})
print(f" {key}: HTTP {c}")
print("\n=== 6. Trigger deploy ===")
code, dep = coolify_api("POST", f"/deploy?uuid={app_uuid}&force_rebuild=true")
print(f"Deploy: HTTP {code} {dep}")
return app_uuid
def main():
git_url = push_gitea()
try:
apply_supabase_schema()
except Exception as ex:
print(f"Supabase schema warning: {ex}")
app_uuid = create_coolify_app(git_url)
print("\n=== DONE ===")
print(f"Gitea: https://gitea.onsethost.com/localadministrator/{REPO_NAME}")
print(f"Coolify app: {app_uuid or 'check Coolify UI'}")
print("Next: add exposedgays.com to Cloudflare tunnel → Coolify container")
if __name__ == "__main__":
main()

14
eslint.config.mjs Normal file
View File

@@ -0,0 +1,14 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [...compat.extends("next/core-web-vitals")];
export default eslintConfig;

15
next.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
{ protocol: "https", hostname: "placehold.co" },
{ protocol: "https", hostname: "*.supabase.co" },
{ protocol: "https", hostname: "supabase.onsethost.com" },
],
},
};
export default nextConfig;

51
package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "exposedgays",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.49.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lucide-react": "^0.475.0",
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^2.1.0",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/leaflet": "^1.9.16",
"@types/leaflet.markercluster": "^1.5.5",
"@types/node": "^22.13.4",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.1",
"eslint-config-next": "^15.2.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
}
}

9
postcss.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

22
public/manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "ExposedGays",
"short_name": "ExposedGays",
"description": "Free gay cruising map, chat & shop",
"start_url": "/",
"display": "standalone",
"background_color": "#0d0a0f",
"theme_color": "#ff2d6b",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

24
src/app/chat/page.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { ChatPanel } from "@/components/ChatPanel";
function ChatContent() {
const searchParams = useSearchParams();
const userId = searchParams.get("user");
return (
<div className="h-[calc(100vh-3.5rem)] md:h-[calc(100vh-3.5rem)]">
<ChatPanel dmUserId={userId} />
</div>
);
}
export default function ChatPage() {
return (
<Suspense fallback={<div className="p-4 text-muted-foreground">Loading chat...</div>}>
<ChatContent />
</Suspense>
);
}

96
src/app/globals.css Normal file
View File

@@ -0,0 +1,96 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 280 30% 6%;
--foreground: 0 0% 98%;
--card: 280 25% 10%;
--card-foreground: 0 0% 98%;
--popover: 280 25% 10%;
--popover-foreground: 0 0% 98%;
--primary: 340 100% 59%;
--primary-foreground: 0 0% 100%;
--secondary: 280 40% 18%;
--secondary-foreground: 0 0% 98%;
--muted: 280 20% 16%;
--muted-foreground: 280 10% 65%;
--accent: 280 60% 45%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%;
--border: 280 20% 20%;
--input: 280 20% 20%;
--ring: 340 100% 59%;
--radius: 0.75rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Leaflet dark theme overrides */
.leaflet-container {
background: #0d0a0f !important;
font-family: inherit;
}
.leaflet-popup-content-wrapper {
background: hsl(280 25% 10%);
color: hsl(0 0% 98%);
border-radius: 0.75rem;
border: 1px solid hsl(280 20% 20%);
}
.leaflet-popup-tip {
background: hsl(280 25% 10%);
}
.marker-cluster-small,
.marker-cluster-medium,
.marker-cluster-large {
background-color: rgba(255, 45, 107, 0.3) !important;
}
.marker-cluster-small div,
.marker-cluster-medium div,
.marker-cluster-large div {
background-color: rgba(255, 45, 107, 0.8) !important;
color: white !important;
font-weight: 700;
}
/* Vanilla mode blur */
.vanilla-blur img,
.vanilla-blur video,
.vanilla-blur .explicit-content {
filter: blur(24px);
transition: filter 0.3s ease;
}
.vanilla-blur:hover img,
.vanilla-blur:hover video,
.vanilla-blur:hover .explicit-content {
filter: blur(8px);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: hsl(280 30% 6%);
}
::-webkit-scrollbar-thumb {
background: hsl(340 100% 59% / 0.5);
border-radius: 3px;
}
/* PWA safe areas */
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}

39
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,39 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { Providers } from "@/components/Providers";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "ExposedGays — Free Gay Cruising & Shop",
description:
"100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "ExposedGays",
},
};
export const viewport: Viewport = {
themeColor: "#ff2d6b",
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}

18
src/app/page.tsx Normal file
View File

@@ -0,0 +1,18 @@
"use client";
import { CruisingMap } from "@/components/Map";
import { useSetActiveCount, useVanillaMode } from "@/components/Providers";
export default function HomePage() {
const setActiveCount = useSetActiveCount();
const vanillaMode = useVanillaMode();
return (
<div className="h-[calc(100vh-4rem)] md:h-[calc(100vh-3.5rem)]">
<CruisingMap
vanillaMode={vanillaMode}
onActiveCountChange={setActiveCount}
/>
</div>
);
}

12
src/app/privacy/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
export default function PrivacyPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 prose prose-invert">
<h1>Privacy Policy</h1>
<p className="text-muted-foreground text-sm">
We collect minimal data. Location is used for map features and city chat auto-join.
Anonymous browsing is supported. Cart data stays in your browser (localStorage).
Placeholder policy consult legal counsel before launch.
</p>
</div>
);
}

257
src/app/profile/page.tsx Normal file
View File

@@ -0,0 +1,257 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { KINK_OPTIONS } from "@/types";
import type { Position } from "@/types";
import { useAuth } from "@/lib/auth/context";
import { AuthModal } from "@/components/AuthModal";
export default function ProfilePage() {
const { user, profile, loading, updateProfile, signOut } = useAuth();
const [authOpen, setAuthOpen] = useState(false);
const [isAnonymous, setIsAnonymous] = useState(true);
const [displayName, setDisplayName] = useState("");
const [age, setAge] = useState("");
const [position, setPosition] = useState<Position | null>(null);
const [bio, setBio] = useState("");
const [kinks, setKinks] = useState<string[]>([]);
const [onPrep, setOnPrep] = useState(false);
const [stiStatus, setStiStatus] = useState("");
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
if (profile) {
setIsAnonymous(profile.is_anonymous);
setDisplayName(profile.display_name || "");
setAge(profile.age?.toString() || "");
setPosition(profile.position);
setBio(profile.bio || "");
setKinks(profile.kinks || []);
setOnPrep(profile.on_prep);
setStiStatus(profile.sti_status || "");
} else {
const local = localStorage.getItem("eg_profile");
if (local) {
try {
const p = JSON.parse(local);
setIsAnonymous(p.isAnonymous ?? true);
setDisplayName(p.displayName || "");
setAge(p.age?.toString() || "");
setPosition(p.position);
setBio(p.bio || "");
setKinks(p.kinks || []);
setOnPrep(p.onPrep || false);
setStiStatus(p.stiStatus || "");
} catch {
/* ignore */
}
}
}
}, [profile]);
const toggleKink = (k: string) => {
setKinks((prev) =>
prev.includes(k) ? prev.filter((x) => x !== k) : [...prev, k]
);
};
const handleSave = async () => {
setSaving(true);
setMessage("");
const data = {
display_name: isAnonymous ? null : displayName || null,
age: parseInt(age) || null,
position,
bio: bio || null,
kinks,
on_prep: onPrep,
sti_status: stiStatus || null,
is_anonymous: isAnonymous,
status: "online" as const,
city: process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale",
lat: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"),
lng: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373"),
};
if (user) {
const { error } = await updateProfile(data);
setMessage(error ? `Error: ${error}` : "Profile synced to cloud.");
} else {
localStorage.setItem(
"eg_profile",
JSON.stringify({
displayName: data.display_name,
age: data.age,
position: data.position,
bio: data.bio,
kinks: data.kinks,
onPrep: data.on_prep,
stiStatus: data.sti_status,
isAnonymous: data.is_anonymous,
})
);
setMessage("Profile saved locally. Sign up free for cloud sync.");
}
setSaving(false);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh] text-muted-foreground">
Loading profile...
</div>
);
}
return (
<div className="mx-auto max-w-lg px-4 py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Your Profile</h1>
<p className="text-sm text-muted-foreground">
{user
? `Signed in as ${user.email || "anonymous user"}`
: "Browsing locally — sign up free for cloud sync"}
</p>
</div>
{!user && (
<Button variant="horny" size="sm" onClick={() => setAuthOpen(true)}>
Sign Up Free
</Button>
)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">Visibility</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label>Browse anonymously</Label>
<Switch checked={isAnonymous} onCheckedChange={setIsAnonymous} />
</div>
{!isAnonymous && (
<div>
<Label>Display Name</Label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your name"
className="mt-1"
/>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Age</Label>
<Input
type="number"
value={age}
onChange={(e) => setAge(e.target.value)}
placeholder="18+"
min={18}
max={99}
className="mt-1"
/>
</div>
<div>
<Label className="mb-2 block">Position</Label>
<div className="flex flex-wrap gap-2">
{(["top", "bottom", "vers", "side"] as Position[]).map((pos) => (
<Badge
key={pos}
variant={position === pos ? pos : "outline"}
className="cursor-pointer uppercase"
onClick={() => setPosition(position === pos ? null : pos)}
>
{pos}
</Badge>
))}
</div>
</div>
<div>
<Label>Bio</Label>
<Input
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder="Say something..."
className="mt-1"
/>
</div>
<div>
<Label className="mb-2 block">Into</Label>
<div className="flex flex-wrap gap-1">
{KINK_OPTIONS.map((k) => (
<Badge
key={k}
variant={kinks.includes(k) ? "default" : "outline"}
className="cursor-pointer text-xs"
onClick={() => toggleKink(k)}
>
{k}
</Badge>
))}
</div>
</div>
<div className="flex items-center justify-between">
<Label>On PrEP</Label>
<Switch checked={onPrep} onCheckedChange={setOnPrep} />
</div>
<div>
<Label>STI Status (optional)</Label>
<Input
value={stiStatus}
onChange={(e) => setStiStatus(e.target.value)}
placeholder="e.g. Negative, tested Jan 2026"
className="mt-1"
/>
</div>
</CardContent>
</Card>
{message && (
<p className={`text-sm text-center ${message.startsWith("Error") ? "text-destructive" : "text-green-400"}`}>
{message}
</p>
)}
<Button
variant="horny"
className="w-full"
size="lg"
onClick={handleSave}
disabled={saving}
>
{saving ? "Saving..." : user ? "Save & Sync to Cloud" : "Save Profile"}
</Button>
{user && (
<Button variant="outline" className="w-full" onClick={() => signOut()}>
Sign Out
</Button>
)}
<AuthModal open={authOpen} onClose={() => setAuthOpen(false)} />
</div>
);
}

5
src/app/shop/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Shop } from "@/components/Shop";
export default function ShopPage() {
return <Shop />;
}

5
src/app/spots/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { SpotDirectory } from "@/components/SpotDirectory";
export default function SpotsPage() {
return <SpotDirectory />;
}

12
src/app/terms/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
export default function TermsPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 prose prose-invert">
<h1>Terms of Service</h1>
<p className="text-muted-foreground text-sm">
ExposedGays is a free adult platform. You must be 18+ to use this site.
All content is user-generated. We reserve the right to remove illegal content.
No paid subscriptions ever. Placeholder terms consult legal counsel before launch.
</p>
</div>
);
}

150
src/components/AgeGate.tsx Normal file
View File

@@ -0,0 +1,150 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ShieldAlert } from "lucide-react";
interface AgeGateProps {
onVerified: () => void;
}
export function AgeGate({ onVerified }: AgeGateProps) {
const [month, setMonth] = useState("");
const [day, setDay] = useState("");
const [year, setYear] = useState("");
const [error, setError] = useState("");
const [agreed, setAgreed] = useState(false);
const handleVerify = () => {
const m = parseInt(month, 10);
const d = parseInt(day, 10);
const y = parseInt(year, 10);
if (!m || !d || !y) {
setError("Please enter your full date of birth.");
return;
}
const dob = new Date(y, m - 1, d);
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const monthDiff = today.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
age--;
}
if (age < 18) {
setError("You must be 18 or older to access this site.");
return;
}
if (!agreed) {
setError("You must confirm you are 18+ and agree to the disclaimer.");
return;
}
localStorage.setItem("eg_age_verified", "true");
localStorage.setItem("eg_age_verified_at", new Date().toISOString());
onVerified();
};
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95 p-4">
<div className="w-full max-w-md rounded-2xl border border-horny-pink/30 bg-horny-surface p-8 shadow-2xl shadow-horny-pink/20">
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-horny-gradient">
<ShieldAlert className="h-8 w-8 text-white" />
</div>
<h1 className="text-2xl font-bold bg-horny-gradient bg-clip-text text-transparent">
ExposedGays
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Adults only. Explicit content ahead.
</p>
</div>
<div className="space-y-4">
<div>
<Label className="mb-2 block">Date of Birth</Label>
<div className="grid grid-cols-3 gap-2">
<select
value={month}
onChange={(e) => setMonth(e.target.value)}
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="">Month</option>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{new Date(2000, i).toLocaleString("en", { month: "long" })}
</option>
))}
</select>
<select
value={day}
onChange={(e) => setDay(e.target.value)}
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="">Day</option>
{Array.from({ length: 31 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{i + 1}
</option>
))}
</select>
<select
value={year}
onChange={(e) => setYear(e.target.value)}
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="">Year</option>
{Array.from({ length: 80 }, (_, i) => {
const y = new Date().getFullYear() - 18 - i;
return (
<option key={y} value={y}>
{y}
</option>
);
})}
</select>
</div>
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-input accent-horny-pink"
/>
<span className="text-xs text-muted-foreground leading-relaxed">
I confirm I am 18 years of age or older. I understand this site contains
explicit adult content including nudity and sexual material. I agree to the{" "}
<a href="/terms" className="text-horny-pink underline">
Terms of Service
</a>{" "}
and{" "}
<a href="/privacy" className="text-horny-pink underline">
Privacy Policy
</a>
. Pursuant to Florida Statute § 847.013, I acknowledge this material may be
harmful to minors. Third-party age verification may be required in the future.
</span>
</label>
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
<Button variant="horny" className="w-full" size="lg" onClick={handleVerify}>
Confirm I am 18+
</Button>
<p className="text-center text-xs text-muted-foreground">
100% free forever. No subscriptions. No credit card required.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { useAuth } from "@/lib/auth/context";
import { Ghost, Mail, Lock } from "lucide-react";
interface AuthModalProps {
open: boolean;
onClose: () => void;
}
export function AuthModal({ open, onClose }: AuthModalProps) {
const { signUp, signIn, signInAnonymously } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [loading, setLoading] = useState(false);
const handleSignUp = async () => {
setLoading(true);
setError("");
setSuccess("");
const { error: err } = await signUp(email, password);
setLoading(false);
if (err) setError(err);
else {
setSuccess("Check your email to confirm, or sign in if confirmation is disabled.");
}
};
const handleSignIn = async () => {
setLoading(true);
setError("");
const { error: err } = await signIn(email, password);
setLoading(false);
if (err) setError(err);
else onClose();
};
const handleAnonymous = async () => {
setLoading(true);
setError("");
const { error: err } = await signInAnonymously();
setLoading(false);
if (err) setError(err);
else onClose();
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Join ExposedGays Free Forever</DialogTitle>
</DialogHeader>
<Tabs defaultValue="signup">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="signup">Sign Up</TabsTrigger>
<TabsTrigger value="login">Log In</TabsTrigger>
</TabsList>
<TabsContent value="signup" className="space-y-4 mt-4">
<div>
<Label>Email</Label>
<div className="relative mt-1">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="pl-10"
/>
</div>
</div>
<div>
<Label>Password</Label>
<div className="relative mt-1">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="8+ characters"
className="pl-10"
minLength={8}
/>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-green-400">{success}</p>}
<Button
variant="horny"
className="w-full"
onClick={handleSignUp}
disabled={loading || !email || password.length < 8}
>
Create Free Account
</Button>
</TabsContent>
<TabsContent value="login" className="space-y-4 mt-4">
<div>
<Label>Email</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="mt-1"
/>
</div>
<div>
<Label>Password</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
variant="horny"
className="w-full"
onClick={handleSignIn}
disabled={loading || !email || !password}
>
Log In
</Button>
</TabsContent>
</Tabs>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
<Button
variant="outline"
className="w-full gap-2"
onClick={handleAnonymous}
disabled={loading}
>
<Ghost className="h-4 w-4" />
Continue Anonymously
</Button>
<p className="text-[10px] text-center text-muted-foreground">
No credit card. No paid tier. Ever.
</p>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,215 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Send, Image as ImageIcon, Users, Lock } from "lucide-react";
import { timeAgo } from "@/lib/utils";
interface Message {
id: string;
sender: string;
content: string;
image_url?: string;
created_at: string;
isMe?: boolean;
}
const CITY = process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale";
const MOCK_CITY_MESSAGES: Message[] = [
{
id: "1",
sender: "Mike_Top",
content: "Anyone at the beach right now?",
created_at: new Date(Date.now() - 300000).toISOString(),
},
{
id: "2",
sender: "Anonymous",
content: "Yeah, south end. Busy tonight.",
created_at: new Date(Date.now() - 240000).toISOString(),
},
{
id: "3",
sender: "VersBear",
content: "Hosting near Wilton. Door code 1234. Clean only.",
created_at: new Date(Date.now() - 120000).toISOString(),
},
{
id: "4",
sender: "PupRex",
content: "Woof! At Ramrod later if anyone wants to meet up 🐾",
created_at: new Date(Date.now() - 60000).toISOString(),
},
];
const MOCK_DM_MESSAGES: Message[] = [
{
id: "dm1",
sender: "You",
content: "Hey, saw you on the map. What's up?",
created_at: new Date(Date.now() - 600000).toISOString(),
isMe: true,
},
{
id: "dm2",
sender: "Mike_Top",
content: "Not much, hosting. You?",
created_at: new Date(Date.now() - 540000).toISOString(),
},
];
interface ChatPanelProps {
dmUserId?: string | null;
}
export function ChatPanel({ dmUserId }: ChatPanelProps) {
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
const [input, setInput] = useState("");
const [activeTab, setActiveTab] = useState(dmUserId ? "dm" : "city");
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [cityMessages, dmMessages, activeTab]);
const sendMessage = () => {
if (!input.trim()) return;
const msg: Message = {
id: Date.now().toString(),
sender: "You",
content: input.trim(),
created_at: new Date().toISOString(),
isMe: true,
};
if (activeTab === "city") {
setCityMessages((prev) => [...prev, msg]);
} else {
setDmMessages((prev) => [...prev, msg]);
}
setInput("");
};
const messages = activeTab === "city" ? cityMessages : dmMessages;
return (
<div className="flex flex-col h-full">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<TabsList className="mx-4 mt-2 grid grid-cols-3">
<TabsTrigger value="city" className="gap-1 text-xs">
<Users className="h-3 w-3" />
{CITY}
</TabsTrigger>
<TabsTrigger value="dm" className="gap-1 text-xs">
<Lock className="h-3 w-3" />
DMs
</TabsTrigger>
<TabsTrigger value="groups" className="gap-1 text-xs">
Groups
</TabsTrigger>
</TabsList>
<TabsContent value="city" className="flex-1 flex flex-col mt-0 overflow-hidden">
<div className="px-4 py-2 border-b border-border">
<Badge variant="online" className="text-[10px]">
{cityMessages.length + 12} in room
</Badge>
<p className="text-xs text-muted-foreground mt-1">
Auto-joined based on your location. Public city chat.
</p>
</div>
<MessageList messages={messages} />
</TabsContent>
<TabsContent value="dm" className="flex-1 flex flex-col mt-0 overflow-hidden">
<div className="px-4 py-2 border-b border-border">
<p className="text-sm font-medium">
{dmUserId ? `Chat with User #${dmUserId}` : "Private Messages"}
</p>
</div>
<MessageList messages={dmMessages} />
</TabsContent>
<TabsContent value="groups" className="flex-1 flex flex-col mt-0 overflow-hidden">
<div className="p-4 space-y-2">
<GroupRoom name="Leather Night FTL" members={8} />
<GroupRoom name="Beach Cruisers" members={15} />
<GroupRoom name="Gym Sauna Squad" members={4} />
<p className="text-xs text-muted-foreground text-center pt-4">
Create a group room free, no limits.
</p>
</div>
</TabsContent>
{(activeTab === "city" || activeTab === "dm") && (
<div className="p-4 border-t border-border flex gap-2 safe-bottom">
<Button variant="outline" size="icon">
<ImageIcon className="h-4 w-4" />
</Button>
<Input
placeholder="Type a message..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
className="flex-1"
/>
<Button variant="horny" size="icon" onClick={sendMessage}>
<Send className="h-4 w-4" />
</Button>
</div>
)}
</Tabs>
<div ref={bottomRef} />
</div>
);
}
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex flex-col ${msg.isMe ? "items-end" : "items-start"}`}
>
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-semibold text-horny-pink">
{msg.sender}
</span>
<span className="text-[10px] text-muted-foreground">
{timeAgo(msg.created_at)}
</span>
</div>
<div
className={`max-w-[80%] rounded-xl px-3 py-2 text-sm ${
msg.isMe
? "bg-horny-pink/20 text-foreground"
: "bg-secondary text-foreground"
}`}
>
{msg.content}
</div>
</div>
))}
</div>
);
}
function GroupRoom({ name, members }: { name: string; members: number }) {
return (
<div className="flex items-center justify-between rounded-lg border border-border p-3 hover:border-horny-pink/30 cursor-pointer transition-colors">
<div>
<p className="text-sm font-medium">{name}</p>
<p className="text-xs text-muted-foreground">{members} members</p>
</div>
<Badge variant="online" className="text-[10px]">
Live
</Badge>
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { MessageCircle, MapPin } from "lucide-react";
import type { Profile } from "@/types";
import { STATUS_LABELS } from "@/types";
import { timeAgo } from "@/lib/utils";
interface CruiserCardProps {
profile: Profile;
onChat: (profile: Profile) => void;
onView: (profile: Profile) => void;
vanillaMode?: boolean;
}
export function CruiserCard({ profile, onChat, onView, vanillaMode }: CruiserCardProps) {
const name = profile.is_anonymous
? "Anonymous"
: profile.display_name || profile.username || "Cruiser";
return (
<Card
className="cursor-pointer overflow-hidden transition-all hover:border-horny-pink/50 hover:shadow-lg hover:shadow-horny-pink/10"
onClick={() => onView(profile)}
>
<div className={`relative aspect-square ${vanillaMode ? "vanilla-blur" : ""}`}>
<Image
src={profile.avatar_url || "https://placehold.co/200x200/1a1220/ff2d6b?text=?"}
alt={name}
fill
className="object-cover explicit-content"
sizes="200px"
/>
{profile.status !== "offline" && (
<div className="absolute top-2 right-2">
<Badge variant="online" className="text-[10px]">
{STATUS_LABELS[profile.status]}
</Badge>
</div>
)}
{profile.position && (
<div className="absolute bottom-2 left-2">
<Badge variant={profile.position} className="text-[10px] uppercase">
{profile.position}
</Badge>
</div>
)}
</div>
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-semibold text-sm truncate">{name}</span>
{profile.age && (
<span className="text-xs text-muted-foreground">{profile.age}</span>
)}
</div>
{profile.kinks.length > 0 && (
<div className="flex flex-wrap gap-1">
{profile.kinks.slice(0, 3).map((k) => (
<Badge key={k} variant="outline" className="text-[9px] px-1.5 py-0">
{k}
</Badge>
))}
</div>
)}
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{profile.city || "Nearby"}
</span>
<span>{timeAgo(profile.last_active)}</span>
</div>
<Button
variant="horny"
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation();
onChat(profile);
}}
>
<MessageCircle className="h-3 w-3" />
Chat Now
</Button>
</CardContent>
</Card>
);
}

360
src/components/Map.tsx Normal file
View File

@@ -0,0 +1,360 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import dynamic from "next/dynamic";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { CruiserCard } from "@/components/CruiserCard";
import { ProfilePopup } from "@/components/ProfilePopup";
import { Filter, Users, X } from "lucide-react";
import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
import { KINK_OPTIONS, POSITION_COLORS } from "@/types";
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
const MapContainer = dynamic(
() => import("react-leaflet").then((m) => m.MapContainer),
{ ssr: false }
);
const TileLayer = dynamic(
() => import("react-leaflet").then((m) => m.TileLayer),
{ ssr: false }
);
const Marker = dynamic(
() => import("react-leaflet").then((m) => m.Marker),
{ ssr: false }
);
const Popup = dynamic(
() => import("react-leaflet").then((m) => m.Popup),
{ ssr: false }
);
const MarkerClusterGroup = dynamic(
() => import("react-leaflet-cluster"),
{ ssr: false }
);
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
interface MapProps {
vanillaMode: boolean;
onActiveCountChange?: (count: number) => void;
}
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
const [profiles, setProfiles] = useState<Profile[]>(MOCK_PROFILES);
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
const [showFilters, setShowFilters] = useState(false);
const [showSidebar, setShowSidebar] = useState(true);
const [leafletReady, setLeafletReady] = useState(false);
const [userIcon, setUserIcon] = useState<unknown>(null);
const [spotIcon, setSpotIcon] = useState<unknown>(null);
const [filters, setFilters] = useState<MapFilters>({
ageMin: 18,
ageMax: 65,
positions: [],
kinks: [],
onPrep: null,
status: [],
onlineOnly: true,
});
useEffect(() => {
import("leaflet").then((L) => {
const createUserIcon = (position: string | null, status: string) => {
const color = position
? POSITION_COLORS[position as Position] || "#ff2d6b"
: "#ff2d6b";
const pulse = status !== "offline" ? "animation:pulse 2s infinite;" : "";
return L.divIcon({
className: "custom-marker",
html: `<div style="width:36px;height:36px;border-radius:50%;background:${color};border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;${pulse}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="white"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</div>`,
iconSize: [36, 36],
iconAnchor: [18, 18],
});
};
const createSpotIcon = (category: string) => {
const colors: Record<string, string> = {
park: "#22c55e",
beach: "#3b82f6",
gym: "#f97316",
restroom: "#a855f7",
bookstore: "#ec4899",
club: "#ff2d6b",
other: "#6b7280",
};
const color = colors[category] || "#6b7280";
return L.divIcon({
className: "spot-marker",
html: `<div style="width:28px;height:28px;border-radius:4px;background:${color};border:2px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="white"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
});
};
setUserIcon({ create: createUserIcon });
setSpotIcon({ create: createSpotIcon });
setLeafletReady(true);
});
}, []);
const filteredProfiles = useMemo(() => {
return profiles.filter((p) => {
if (filters.onlineOnly && p.status === "offline") return false;
if (p.age && (p.age < filters.ageMin || p.age > filters.ageMax)) return false;
if (filters.positions.length && p.position && !filters.positions.includes(p.position))
return false;
if (filters.kinks.length && !filters.kinks.some((k) => p.kinks.includes(k)))
return false;
if (filters.onPrep === true && !p.on_prep) return false;
if (filters.status.length && !filters.status.includes(p.status)) return false;
return true;
});
}, [profiles, filters]);
useEffect(() => {
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);
}, [filteredProfiles, onActiveCountChange]);
const handleChat = useCallback((profile: Profile) => {
window.location.href = `/chat?user=${profile.id}`;
}, []);
const togglePosition = (pos: Position) => {
setFilters((f) => ({
...f,
positions: f.positions.includes(pos)
? f.positions.filter((p) => p !== pos)
: [...f.positions, pos],
}));
};
const toggleKink = (kink: string) => {
setFilters((f) => ({
...f,
kinks: f.kinks.includes(kink)
? f.kinks.filter((k) => k !== kink)
: [...f.kinks, kink],
}));
};
return (
<div className="relative h-full w-full">
{/* Filter bar */}
<div className="absolute top-2 left-2 right-2 z-[1000] flex gap-2 flex-wrap">
<Button
variant="secondary"
size="sm"
onClick={() => setShowFilters(!showFilters)}
className="shadow-lg"
>
<Filter className="h-4 w-4" />
Filters
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setShowSidebar(!showSidebar)}
className="shadow-lg md:hidden"
>
<Users className="h-4 w-4" />
{filteredProfiles.length} Cruisers
</Button>
<Badge variant="online" className="shadow-lg self-center">
{filteredProfiles.filter((p) => p.status !== "offline").length} active now
</Badge>
</div>
{/* Filter panel */}
{showFilters && (
<div className="absolute top-14 left-2 z-[1000] w-72 rounded-xl border border-border bg-horny-surface/95 backdrop-blur-md p-4 shadow-2xl space-y-4 max-h-[70vh] overflow-y-auto">
<div className="flex items-center justify-between">
<span className="font-semibold text-sm">Map Filters</span>
<Button variant="ghost" size="icon" onClick={() => setShowFilters(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div>
<Label className="text-xs">Age: {filters.ageMin}{filters.ageMax}</Label>
<Slider
min={18}
max={80}
step={1}
value={[filters.ageMin, filters.ageMax]}
onValueChange={([min, max]) =>
setFilters((f) => ({ ...f, ageMin: min, ageMax: max }))
}
className="mt-2"
/>
</div>
<div>
<Label className="text-xs mb-2 block">Position</Label>
<div className="flex flex-wrap gap-1">
{(["top", "bottom", "vers", "side"] as Position[]).map((pos) => (
<Badge
key={pos}
variant={filters.positions.includes(pos) ? pos : "outline"}
className="cursor-pointer uppercase"
onClick={() => togglePosition(pos)}
>
{pos}
</Badge>
))}
</div>
</div>
<div>
<Label className="text-xs mb-2 block">Into</Label>
<div className="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
{KINK_OPTIONS.map((k) => (
<Badge
key={k}
variant={filters.kinks.includes(k) ? "default" : "outline"}
className="cursor-pointer text-[10px]"
onClick={() => toggleKink(k)}
>
{k}
</Badge>
))}
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">On PrEP only</Label>
<Switch
checked={filters.onPrep === true}
onCheckedChange={(v) =>
setFilters((f) => ({ ...f, onPrep: v ? true : null }))
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">Online only</Label>
<Switch
checked={filters.onlineOnly}
onCheckedChange={(v) =>
setFilters((f) => ({ ...f, onlineOnly: v }))
}
/>
</div>
</div>
)}
{/* Sidebar — active cruisers */}
{showSidebar && (
<div className="absolute top-14 right-2 z-[1000] w-64 hidden md:block max-h-[calc(100%-5rem)] overflow-y-auto space-y-2 rounded-xl border border-border bg-horny-surface/95 backdrop-blur-md p-3 shadow-2xl">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Active Cruisers
</p>
{filteredProfiles
.filter((p) => p.status !== "offline")
.map((p) => (
<CruiserCard
key={p.id}
profile={p}
onChat={handleChat}
onView={setSelectedProfile}
vanillaMode={vanillaMode}
/>
))}
</div>
)}
{/* Map */}
{leafletReady && userIcon && spotIcon && (
<MapContainer
center={[DEFAULT_LAT, DEFAULT_LNG]}
zoom={14}
className="h-full w-full"
zoomControl={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MarkerClusterGroup chunkedLoading>
{filteredProfiles.map((p) =>
p.lat && p.lng ? (
<Marker
key={p.id}
position={[p.lat, p.lng]}
icon={(userIcon as { create: (pos: string | null, status: string) => unknown }).create(p.position, p.status)}
eventHandlers={{
click: () => setSelectedProfile(p),
}}
>
<Popup>
<div className="text-sm">
<strong>
{p.is_anonymous ? "Anonymous" : p.display_name || "Cruiser"}
</strong>
{p.position && (
<Badge variant={p.position} className="ml-2 text-[10px] uppercase">
{p.position}
</Badge>
)}
<p className="text-xs mt-1">{p.bio}</p>
<Button
variant="horny"
size="sm"
className="mt-2 w-full"
onClick={() => handleChat(p)}
>
Chat
</Button>
</div>
</Popup>
</Marker>
) : null
)}
</MarkerClusterGroup>
{spots.map((s) => (
<Marker
key={s.id}
position={[s.lat, s.lng]}
icon={(spotIcon as { create: (cat: string) => unknown }).create(s.category)}
>
<Popup>
<div className="text-sm">
<strong>{s.name}</strong>
<Badge variant="outline" className="ml-2 text-[10px]">
{s.category}
</Badge>
<p className="text-xs mt-1">{s.description}</p>
<p className="text-xs text-muted-foreground mt-1">
{s.active_count} active · {s.rating}
</p>
</div>
</Popup>
</Marker>
))}
</MapContainer>
)}
<ProfilePopup
profile={selectedProfile}
open={!!selectedProfile}
onClose={() => setSelectedProfile(null)}
onChat={handleChat}
vanillaMode={vanillaMode}
/>
</div>
);
}

124
src/components/Nav.tsx Normal file
View File

@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Map, MessageCircle, MapPin, ShoppingBag, User, Eye, EyeOff, LogIn, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useCartStore } from "@/lib/cart";
import { useAuth } from "@/lib/auth/context";
import { AuthModal } from "@/components/AuthModal";
import { cn } from "@/lib/utils";
interface NavProps {
vanillaMode: boolean;
onVanillaToggle: (v: boolean) => void;
activeCount?: number;
}
const NAV_ITEMS = [
{ href: "/", label: "Map", icon: Map },
{ href: "/spots", label: "Spots", icon: MapPin },
{ href: "/chat", label: "Chat", icon: MessageCircle },
{ href: "/shop", label: "Shop", icon: ShoppingBag },
{ href: "/profile", label: "Profile", icon: User },
];
export function Nav({ vanillaMode, onVanillaToggle, activeCount = 0 }: NavProps) {
const pathname = usePathname();
const itemCount = useCartStore((s) => s.itemCount());
const { user, profile, signOut } = useAuth();
const [authOpen, setAuthOpen] = useState(false);
return (
<>
{/* Desktop top nav */}
<header className="hidden md:flex fixed top-0 left-0 right-0 z-50 h-14 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-6">
<Link href="/" className="flex items-center gap-2">
<span className="text-xl font-bold bg-horny-gradient bg-clip-text text-transparent">
ExposedGays
</span>
{activeCount > 0 && (
<Badge variant="online" className="text-[10px]">
{activeCount} active
</Badge>
)}
</Link>
<nav className="flex items-center gap-1">
{NAV_ITEMS.map(({ href, label, icon: Icon }) => (
<Link key={href} href={href}>
<Button
variant={pathname === href ? "secondary" : "ghost"}
size="sm"
className={cn(
"relative gap-2",
href === "/shop" && "text-horny-pink font-semibold"
)}
>
<Icon className="h-4 w-4" />
{label}
{href === "/shop" && itemCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-horny-pink text-[10px] font-bold text-white">
{itemCount}
</span>
)}
</Button>
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onVanillaToggle(!vanillaMode)}
className="gap-2"
>
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
Vanilla
</Button>
{user ? (
<Button variant="ghost" size="sm" onClick={() => signOut()} className="gap-2">
<LogOut className="h-4 w-4" />
{profile?.display_name || "Sign Out"}
</Button>
) : (
<Button variant="horny" size="sm" onClick={() => setAuthOpen(true)} className="gap-2">
<LogIn className="h-4 w-4" />
Join Free
</Button>
)}
<Badge variant="secondary" className="text-[10px]">
FREE FOREVER
</Badge>
</div>
</header>
<AuthModal open={authOpen} onClose={() => setAuthOpen(false)} />
{/* Mobile bottom nav */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 flex h-16 items-center justify-around border-t border-border bg-horny-dark/95 backdrop-blur-md safe-bottom">
{NAV_ITEMS.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
"flex flex-col items-center gap-0.5 px-3 py-1 text-[10px] transition-colors relative",
pathname === href ? "text-horny-pink" : "text-muted-foreground"
)}
>
<Icon className="h-5 w-5" />
{label}
{href === "/shop" && itemCount > 0 && (
<span className="absolute top-0 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-horny-pink text-[8px] font-bold text-white">
{itemCount}
</span>
)}
</Link>
))}
</nav>
</>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MessageCircle, Camera, Shield } from "lucide-react";
import type { Profile } from "@/types";
import { STATUS_LABELS } from "@/types";
import { timeAgo } from "@/lib/utils";
interface ProfilePopupProps {
profile: Profile | null;
open: boolean;
onClose: () => void;
onChat: (profile: Profile) => void;
vanillaMode?: boolean;
}
export function ProfilePopup({ profile, open, onClose, onChat, vanillaMode }: ProfilePopupProps) {
if (!profile) return null;
const name = profile.is_anonymous
? "Anonymous Cruiser"
: profile.display_name || profile.username || "Cruiser";
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{name}
{profile.age && (
<span className="text-muted-foreground font-normal">{profile.age}</span>
)}
</DialogTitle>
</DialogHeader>
<div className={`relative aspect-[4/3] rounded-lg overflow-hidden ${vanillaMode ? "vanilla-blur" : ""}`}>
<Image
src={profile.avatar_url || "https://placehold.co/400x300/1a1220/ff2d6b?text=Profile"}
alt={name}
fill
className="object-cover explicit-content"
/>
</div>
<div className="flex flex-wrap gap-2">
{profile.position && (
<Badge variant={profile.position} className="uppercase">
{profile.position}
</Badge>
)}
<Badge variant={profile.status === "offline" ? "secondary" : "online"}>
{STATUS_LABELS[profile.status]}
</Badge>
{profile.on_prep && (
<Badge variant="outline" className="text-green-400 border-green-400/30">
<Shield className="h-3 w-3 mr-1" />
On PrEP
</Badge>
)}
</div>
{profile.bio && (
<p className="text-sm text-muted-foreground">{profile.bio}</p>
)}
{profile.kinks.length > 0 && (
<div>
<p className="text-xs font-semibold mb-1 text-muted-foreground">INTO</p>
<div className="flex flex-wrap gap-1">
{profile.kinks.map((k) => (
<Badge key={k} variant="outline" className="text-xs">
{k}
</Badge>
))}
</div>
</div>
)}
{profile.sti_status && (
<p className="text-xs text-muted-foreground">
STI Status: {profile.sti_status}
</p>
)}
<p className="text-xs text-muted-foreground">
Last active: {timeAgo(profile.last_active)} · {profile.city}
</p>
<div className="flex gap-2">
<Button variant="horny" className="flex-1" onClick={() => onChat(profile)}>
<MessageCircle className="h-4 w-4" />
Instant Chat
</Button>
<Button variant="outline" size="icon">
<Camera className="h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useState, useEffect, createContext, useContext } from "react";
import { AgeGate } from "@/components/AgeGate";
import { Nav } from "@/components/Nav";
import { AuthProvider } from "@/lib/auth/context";
const ActiveCountContext = createContext<(n: number) => void>(() => {});
const VanillaModeContext = createContext(false);
export function useSetActiveCount() {
return useContext(ActiveCountContext);
}
export function useVanillaMode() {
return useContext(VanillaModeContext);
}
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
const [verified, setVerified] = useState(false);
const [vanillaMode, setVanillaMode] = useState(false);
const [activeCount, setActiveCount] = useState(0);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
setVerified(localStorage.getItem("eg_age_verified") === "true");
setVanillaMode(localStorage.getItem("eg_vanilla_mode") === "true");
}, []);
const handleVanillaToggle = (v: boolean) => {
setVanillaMode(v);
localStorage.setItem("eg_vanilla_mode", String(v));
};
if (!mounted) return null;
return (
<>
{!verified && <AgeGate onVerified={() => setVerified(true)} />}
<AuthProvider>
<VanillaModeContext.Provider value={vanillaMode}>
<Nav
vanillaMode={vanillaMode}
onVanillaToggle={handleVanillaToggle}
activeCount={activeCount}
/>
<main className="md:pt-14 pb-16 md:pb-0 min-h-screen">
<ActiveCountContext.Provider value={setActiveCount}>
{children}
</ActiveCountContext.Provider>
</main>
</VanillaModeContext.Provider>
</AuthProvider>
</>
);
}

321
src/components/Shop.tsx Normal file
View File

@@ -0,0 +1,321 @@
"use client";
import { useState, useMemo } from "react";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Slider } from "@/components/ui/slider";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ShoppingCart, ExternalLink, Search, Package } from "lucide-react";
import { SHOP_PRODUCTS, CATEGORY_LABELS, CRUISING_BUNDLES } from "@/lib/products";
import { useCartStore } from "@/lib/cart";
import { formatPrice } from "@/lib/utils";
import type { ShopCategory } from "@/types";
export function Shop() {
const [search, setSearch] = useState("");
const [category, setCategory] = useState<ShopCategory | "all">("all");
const [priceRange, setPriceRange] = useState([0, 100]);
const [selectedKink, setSelectedKink] = useState<string | null>(null);
const [showCart, setShowCart] = useState(false);
const { items, addItem, removeItem, updateQuantity, itemCount, clearCart } =
useCartStore();
const allKinks = useMemo(() => {
const kinks = new Set<string>();
SHOP_PRODUCTS.forEach((p) => p.kinks.forEach((k) => kinks.add(k)));
return Array.from(kinks).sort();
}, []);
const filtered = useMemo(() => {
return SHOP_PRODUCTS.filter((p) => {
if (category !== "all" && p.category !== category) return false;
if (p.price < priceRange[0] || p.price > priceRange[1]) return false;
if (search && !p.name.toLowerCase().includes(search.toLowerCase())) return false;
if (selectedKink && !p.kinks.includes(selectedKink)) return false;
return true;
});
}, [category, priceRange, search, selectedKink]);
const cartProducts = items.map((item) => {
const product = SHOP_PRODUCTS.find((p) => p.id === item.productId);
return product ? { ...product, quantity: item.quantity } : null;
}).filter(Boolean);
return (
<div className="min-h-screen bg-horny-dark">
{/* Hero */}
<div className="bg-horny-gradient px-4 py-8 md:py-12">
<div className="mx-auto max-w-6xl">
<h1 className="text-3xl md:text-4xl font-bold text-white">
ExposedGays Shop
</h1>
<p className="mt-2 text-white/80 text-sm md:text-base">
Toys, lube, poppers, jocks & more affiliate links, 100% free to browse.
We earn a small commission. You pay nothing extra.
</p>
</div>
</div>
<div className="mx-auto max-w-6xl px-4 py-6">
{/* Search + Cart */}
<div className="flex gap-3 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button
variant="secondary"
className="relative gap-2"
onClick={() => setShowCart(!showCart)}
>
<ShoppingCart className="h-4 w-4" />
<span className="hidden sm:inline">Cart</span>
{itemCount() > 0 && (
<span className="absolute -top-2 -right-2 flex h-5 w-5 items-center justify-center rounded-full bg-horny-pink text-[10px] font-bold text-white">
{itemCount()}
</span>
)}
</Button>
</div>
{/* Cart drawer */}
{showCart && (
<Card className="mb-6 border-horny-pink/30">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold flex items-center gap-2">
<Package className="h-4 w-4" />
Your Cart ({itemCount()} items)
</h3>
{items.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearCart}>
Clear
</Button>
)}
</div>
{cartProducts.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
Cart is empty. Add items to track what you want to buy.
</p>
) : (
<div className="space-y-3">
{cartProducts.map((p) => p && (
<div key={p.id} className="flex items-center gap-3">
<Image
src={p.image}
alt={p.name}
width={48}
height={48}
className="rounded-md object-cover"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">
{formatPrice(p.price)} × {p.quantity}
</p>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => updateQuantity(p.id, p.quantity - 1)}
>
</Button>
<span className="text-sm w-6 text-center">{p.quantity}</span>
<Button
variant="outline"
size="sm"
onClick={() => updateQuantity(p.id, p.quantity + 1)}
>
+
</Button>
</div>
<Button
variant="horny"
size="sm"
asChild
>
<a href={p.affiliateUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3" />
</a>
</Button>
</div>
))}
<p className="text-xs text-muted-foreground text-center pt-2">
Checkout happens on affiliate sites. Cart is local only.
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Categories */}
<Tabs value={category} onValueChange={(v) => setCategory(v as ShopCategory | "all")} className="mb-6">
<TabsList className="flex flex-wrap h-auto gap-1 bg-transparent p-0">
<TabsTrigger value="all" className="data-[state=active]:bg-horny-pink data-[state=active]:text-white">
All
</TabsTrigger>
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
<TabsTrigger
key={key}
value={key}
className="data-[state=active]:bg-horny-pink data-[state=active]:text-white text-xs"
>
{label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{/* Filters row */}
<div className="flex flex-wrap gap-4 mb-6 items-center">
<div className="flex-1 min-w-[200px]">
<p className="text-xs text-muted-foreground mb-1">
Price: {formatPrice(priceRange[0])} {formatPrice(priceRange[1])}
</p>
<Slider
min={0}
max={100}
step={5}
value={priceRange}
onValueChange={setPriceRange}
/>
</div>
<div className="flex flex-wrap gap-1">
{allKinks.map((k) => (
<Badge
key={k}
variant={selectedKink === k ? "default" : "outline"}
className="cursor-pointer text-[10px]"
onClick={() => setSelectedKink(selectedKink === k ? null : k)}
>
{k}
</Badge>
))}
</div>
</div>
{/* Cruising bundles */}
<div className="mb-8">
<h2 className="text-lg font-semibold mb-3">Frequently Bought With Cruising Supplies</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{CRUISING_BUNDLES.map((bundle) => {
const bundleProducts = bundle.productIds
.map((id) => SHOP_PRODUCTS.find((p) => p.id === id))
.filter(Boolean);
const totalPrice = bundleProducts.reduce((sum, p) => sum + (p?.price || 0), 0);
return (
<Card key={bundle.id} className="border-horny-purple/30">
<CardContent className="p-4">
<h3 className="font-semibold">{bundle.name}</h3>
<p className="text-xs text-muted-foreground mt-1">
{bundleProducts.map((p) => p?.name).join(" + ")}
</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-lg font-bold text-horny-pink">
{formatPrice(totalPrice - bundle.savings)}
</span>
<span className="text-xs text-muted-foreground line-through">
{formatPrice(totalPrice)}
</span>
<Badge variant="secondary" className="text-[10px]">
Save {formatPrice(bundle.savings)}
</Badge>
</div>
<Button
variant="horny"
size="sm"
className="mt-3"
onClick={() =>
bundle.productIds.forEach((id) => addItem(id))
}
>
<ShoppingCart className="h-3 w-3" />
Add Bundle to Cart
</Button>
</CardContent>
</Card>
);
})}
</div>
</div>
{/* Product grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{filtered.map((product) => (
<Card
key={product.id}
className="overflow-hidden group hover:border-horny-pink/50 transition-all"
>
<div className="relative aspect-square">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover group-hover:scale-105 transition-transform"
sizes="(max-width: 768px) 50vw, 25vw"
/>
</div>
<CardContent className="p-3 space-y-1">
<h3 className="font-semibold text-sm leading-tight">{product.name}</h3>
<p className="text-xs text-muted-foreground line-clamp-2">
{product.description}
</p>
<p className="text-lg font-bold text-horny-pink">
{product.price === 0 ? "FREE" : formatPrice(product.price)}
</p>
{product.kinks.length > 0 && (
<div className="flex flex-wrap gap-1">
{product.kinks.map((k) => (
<Badge key={k} variant="outline" className="text-[9px] px-1">
{k}
</Badge>
))}
</div>
)}
</CardContent>
<CardFooter className="p-3 pt-0 flex flex-col gap-2">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => addItem(product.id)}
>
<ShoppingCart className="h-3 w-3" />
Add to Cart
</Button>
<Button variant="horny" size="sm" className="w-full" asChild>
<a
href={product.affiliateUrl}
target="_blank"
rel="noopener noreferrer sponsored"
>
<ExternalLink className="h-3 w-3" />
{product.affiliateLabel}
</a>
</Button>
</CardFooter>
</Card>
))}
</div>
{filtered.length === 0 && (
<p className="text-center text-muted-foreground py-12">
No products match your filters.
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Search, MapPin, Star, Users, MessageCircle } from "lucide-react";
import { MOCK_SPOTS } from "@/lib/mock-data";
import type { CruisingSpot } from "@/types";
const CATEGORY_LABELS: Record<string, string> = {
park: "Park",
beach: "Beach",
gym: "Gym",
restroom: "Restroom",
bookstore: "Bookstore",
club: "Club",
other: "Other",
};
export function SpotDirectory() {
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string | null>(null);
const filtered = MOCK_SPOTS.filter((s) => {
if (search && !s.name.toLowerCase().includes(search.toLowerCase())) return false;
if (category && s.category !== category) return false;
return true;
});
return (
<div className="mx-auto max-w-4xl px-4 py-6">
<h1 className="text-2xl font-bold mb-2">Cruising Spots</h1>
<p className="text-sm text-muted-foreground mb-6">
Directory of popular cruising locations. Tap to view on map or join spot chat.
</p>
<div className="flex gap-3 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search spots..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex flex-wrap gap-1 mb-6">
<Badge
variant={category === null ? "default" : "outline"}
className="cursor-pointer"
onClick={() => setCategory(null)}
>
All
</Badge>
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
<Badge
key={key}
variant={category === key ? "default" : "outline"}
className="cursor-pointer"
onClick={() => setCategory(category === key ? null : key)}
>
{label}
</Badge>
))}
</div>
<div className="space-y-3">
{filtered.map((spot) => (
<SpotCard key={spot.id} spot={spot} />
))}
</div>
</div>
);
}
function SpotCard({ spot }: { spot: CruisingSpot }) {
return (
<Card className="hover:border-horny-pink/30 transition-colors">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold">{spot.name}</h3>
<Badge variant="outline" className="text-[10px]">
{CATEGORY_LABELS[spot.category]}
</Badge>
{spot.is_popular && (
<Badge className="text-[10px] bg-horny-pink/20 text-horny-pink">
Popular
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">{spot.description}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{spot.city}
</span>
<span className="flex items-center gap-1">
<Star className="h-3 w-3 text-yellow-500" />
{spot.rating}
</span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3 text-green-400" />
{spot.active_count} active
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" asChild>
<a href={`/?spot=${spot.id}`}>View Map</a>
</Button>
<Button variant="horny" size="sm" className="gap-1">
<MessageCircle className="h-3 w-3" />
Spot Chat
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,33 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
top: "border-transparent bg-blue-500/20 text-blue-400",
bottom: "border-transparent bg-orange-500/20 text-orange-400",
vers: "border-transparent bg-purple-500/20 text-purple-400",
side: "border-transparent bg-green-500/20 text-green-400",
online: "border-transparent bg-green-500/20 text-green-400 animate-pulse",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
horny: "bg-horny-gradient text-white shadow-lg hover:opacity-90 font-semibold",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-12 rounded-md px-8 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} {...props} />
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,64 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle };

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,19 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,51 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

189
src/lib/auth/context.tsx Normal file
View File

@@ -0,0 +1,189 @@
"use client";
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
import { createClient } from "@/lib/supabase/client";
import type { User, Session } from "@supabase/supabase-js";
import type { Profile } from "@/types";
interface AuthContextValue {
user: User | null;
session: Session | null;
profile: Profile | null;
loading: boolean;
signUp: (email: string, password: string) => Promise<{ error: string | null }>;
signIn: (email: string, password: string) => Promise<{ error: string | null }>;
signInAnonymously: () => Promise<{ error: string | null }>;
signOut: () => Promise<void>;
updateProfile: (data: Partial<Profile>) => Promise<{ error: string | null }>;
refreshProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
function mapDbProfile(row: Record<string, unknown>): Profile {
const loc = row.location as { coordinates?: [number, number] } | null;
return {
id: row.id as string,
username: (row.username as string) ?? null,
display_name: (row.display_name as string) ?? null,
age: (row.age as number) ?? null,
position: (row.position as Profile["position"]) ?? null,
bio: (row.bio as string) ?? null,
avatar_url: (row.avatar_url as string) ?? null,
status: (row.status as Profile["status"]) ?? "offline",
is_anonymous: (row.is_anonymous as boolean) ?? true,
kinks: (row.kinks as string[]) ?? [],
sti_status: (row.sti_status as string) ?? null,
on_prep: (row.on_prep as boolean) ?? false,
last_active: (row.last_active as string) ?? new Date().toISOString(),
lat: loc?.coordinates?.[1] ?? null,
lng: loc?.coordinates?.[0] ?? null,
city: (row.city as string) ?? null,
vanilla_mode: (row.vanilla_mode as boolean) ?? false,
created_at: (row.created_at as string) ?? new Date().toISOString(),
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const supabase = createClient();
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const fetchProfile = useCallback(
async (userId: string) => {
const { data, error } = await supabase
.from("profiles")
.select("*")
.eq("id", userId)
.single();
if (!error && data) {
setProfile(mapDbProfile(data));
}
},
[supabase]
);
useEffect(() => {
supabase.auth.getSession().then(({ data: { session: s } }) => {
setSession(s);
setUser(s?.user ?? null);
if (s?.user) fetchProfile(s.user.id);
setLoading(false);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s);
setUser(s?.user ?? null);
if (s?.user) {
fetchProfile(s.user.id);
} else {
setProfile(null);
}
});
return () => subscription.unsubscribe();
}, [supabase, fetchProfile]);
const signUp = async (email: string, password: string) => {
const { error } = await supabase.auth.signUp({
email,
password,
options: { emailRedirectTo: `${window.location.origin}/profile` },
});
return { error: error?.message ?? null };
};
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
return { error: error?.message ?? null };
};
const signInAnonymously = async () => {
const { error } = await supabase.auth.signInAnonymously();
return { error: error?.message ?? null };
};
const signOut = async () => {
await supabase.auth.signOut();
setProfile(null);
};
const updateProfile = async (data: Partial<Profile>) => {
if (!user) return { error: "Not signed in" };
const payload: Record<string, unknown> = {
updated_at: new Date().toISOString(),
last_active: new Date().toISOString(),
};
if (data.display_name !== undefined) payload.display_name = data.display_name;
if (data.username !== undefined) payload.username = data.username;
if (data.age !== undefined) payload.age = data.age;
if (data.position !== undefined) payload.position = data.position;
if (data.bio !== undefined) payload.bio = data.bio;
if (data.kinks !== undefined) payload.kinks = data.kinks;
if (data.sti_status !== undefined) payload.sti_status = data.sti_status;
if (data.on_prep !== undefined) payload.on_prep = data.on_prep;
if (data.is_anonymous !== undefined) payload.is_anonymous = data.is_anonymous;
if (data.status !== undefined) payload.status = data.status;
if (data.city !== undefined) payload.city = data.city;
const { error } = await supabase
.from("profiles")
.update(payload)
.eq("id", user.id);
if (error) return { error: error.message };
if (data.lat != null && data.lng != null) {
await supabase.rpc("update_profile_location", {
p_user_id: user.id,
p_lat: data.lat,
p_lng: data.lng,
});
}
await fetchProfile(user.id);
return { error: null };
};
const refreshProfile = async () => {
if (user) await fetchProfile(user.id);
};
return (
<AuthContext.Provider
value={{
user,
session,
profile,
loading,
signUp,
signIn,
signInAnonymously,
signOut,
updateProfile,
refreshProfile,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

58
src/lib/cart.ts Normal file
View File

@@ -0,0 +1,58 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { CartItem } from "@/types";
interface CartStore {
items: CartItem[];
addItem: (productId: string) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
itemCount: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (productId) => {
const existing = get().items.find((i) => i.productId === productId);
if (existing) {
set({
items: get().items.map((i) =>
i.productId === productId
? { ...i, quantity: i.quantity + 1 }
: i
),
});
} else {
set({
items: [
...get().items,
{ productId, quantity: 1, addedAt: new Date().toISOString() },
],
});
}
},
removeItem: (productId) => {
set({ items: get().items.filter((i) => i.productId !== productId) });
},
updateQuantity: (productId, quantity) => {
if (quantity <= 0) {
get().removeItem(productId);
return;
}
set({
items: get().items.map((i) =>
i.productId === productId ? { ...i, quantity } : i
),
});
},
clearCart: () => set({ items: [] }),
itemCount: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
}),
{ name: "exposedgays-cart" }
)
);

179
src/lib/mock-data.ts Normal file
View File

@@ -0,0 +1,179 @@
import type { Profile, CruisingSpot } from "@/types";
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
function jitter(base: number, range: number) {
return base + (Math.random() - 0.5) * range;
}
export const MOCK_PROFILES: Profile[] = [
{
id: "1",
username: "ftl_top",
display_name: "Mike",
age: 32,
position: "top",
bio: "Hosting tonight near Wilton Drive. HMU.",
avatar_url: "https://placehold.co/200x200/1a1220/3b82f6?text=Top",
status: "hosting",
is_anonymous: false,
kinks: ["oral", "rimming", "daddy"],
sti_status: "Negative · tested Jan 2026",
on_prep: true,
last_active: new Date().toISOString(),
lat: jitter(DEFAULT_LAT, 0.02),
lng: jitter(DEFAULT_LNG, 0.02),
city: "Fort Lauderdale",
vanilla_mode: false,
created_at: new Date().toISOString(),
},
{
id: "2",
username: null,
display_name: null,
age: 28,
position: "bottom",
bio: "At the beach. Looking now.",
avatar_url: "https://placehold.co/200x200/1a1220/f97316?text=Bot",
status: "looking",
is_anonymous: true,
kinks: ["anon", "oral", "group"],
sti_status: null,
on_prep: false,
last_active: new Date(Date.now() - 120000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.03),
lng: jitter(DEFAULT_LNG, 0.03),
city: "Fort Lauderdale",
vanilla_mode: false,
created_at: new Date().toISOString(),
},
{
id: "3",
username: "vers_bear",
display_name: "Jake",
age: 45,
position: "vers",
bio: "Bear. Leather friendly. Gym rat.",
avatar_url: "https://placehold.co/200x200/1a1220/a855f7?text=Vers",
status: "online",
is_anonymous: false,
kinks: ["leather", "bdsm", "oral"],
sti_status: "Negative",
on_prep: true,
last_active: new Date(Date.now() - 300000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.025),
lng: jitter(DEFAULT_LNG, 0.025),
city: "Fort Lauderdale",
vanilla_mode: false,
created_at: new Date().toISOString(),
},
{
id: "4",
username: "pup_play",
display_name: "Rex",
age: 26,
position: "side",
bio: "Pup scene. Woof.",
avatar_url: "https://placehold.co/200x200/1a1220/22c55e?text=Side",
status: "looking",
is_anonymous: false,
kinks: ["pup", "bondage", "roleplay"],
sti_status: null,
on_prep: true,
last_active: new Date(Date.now() - 60000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.015),
lng: jitter(DEFAULT_LNG, 0.015),
city: "Fort Lauderdale",
vanilla_mode: false,
created_at: new Date().toISOString(),
},
{
id: "5",
username: "traveler_mia",
display_name: "Alex",
age: 35,
position: "vers",
bio: "Visiting from Miami. Here till Sunday.",
avatar_url: "https://placehold.co/200x200/1a1220/a855f7?text=Vers",
status: "traveling",
is_anonymous: false,
kinks: ["cruising", "anon", "oral"],
sti_status: "Negative · tested Dec 2025",
on_prep: true,
last_active: new Date(Date.now() - 180000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.01),
lng: jitter(DEFAULT_LNG, 0.01),
city: "Fort Lauderdale",
vanilla_mode: false,
created_at: new Date().toISOString(),
},
];
export const MOCK_SPOTS: CruisingSpot[] = [
{
id: "s1",
name: "Hugh Taylor Birch State Park",
description: "Popular trails behind the nature center. Active after dark.",
category: "park",
lat: 26.1452,
lng: -80.1048,
city: "Fort Lauderdale",
rating: 4.2,
active_count: 8,
is_popular: true,
created_at: new Date().toISOString(),
},
{
id: "s2",
name: "Sebastian Street Beach",
description: "Gay beach section. Day and night action.",
category: "beach",
lat: 26.1175,
lng: -80.1042,
city: "Fort Lauderdale",
rating: 4.5,
active_count: 12,
is_popular: true,
created_at: new Date().toISOString(),
},
{
id: "s3",
name: "LA Fitness — Oakland Park",
description: "Locker room and sauna. Peak hours 5-8pm.",
category: "gym",
lat: 26.1723,
lng: -80.1317,
city: "Fort Lauderdale",
rating: 3.8,
active_count: 4,
is_popular: false,
created_at: new Date().toISOString(),
},
{
id: "s4",
name: "Ramrod Bar Restroom",
description: "Back bar area. Weekend nights.",
category: "restroom",
lat: 26.1603,
lng: -80.1389,
city: "Fort Lauderdale",
rating: 3.5,
active_count: 2,
is_popular: false,
created_at: new Date().toISOString(),
},
{
id: "s5",
name: "Triple Crown Bookstore",
description: "Adult bookstore with viewing booths.",
category: "bookstore",
lat: 26.1912,
lng: -80.1534,
city: "Oakland Park",
rating: 4.0,
active_count: 6,
is_popular: true,
created_at: new Date().toISOString(),
},
];

167
src/lib/products.ts Normal file
View File

@@ -0,0 +1,167 @@
import type { ShopProduct } from "@/types";
export const SHOP_PRODUCTS: ShopProduct[] = [
{
id: "pistol-pete-jock",
name: "Pistol Pete Jockstrap",
description: "Premium mesh jock with contoured pouch. Perfect for the gym or cruising.",
price: 24.99,
category: "jocks-harness",
image: "https://placehold.co/400x400/1a1220/ff2d6b?text=Pistol+Pete+Jock",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["underwear", "cruising"],
frequentlyBoughtWith: ["rush-poppers", "gun-oil-lube"],
},
{
id: "rush-poppers",
name: "Rush Poppers",
description: "Classic amyl nitrite formula. 10ml bottle. Ships discreet.",
price: 18.99,
category: "poppers-lube",
image: "https://placehold.co/400x400/1a1220/8b2fc9?text=Rush+Poppers",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["pnp"],
frequentlyBoughtWith: ["gun-oil-lube", "condom-pack"],
},
{
id: "10in-dildo",
name: '10" Realistic Dildo',
description: "Veined silicone, suction cup base. Body-safe platinum cure silicone.",
price: 49.99,
category: "dildos-plugs",
image: "https://placehold.co/400x400/1a1220/ff2d6b?text=10in+Dildo",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["anal"],
frequentlyBoughtWith: ["gun-oil-lube", "douche-kit"],
},
{
id: "gun-oil-lube",
name: "Gun Oil Silicone Lube",
description: "Long-lasting silicone formula. 8oz pump bottle.",
price: 16.99,
category: "poppers-lube",
image: "https://placehold.co/400x400/1a1220/8b2fc9?text=Gun+Oil+Lube",
affiliateUrl: "https://www.amazon.com",
affiliateLabel: "Buy on Amazon",
kinks: ["anal", "oral"],
frequentlyBoughtWith: ["condom-pack", "10in-dildo"],
},
{
id: "nasty-pig-harness",
name: "Nasty Pig Snap Harness",
description: "Adjustable leather-look harness with metal snaps. One size fits most.",
price: 89.99,
category: "jocks-harness",
image: "https://placehold.co/400x400/1a1220/ff2d6b?text=Nasty+Pig+Harness",
affiliateUrl: "https://www.etsy.com",
affiliateLabel: "Buy on Etsy",
kinks: ["leather", "bdsm"],
frequentlyBoughtWith: ["leather-jock", "wrist-cuffs"],
},
{
id: "bator-bros-dvd",
name: "Bator Bros Vol. 3 DVD",
description: "120 min uncut studio release. Region-free.",
price: 29.99,
category: "porn-dvds",
image: "https://placehold.co/400x400/1a1220/8b2fc9?text=Bator+Bros+DVD",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["porn"],
},
{
id: "descovy-prep",
name: "Descovy PrEP (Info Kit)",
description: "Free PrEP info + telehealth referral. 99% effective when taken daily.",
price: 0,
category: "prep-health",
image: "https://placehold.co/400x400/1a1220/22c55e?text=PrEP+Info",
affiliateUrl: "https://www.prepisforme.com",
affiliateLabel: "Learn More",
kinks: [],
},
{
id: "condom-pack",
name: "Magnum XL Condom 12-Pack",
description: "Larger-fit condoms with reservoir tip. Lubricated.",
price: 12.99,
category: "prep-health",
image: "https://placehold.co/400x400/1a1220/22c55e?text=Magnum+Condoms",
affiliateUrl: "https://www.amazon.com",
affiliateLabel: "Buy on Amazon",
kinks: [],
frequentlyBoughtWith: ["gun-oil-lube"],
},
{
id: "douche-kit",
name: "Shower Shot Douche Kit",
description: "Reusable bulb + 3 nozzles. Essential cruising prep.",
price: 22.99,
category: "prep-health",
image: "https://placehold.co/400x400/1a1220/22c55e?text=Douche+Kit",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["anal", "cruising"],
frequentlyBoughtWith: ["10in-dildo", "gun-oil-lube"],
},
{
id: "leather-jock",
name: "Cellblock 13 Leather Jock",
description: "Genuine leather jockstrap with metal ring detail.",
price: 54.99,
category: "jocks-harness",
image: "https://placehold.co/400x400/1a1220/ff2d6b?text=Leather+Jock",
affiliateUrl: "https://www.etsy.com",
affiliateLabel: "Buy on Etsy",
kinks: ["leather", "underwear"],
},
{
id: "plug-set",
name: "3-Piece Plug Training Set",
description: "Graduated silicone plugs with flared bases. Beginner to advanced.",
price: 34.99,
category: "dildos-plugs",
image: "https://placehold.co/400x400/1a1220/ff2d6b?text=Plug+Set",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["anal"],
frequentlyBoughtWith: ["gun-oil-lube"],
},
{
id: "wrist-cuffs",
name: "Faux Leather Wrist Cuffs",
description: "Soft padded cuffs with D-ring. Adjustable velcro.",
price: 19.99,
category: "jocks-harness",
image: "https://placehold.co/400x400/1a1220/8b2fc9?text=Wrist+Cuffs",
affiliateUrl: "https://www.amazon.com",
affiliateLabel: "Buy on Amazon",
kinks: ["bdsm", "bondage"],
},
];
export const CATEGORY_LABELS: Record<string, string> = {
"dildos-plugs": "Dildos & Plugs",
"poppers-lube": "Poppers & Lube",
"jocks-harness": "Jocks & Harness",
"porn-dvds": "Porn & DVDs",
"prep-health": "PrEP & Health",
};
export const CRUISING_BUNDLES = [
{
id: "cruising-starter",
name: "Cruising Starter Kit",
productIds: ["rush-poppers", "gun-oil-lube", "condom-pack", "douche-kit"],
savings: 8.0,
},
{
id: "leather-night",
name: "Leather Night Bundle",
productIds: ["nasty-pig-harness", "leather-jock", "wrist-cuffs", "rush-poppers"],
savings: 15.0,
},
];

View File

@@ -0,0 +1,8 @@
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}

93
src/lib/supabase/hooks.ts Normal file
View File

@@ -0,0 +1,93 @@
"use client";
import { useEffect, useState } from "react";
import { createClient } from "./client";
import type { Profile, ChatMessage } from "@/types";
const supabase = createClient();
export function useNearbyProfiles(lat: number, lng: number, radiusKm = 10) {
const [profiles, setProfiles] = useState<Profile[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchProfiles() {
const { data, error } = await supabase.rpc("nearby_profiles", {
p_lat: lat,
p_lng: lng,
p_radius_km: radiusKm,
});
if (!error && data) {
setProfiles(data);
}
setLoading(false);
}
fetchProfiles();
const channel = supabase
.channel("profile-changes")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "profiles" },
() => fetchProfiles()
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [lat, lng, radiusKm]);
return { profiles, loading };
}
export function useChatMessages(roomId: string) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
useEffect(() => {
async function fetchMessages() {
const { data } = await supabase
.from("chat_messages")
.select("*, sender:profiles(*)")
.eq("room_id", roomId)
.order("created_at", { ascending: true })
.limit(100);
if (data) setMessages(data as ChatMessage[]);
}
fetchMessages();
const channel = supabase
.channel(`room-${roomId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "chat_messages",
filter: `room_id=eq.${roomId}`,
},
(payload) => {
setMessages((prev) => [...prev, payload.new as ChatMessage]);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [roomId]);
const sendMessage = async (content: string, senderId: string) => {
await supabase.from("chat_messages").insert({
room_id: roomId,
sender_id: senderId,
content,
});
};
return { messages, sendMessage };
}

View File

@@ -0,0 +1,30 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
await supabase.auth.getUser();
return supabaseResponse;
}

View File

@@ -0,0 +1,27 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Server Component — ignore
}
},
},
}
);
}

21
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,21 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatPrice(cents: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents);
}
export function timeAgo(date: string): string {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
if (seconds < 60) return "just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}

12
src/middleware.ts Normal file
View File

@@ -0,0 +1,12 @@
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|manifest.json|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};

131
src/types/index.ts Normal file
View File

@@ -0,0 +1,131 @@
export type Position = "top" | "bottom" | "vers" | "side";
export type UserStatus = "online" | "looking" | "hosting" | "traveling" | "offline";
export interface Profile {
id: string;
username: string | null;
display_name: string | null;
age: number | null;
position: Position | null;
bio: string | null;
avatar_url: string | null;
status: UserStatus;
is_anonymous: boolean;
kinks: string[];
sti_status: string | null;
on_prep: boolean;
last_active: string;
lat: number | null;
lng: number | null;
city: string | null;
vanilla_mode: boolean;
created_at: string;
}
export interface CruisingSpot {
id: string;
name: string;
description: string | null;
category: "park" | "beach" | "gym" | "restroom" | "bookstore" | "club" | "other";
lat: number;
lng: number;
city: string;
rating: number;
active_count: number;
is_popular: boolean;
created_at: string;
}
export interface ChatMessage {
id: string;
room_id: string;
sender_id: string;
content: string;
image_url: string | null;
created_at: string;
sender?: Profile;
}
export interface ChatRoom {
id: string;
name: string;
type: "city" | "spot" | "group" | "dm";
city: string | null;
spot_id: string | null;
created_at: string;
}
export interface ShopProduct {
id: string;
name: string;
description: string;
price: number;
category: ShopCategory;
image: string;
affiliateUrl: string;
affiliateLabel: string;
kinks: string[];
frequentlyBoughtWith?: string[];
}
export type ShopCategory =
| "dildos-plugs"
| "poppers-lube"
| "jocks-harness"
| "porn-dvds"
| "prep-health";
export interface CartItem {
productId: string;
quantity: number;
addedAt: string;
}
export interface MapFilters {
ageMin: number;
ageMax: number;
positions: Position[];
kinks: string[];
onPrep: boolean | null;
status: UserStatus[];
onlineOnly: boolean;
}
export const KINK_OPTIONS = [
"anon",
"bdsm",
"bondage",
"chastity",
"cruising",
"daddy",
"feet",
"fisting",
"group",
"leather",
"oral",
"pnp",
"pup",
"raw",
"rimming",
"roleplay",
"spanking",
"underwear",
"watersports",
"ws",
] as const;
export const POSITION_COLORS: Record<Position, string> = {
top: "#3b82f6",
bottom: "#f97316",
vers: "#a855f7",
side: "#22c55e",
};
export const STATUS_LABELS: Record<UserStatus, string> = {
online: "Online",
looking: "Looking Now",
hosting: "Hosting",
traveling: "Traveling",
offline: "Offline",
};

45
supabase/functions.sql Normal file
View File

@@ -0,0 +1,45 @@
-- Additional Supabase functions for ExposedGays
-- Run after schema.sql
-- Nearby profiles using PostGIS
CREATE OR REPLACE FUNCTION public.nearby_profiles(
p_lat DOUBLE PRECISION,
p_lng DOUBLE PRECISION,
p_radius_km DOUBLE PRECISION DEFAULT 10
)
RETURNS SETOF public.profiles AS $$
BEGIN
RETURN QUERY
SELECT p.*
FROM public.profiles p
WHERE p.location IS NOT NULL
AND p.status != 'offline'
AND ST_DWithin(
p.location,
ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
p_radius_km * 1000
)
ORDER BY p.last_active DESC
LIMIT 200;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Nearby cruising spots
CREATE OR REPLACE FUNCTION public.nearby_spots(
p_lat DOUBLE PRECISION,
p_lng DOUBLE PRECISION,
p_radius_km DOUBLE PRECISION DEFAULT 25
)
RETURNS SETOF public.cruising_spots AS $$
BEGIN
RETURN QUERY
SELECT s.*
FROM public.cruising_spots s
WHERE ST_DWithin(
s.location,
ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
p_radius_km * 1000
)
ORDER BY s.active_count DESC, s.rating DESC;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

246
supabase/schema.sql Normal file
View File

@@ -0,0 +1,246 @@
-- ExposedGays Supabase Schema
-- Run in Supabase SQL Editor or via CLI: supabase db push
-- Enable PostGIS for map geolocation
CREATE EXTENSION IF NOT EXISTS postgis;
-- ============================================================
-- PROFILES
-- ============================================================
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
username TEXT UNIQUE,
display_name TEXT,
age INTEGER CHECK (age >= 18 AND age <= 120),
position TEXT CHECK (position IN ('top', 'bottom', 'vers', 'side')),
bio TEXT,
avatar_url TEXT,
status TEXT NOT NULL DEFAULT 'offline'
CHECK (status IN ('online', 'looking', 'hosting', 'traveling', 'offline')),
is_anonymous BOOLEAN NOT NULL DEFAULT true,
kinks TEXT[] DEFAULT '{}',
sti_status TEXT,
on_prep BOOLEAN DEFAULT false,
last_active TIMESTAMPTZ NOT NULL DEFAULT now(),
location GEOGRAPHY(POINT, 4326),
city TEXT,
vanilla_mode BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_profiles_location ON public.profiles USING GIST (location);
CREATE INDEX idx_profiles_status ON public.profiles (status);
CREATE INDEX idx_profiles_city ON public.profiles (city);
CREATE INDEX idx_profiles_last_active ON public.profiles (last_active DESC);
-- ============================================================
-- CRUISING SPOTS
-- ============================================================
CREATE TABLE public.cruising_spots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL
CHECK (category IN ('park', 'beach', 'gym', 'restroom', 'bookstore', 'club', 'other')),
location GEOGRAPHY(POINT, 4326) NOT NULL,
city TEXT NOT NULL,
rating NUMERIC(2,1) DEFAULT 0,
active_count INTEGER DEFAULT 0,
is_popular BOOLEAN DEFAULT false,
created_by UUID REFERENCES public.profiles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_spots_location ON public.cruising_spots USING GIST (location);
CREATE INDEX idx_spots_city ON public.cruising_spots (city);
CREATE INDEX idx_spots_category ON public.cruising_spots (category);
-- ============================================================
-- CHAT ROOMS
-- ============================================================
CREATE TABLE public.chat_rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('city', 'spot', 'group', 'dm')),
city TEXT,
spot_id UUID REFERENCES public.cruising_spots(id) ON DELETE CASCADE,
created_by UUID REFERENCES public.profiles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_rooms_type ON public.chat_rooms (type);
CREATE INDEX idx_rooms_city ON public.chat_rooms (city);
-- Room membership
CREATE TABLE public.chat_room_members (
room_id UUID REFERENCES public.chat_rooms(id) ON DELETE CASCADE,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (room_id, user_id)
);
-- ============================================================
-- CHAT MESSAGES
-- ============================================================
CREATE TABLE public.chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
room_id UUID NOT NULL REFERENCES public.chat_rooms(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
content TEXT NOT NULL,
image_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_messages_room ON public.chat_messages (room_id, created_at DESC);
CREATE INDEX idx_messages_sender ON public.chat_messages (sender_id);
-- Enable Realtime
ALTER PUBLICATION supabase_realtime ADD TABLE public.chat_messages;
ALTER PUBLICATION supabase_realtime ADD TABLE public.profiles;
-- ============================================================
-- PHOTO GALLERY
-- ============================================================
CREATE TABLE public.gallery_photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
url TEXT NOT NULL,
is_explicit BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_gallery_user ON public.gallery_photos (user_id);
-- ============================================================
-- SHOP PRODUCTS (optional DB-backed; static JSON also works)
-- ============================================================
CREATE TABLE public.shop_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
price NUMERIC(10,2) NOT NULL DEFAULT 0,
category TEXT NOT NULL,
image_url TEXT,
affiliate_url TEXT NOT NULL,
affiliate_label TEXT NOT NULL DEFAULT 'Buy Now',
kinks TEXT[] DEFAULT '{}',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- HELPER: Update location from lat/lng
-- ============================================================
CREATE OR REPLACE FUNCTION public.update_profile_location(
p_user_id UUID,
p_lat DOUBLE PRECISION,
p_lng DOUBLE PRECISION
) RETURNS VOID AS $$
BEGIN
UPDATE public.profiles
SET location = ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
last_active = now(),
updated_at = now()
WHERE id = p_user_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Auto-create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, username, is_anonymous)
VALUES (NEW.id, NEW.email, true);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- ============================================================
-- ROW LEVEL SECURITY
-- ============================================================
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.cruising_spots ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.chat_rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.chat_room_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.chat_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.gallery_photos ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.shop_products ENABLE ROW LEVEL SECURITY;
-- Profiles: anyone can read, owners can update
CREATE POLICY "Profiles are viewable by everyone"
ON public.profiles FOR SELECT USING (true);
CREATE POLICY "Users can update own profile"
ON public.profiles FOR UPDATE USING (auth.uid() = id);
CREATE POLICY "Users can insert own profile"
ON public.profiles FOR INSERT WITH CHECK (auth.uid() = id);
-- Spots: public read, authenticated create
CREATE POLICY "Spots are viewable by everyone"
ON public.cruising_spots FOR SELECT USING (true);
CREATE POLICY "Authenticated users can create spots"
ON public.cruising_spots FOR INSERT
WITH CHECK (auth.role() = 'authenticated');
-- Chat rooms: members can read
CREATE POLICY "Chat rooms viewable by authenticated users"
ON public.chat_rooms FOR SELECT
USING (auth.role() = 'authenticated');
CREATE POLICY "Authenticated users can create rooms"
ON public.chat_rooms FOR INSERT
WITH CHECK (auth.role() = 'authenticated');
-- Chat messages: room members can read/write
CREATE POLICY "Messages viewable by authenticated users"
ON public.chat_messages FOR SELECT
USING (auth.role() = 'authenticated');
CREATE POLICY "Authenticated users can send messages"
ON public.chat_messages FOR INSERT
WITH CHECK (auth.uid() = sender_id);
-- Gallery: public read for non-anonymous, owner write
CREATE POLICY "Gallery photos viewable by everyone"
ON public.gallery_photos FOR SELECT USING (true);
CREATE POLICY "Users can manage own gallery"
ON public.gallery_photos FOR ALL USING (auth.uid() = user_id);
-- Shop products: public read
CREATE POLICY "Shop products are public"
ON public.shop_products FOR SELECT USING (is_active = true);
-- ============================================================
-- SEED: Fort Lauderdale city chat room
-- ============================================================
INSERT INTO public.chat_rooms (name, type, city)
VALUES ('Fort Lauderdale Chat', 'city', 'Fort Lauderdale');
-- SEED: Sample cruising spots
INSERT INTO public.cruising_spots (name, description, category, location, city, rating, active_count, is_popular)
VALUES
('Hugh Taylor Birch State Park', 'Popular trails behind the nature center.', 'park',
ST_SetSRID(ST_MakePoint(-80.1048, 26.1452), 4326)::geography, 'Fort Lauderdale', 4.2, 8, true),
('Sebastian Street Beach', 'Gay beach section. Day and night action.', 'beach',
ST_SetSRID(ST_MakePoint(-80.1042, 26.1175), 4326)::geography, 'Fort Lauderdale', 4.5, 12, true),
('Triple Crown Bookstore', 'Adult bookstore with viewing booths.', 'bookstore',
ST_SetSRID(ST_MakePoint(-80.1534, 26.1912), 4326)::geography, 'Oakland Park', 4.0, 6, true);
-- SEED: Shop products
INSERT INTO public.shop_products (slug, name, description, price, category, image_url, affiliate_url, affiliate_label, kinks)
VALUES
('pistol-pete-jock', 'Pistol Pete Jockstrap', 'Premium mesh jock with contoured pouch.', 24.99, 'jocks-harness',
'https://placehold.co/400x400/1a1220/ff2d6b?text=Pistol+Pete+Jock', 'https://www.adammale.com', 'Buy on AdamMale', '{underwear,cruising}'),
('rush-poppers', 'Rush Poppers', 'Classic amyl nitrite formula. 10ml bottle.', 18.99, 'poppers-lube',
'https://placehold.co/400x400/1a1220/8b2fc9?text=Rush+Poppers', 'https://www.adammale.com', 'Buy on AdamMale', '{pnp}'),
('10in-dildo', '10" Realistic Dildo', 'Veined silicone, suction cup base.', 49.99, 'dildos-plugs',
'https://placehold.co/400x400/1a1220/ff2d6b?text=10in+Dildo', 'https://www.adammale.com', 'Buy on AdamMale', '{anal}');

66
tailwind.config.ts Normal file
View File

@@ -0,0 +1,66 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
horny: {
pink: "#ff2d6b",
purple: "#8b2fc9",
dark: "#0d0a0f",
surface: "#1a1220",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
backgroundImage: {
"horny-gradient": "linear-gradient(135deg, #ff2d6b 0%, #8b2fc9 50%, #ff2d6b 100%)",
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}