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

@@ -46,6 +46,9 @@ function mapDbProfile(row: Record<string, unknown>): Profile {
lat: loc?.coordinates?.[1] ?? null,
lng: loc?.coordinates?.[0] ?? null,
city: (row.city as string) ?? null,
state: (row.state as string) ?? null,
id_verified: (row.id_verified as boolean) ?? false,
id_verified_at: (row.id_verified_at as string) ?? null,
vanilla_mode: (row.vanilla_mode as boolean) ?? false,
created_at: (row.created_at as string) ?? new Date().toISOString(),
};
@@ -139,6 +142,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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;
if (data.state !== undefined) payload.state = data.state;
if (data.id_verified !== undefined) payload.id_verified = data.id_verified;
if (data.id_verified_at !== undefined) payload.id_verified_at = data.id_verified_at;
const { error } = await supabase
.from("profiles")
.update(payload)

View File

@@ -0,0 +1,180 @@
import { createClient } from "@/lib/supabase/client";
import { MAX_CANNED_MESSAGES, MAX_CANNED_MESSAGE_LENGTH } from "@/lib/limits";
export interface CannedMessage {
id: string;
title: string;
content: string;
sort_order: number;
created_at: string;
}
const LS_KEY = "eg_canned_messages";
export const DEFAULT_CANNED_MESSAGES: Omit<CannedMessage, "id" | "created_at" | "sort_order">[] = [
{
title: "Address",
content: "I'm at 123 Example St, Wilton Manors. Buzzer #4.",
},
{
title: "Directions",
content: "Park in the back lot off NE 6th Ave. Walk through the gate on the left.",
},
{
title: "Hosting now",
content: "Hosting now — clean only, door unlocked. Text when you're outside.",
},
{
title: "On my way",
content: "On my way — ETA 10 min. Save my number.",
},
];
function uid() {
return crypto.randomUUID();
}
function readLocal(): CannedMessage[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) return JSON.parse(raw) as CannedMessage[];
const seeded = DEFAULT_CANNED_MESSAGES.map((m, i) => ({
...m,
id: uid(),
sort_order: i,
created_at: new Date().toISOString(),
}));
localStorage.setItem(LS_KEY, JSON.stringify(seeded));
return seeded;
} catch {
return [];
}
}
function writeLocal(messages: CannedMessage[]) {
localStorage.setItem(LS_KEY, JSON.stringify(messages));
}
export async function loadCannedMessages(
userId?: string | null
): Promise<CannedMessage[]> {
if (!userId) return readLocal();
const supabase = createClient();
const { data, error } = await supabase
.from("canned_messages")
.select("*")
.eq("user_id", userId)
.order("sort_order");
if (error || !data?.length) {
const local = readLocal();
if (local.length && userId) {
for (const msg of local) {
await supabase.from("canned_messages").insert({
user_id: userId,
title: msg.title,
content: msg.content,
sort_order: msg.sort_order,
});
}
const { data: synced } = await supabase
.from("canned_messages")
.select("*")
.eq("user_id", userId)
.order("sort_order");
return (synced as CannedMessage[]) ?? local;
}
return local;
}
return data as CannedMessage[];
}
export async function saveCannedMessage(
msg: { id?: string; title: string; content: string },
userId?: string | null
): Promise<{ message?: CannedMessage; error?: string }> {
const title = msg.title.trim().slice(0, 40);
const content = msg.content.trim().slice(0, MAX_CANNED_MESSAGE_LENGTH);
if (!title || !content) return { error: "Title and message required." };
if (!userId) {
const all = readLocal();
if (!msg.id && all.length >= MAX_CANNED_MESSAGES) {
return { error: `Maximum ${MAX_CANNED_MESSAGES} canned messages.` };
}
if (msg.id) {
const updated = all.map((m) =>
m.id === msg.id ? { ...m, title, content } : m
);
writeLocal(updated);
return { message: updated.find((m) => m.id === msg.id) };
}
const newMsg: CannedMessage = {
id: uid(),
title,
content,
sort_order: all.length,
created_at: new Date().toISOString(),
};
writeLocal([...all, newMsg]);
return { message: newMsg };
}
const supabase = createClient();
if (msg.id) {
const { data, error } = await supabase
.from("canned_messages")
.update({ title, content })
.eq("id", msg.id)
.eq("user_id", userId)
.select()
.single();
if (error) return { error: error.message };
return { message: data as CannedMessage };
}
const { count } = await supabase
.from("canned_messages")
.select("*", { count: "exact", head: true })
.eq("user_id", userId);
if ((count ?? 0) >= MAX_CANNED_MESSAGES) {
return { error: `Maximum ${MAX_CANNED_MESSAGES} canned messages.` };
}
const { data, error } = await supabase
.from("canned_messages")
.insert({
user_id: userId,
title,
content,
sort_order: count ?? 0,
})
.select()
.single();
if (error) return { error: error.message };
return { message: data as CannedMessage };
}
export async function deleteCannedMessage(
id: string,
userId?: string | null
): Promise<{ error?: string }> {
if (!userId) {
writeLocal(readLocal().filter((m) => m.id !== id));
return {};
}
const supabase = createClient();
const { error } = await supabase
.from("canned_messages")
.delete()
.eq("id", id)
.eq("user_id", userId);
return error ? { error: error.message } : {};
}

251
src/lib/gallery-store.ts Normal file
View File

@@ -0,0 +1,251 @@
import { createClient } from "@/lib/supabase/client";
import {
MAX_ALBUMS_PER_USER,
MAX_PHOTOS_PER_USER,
MAX_ALBUM_NAME_LENGTH,
} from "@/lib/limits";
export interface GalleryAlbum {
id: string;
name: string;
is_default: boolean;
created_at: string;
}
export interface GalleryPhoto {
id: string;
album_id: string | null;
url: string;
is_explicit: boolean;
created_at: string;
}
const LS_ALBUMS = "eg_gallery_albums";
const LS_PHOTOS = "eg_gallery_photos";
function uid() {
return crypto.randomUUID();
}
function readLocal<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function writeLocal<T>(key: string, data: T) {
localStorage.setItem(key, JSON.stringify(data));
}
export function getDefaultAlbums(): GalleryAlbum[] {
return [
{
id: "main",
name: "Main",
is_default: true,
created_at: new Date().toISOString(),
},
];
}
export async function loadGallery(userId?: string | null): Promise<{
albums: GalleryAlbum[];
photos: GalleryPhoto[];
}> {
if (!userId) {
const albums = readLocal<GalleryAlbum[]>(LS_ALBUMS, getDefaultAlbums());
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
return { albums: albums.length ? albums : getDefaultAlbums(), photos };
}
const supabase = createClient();
const [{ data: albums }, { data: photos }] = await Promise.all([
supabase
.from("gallery_albums")
.select("*")
.eq("user_id", userId)
.order("created_at"),
supabase
.from("gallery_photos")
.select("*")
.eq("user_id", userId)
.order("created_at"),
]);
return {
albums: (albums as GalleryAlbum[])?.length
? (albums as GalleryAlbum[])
: getDefaultAlbums(),
photos: (photos as GalleryPhoto[]) ?? [],
};
}
export async function createAlbum(
name: string,
userId?: string | null
): Promise<{ album?: GalleryAlbum; error?: string }> {
const trimmed = name.trim().slice(0, MAX_ALBUM_NAME_LENGTH);
if (!trimmed) return { error: "Album name required." };
if (!userId) {
const albums = readLocal<GalleryAlbum[]>(LS_ALBUMS, getDefaultAlbums());
if (albums.length >= MAX_ALBUMS_PER_USER) {
return { error: `Maximum ${MAX_ALBUMS_PER_USER} albums allowed.` };
}
const album: GalleryAlbum = {
id: uid(),
name: trimmed,
is_default: false,
created_at: new Date().toISOString(),
};
writeLocal(LS_ALBUMS, [...albums, album]);
return { album };
}
const supabase = createClient();
const { count } = await supabase
.from("gallery_albums")
.select("*", { count: "exact", head: true })
.eq("user_id", userId);
if ((count ?? 0) >= MAX_ALBUMS_PER_USER) {
return { error: `Maximum ${MAX_ALBUMS_PER_USER} albums allowed.` };
}
const { data, error } = await supabase
.from("gallery_albums")
.insert({ user_id: userId, name: trimmed })
.select()
.single();
if (error) return { error: error.message };
return { album: data as GalleryAlbum };
}
export async function addPhoto(
file: File,
albumId: string | null,
userId?: string | null
): Promise<{ photo?: GalleryPhoto; error?: string }> {
if (!userId) {
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
if (photos.length >= MAX_PHOTOS_PER_USER) {
return { error: `Maximum ${MAX_PHOTOS_PER_USER} photos allowed.` };
}
const url = await fileToDataUrl(file);
const photo: GalleryPhoto = {
id: uid(),
album_id: albumId,
url,
is_explicit: true,
created_at: new Date().toISOString(),
};
writeLocal(LS_PHOTOS, [...photos, photo]);
return { photo };
}
const supabase = createClient();
const { count } = await supabase
.from("gallery_photos")
.select("*", { count: "exact", head: true })
.eq("user_id", userId);
if ((count ?? 0) >= MAX_PHOTOS_PER_USER) {
return { error: `Maximum ${MAX_PHOTOS_PER_USER} photos allowed.` };
}
const ext = file.name.split(".").pop() || "jpg";
const path = `${userId}/${uid()}.${ext}`;
const { error: uploadError } = await supabase.storage
.from("gallery")
.upload(path, file, { upsert: false });
if (uploadError) return { error: uploadError.message };
const {
data: { publicUrl },
} = supabase.storage.from("gallery").getPublicUrl(path);
const { data, error } = await supabase
.from("gallery_photos")
.insert({
user_id: userId,
album_id: albumId === "main" ? null : albumId,
url: publicUrl,
is_explicit: true,
})
.select()
.single();
if (error) return { error: error.message };
return { photo: data as GalleryPhoto };
}
export async function deletePhoto(
photoId: string,
userId?: string | null
): Promise<{ error?: string }> {
if (!userId) {
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
writeLocal(
LS_PHOTOS,
photos.filter((p) => p.id !== photoId)
);
return {};
}
const supabase = createClient();
const { error } = await supabase
.from("gallery_photos")
.delete()
.eq("id", photoId)
.eq("user_id", userId);
return error ? { error: error.message } : {};
}
export async function deleteAlbum(
albumId: string,
userId?: string | null
): Promise<{ error?: string }> {
if (albumId === "main") return { error: "Cannot delete the Main album." };
if (!userId) {
const albums = readLocal<GalleryAlbum[]>(LS_ALBUMS, getDefaultAlbums());
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
writeLocal(
LS_ALBUMS,
albums.filter((a) => a.id !== albumId)
);
writeLocal(
LS_PHOTOS,
photos.map((p) =>
p.album_id === albumId ? { ...p, album_id: "main" } : p
)
);
return {};
}
const supabase = createClient();
const { error } = await supabase
.from("gallery_albums")
.delete()
.eq("id", albumId)
.eq("user_id", userId);
return error ? { error: error.message } : {};
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}

124
src/lib/id-verification.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* US states requiring government-ID age verification to view explicit/nude content.
* Updated for 20252026 age-verification statutes (HB/SB laws).
*/
export const ID_VERIFICATION_STATES = [
"AL", // Alabama
"AR", // Arkansas
"AZ", // Arizona
"FL", // Florida
"GA", // Georgia
"ID", // Idaho
"IN", // Indiana
"KS", // Kansas
"KY", // Kentucky
"LA", // Louisiana
"MS", // Mississippi
"MT", // Montana
"NC", // North Carolina
"ND", // North Dakota
"NE", // Nebraska
"OK", // Oklahoma
"SC", // South Carolina
"SD", // South Dakota
"TN", // Tennessee
"TX", // Texas
"UT", // Utah
"VA", // Virginia
"WV", // West Virginia
"WY", // Wyoming
] as const;
export type IdVerificationState = (typeof ID_VERIFICATION_STATES)[number];
export const US_STATES: { code: string; name: string }[] = [
{ code: "AL", name: "Alabama" },
{ code: "AK", name: "Alaska" },
{ code: "AZ", name: "Arizona" },
{ code: "AR", name: "Arkansas" },
{ code: "CA", name: "California" },
{ code: "CO", name: "Colorado" },
{ code: "CT", name: "Connecticut" },
{ code: "DE", name: "Delaware" },
{ code: "FL", name: "Florida" },
{ code: "GA", name: "Georgia" },
{ code: "HI", name: "Hawaii" },
{ code: "ID", name: "Idaho" },
{ code: "IL", name: "Illinois" },
{ code: "IN", name: "Indiana" },
{ code: "IA", name: "Iowa" },
{ code: "KS", name: "Kansas" },
{ code: "KY", name: "Kentucky" },
{ code: "LA", name: "Louisiana" },
{ code: "ME", name: "Maine" },
{ code: "MD", name: "Maryland" },
{ code: "MA", name: "Massachusetts" },
{ code: "MI", name: "Michigan" },
{ code: "MN", name: "Minnesota" },
{ code: "MS", name: "Mississippi" },
{ code: "MO", name: "Missouri" },
{ code: "MT", name: "Montana" },
{ code: "NE", name: "Nebraska" },
{ code: "NV", name: "Nevada" },
{ code: "NH", name: "New Hampshire" },
{ code: "NJ", name: "New Jersey" },
{ code: "NM", name: "New Mexico" },
{ code: "NY", name: "New York" },
{ code: "NC", name: "North Carolina" },
{ code: "ND", name: "North Dakota" },
{ code: "OH", name: "Ohio" },
{ code: "OK", name: "Oklahoma" },
{ code: "OR", name: "Oregon" },
{ code: "PA", name: "Pennsylvania" },
{ code: "RI", name: "Rhode Island" },
{ code: "SC", name: "South Carolina" },
{ code: "SD", name: "South Dakota" },
{ code: "TN", name: "Tennessee" },
{ code: "TX", name: "Texas" },
{ code: "UT", name: "Utah" },
{ code: "VT", name: "Vermont" },
{ code: "VA", name: "Virginia" },
{ code: "WA", name: "Washington" },
{ code: "WV", name: "West Virginia" },
{ code: "WI", name: "Wisconsin" },
{ code: "WY", name: "Wyoming" },
{ code: "DC", name: "Washington D.C." },
];
export function requiresIdVerification(state: string | null | undefined): boolean {
if (!state) return false;
return (ID_VERIFICATION_STATES as readonly string[]).includes(state.toUpperCase());
}
export function getUserState(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("eg_user_state");
}
export function setUserState(state: string): void {
localStorage.setItem("eg_user_state", state.toUpperCase());
}
export function isIdVerified(profileVerified?: boolean): boolean {
if (profileVerified) return true;
if (typeof window === "undefined") return false;
return localStorage.getItem("eg_id_verified") === "true";
}
export function setIdVerified(verified: boolean): void {
localStorage.setItem("eg_id_verified", String(verified));
if (verified) {
localStorage.setItem("eg_id_verified_at", new Date().toISOString());
}
}
/** True when explicit/nude photos must stay blurred */
export function mustBlurExplicit(profileVerified?: boolean): boolean {
const state = getUserState();
if (!requiresIdVerification(state)) return false;
return !isIdVerified(profileVerified);
}
export function stateName(code: string): string {
return US_STATES.find((s) => s.code === code)?.name ?? code;
}

7
src/lib/limits.ts Normal file
View File

@@ -0,0 +1,7 @@
/** Hard product limits — enforced in UI and DB triggers */
export const MAX_PHOTOS_PER_USER = 15;
export const MAX_ALBUMS_PER_USER = 5;
export const MAX_CANNED_MESSAGES = 20;
export const MAX_PHOTO_SIZE_MB = 8;
export const MAX_CANNED_MESSAGE_LENGTH = 500;
export const MAX_ALBUM_NAME_LENGTH = 40;

View File

@@ -25,6 +25,9 @@ export const MOCK_PROFILES: Profile[] = [
lat: jitter(DEFAULT_LAT, 0.02),
lng: jitter(DEFAULT_LNG, 0.02),
city: "Fort Lauderdale",
state: "FL",
id_verified: false,
id_verified_at: null,
vanilla_mode: false,
created_at: new Date().toISOString(),
},
@@ -45,6 +48,9 @@ export const MOCK_PROFILES: Profile[] = [
lat: jitter(DEFAULT_LAT, 0.03),
lng: jitter(DEFAULT_LNG, 0.03),
city: "Fort Lauderdale",
state: "FL",
id_verified: false,
id_verified_at: null,
vanilla_mode: false,
created_at: new Date().toISOString(),
},
@@ -65,6 +71,9 @@ export const MOCK_PROFILES: Profile[] = [
lat: jitter(DEFAULT_LAT, 0.025),
lng: jitter(DEFAULT_LNG, 0.025),
city: "Fort Lauderdale",
state: "FL",
id_verified: false,
id_verified_at: null,
vanilla_mode: false,
created_at: new Date().toISOString(),
},
@@ -85,6 +94,9 @@ export const MOCK_PROFILES: Profile[] = [
lat: jitter(DEFAULT_LAT, 0.015),
lng: jitter(DEFAULT_LNG, 0.015),
city: "Fort Lauderdale",
state: "FL",
id_verified: false,
id_verified_at: null,
vanilla_mode: false,
created_at: new Date().toISOString(),
},
@@ -105,6 +117,9 @@ export const MOCK_PROFILES: Profile[] = [
lat: jitter(DEFAULT_LAT, 0.01),
lng: jitter(DEFAULT_LNG, 0.01),
city: "Fort Lauderdale",
state: "FL",
id_verified: false,
id_verified_at: null,
vanilla_mode: false,
created_at: new Date().toISOString(),
},