ExposedGays: map, shop, auth, Supabase schema, Coolify Dockerfile
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal 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
35
.gitignore
vendored
Normal 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
29
Dockerfile
Normal 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
163
README.md
Normal 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
21
components.json
Normal 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
237
deploy/setup-infra.py
Normal 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
14
eslint.config.mjs
Normal 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
15
next.config.ts
Normal 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
51
package.json
Normal 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
9
postcss.config.mjs
Normal 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
22
public/manifest.json
Normal 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
24
src/app/chat/page.tsx
Normal 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
96
src/app/globals.css
Normal 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
39
src/app/layout.tsx
Normal 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
18
src/app/page.tsx
Normal 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
12
src/app/privacy/page.tsx
Normal 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
257
src/app/profile/page.tsx
Normal 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
5
src/app/shop/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Shop } from "@/components/Shop";
|
||||||
|
|
||||||
|
export default function ShopPage() {
|
||||||
|
return <Shop />;
|
||||||
|
}
|
||||||
5
src/app/spots/page.tsx
Normal file
5
src/app/spots/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SpotDirectory } from "@/components/SpotDirectory";
|
||||||
|
|
||||||
|
export default function SpotsPage() {
|
||||||
|
return <SpotDirectory />;
|
||||||
|
}
|
||||||
12
src/app/terms/page.tsx
Normal file
12
src/app/terms/page.tsx
Normal 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
150
src/components/AgeGate.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
src/components/AuthModal.tsx
Normal file
170
src/components/AuthModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/ChatPanel.tsx
Normal file
215
src/components/ChatPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/CruiserCard.tsx
Normal file
90
src/components/CruiserCard.tsx
Normal 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
360
src/components/Map.tsx
Normal 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='© <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
124
src/components/Nav.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/components/ProfilePopup.tsx
Normal file
104
src/components/ProfilePopup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/Providers.tsx
Normal file
61
src/components/Providers.tsx
Normal 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
321
src/components/Shop.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/SpotDirectory.tsx
Normal file
126
src/components/SpotDirectory.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/ui/badge.tsx
Normal file
33
src/components/ui/badge.tsx
Normal 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 };
|
||||||
46
src/components/ui/button.tsx
Normal file
46
src/components/ui/button.tsx
Normal 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 };
|
||||||
46
src/components/ui/card.tsx
Normal file
46
src/components/ui/card.tsx
Normal 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 };
|
||||||
64
src/components/ui/dialog.tsx
Normal file
64
src/components/ui/dialog.tsx
Normal 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 };
|
||||||
19
src/components/ui/input.tsx
Normal file
19
src/components/ui/input.tsx
Normal 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 };
|
||||||
19
src/components/ui/label.tsx
Normal file
19
src/components/ui/label.tsx
Normal 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 };
|
||||||
25
src/components/ui/slider.tsx
Normal file
25
src/components/ui/slider.tsx
Normal 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 };
|
||||||
28
src/components/ui/switch.tsx
Normal file
28
src/components/ui/switch.tsx
Normal 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 };
|
||||||
51
src/components/ui/tabs.tsx
Normal file
51
src/components/ui/tabs.tsx
Normal 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
189
src/lib/auth/context.tsx
Normal 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
58
src/lib/cart.ts
Normal 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
179
src/lib/mock-data.ts
Normal 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
167
src/lib/products.ts
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
8
src/lib/supabase/client.ts
Normal file
8
src/lib/supabase/client.ts
Normal 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
93
src/lib/supabase/hooks.ts
Normal 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 };
|
||||||
|
}
|
||||||
30
src/lib/supabase/middleware.ts
Normal file
30
src/lib/supabase/middleware.ts
Normal 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;
|
||||||
|
}
|
||||||
27
src/lib/supabase/server.ts
Normal file
27
src/lib/supabase/server.ts
Normal 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
21
src/lib/utils.ts
Normal 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
12
src/middleware.ts
Normal 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
131
src/types/index.ts
Normal 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
45
supabase/functions.sql
Normal 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
246
supabase/schema.sql
Normal 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
66
tailwind.config.ts
Normal 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
23
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user