v2: ID verification by state, 15 photos/5 albums, 20 quick replies, logo, mobile polish

This commit is contained in:
Ryan Salazar
2026-06-26 21:08:57 -04:00
parent c6238c3bcf
commit e03d929977
121 changed files with 6380 additions and 153 deletions

View File

@@ -3,7 +3,8 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ShieldAlert } from "lucide-react";
import { Logo } from "@/components/Logo";
import { US_STATES, setUserState, requiresIdVerification } from "@/lib/id-verification";
interface AgeGateProps {
onVerified: () => void;
@@ -13,6 +14,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
const [month, setMonth] = useState("");
const [day, setDay] = useState("");
const [year, setYear] = useState("");
const [state, setState] = useState("");
const [error, setError] = useState("");
const [agreed, setAgreed] = useState(false);
@@ -26,6 +28,11 @@ export function AgeGate({ onVerified }: AgeGateProps) {
return;
}
if (!state) {
setError("Please select your state — required for content access rules.");
return;
}
const dob = new Date(y, m - 1, d);
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
@@ -46,15 +53,25 @@ export function AgeGate({ onVerified }: AgeGateProps) {
localStorage.setItem("eg_age_verified", "true");
localStorage.setItem("eg_age_verified_at", new Date().toISOString());
setUserState(state);
if (requiresIdVerification(state)) {
localStorage.setItem("eg_id_verification_required", "true");
} else {
localStorage.removeItem("eg_id_verification_required");
}
onVerified();
};
const idRequired = requiresIdVerification(state);
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="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95 p-4 overflow-y-auto">
<div className="w-full max-w-md rounded-2xl border border-horny-pink/30 bg-horny-surface p-6 sm:p-8 shadow-2xl shadow-horny-pink/20 my-auto">
<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 className="mx-auto mb-4 flex justify-center">
<Logo size="lg" showText={false} />
</div>
<h1 className="text-2xl font-bold bg-horny-gradient bg-clip-text text-transparent">
ExposedGays
@@ -71,7 +88,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
<select
value={month}
onChange={(e) => setMonth(e.target.value)}
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
className="h-11 rounded-md border border-input bg-background px-2 text-sm touch-manipulation"
>
<option value="">Month</option>
{Array.from({ length: 12 }, (_, i) => (
@@ -83,7 +100,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
<select
value={day}
onChange={(e) => setDay(e.target.value)}
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
className="h-11 rounded-md border border-input bg-background px-2 text-sm touch-manipulation"
>
<option value="">Day</option>
{Array.from({ length: 31 }, (_, i) => (
@@ -95,7 +112,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
<select
value={year}
onChange={(e) => setYear(e.target.value)}
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
className="h-11 rounded-md border border-input bg-background px-2 text-sm touch-manipulation"
>
<option value="">Year</option>
{Array.from({ length: 80 }, (_, i) => {
@@ -110,12 +127,34 @@ export function AgeGate({ onVerified }: AgeGateProps) {
</div>
</div>
<div>
<Label className="mb-2 block">Your State</Label>
<select
value={state}
onChange={(e) => setState(e.target.value)}
className="h-11 w-full rounded-md border border-input bg-background px-3 text-sm touch-manipulation"
>
<option value="">Select state...</option>
{US_STATES.map((s) => (
<option key={s.code} value={s.code}>
{s.name}
</option>
))}
</select>
{idRequired && (
<p className="text-xs text-amber-400 mt-1.5">
{US_STATES.find((s) => s.code === state)?.name} requires ID verification
to view nude photos. You can verify after entering.
</p>
)}
</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"
className="mt-1 h-5 w-5 rounded border-input accent-horny-pink touch-manipulation"
/>
<span className="text-xs text-muted-foreground leading-relaxed">
I confirm I am 18 years of age or older. I understand this site contains
@@ -127,8 +166,8 @@ export function AgeGate({ onVerified }: AgeGateProps) {
<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.
. Pursuant to applicable state law, I acknowledge government ID verification
may be required to view nude content in certain states.
</span>
</label>
@@ -136,7 +175,12 @@ export function AgeGate({ onVerified }: AgeGateProps) {
<p className="text-sm text-destructive text-center">{error}</p>
)}
<Button variant="horny" className="w-full" size="lg" onClick={handleVerify}>
<Button
variant="horny"
className="w-full h-12 text-base touch-manipulation"
size="lg"
onClick={handleVerify}
>
Confirm I am 18+
</Button>

View File

@@ -0,0 +1,216 @@
"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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MessageSquarePlus, Trash2, Pencil, Check, X } from "lucide-react";
import {
loadCannedMessages,
saveCannedMessage,
deleteCannedMessage,
type CannedMessage,
} from "@/lib/canned-messages-store";
import { MAX_CANNED_MESSAGES, MAX_CANNED_MESSAGE_LENGTH } from "@/lib/limits";
import { useAuth } from "@/lib/auth/context";
interface CannedMessagesPanelProps {
compact?: boolean;
onSelect?: (content: string) => void;
}
export function CannedMessagesPanel({ compact, onSelect }: CannedMessagesPanelProps) {
const { user } = useAuth();
const [messages, setMessages] = useState<CannedMessage[]>([]);
const [editing, setEditing] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const [editContent, setEditContent] = useState("");
const [showNew, setShowNew] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newContent, setNewContent] = useState("");
const [error, setError] = useState("");
const refresh = async () => {
setMessages(await loadCannedMessages(user?.id));
};
useEffect(() => {
refresh();
}, [user?.id]);
const startEdit = (msg: CannedMessage) => {
setEditing(msg.id);
setEditTitle(msg.title);
setEditContent(msg.content);
};
const saveEdit = async () => {
const { error: err } = await saveCannedMessage(
{ id: editing!, title: editTitle, content: editContent },
user?.id
);
if (err) setError(err);
else {
setEditing(null);
setError("");
await refresh();
}
};
const handleCreate = async () => {
const { error: err } = await saveCannedMessage(
{ title: newTitle, content: newContent },
user?.id
);
if (err) setError(err);
else {
setShowNew(false);
setNewTitle("");
setNewContent("");
setError("");
await refresh();
}
};
const handleDelete = async (id: string) => {
await deleteCannedMessage(id, user?.id);
await refresh();
};
if (compact) {
return (
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
{messages.map((msg) => (
<button
key={msg.id}
type="button"
onClick={() => onSelect?.(msg.content)}
className="text-xs px-2.5 py-1.5 rounded-full border border-border hover:border-horny-pink/50 hover:bg-horny-pink/10 transition-colors truncate max-w-[140px]"
title={msg.content}
>
{msg.title}
</button>
))}
{messages.length === 0 && (
<p className="text-xs text-muted-foreground">No quick replies add them in Profile</p>
)}
</div>
);
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquarePlus className="h-5 w-5 text-horny-pink" />
Quick Replies
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{messages.length}/{MAX_CANNED_MESSAGES}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Save addresses, directions, hosting notes tap to insert while messaging.
</p>
</CardHeader>
<CardContent className="space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className="rounded-lg border border-border p-3 space-y-2"
>
{editing === msg.id ? (
<>
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Label (e.g. Address)"
/>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
maxLength={MAX_CANNED_MESSAGE_LENGTH}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none"
placeholder="Message text..."
/>
<div className="flex gap-2">
<Button variant="horny" size="sm" onClick={saveEdit}>
<Check className="h-3 w-3 mr-1" /> Save
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(null)}>
<X className="h-3 w-3" />
</Button>
</div>
</>
) : (
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-horny-pink">{msg.title}</p>
<p className="text-xs text-muted-foreground truncate">{msg.content}</p>
</div>
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => startEdit(msg)}>
<Pencil className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleDelete(msg.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
)}
</div>
))}
{showNew ? (
<div className="space-y-2 border border-dashed border-border rounded-lg p-3">
<div>
<Label className="text-xs">Label</Label>
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="e.g. Door code"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">Message</Label>
<textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
maxLength={MAX_CANNED_MESSAGE_LENGTH}
rows={3}
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none"
placeholder="Full message to insert..."
/>
</div>
<div className="flex gap-2">
<Button variant="horny" size="sm" onClick={handleCreate}>
Add Quick Reply
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowNew(false)}>
Cancel
</Button>
</div>
</div>
) : (
messages.length < MAX_CANNED_MESSAGES && (
<Button
variant="outline"
className="w-full"
onClick={() => setShowNew(true)}
>
<MessageSquarePlus className="h-4 w-4 mr-2" />
Add Quick Reply
</Button>
)
)}
{error && <p className="text-xs text-destructive text-center">{error}</p>}
</CardContent>
</Card>
);
}

View File

@@ -5,8 +5,9 @@ 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 { Send, Image as ImageIcon, Users, Lock, Zap, ChevronDown, ChevronUp } from "lucide-react";
import { timeAgo } from "@/lib/utils";
import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
interface Message {
id: string;
@@ -41,7 +42,7 @@ const MOCK_CITY_MESSAGES: Message[] = [
{
id: "4",
sender: "PupRex",
content: "Woof! At Ramrod later if anyone wants to meet up 🐾",
content: "Woof! At Ramrod later if anyone wants to meet up",
created_at: new Date(Date.now() - 60000).toISOString(),
},
];
@@ -71,18 +72,20 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
const [input, setInput] = useState("");
const [activeTab, setActiveTab] = useState(dmUserId ? "dm" : "city");
const [showQuickReplies, setShowQuickReplies] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [cityMessages, dmMessages, activeTab]);
const sendMessage = () => {
if (!input.trim()) return;
const sendMessage = (text?: string) => {
const content = (text ?? input).trim();
if (!content) return;
const msg: Message = {
id: Date.now().toString(),
sender: "You",
content: input.trim(),
content,
created_at: new Date().toISOString(),
isMe: true,
};
@@ -93,29 +96,31 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
setDmMessages((prev) => [...prev, msg]);
}
setInput("");
setShowQuickReplies(false);
};
const messages = activeTab === "city" ? cityMessages : dmMessages;
const showComposer = activeTab === "city" || activeTab === "dm";
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">
<TabsList className="mx-3 sm:mx-4 mt-2 grid grid-cols-3">
<TabsTrigger value="city" className="gap-1 text-xs min-h-[40px]">
<Users className="h-3 w-3" />
{CITY}
<span className="truncate">{CITY}</span>
</TabsTrigger>
<TabsTrigger value="dm" className="gap-1 text-xs">
<TabsTrigger value="dm" className="gap-1 text-xs min-h-[40px]">
<Lock className="h-3 w-3" />
DMs
</TabsTrigger>
<TabsTrigger value="groups" className="gap-1 text-xs">
<TabsTrigger value="groups" className="gap-1 text-xs min-h-[40px]">
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">
<TabsContent value="city" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden">
<div className="px-3 sm:px-4 py-2 border-b border-border">
<Badge variant="online" className="text-[10px]">
{cityMessages.length + 12} in room
</Badge>
@@ -126,17 +131,20 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
<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">
<TabsContent value="dm" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden">
<div className="px-3 sm:px-4 py-2 border-b border-border">
<p className="text-sm font-medium">
{dmUserId ? `Chat with User #${dmUserId}` : "Private Messages"}
</p>
<p className="text-xs text-muted-foreground">
Use quick replies for address, directions & hosting notes
</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">
<TabsContent value="groups" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden">
<div className="p-3 sm:p-4 space-y-2 overflow-y-auto">
<GroupRoom name="Leather Night FTL" members={8} />
<GroupRoom name="Beach Cruisers" members={15} />
<GroupRoom name="Gym Sauna Squad" members={4} />
@@ -146,21 +154,50 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
</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>
{showComposer && (
<div className="border-t border-border">
{activeTab === "dm" && (
<div className="px-3 sm:px-4 pt-2">
<button
type="button"
onClick={() => setShowQuickReplies(!showQuickReplies)}
className="flex items-center gap-1.5 text-xs text-horny-pink font-medium mb-2 touch-manipulation"
>
<Zap className="h-3.5 w-3.5" />
Quick Replies
{showQuickReplies ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
{showQuickReplies && (
<div className="mb-2">
<CannedMessagesPanel compact onSelect={(c) => sendMessage(c)} />
</div>
)}
</div>
)}
<div className="p-3 sm:p-4 flex gap-2 safe-bottom">
<Button variant="outline" size="icon" className="shrink-0 h-11 w-11 touch-manipulation">
<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 h-11 text-base"
/>
<Button
variant="horny"
size="icon"
className="shrink-0 h-11 w-11 touch-manipulation"
onClick={() => sendMessage()}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
)}
</Tabs>
@@ -171,7 +208,7 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
<div className="flex-1 overflow-y-auto px-3 sm:px-4 py-3 space-y-3 overscroll-contain">
{messages.map((msg) => (
<div
key={msg.id}
@@ -186,7 +223,7 @@ function MessageList({ messages }: { messages: Message[] }) {
</span>
</div>
<div
className={`max-w-[80%] rounded-xl px-3 py-2 text-sm ${
className={`max-w-[85%] sm:max-w-[80%] rounded-xl px-3 py-2.5 text-sm ${
msg.isMe
? "bg-horny-pink/20 text-foreground"
: "bg-secondary text-foreground"
@@ -202,7 +239,7 @@ function MessageList({ messages }: { messages: Message[] }) {
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 className="flex items-center justify-between rounded-lg border border-border p-3 hover:border-horny-pink/30 cursor-pointer transition-colors min-h-[56px] touch-manipulation active:bg-secondary/50">
<div>
<p className="text-sm font-medium">{name}</p>
<p className="text-xs text-muted-foreground">{members} members</p>

View File

@@ -0,0 +1,169 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { ShieldCheck, Upload, AlertTriangle } from "lucide-react";
import { setIdVerified, stateName, getUserState } from "@/lib/id-verification";
import { useAuth } from "@/lib/auth/context";
interface IdVerificationModalProps {
open: boolean;
onClose: () => void;
onVerified: () => void;
}
export function IdVerificationModal({
open,
onClose,
onVerified,
}: IdVerificationModalProps) {
const { user, updateProfile } = useAuth();
const [step, setStep] = useState<"info" | "upload" | "done">("info");
const [agreed, setAgreed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const state = getUserState();
const handleVerify = async () => {
if (!agreed) {
setError("You must agree to the verification terms.");
return;
}
setSubmitting(true);
setError("");
// Self-attestation flow — production hooks to Veriff/Yoti via NEXT_PUBLIC_AGE_VERIFY_PROVIDER
setIdVerified(true);
if (user) {
await updateProfile({
id_verified: true,
id_verified_at: new Date().toISOString(),
} as Parameters<typeof updateProfile>[0]);
}
setStep("done");
setSubmitting(false);
onVerified();
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-horny-pink" />
ID Verification Required
</DialogTitle>
<DialogDescription>
{state
? `${stateName(state)} law requires government ID verification to view nude photos.`
: "Your state requires ID verification to view explicit content."}
</DialogDescription>
</DialogHeader>
{step === "info" && (
<div className="space-y-4">
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 flex gap-2">
<AlertTriangle className="h-5 w-5 text-amber-400 shrink-0 mt-0.5" />
<p className="text-xs text-muted-foreground leading-relaxed">
Nude and explicit photos are blurred until you complete a one-time age
verification. We never store your ID image on our servers verification
is processed by a third-party provider and only a pass/fail result is
retained.
</p>
</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 accent-horny-pink"
/>
<span className="text-xs text-muted-foreground leading-relaxed">
I confirm I am 18+ and consent to age verification. I understand explicit
photos remain blurred until verification is complete. I agree to the{" "}
<a href="/privacy" className="text-horny-pink underline">
Privacy Policy
</a>
.
</span>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={onClose}>
Later
</Button>
<Button
variant="horny"
className="flex-1"
onClick={() => setStep("upload")}
disabled={!agreed}
>
Continue
</Button>
</div>
</div>
)}
{step === "upload" && (
<div className="space-y-4">
<div className="border-2 border-dashed border-border rounded-xl p-8 text-center">
<Upload className="h-10 w-10 mx-auto text-muted-foreground mb-3" />
<p className="text-sm font-medium">Government ID Upload</p>
<p className="text-xs text-muted-foreground mt-1">
Driver&apos;s license or passport. Encrypted & deleted after verification.
</p>
<p className="text-xs text-horny-pink mt-3">
Provider: {process.env.NEXT_PUBLIC_AGE_VERIFY_PROVIDER || "pending integration"}
</p>
</div>
<p className="text-xs text-center text-muted-foreground">
For now, tap Verify to confirm your age via self-attestation. Full Veriff/Yoti
integration will replace this step.
</p>
{error && <p className="text-sm text-destructive text-center">{error}</p>}
<Button
variant="horny"
className="w-full"
size="lg"
onClick={handleVerify}
disabled={submitting}
>
{submitting ? "Verifying..." : "Verify My Age"}
</Button>
</div>
)}
{step === "done" && (
<div className="text-center space-y-4 py-4">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-500/20">
<ShieldCheck className="h-8 w-8 text-green-400" />
</div>
<p className="font-semibold text-green-400">Verified!</p>
<p className="text-sm text-muted-foreground">
Nude photos are now visible. This verification lasts 90 days.
</p>
<Button variant="horny" className="w-full" onClick={onClose}>
Got it
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}

39
src/components/Logo.tsx Normal file
View File

@@ -0,0 +1,39 @@
import Image from "next/image";
import { cn } from "@/lib/utils";
interface LogoProps {
size?: "sm" | "md" | "lg";
showText?: boolean;
className?: string;
}
const SIZES = { sm: 28, md: 36, lg: 48 };
export function Logo({ size = "md", showText = true, className }: LogoProps) {
const px = SIZES[size];
return (
<span className={cn("inline-flex items-center gap-2", className)}>
<Image
src="/logo.svg"
alt="ExposedGays"
width={px}
height={px}
className="shrink-0"
priority
/>
{showText && (
<span
className={cn(
"font-bold bg-horny-gradient bg-clip-text text-transparent",
size === "sm" && "text-base",
size === "md" && "text-xl",
size === "lg" && "text-2xl"
)}
>
ExposedGays
</span>
)}
</span>
);
}

View File

@@ -13,6 +13,7 @@ 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 { useOpenIdVerification } from "@/components/Providers";
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
@@ -48,6 +49,7 @@ interface MapProps {
}
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
const openIdVerification = useOpenIdVerification();
const [profiles, setProfiles] = useState<Profile[]>(MOCK_PROFILES);
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
@@ -358,6 +360,7 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
onClose={() => setSelectedProfile(null)}
onChat={handleChat}
vanillaMode={vanillaMode}
onVerifyClick={openIdVerification}
/>
</div>
);

View File

@@ -0,0 +1,50 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Logo } from "@/components/Logo";
import { Eye, EyeOff } from "lucide-react";
interface MobileHeaderProps {
vanillaMode: boolean;
onVanillaToggle: (v: boolean) => void;
activeCount?: number;
}
export function MobileHeader({
vanillaMode,
onVanillaToggle,
activeCount = 0,
}: MobileHeaderProps) {
return (
<header className="md:hidden fixed top-0 left-0 right-0 z-50 flex h-12 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-3 safe-top">
<Link href="/" className="flex items-center gap-1.5 min-w-0">
<Logo size="sm" showText={false} />
<span className="text-sm font-bold bg-horny-gradient bg-clip-text text-transparent truncate">
ExposedGays
</span>
{activeCount > 0 && (
<Badge variant="online" className="text-[9px] shrink-0">
{activeCount}
</Badge>
)}
</Link>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => onVanillaToggle(!vanillaMode)}
aria-label="Toggle vanilla mode"
>
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Badge variant="secondary" className="text-[8px] px-1.5">
FREE
</Badge>
</div>
</header>
);
}

View File

@@ -3,9 +3,10 @@
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 { Map, MessageCircle, MapPin, ShoppingBag, User, Eye, EyeOff, LogIn, LogOut, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Logo } from "@/components/Logo";
import { useCartStore } from "@/lib/cart";
import { useAuth } from "@/lib/auth/context";
import { AuthModal } from "@/components/AuthModal";
@@ -15,6 +16,8 @@ interface NavProps {
vanillaMode: boolean;
onVanillaToggle: (v: boolean) => void;
activeCount?: number;
onIdVerifyClick?: () => void;
idBlur?: boolean;
}
const NAV_ITEMS = [
@@ -25,7 +28,13 @@ const NAV_ITEMS = [
{ href: "/profile", label: "Profile", icon: User },
];
export function Nav({ vanillaMode, onVanillaToggle, activeCount = 0 }: NavProps) {
export function Nav({
vanillaMode,
onVanillaToggle,
activeCount = 0,
onIdVerifyClick,
idBlur,
}: NavProps) {
const pathname = usePathname();
const itemCount = useCartStore((s) => s.itemCount());
const { user, profile, signOut } = useAuth();
@@ -33,12 +42,9 @@ export function Nav({ vanillaMode, onVanillaToggle, activeCount = 0 }: NavProps)
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>
<Logo size="md" />
{activeCount > 0 && (
<Badge variant="online" className="text-[10px]">
{activeCount} active
@@ -70,6 +76,17 @@ export function Nav({ vanillaMode, onVanillaToggle, activeCount = 0 }: NavProps)
</nav>
<div className="flex items-center gap-2">
{idBlur && onIdVerifyClick && (
<Button
variant="outline"
size="sm"
onClick={onIdVerifyClick}
className="gap-2 text-amber-400 border-amber-400/30"
>
<ShieldCheck className="h-4 w-4" />
Verify ID
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -98,21 +115,20 @@ export function Nav({ vanillaMode, onVanillaToggle, activeCount = 0 }: NavProps)
<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 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 touch-manipulation">
{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",
"flex flex-col items-center gap-0.5 px-3 py-2 min-w-[56px] min-h-[44px] justify-center text-[10px] transition-colors relative active:scale-95",
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">
<span className="absolute top-0.5 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-horny-pink text-[8px] font-bold text-white">
{itemCount}
</span>
)}

View File

@@ -0,0 +1,239 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, Trash2, ImageIcon, FolderPlus, Lock } from "lucide-react";
import {
loadGallery,
createAlbum,
addPhoto,
deletePhoto,
deleteAlbum,
type GalleryAlbum,
type GalleryPhoto,
} from "@/lib/gallery-store";
import {
MAX_PHOTOS_PER_USER,
MAX_ALBUMS_PER_USER,
MAX_PHOTO_SIZE_MB,
} from "@/lib/limits";
import { mustBlurExplicit } from "@/lib/id-verification";
import { useAuth } from "@/lib/auth/context";
interface PhotoGalleryManagerProps {
onVerifyClick?: () => void;
}
export function PhotoGalleryManager({ onVerifyClick }: PhotoGalleryManagerProps) {
const { user, profile } = useAuth();
const [albums, setAlbums] = useState<GalleryAlbum[]>([]);
const [photos, setPhotos] = useState<GalleryPhoto[]>([]);
const [activeAlbum, setActiveAlbum] = useState<string>("main");
const [newAlbumName, setNewAlbumName] = useState("");
const [showNewAlbum, setShowNewAlbum] = useState(false);
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(true);
const fileRef = useRef<HTMLInputElement>(null);
const blurExplicit = mustBlurExplicit(profile?.id_verified);
const refresh = async () => {
const data = await loadGallery(user?.id);
setAlbums(data.albums);
setPhotos(data.photos);
setLoading(false);
};
useEffect(() => {
refresh();
}, [user?.id]);
const albumPhotos = photos.filter(
(p) =>
activeAlbum === "main"
? !p.album_id || p.album_id === "main"
: p.album_id === activeAlbum
);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > MAX_PHOTO_SIZE_MB * 1024 * 1024) {
setMessage(`Max file size is ${MAX_PHOTO_SIZE_MB}MB.`);
return;
}
const { photo, error } = await addPhoto(file, activeAlbum, user?.id);
if (error) setMessage(error);
else {
setMessage("Photo uploaded!");
await refresh();
}
if (fileRef.current) fileRef.current.value = "";
};
const handleCreateAlbum = async () => {
const { album, error } = await createAlbum(newAlbumName, user?.id);
if (error) setMessage(error);
else {
setNewAlbumName("");
setShowNewAlbum(false);
if (album) setActiveAlbum(album.id);
await refresh();
}
};
const handleDeletePhoto = async (id: string) => {
await deletePhoto(id, user?.id);
await refresh();
};
const handleDeleteAlbum = async (id: string) => {
const { error } = await deleteAlbum(id, user?.id);
if (error) setMessage(error);
else {
setActiveAlbum("main");
await refresh();
}
};
if (loading) {
return <p className="text-sm text-muted-foreground">Loading gallery...</p>;
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<ImageIcon className="h-5 w-5 text-horny-pink" />
Photo Gallery
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{photos.length}/{MAX_PHOTOS_PER_USER} photos · {albums.length}/{MAX_ALBUMS_PER_USER} albums
</Badge>
</div>
{blurExplicit && (
<button
type="button"
onClick={onVerifyClick}
className="flex items-center gap-2 text-xs text-amber-400 hover:underline mt-1"
>
<Lock className="h-3 w-3" />
ID verification required to view nude photos in your state
</button>
)}
</CardHeader>
<CardContent className="space-y-4">
<Tabs value={activeAlbum} onValueChange={setActiveAlbum}>
<div className="flex items-center gap-2 overflow-x-auto pb-1 -mx-1 px-1">
<TabsList className="h-auto flex-wrap justify-start">
{albums.map((a) => (
<TabsTrigger key={a.id} value={a.id} className="text-xs gap-1">
{a.name}
{!a.is_default && (
<button
type="button"
className="ml-1 opacity-50 hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
handleDeleteAlbum(a.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</TabsTrigger>
))}
</TabsList>
{albums.length < MAX_ALBUMS_PER_USER && (
<Button
variant="outline"
size="sm"
className="shrink-0 h-8 text-xs"
onClick={() => setShowNewAlbum(true)}
>
<FolderPlus className="h-3 w-3 mr-1" />
Album
</Button>
)}
</div>
</Tabs>
{showNewAlbum && (
<div className="flex gap-2">
<Input
placeholder="Album name"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
className="flex-1"
/>
<Button variant="horny" size="sm" onClick={handleCreateAlbum}>
Create
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowNewAlbum(false)}>
Cancel
</Button>
</div>
)}
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{albumPhotos.map((photo) => (
<div
key={photo.id}
className={`relative aspect-square rounded-lg overflow-hidden group ${blurExplicit && photo.is_explicit ? "id-blur" : ""}`}
>
<Image
src={photo.url}
alt="Gallery photo"
fill
className="object-cover explicit-content"
unoptimized={photo.url.startsWith("data:")}
/>
<button
type="button"
onClick={() => handleDeletePhoto(photo.id)}
className="absolute top-1 right-1 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-3 w-3 text-white" />
</button>
</div>
))}
{photos.length < MAX_PHOTOS_PER_USER && (
<button
type="button"
onClick={() => fileRef.current?.click()}
className="aspect-square rounded-lg border-2 border-dashed border-border flex flex-col items-center justify-center gap-1 hover:border-horny-pink/50 transition-colors"
>
<Plus className="h-6 w-6 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">Add photo</span>
</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleUpload}
/>
{message && (
<p className={`text-xs text-center ${message.includes("Maximum") || message.includes("Max") ? "text-destructive" : "text-green-400"}`}>
{message}
</p>
)}
<p className="text-[10px] text-muted-foreground text-center">
Up to {MAX_PHOTOS_PER_USER} photos across {MAX_ALBUMS_PER_USER} albums. JPG, PNG, WebP · max {MAX_PHOTO_SIZE_MB}MB each.
</p>
</CardContent>
</Card>
);
}

View File

@@ -4,10 +4,11 @@ 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 { MessageCircle, Camera, Shield, Lock } from "lucide-react";
import type { Profile } from "@/types";
import { STATUS_LABELS } from "@/types";
import { timeAgo } from "@/lib/utils";
import { useIdBlur } from "@/components/Providers";
interface ProfilePopupProps {
profile: Profile | null;
@@ -15,15 +16,27 @@ interface ProfilePopupProps {
onClose: () => void;
onChat: (profile: Profile) => void;
vanillaMode?: boolean;
onVerifyClick?: () => void;
}
export function ProfilePopup({ profile, open, onClose, onChat, vanillaMode }: ProfilePopupProps) {
export function ProfilePopup({
profile,
open,
onClose,
onChat,
vanillaMode,
onVerifyClick,
}: ProfilePopupProps) {
const idBlur = useIdBlur();
if (!profile) return null;
const name = profile.is_anonymous
? "Anonymous Cruiser"
: profile.display_name || profile.username || "Cruiser";
const shouldBlur = vanillaMode || idBlur;
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
@@ -36,13 +49,29 @@ export function ProfilePopup({ profile, open, onClose, onChat, vanillaMode }: Pr
</DialogTitle>
</DialogHeader>
<div className={`relative aspect-[4/3] rounded-lg overflow-hidden ${vanillaMode ? "vanilla-blur" : ""}`}>
<div
className={`relative aspect-[4/3] rounded-lg overflow-hidden ${
shouldBlur ? "vanilla-blur id-blur" : ""
}`}
>
<Image
src={profile.avatar_url || "https://placehold.co/400x300/1a1220/ff2d6b?text=Profile"}
alt={name}
fill
className="object-cover explicit-content"
/>
{idBlur && !vanillaMode && (
<button
type="button"
onClick={onVerifyClick}
className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 gap-2"
>
<Lock className="h-8 w-8 text-white" />
<span className="text-xs text-white font-medium px-4 text-center">
Verify ID to view nude photos
</span>
</button>
)}
</div>
<div className="flex flex-wrap gap-2">

View File

@@ -1,12 +1,22 @@
"use client";
import { useState, useEffect, createContext, useContext } from "react";
import { useState, useEffect, createContext, useContext, useCallback } from "react";
import { AgeGate } from "@/components/AgeGate";
import { Nav } from "@/components/Nav";
import { AuthProvider } from "@/lib/auth/context";
import { MobileHeader } from "@/components/MobileHeader";
import { IdVerificationModal } from "@/components/IdVerificationModal";
import { AuthProvider, useAuth } from "@/lib/auth/context";
import {
mustBlurExplicit,
requiresIdVerification,
getUserState,
isIdVerified,
} from "@/lib/id-verification";
const ActiveCountContext = createContext<(n: number) => void>(() => {});
const VanillaModeContext = createContext(false);
const IdBlurContext = createContext(false);
const OpenIdVerificationContext = createContext<() => void>(() => {});
export function useSetActiveCount() {
return useContext(ActiveCountContext);
@@ -16,45 +26,105 @@ export function useVanillaMode() {
return useContext(VanillaModeContext);
}
export function useIdBlur() {
return useContext(IdBlurContext);
}
export function useOpenIdVerification() {
return useContext(OpenIdVerificationContext);
}
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
const [verified, setVerified] = useState(false);
function InnerProviders({ children }: ProvidersProps) {
const { profile } = useAuth();
const [vanillaMode, setVanillaMode] = useState(false);
const [activeCount, setActiveCount] = useState(0);
const [mounted, setMounted] = useState(false);
const [idBlur, setIdBlur] = useState(false);
const [idModalOpen, setIdModalOpen] = useState(false);
useEffect(() => {
setMounted(true);
setVerified(localStorage.getItem("eg_age_verified") === "true");
setVanillaMode(localStorage.getItem("eg_vanilla_mode") === "true");
}, []);
setIdBlur(mustBlurExplicit(profile?.id_verified));
const state = getUserState();
if (
requiresIdVerification(state) &&
!isIdVerified(profile?.id_verified) &&
localStorage.getItem("eg_id_verification_prompted") !== "true"
) {
const timer = setTimeout(() => setIdModalOpen(true), 1500);
return () => clearTimeout(timer);
}
}, [profile?.id_verified]);
const handleVanillaToggle = (v: boolean) => {
setVanillaMode(v);
localStorage.setItem("eg_vanilla_mode", String(v));
};
const handleIdVerified = useCallback(() => {
setIdBlur(false);
localStorage.setItem("eg_id_verification_prompted", "true");
}, []);
const openIdVerification = useCallback(() => {
setIdModalOpen(true);
}, []);
return (
<VanillaModeContext.Provider value={vanillaMode}>
<IdBlurContext.Provider value={idBlur}>
<OpenIdVerificationContext.Provider value={openIdVerification}>
<Nav
vanillaMode={vanillaMode}
onVanillaToggle={handleVanillaToggle}
activeCount={activeCount}
onIdVerifyClick={openIdVerification}
idBlur={idBlur}
/>
<MobileHeader
vanillaMode={vanillaMode}
onVanillaToggle={handleVanillaToggle}
activeCount={activeCount}
/>
<main className="md:pt-14 pt-12 pb-16 md:pb-0 min-h-screen min-h-[100dvh]">
<ActiveCountContext.Provider value={setActiveCount}>
{children}
</ActiveCountContext.Provider>
</main>
<IdVerificationModal
open={idModalOpen}
onClose={() => {
setIdModalOpen(false);
localStorage.setItem("eg_id_verification_prompted", "true");
}}
onVerified={handleIdVerified}
/>
</OpenIdVerificationContext.Provider>
</IdBlurContext.Provider>
</VanillaModeContext.Provider>
);
}
export function Providers({ children }: ProvidersProps) {
const [verified, setVerified] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
setVerified(localStorage.getItem("eg_age_verified") === "true");
}, []);
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>
<InnerProviders>{children}</InnerProviders>
</AuthProvider>
</>
);