tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/web/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..b463afd
--- /dev/null
+++ b/web/src/components/ui/tabs.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Tabs as TabsPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/web/src/components/ui/textarea.tsx b/web/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..e67d8fe
--- /dev/null
+++ b/web/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/web/src/features/admin/dashboard-page.tsx b/web/src/features/admin/dashboard-page.tsx
new file mode 100644
index 0000000..c230025
--- /dev/null
+++ b/web/src/features/admin/dashboard-page.tsx
@@ -0,0 +1,102 @@
+import { useQuery } from "@tanstack/react-query";
+import {
+ Ban,
+ Heart,
+ Image as ImageIcon,
+ Layers,
+ MessageSquare,
+ ThumbsUp,
+ Users,
+} from "lucide-react";
+import { Link } from "react-router";
+import { getStatistics } from "@/api/admin";
+import type { Statistics } from "@/api/types";
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { errorMessages } from "@/lib/errors";
+
+interface StatDef {
+ key: keyof Statistics;
+ label: string;
+ icon: typeof Users;
+ to?: string;
+}
+
+const stats: StatDef[] = [
+ { key: "usersCount", label: "Users", icon: Users, to: "/admin/users" },
+ { key: "bannedUsersCount", label: "Banned users", icon: Ban, to: "/admin/users?banned=1" },
+ { key: "matchesCount", label: "Matches", icon: Heart },
+ { key: "likedUsersCount", label: "Likes", icon: ThumbsUp },
+ { key: "notSwipedUsersCount", label: "Not swiped users", icon: Layers },
+ { key: "imagesCount", label: "Images", icon: ImageIcon },
+ { key: "messagesCount", label: "Messages", icon: MessageSquare },
+];
+
+export default function DashboardPage() {
+ const query = useQuery({ queryKey: ["admin", "stats"], queryFn: getStatistics });
+
+ return (
+
+
Dashboard
+
+ A live snapshot of the LOVE.NET community.
+
+
+ {query.isError && (
+
+ {errorMessages(query.error).map((line) => (
+
{line}
+ ))}
+
+ )}
+
+
+ {query.isPending &&
+ stats.map((stat) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ {query.data &&
+ stats.map((stat) =>
)}
+
+
+ );
+}
+
+function StatCard({ stat, value }: { stat: StatDef; value: number }) {
+ const card = (
+
+
+
+
+
+
+
{stat.label}
+
{value.toLocaleString()}
+
+
+
+ );
+
+ if (!stat.to) return card;
+
+ return (
+
+ {card}
+
+ );
+}
diff --git a/web/src/features/admin/moderate-dialog.tsx b/web/src/features/admin/moderate-dialog.tsx
new file mode 100644
index 0000000..0ee2ed8
--- /dev/null
+++ b/web/src/features/admin/moderate-dialog.tsx
@@ -0,0 +1,105 @@
+import { useMutation } from "@tanstack/react-query";
+import dayjs from "dayjs";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { moderateUser } from "@/api/admin";
+import type { AccountDetails } from "@/api/types";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { errorMessages } from "@/lib/errors";
+
+interface ModerateDialogProps {
+ /** The user being moderated; null keeps the dialog closed. */
+ user: AccountDetails | null;
+ onClose: () => void;
+ /** Called after the API confirms, so the caller can patch its cache. */
+ onModerated: (userId: string, isBanned: boolean) => void;
+}
+
+export default function ModerateDialog({ user, onClose, onModerated }: ModerateDialogProps) {
+ const today = dayjs().format("YYYY-MM-DD");
+ const [bannedUntil, setBannedUntil] = useState(today);
+ const isBan = !!user && !user.isBanned;
+
+ // Reset per-target state whenever a new user is picked.
+ const userId = user?.id;
+ useEffect(() => {
+ if (userId) setBannedUntil(dayjs().format("YYYY-MM-DD"));
+ }, [userId]);
+
+ const mutation = useMutation({
+ mutationFn: (input: { userId: string; bannedUntil: string | null }) => moderateUser(input),
+ onSuccess: (_, input) => {
+ const banned = input.bannedUntil !== null;
+ toast.success(banned ? "User banned" : "User unbanned");
+ onModerated(input.userId, banned);
+ onClose();
+ },
+ });
+
+ function onConfirm() {
+ if (!user) return;
+ mutation.mutate({
+ userId: user.id,
+ bannedUntil: isBan ? dayjs(bannedUntil).toISOString() : null,
+ });
+ }
+
+ return (
+ !open && onClose()}>
+
+
+ {isBan ? "Ban user" : "Unban user"}
+
+ {isBan
+ ? `${user?.userName ?? "This user"} will be banned until the selected date.`
+ : `${user?.userName ?? "This user"} will regain access immediately.`}
+
+
+
+ {isBan && (
+
+ Banned until
+ setBannedUntil(e.target.value)}
+ />
+
+ )}
+
+ {mutation.isError && (
+
+ {errorMessages(mutation.error).map((line) => (
+
{line}
+ ))}
+
+ )}
+
+
+
+ Cancel
+
+
+ {mutation.isPending ? "Saving…" : isBan ? "Ban" : "Unban"}
+
+
+
+
+ );
+}
diff --git a/web/src/features/admin/users-page.tsx b/web/src/features/admin/users-page.tsx
new file mode 100644
index 0000000..ec162ee
--- /dev/null
+++ b/web/src/features/admin/users-page.tsx
@@ -0,0 +1,234 @@
+import {
+ useInfiniteQuery,
+ useQueryClient,
+ type InfiniteData,
+} from "@tanstack/react-query";
+import { Search } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useSearchParams } from "react-router";
+import { getUsers } from "@/api/admin";
+import type { AccountDetails, AdminUsersResponse } from "@/api/types";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { ageFrom } from "@/lib/dates";
+import { errorMessages } from "@/lib/errors";
+import ModerateDialog from "./moderate-dialog";
+
+export default function UsersPage() {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [showBanned, setShowBanned] = useState(searchParams.get("banned") === "1");
+ const [searchInput, setSearchInput] = useState("");
+ const [search, setSearch] = useState("");
+ const [moderateTarget, setModerateTarget] = useState(null);
+ const queryClient = useQueryClient();
+
+ // Debounce the free-text search into the query key.
+ useEffect(() => {
+ const handle = setTimeout(() => setSearch(searchInput.trim()), 300);
+ return () => clearTimeout(handle);
+ }, [searchInput]);
+
+ const query = useInfiniteQuery({
+ queryKey: ["admin", "users", search, showBanned],
+ queryFn: ({ pageParam }) =>
+ getUsers({ page: pageParam, search: search || undefined, showBanned }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) => {
+ const loaded = allPages.reduce((count, page) => count + page.users.length, 0);
+ return loaded < lastPage.totalUsers ? allPages.length + 1 : undefined;
+ },
+ });
+
+ const users = query.data?.pages.flatMap((page) => page.users) ?? [];
+
+ function onTabChange(value: string) {
+ const banned = value === "banned";
+ setShowBanned(banned);
+ setSearchParams(banned ? { banned: "1" } : {}, { replace: true });
+ }
+
+ function onModerated(userId: string, isBanned: boolean) {
+ // Patch the row in every cached filter combination.
+ queryClient.setQueriesData>(
+ { queryKey: ["admin", "users"] },
+ (old) =>
+ old && {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ users: page.users.map((u) => (u.id === userId ? { ...u, isBanned } : u)),
+ })),
+ },
+ );
+ }
+
+ return (
+
+
+
+
Users
+
+ Search, review, and moderate the community.
+
+
+
+
+
+ setSearchInput(e.target.value)}
+ />
+
+
+
+ All
+ Banned
+
+
+
+
+
+ {query.isError && (
+
+ {errorMessages(query.error).map((line) => (
+
{line}
+ ))}
+
+ )}
+
+
+
+
+
+ User
+ Email
+ Location
+ Age
+ Status
+
+
+
+
+ {query.isPending && }
+
+ {!query.isPending && users.length === 0 && !query.isError && (
+
+
+ No users found.
+
+
+ )}
+
+ {users.map((user) => (
+ setModerateTarget(user)} />
+ ))}
+
+
+
+
+ {query.hasNextPage && (
+
+ void query.fetchNextPage()}
+ disabled={query.isFetchingNextPage}
+ >
+ {query.isFetchingNextPage ? "Loading…" : "Load more"}
+
+
+ )}
+
+
setModerateTarget(null)}
+ onModerated={onModerated}
+ />
+
+ );
+}
+
+function UserRow({ user, onModerate }: { user: AccountDetails; onModerate: () => void }) {
+ const profilePicture =
+ user.images.find((image) => image.isProfilePicture) ?? user.images[0];
+ const location =
+ [user.cityName, user.countryName].filter(Boolean).join(", ") || "—";
+
+ return (
+
+
+
+
+
+ {user.userName[0]?.toUpperCase()}
+
+
{user.userName}
+
+
+ {user.email}
+ {location}
+ {ageFrom(user.birthdate)}
+
+ {user.isBanned ? (
+ Banned
+ ) : (
+ Active
+ )}
+
+
+
+ {user.isBanned ? "Unban" : "Ban"}
+
+
+
+ );
+}
+
+function SkeletonRows() {
+ return (
+ <>
+ {Array.from({ length: 5 }, (_, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+}
diff --git a/web/src/features/auth/auth-card.tsx b/web/src/features/auth/auth-card.tsx
new file mode 100644
index 0000000..a159d9a
--- /dev/null
+++ b/web/src/features/auth/auth-card.tsx
@@ -0,0 +1,35 @@
+import { Heart } from "lucide-react";
+import type { ReactNode } from "react";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+
+interface AuthCardProps {
+ title: string;
+ subtitle: string;
+ /** Width override, e.g. "max-w-md" for the wider register wizard. */
+ widthClass?: string;
+ children: ReactNode;
+}
+
+/** Centered auth page shell matching the login page's look. */
+export default function AuthCard({
+ title,
+ subtitle,
+ widthClass = "max-w-sm",
+ children,
+}: AuthCardProps) {
+ return (
+
+
+
+
+
+
+ {title}
+ {subtitle}
+
+ {children}
+
+
+ );
+}
diff --git a/web/src/features/auth/login-page.tsx b/web/src/features/auth/login-page.tsx
new file mode 100644
index 0000000..a464461
--- /dev/null
+++ b/web/src/features/auth/login-page.tsx
@@ -0,0 +1,122 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation } from "@tanstack/react-query";
+import { Heart } from "lucide-react";
+import { useForm } from "react-hook-form";
+import { Link, useNavigate } from "react-router";
+import { z } from "zod";
+import { login } from "@/api/identity";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { validation } from "@/lib/constants";
+import { errorMessages } from "@/lib/errors";
+import { useAuthStore } from "@/stores/auth";
+
+const schema = z.object({
+ email: z.email("Invalid email"),
+ password: z.string().min(validation.PASSWORD_MIN_LENGTH, "Password is too short"),
+});
+
+type FormValues = z.infer;
+
+export default function LoginPage() {
+ const storeLogin = useAuthStore((s) => s.login);
+ const navigate = useNavigate();
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: { email: "", password: "" },
+ });
+
+ const mutation = useMutation({
+ mutationFn: (values: FormValues) => login(values.email, values.password),
+ onSuccess: (data) => {
+ storeLogin(data);
+ void navigate(data.isAdmin ? "/admin" : "/", { replace: true });
+ },
+ });
+
+ return (
+
+
+
+
+
+
+ Welcome back
+ Log in to keep the sparks flying
+
+
+
+
+
+
+
+ Forgot your password?{" "}
+
+ Reset it
+
+
+
+ New here?{" "}
+
+ Create an account
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/features/auth/photo-picker.tsx b/web/src/features/auth/photo-picker.tsx
new file mode 100644
index 0000000..0b11d1b
--- /dev/null
+++ b/web/src/features/auth/photo-picker.tsx
@@ -0,0 +1,106 @@
+import { ImagePlus, Star, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface PhotoItem {
+ id: string;
+ file: File;
+ /** Object URL for the thumbnail preview (owner revokes it). */
+ url: string;
+}
+
+interface PhotoPickerProps {
+ photos: PhotoItem[];
+ /** Photo currently marked as the profile picture. */
+ profileId: string | null;
+ onAdd: (files: File[]) => void;
+ onRemove: (id: string) => void;
+ onChooseProfile: (id: string) => void;
+}
+
+/** Multi-photo upload with previews, a star to pick the profile picture, and remove buttons. */
+export default function PhotoPicker({
+ photos,
+ profileId,
+ onAdd,
+ onRemove,
+ onChooseProfile,
+}: PhotoPickerProps) {
+ return (
+
+
+
+ Add photos
+ JPG or PNG — you can pick several
+ {
+ const files = event.target.files;
+ if (files && files.length > 0) onAdd(Array.from(files));
+ event.target.value = "";
+ }}
+ />
+
+
+ {photos.length > 0 && (
+ <>
+
+ {photos.map((photo) => {
+ const isProfile = photo.id === profileId;
+ return (
+
+
+
onChooseProfile(photo.id)}
+ className={cn(
+ "absolute top-1 left-1 grid size-6 place-items-center rounded-full",
+ "bg-black/50 transition-colors hover:bg-black/70",
+ isProfile ? "text-amber-400" : "text-white",
+ )}
+ >
+
+
+
onRemove(photo.id)}
+ className={cn(
+ "absolute top-1 right-1 grid size-6 place-items-center rounded-full",
+ "bg-black/50 text-white transition-colors hover:bg-black/70",
+ )}
+ >
+
+
+
+ );
+ })}
+
+
+ The starred photo becomes your profile picture.
+
+ >
+ )}
+
+ );
+}
diff --git a/web/src/features/auth/register-page.tsx b/web/src/features/auth/register-page.tsx
new file mode 100644
index 0000000..1a0ca5f
--- /dev/null
+++ b/web/src/features/auth/register-page.tsx
@@ -0,0 +1,539 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import { useEffect, useRef, useState } from "react";
+import { useForm, type FieldPath } from "react-hook-form";
+import { Link, useNavigate } from "react-router";
+import { z } from "zod";
+import { getCitiesByCountry, getCountries, getGenders } from "@/api/geo";
+import { register } from "@/api/identity";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Textarea } from "@/components/ui/textarea";
+import AuthCard from "@/features/auth/auth-card";
+import PhotoPicker, { type PhotoItem } from "@/features/auth/photo-picker";
+import StepIndicator from "@/features/auth/step-indicator";
+import TermsDialog from "@/features/auth/terms-dialog";
+import { validation } from "@/lib/constants";
+import { ageFrom, latestLegalBirthdate } from "@/lib/dates";
+import { errorMessages } from "@/lib/errors";
+import { cn } from "@/lib/utils";
+
+const schema = z
+ .object({
+ email: z.email("Invalid email"),
+ userName: z
+ .string()
+ .min(1, "Username is required")
+ .max(validation.USERNAME_MAX_LENGTH, "Username is too long"),
+ password: z.string().min(validation.PASSWORD_MIN_LENGTH, "Password is too short"),
+ confirmPassword: z.string().min(1, "Confirm your password"),
+ birthdate: z
+ .string()
+ .min(1, "Birthdate is required")
+ .refine(
+ (value) => ageFrom(value) >= validation.MINIMAL_AGE,
+ `You must be at least ${validation.MINIMAL_AGE} years old`,
+ ),
+ genderId: z.string().min(1, "Pick a gender"),
+ countryId: z.string().refine((value) => Number(value) > 0, "Pick a country"),
+ cityId: z.string().refine((value) => Number(value) > 0, "Pick a city"),
+ bio: z
+ .string()
+ .min(1, "Tell us a bit about yourself")
+ .max(validation.BIO_MAX_LENGTH, "Bio is too long"),
+ })
+ .refine((values) => values.password === values.confirmPassword, {
+ message: "Passwords do not match",
+ path: ["confirmPassword"],
+ });
+
+type FormValues = z.infer;
+
+const STEP_LABELS = ["Account", "About you", "Photos & finish"] as const;
+
+const STEP_FIELDS: FieldPath[][] = [
+ ["email", "userName", "password", "confirmPassword", "birthdate"],
+ ["genderId", "countryId", "cityId", "bio"],
+ [],
+];
+
+const stepVariants = {
+ enter: (direction: number) => ({ opacity: 0, x: direction * 32 }),
+ center: { opacity: 1, x: 0 },
+ exit: (direction: number) => ({ opacity: 0, x: direction * -32 }),
+};
+
+export default function RegisterPage() {
+ const navigate = useNavigate();
+ const [step, setStep] = useState(0);
+ const [direction, setDirection] = useState(1);
+ const [photos, setPhotos] = useState([]);
+ const [profileId, setProfileId] = useState(null);
+ const [agreed, setAgreed] = useState(false);
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ email: "",
+ userName: "",
+ password: "",
+ confirmPassword: "",
+ birthdate: "",
+ genderId: "",
+ countryId: "",
+ cityId: "",
+ bio: "",
+ },
+ });
+
+ const gendersQuery = useQuery({ queryKey: ["genders"], queryFn: getGenders });
+ const countriesQuery = useQuery({ queryKey: ["countries"], queryFn: getCountries });
+
+ const selectedCountryId = Number(form.watch("countryId") || "0");
+ const citiesQuery = useQuery({
+ queryKey: ["cities", selectedCountryId],
+ queryFn: () => getCitiesByCountry(selectedCountryId),
+ enabled: selectedCountryId > 0,
+ });
+
+ const bioLength = form.watch("bio").length;
+
+ // Falls back to the first photo so a lone upload is the profile picture.
+ const effectiveProfileId = profileId ?? photos[0]?.id ?? null;
+
+ // Revoke preview object URLs when the page unmounts.
+ const photosRef = useRef(photos);
+ photosRef.current = photos;
+ useEffect(
+ () => () => {
+ for (const photo of photosRef.current) URL.revokeObjectURL(photo.url);
+ },
+ [],
+ );
+
+ const addPhotos = (files: File[]) => {
+ setPhotos((previous) => {
+ // The register API matches the profile picture by file name, so names must be unique.
+ const existingNames = new Set(previous.map((photo) => photo.file.name));
+ const added = files
+ .filter((file) => !existingNames.has(file.name))
+ .map((file) => ({
+ id: crypto.randomUUID(),
+ file,
+ url: URL.createObjectURL(file),
+ }));
+ return [...previous, ...added];
+ });
+ };
+
+ const removePhoto = (id: string) => {
+ setPhotos((previous) => {
+ const removed = previous.find((photo) => photo.id === id);
+ if (removed) URL.revokeObjectURL(removed.url);
+ return previous.filter((photo) => photo.id !== id);
+ });
+ setProfileId((previous) => (previous === id ? null : previous));
+ };
+
+ const mutation = useMutation({
+ mutationFn: (values: FormValues) => {
+ const profile = photos.find((photo) => photo.id === effectiveProfileId);
+ return register({
+ email: values.email,
+ password: values.password,
+ confirmPassword: values.confirmPassword,
+ userName: values.userName,
+ bio: values.bio,
+ birthdate: values.birthdate,
+ countryId: Number(values.countryId),
+ genderId: Number(values.genderId),
+ cityId: Number(values.cityId),
+ image: profile?.file ?? null,
+ newImages: photos.map((photo) => photo.file),
+ });
+ },
+ onSuccess: (_data, values) => {
+ void navigate(`/verify?email=${encodeURIComponent(values.email)}`);
+ },
+ });
+
+ const isLastStep = step === STEP_LABELS.length - 1;
+
+ const goNext = async () => {
+ const valid = await form.trigger(STEP_FIELDS[step], { shouldFocus: true });
+ if (!valid) return;
+ setDirection(1);
+ setStep((current) => current + 1);
+ };
+
+ const goBack = () => {
+ setDirection(-1);
+ setStep((current) => current - 1);
+ };
+
+ const genders = gendersQuery.data ?? [];
+ // Index 0 of countries/cities is a "choose here" placeholder row (id 0).
+ const countries = (countriesQuery.data ?? []).filter((country) => country.countryId !== 0);
+ const cities = (citiesQuery.data?.cities ?? []).filter((city) => city.cityId !== 0);
+
+ return (
+
+
+
+
+
+
+
+
+ Already have an account?{" "}
+
+ Log in
+
+
+
+
+ );
+}
diff --git a/web/src/features/auth/reset-page.tsx b/web/src/features/auth/reset-page.tsx
new file mode 100644
index 0000000..130d91e
--- /dev/null
+++ b/web/src/features/auth/reset-page.tsx
@@ -0,0 +1,219 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation } from "@tanstack/react-query";
+import { BadgeCheck, MailCheck } from "lucide-react";
+import { useForm } from "react-hook-form";
+import { Link, useSearchParams } from "react-router";
+import { z } from "zod";
+import { resetPassword, sendResetPasswordLink } from "@/api/identity";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import AuthCard from "@/features/auth/auth-card";
+import { validation } from "@/lib/constants";
+import { errorMessages } from "@/lib/errors";
+
+// Dual mode like the old app: without a token we email a reset link,
+// with a token (+ email) we set the new password.
+export default function ResetPage() {
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get("token");
+ const email = searchParams.get("email") ?? "";
+
+ return token ? : ;
+}
+
+const requestSchema = z.object({
+ email: z.email("Invalid email"),
+});
+
+type RequestValues = z.infer;
+
+function RequestLinkForm() {
+ const form = useForm({
+ resolver: zodResolver(requestSchema),
+ defaultValues: { email: "" },
+ });
+
+ const mutation = useMutation({
+ mutationFn: (values: RequestValues) => sendResetPasswordLink(values.email),
+ });
+
+ if (mutation.isSuccess) {
+ return (
+
+
+
+
+ We sent a password reset link to{" "}
+
+ {form.getValues("email")}
+
+ . Follow it to choose a new password.
+
+
mutation.reset()}
+ >
+ Use a different email
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ mutation.mutate(values))}
+ >
+ (
+
+ Email
+
+
+
+
+
+ )}
+ />
+
+ {mutation.isError && }
+
+
+ {mutation.isPending ? "Sending…" : "Send reset link"}
+
+
+
+
+
+
+
+ );
+}
+
+const passwordSchema = z
+ .object({
+ password: z.string().min(validation.PASSWORD_MIN_LENGTH, "Password is too short"),
+ confirmPassword: z.string().min(1, "Confirm your password"),
+ })
+ .refine((values) => values.password === values.confirmPassword, {
+ message: "Passwords do not match",
+ path: ["confirmPassword"],
+ });
+
+type PasswordValues = z.infer;
+
+function NewPasswordForm({ token, email }: { token: string; email: string }) {
+ const form = useForm({
+ resolver: zodResolver(passwordSchema),
+ defaultValues: { password: "", confirmPassword: "" },
+ });
+
+ const mutation = useMutation({
+ mutationFn: (values: PasswordValues) =>
+ resetPassword({
+ token,
+ email,
+ password: values.password,
+ confirmPassword: values.confirmPassword,
+ }),
+ });
+
+ if (mutation.isSuccess) {
+ return (
+
+
+
+
+ {mutation.data || "Your password was reset — log in with the new one."}
+
+
+ Log in
+
+
+
+ );
+ }
+
+ return (
+
+
+ mutation.mutate(values))}
+ >
+ (
+
+ New password
+
+
+
+
+
+ )}
+ />
+ (
+
+ Confirm password
+
+
+
+
+
+ )}
+ />
+
+ {mutation.isError && }
+
+
+ {mutation.isPending ? "Resetting…" : "Reset password"}
+
+
+
+
+
+
+
+ );
+}
+
+function ErrorLines({ error }: { error: unknown }) {
+ return (
+
+ {errorMessages(error).map((line) => (
+
{line}
+ ))}
+
+ );
+}
+
+function BackToLogin() {
+ return (
+
+ Remembered it?{" "}
+
+ Log in
+
+
+ );
+}
diff --git a/web/src/features/auth/step-indicator.tsx b/web/src/features/auth/step-indicator.tsx
new file mode 100644
index 0000000..3c89518
--- /dev/null
+++ b/web/src/features/auth/step-indicator.tsx
@@ -0,0 +1,30 @@
+import { cn } from "@/lib/utils";
+
+interface StepIndicatorProps {
+ /** Zero-based current step. */
+ step: number;
+ labels: readonly string[];
+}
+
+/** Segmented wizard progress: one bar per step plus a "Step x of y" caption. */
+export default function StepIndicator({ step, labels }: StepIndicatorProps) {
+ return (
+
+
+ {labels.map((label, index) => (
+
+ ))}
+
+
+ Step {step + 1} of {labels.length} —{" "}
+ {labels[step]}
+
+
+ );
+}
diff --git a/web/src/features/auth/terms-dialog.tsx b/web/src/features/auth/terms-dialog.tsx
new file mode 100644
index 0000000..24b41fa
--- /dev/null
+++ b/web/src/features/auth/terms-dialog.tsx
@@ -0,0 +1,84 @@
+import type { ReactNode } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+/** Condensed version of the old app's Terms & Conditions modal text. */
+export default function TermsDialog({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+
+ Terms and conditions
+
+ The short version of the rules for using LOVE.NET.
+
+
+
+
+
+ Welcome to LOVE.NET! By creating an account or continuing to use the
+ site you accept these terms and conditions; if you do not agree with
+ them, please do not use LOVE.NET.
+
+
+ Cookies
+
+ We use cookies to keep parts of the site working (such as staying
+ logged in), in line with our privacy policy.
+
+
+
+ License
+
+ LOVE.NET and its licensors own the material on the site. You may use
+ it for personal purposes only — you must not republish, sell, rent,
+ reproduce, or redistribute it.
+
+
+
+ Your content
+
+ You are responsible for anything you post. It must be yours to
+ share, must not infringe anyone's rights, and must not be
+ defamatory, offensive, unlawful, or used to promote business or
+ unlawful activity. We may monitor and remove content that breaches
+ these terms, and by posting you grant LOVE.NET a non-exclusive
+ license to use, reproduce, and edit it.
+
+
+
+ Linking and framing
+
+ Approved organizations may link to our site as long as the link is
+ not deceptive and does not falsely imply endorsement. You may not
+ frame our pages or use our logo without written permission, and we
+ may ask for links to be removed at any time.
+
+
+
+ Disclaimer
+
+ The service is provided free of charge and as-is. To the maximum
+ extent permitted by law we exclude all warranties and are not liable
+ for any loss or damage arising from its use — except for liability
+ that cannot be excluded under applicable law. We do not guarantee
+ the information on the site is complete, accurate, or always
+ available.
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/features/auth/verify-page.tsx b/web/src/features/auth/verify-page.tsx
new file mode 100644
index 0000000..95da3e1
--- /dev/null
+++ b/web/src/features/auth/verify-page.tsx
@@ -0,0 +1,101 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { BadgeCheck, Loader2, MailCheck } from "lucide-react";
+import { Link, useSearchParams } from "react-router";
+import { resendVerificationEmail, verifyEmail } from "@/api/identity";
+import { Button } from "@/components/ui/button";
+import AuthCard from "@/features/auth/auth-card";
+import { errorMessages } from "@/lib/errors";
+
+export default function VerifyPage() {
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get("token");
+ const email = searchParams.get("email") ?? "";
+
+ const verifyQuery = useQuery({
+ queryKey: ["verify-email", token, email],
+ queryFn: () => verifyEmail(token ?? "", email),
+ enabled: token !== null,
+ retry: false,
+ });
+
+ const resend = useMutation({ mutationFn: () => resendVerificationEmail(email) });
+
+ const resendBlock = (
+
+ {resend.isError && (
+
+ {errorMessages(resend.error).map((line) => (
+
{line}
+ ))}
+
+ )}
+ {resend.isSuccess &&
{resend.data}
}
+
resend.mutate()}
+ >
+ {resend.isPending ? "Sending…" : "Resend email"}
+
+
+ );
+
+ let content;
+ if (token !== null && verifyQuery.isPending) {
+ content = (
+ <>
+
+ Confirming your email…
+ >
+ );
+ } else if (token !== null && verifyQuery.isSuccess) {
+ content = (
+ <>
+
+ Email confirmed — you can now login.
+
+ Log in
+
+ >
+ );
+ } else if (token !== null) {
+ content = (
+ <>
+
+ {errorMessages(verifyQuery.error).map((line) => (
+
{line}
+ ))}
+
+ {resendBlock}
+ >
+ );
+ } else {
+ content = (
+ <>
+
+ Check your inbox
+
+ We sent a verification link to{" "}
+ {email || "your email address"}
+ . Click it to activate your account.
+
+ {resendBlock}
+ >
+ );
+ }
+
+ return (
+
+
+ {content}
+
+ Already verified?{" "}
+
+ Log in
+
+
+
+
+ );
+}
diff --git a/web/src/features/discover/discover-page.tsx b/web/src/features/discover/discover-page.tsx
new file mode 100644
index 0000000..206ed7d
--- /dev/null
+++ b/web/src/features/discover/discover-page.tsx
@@ -0,0 +1,142 @@
+import { useMemo, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { HeartOff, RotateCw } from "lucide-react";
+import { AnimatePresence } from "motion/react";
+import { Navigate } from "react-router";
+import { getUsersToSwipe, likeUser } from "@/api/dating";
+import type { UserCard } from "@/api/types";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { distanceInKms } from "@/lib/distance";
+import MatchOverlay from "@/features/discover/match-overlay";
+import PreferencesSheet from "@/features/discover/preferences-sheet";
+import SwipeDeck, { type SwipeDirection } from "@/features/discover/swipe-deck";
+import { useAuthStore } from "@/stores/auth";
+import { usePreferencesStore } from "@/stores/preferences";
+
+export default function DiscoverPage() {
+ const user = useAuthStore((s) => s.user);
+ const location = useAuthStore((s) => s.location);
+ const maxAge = usePreferencesStore((s) => s.maxAge);
+ const maxDistance = usePreferencesStore((s) => s.maxDistance);
+ const aroundTheWorld = usePreferencesStore((s) => s.aroundTheWorld);
+ const gender = usePreferencesStore((s) => s.gender);
+
+ const queryClient = useQueryClient();
+ const deckQuery = useQuery({
+ queryKey: ["deck"],
+ queryFn: getUsersToSwipe,
+ enabled: !user?.isAdmin,
+ });
+
+ // Swiped cards disappear locally without refetching the deck.
+ const [removedIds, setRemovedIds] = useState>(new Set());
+ const [match, setMatch] = useState(null);
+
+ const likeMutation = useMutation({
+ mutationFn: likeUser,
+ onSuccess: (response) => {
+ if (response.isMatch && response.user) setMatch(response.user);
+ },
+ });
+
+ const cards = useMemo(
+ () =>
+ (deckQuery.data ?? []).filter((candidate) => {
+ if (removedIds.has(candidate.id)) return false;
+ if (candidate.age > maxAge) return false;
+ if (gender !== -1 && candidate.genderId !== gender) return false;
+ if (!aroundTheWorld && location) {
+ const km = distanceInKms(
+ location.latitude,
+ location.longitude,
+ candidate.latitude,
+ candidate.longitude,
+ );
+ return km <= maxDistance;
+ }
+ return true;
+ }),
+ [deckQuery.data, removedIds, maxAge, gender, aroundTheWorld, location, maxDistance],
+ );
+
+ if (user?.isAdmin) {
+ return ;
+ }
+
+ function handleSwipe(card: UserCard, direction: SwipeDirection) {
+ setRemovedIds((prev) => new Set(prev).add(card.id));
+ if (direction === "right") likeMutation.mutate(card.id);
+ }
+
+ function refreshDeck() {
+ setRemovedIds(new Set());
+ void queryClient.invalidateQueries({ queryKey: ["deck"] });
+ }
+
+ return (
+
+
+
+
Discover
+
Find your next spark
+
+
+
+
+
+ {deckQuery.isPending ? (
+
+ ) : cards.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {match && setMatch(null)} />}
+
+
+ );
+}
+
+function DeckSkeleton() {
+ return (
+
+ );
+}
+
+function EmptyDeck({ onRefresh, refreshing }: { onRefresh: () => void; refreshing: boolean }) {
+ return (
+
+
+
+
+
+
+
No more profiles
+
+ You have seen everyone nearby — check back later.
+
+
+
+
+ Refresh
+
+
+
+ );
+}
diff --git a/web/src/features/discover/match-overlay.tsx b/web/src/features/discover/match-overlay.tsx
new file mode 100644
index 0000000..e2e28b1
--- /dev/null
+++ b/web/src/features/discover/match-overlay.tsx
@@ -0,0 +1,77 @@
+import { Heart } from "lucide-react";
+import { motion } from "motion/react";
+import { useNavigate } from "react-router";
+import type { UserCard } from "@/api/types";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { useAuthStore } from "@/stores/auth";
+
+interface MatchOverlayProps {
+ user: UserCard;
+ onDismiss: () => void;
+}
+
+/** Full-screen celebration shown when a right-swipe comes back as a mutual match. */
+export default function MatchOverlay({ user, onDismiss }: MatchOverlayProps) {
+ const me = useAuthStore((s) => s.user);
+ const navigate = useNavigate();
+ const theirPhoto = user.images.find((image) => image.isProfilePicture) ?? user.images[0];
+
+ return (
+
+
+
+
+ It's a match!
+
+
You and {user.userName} liked each other.
+
+
+
+
+
+
+ {me?.userName?.[0]?.toUpperCase()}
+
+
+
+
+ {user.userName[0]?.toUpperCase()}
+
+
+
+
+
+
+
+ void navigate(user.roomId ? `/messages/${user.roomId}` : "/messages")}
+ >
+ Send a message
+
+
+ Keep swiping
+
+
+
+
+ );
+}
diff --git a/web/src/features/discover/preferences-sheet.tsx b/web/src/features/discover/preferences-sheet.tsx
new file mode 100644
index 0000000..84dd3b2
--- /dev/null
+++ b/web/src/features/discover/preferences-sheet.tsx
@@ -0,0 +1,105 @@
+import { useQuery } from "@tanstack/react-query";
+import { SlidersHorizontal } from "lucide-react";
+import { getGenders } from "@/api/geo";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Separator } from "@/components/ui/separator";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { Slider } from "@/components/ui/slider";
+import { usePreferencesStore } from "@/stores/preferences";
+
+/** Filter controls writing straight to the persisted preferences store (live filtering). */
+export default function PreferencesSheet() {
+ const { maxAge, maxDistance, aroundTheWorld, gender, set } = usePreferencesStore();
+ const gendersQuery = useQuery({ queryKey: ["genders"], queryFn: getGenders });
+ const genderOptions = [{ id: -1, name: "All" }, ...(gendersQuery.data ?? [])];
+
+ return (
+
+
+
+
+
+
+
+
+ Preferences
+ Filters apply to your deck instantly.
+
+
+
+
+
+ Age
+ up to {maxAge}
+
+ set({ maxAge: value })}
+ />
+
+
+
+
+
+ Distance
+
+ set({ aroundTheWorld: checked === true })}
+ />
+
+ All over the world
+
+
+
+ Max distance
+ {maxDistance} km
+
+ set({ maxDistance: value })}
+ aria-label="Max distance"
+ />
+
+
+
+
+
+ Interested in
+ set({ gender: Number(value) })}
+ >
+ {genderOptions.map((option) => (
+
+
+
+ {option.name}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/web/src/features/discover/swipe-card.tsx b/web/src/features/discover/swipe-card.tsx
new file mode 100644
index 0000000..e837301
--- /dev/null
+++ b/web/src/features/discover/swipe-card.tsx
@@ -0,0 +1,115 @@
+import { useMemo, useState } from "react";
+import { MapPin } from "lucide-react";
+import { motion, type MotionValue } from "motion/react";
+import type { UserCard } from "@/api/types";
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+interface SwipeCardProps {
+ card: UserCard;
+ /** Kilometers from the logged-in user, or null when their location is unknown. */
+ distanceKm: number | null;
+ /** Stamp opacities driven by the drag x motion value; only passed for the top card. */
+ likeOpacity?: MotionValue;
+ nopeOpacity?: MotionValue;
+}
+
+/** Presentational swipe card: full-bleed photo carousel, info overlay, LIKE/NOPE stamps. */
+export default function SwipeCard({ card, distanceKm, likeOpacity, nopeOpacity }: SwipeCardProps) {
+ const photos = useMemo(
+ () => [...card.images].sort((a, b) => Number(b.isProfilePicture) - Number(a.isProfilePicture)),
+ [card.images],
+ );
+ const [photoIndex, setPhotoIndex] = useState(0);
+ const photo = photos[Math.min(photoIndex, photos.length - 1)];
+
+ const caption = [
+ [card.cityName, card.countryName].filter(Boolean).join(", "),
+ card.genderName,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+
+ return (
+
+ {photo ? (
+
+ ) : (
+
+
+ {card.userName[0]?.toUpperCase()}
+
+
+ )}
+
+ {/* Tap left/right halves to step through photos. */}
+ {photos.length > 1 && (
+ <>
+
setPhotoIndex((i) => Math.max(0, i - 1))}
+ />
+ setPhotoIndex((i) => Math.min(photos.length - 1, i + 1))}
+ />
+
+ {photos.map((p, i) => (
+
+ ))}
+
+ >
+ )}
+
+ {likeOpacity && (
+
+ LIKE
+
+ )}
+ {nopeOpacity && (
+
+ NOPE
+
+ )}
+
+
+
+
+ {card.userName}
+ {card.age}
+
+ {distanceKm !== null && (
+
+ {distanceKm} km away
+
+ )}
+
+ {card.bio &&
{card.bio}
}
+ {caption &&
{caption}
}
+
+
+ );
+}
diff --git a/web/src/features/discover/swipe-deck.tsx b/web/src/features/discover/swipe-deck.tsx
new file mode 100644
index 0000000..dea6fb4
--- /dev/null
+++ b/web/src/features/discover/swipe-deck.tsx
@@ -0,0 +1,201 @@
+import { useCallback, useEffect, useRef } from "react";
+import { Heart, X } from "lucide-react";
+import {
+ AnimatePresence,
+ animate,
+ motion,
+ useMotionValue,
+ useTransform,
+ type PanInfo,
+} from "motion/react";
+import type { UserCard } from "@/api/types";
+import { Button } from "@/components/ui/button";
+import { distanceInKms } from "@/lib/distance";
+import { cn } from "@/lib/utils";
+import SwipeCard from "@/features/discover/swipe-card";
+import type { GeoLocation } from "@/stores/auth";
+
+export type SwipeDirection = "left" | "right";
+
+const VISIBLE_CARDS = 3;
+const SWIPE_OFFSET_THRESHOLD = 120;
+const SWIPE_VELOCITY_THRESHOLD = 500;
+const PROGRAMMATIC_FLING_VELOCITY = 900;
+
+interface SwipeDeckProps {
+ cards: UserCard[];
+ myLocation: GeoLocation | null;
+ /** Disables the action buttons while a like request is in flight. */
+ likePending: boolean;
+ onSwipe: (card: UserCard, direction: SwipeDirection) => void;
+}
+
+/** Stacked deck: top card is draggable, the next two peek from behind; action bar below. */
+export default function SwipeDeck({ cards, myLocation, likePending, onSwipe }: SwipeDeckProps) {
+ // Ownership-checked registration instead of a plain shared ref: the old top
+ // card unmounts AFTER its successor mounts (AnimatePresence exit), and a
+ // shared ref would be nulled by that late cleanup, killing the buttons.
+ const topCardRef = useRef(null);
+ const registerTopCard = useCallback((handle: DeckCardHandle) => {
+ topCardRef.current = handle;
+ return () => {
+ if (topCardRef.current === handle) {
+ topCardRef.current = null;
+ }
+ };
+ }, []);
+
+ const visible = cards.slice(0, VISIBLE_CARDS);
+ const actionsDisabled = likePending || visible.length === 0;
+
+ return (
+
+
+
+ {visible.map((card, index) => (
+
+ ))}
+
+
+
+
+ topCardRef.current?.swipe("left")}
+ >
+
+
+ topCardRef.current?.swipe("right")}
+ >
+
+
+
+
+ );
+}
+
+interface DeckCardHandle {
+ swipe: (direction: SwipeDirection) => void;
+}
+
+interface DeckCardProps {
+ card: UserCard;
+ index: number;
+ stackSize: number;
+ isTop: boolean;
+ distanceKm: number | null;
+ onCommit: (card: UserCard, direction: SwipeDirection) => void;
+ /** Present only for the top card; returns an unregister cleanup. */
+ registerTopCard?: (handle: DeckCardHandle) => () => void;
+}
+
+function DeckCard({
+ card,
+ index,
+ stackSize,
+ isTop,
+ distanceKm,
+ onCommit,
+ registerTopCard,
+}: DeckCardProps) {
+ const x = useMotionValue(0);
+ const rotate = useTransform(x, [-200, 200], [-18, 18]);
+ const likeOpacity = useTransform(x, [40, 140], [0, 1]);
+ // Same as the spec's [-40, -140] → [0, 1], written with an ascending input range.
+ const nopeOpacity = useTransform(x, [-140, -40], [1, 0]);
+
+ const committed = useRef(false);
+ const suppressClick = useRef(false);
+
+ const fling = useCallback(
+ (direction: 1 | -1, velocity: number = direction * PROGRAMMATIC_FLING_VELOCITY) => {
+ if (committed.current) return;
+ committed.current = true;
+ const target = direction * window.innerWidth;
+ void animate(x, target, {
+ type: "spring",
+ stiffness: 160,
+ damping: 26,
+ velocity,
+ restDelta: 1,
+ }).then(() => onCommit(card, direction > 0 ? "right" : "left"));
+ },
+ [card, onCommit, x],
+ );
+
+ useEffect(() => {
+ if (!registerTopCard) return;
+ return registerTopCard({ swipe: (dir) => fling(dir === "right" ? 1 : -1) });
+ }, [registerTopCard, fling]);
+
+ function handleDragEnd(_: unknown, info: PanInfo) {
+ // The click that follows a drag release would step the photo carousel; swallow it.
+ suppressClick.current = true;
+ requestAnimationFrame(() => {
+ suppressClick.current = false;
+ });
+
+ const { offset, velocity } = info;
+ const passedOffset = Math.abs(offset.x) > SWIPE_OFFSET_THRESHOLD;
+ if (passedOffset || Math.abs(velocity.x) > SWIPE_VELOCITY_THRESHOLD) {
+ const direction = (passedOffset ? offset.x : velocity.x) > 0 ? 1 : -1;
+ fling(direction, velocity.x);
+ }
+ // Otherwise the all-zero dragConstraints spring the card back automatically.
+ }
+
+ return (
+ {
+ if (suppressClick.current) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }}
+ >
+
+
+ );
+}
diff --git a/web/src/features/landing/landing-page.tsx b/web/src/features/landing/landing-page.tsx
new file mode 100644
index 0000000..faf696a
--- /dev/null
+++ b/web/src/features/landing/landing-page.tsx
@@ -0,0 +1,122 @@
+import { Bell, Flame, Heart, MessageCircle, Quote } from "lucide-react";
+import { motion } from "motion/react";
+import { Link } from "react-router";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+
+const features = [
+ {
+ icon: Flame,
+ title: "Swipe & match",
+ description:
+ "Discover people near you and around the world. Like who catches your eye — when it's mutual, it's a match.",
+ },
+ {
+ icon: MessageCircle,
+ title: "Chat in realtime",
+ description:
+ "Every match opens a private room. Messages, photos, and typing indicators arrive instantly.",
+ },
+ {
+ icon: Bell,
+ title: "Stay notified",
+ description:
+ "New match? New message? You'll know the moment it happens, on any device.",
+ },
+] as const;
+
+// Condensed from the classic LOVE.NET dating advice.
+const advices = [
+ "Be yourself — genuine beats impressive when building a real connection.",
+ "Communicate openly and honestly; trust is what turns dates into relationships.",
+ "Stay safe: meet in public and let a friend know where you're going.",
+ "Be patient — great relationships take time, so enjoy getting to know each other.",
+] as const;
+
+const fadeUp = {
+ initial: { opacity: 0, y: 16 },
+ animate: { opacity: 1, y: 0 },
+} as const;
+
+export default function LandingPage() {
+ return (
+
+
+ {/* Hero */}
+
+
+
+
+
+ LOVE.NET
+
+
+ Match with people around the world and chat in real time.
+
+
+
+ Create account
+
+
+ Log in
+
+
+
+
+ {/* Features */}
+
+ {features.map((feature, index) => (
+
+
+
+
+
+
+ {feature.title}
+ {feature.description}
+
+
+
+ ))}
+
+
+ {/* Dating advice strip */}
+
+
+ Dating advice
+
+
+ {advices.map((advice) => (
+
+ ))}
+
+
+
+
+
+ © {new Date().getFullYear()} LOVE.NET
+
+
+ );
+}
diff --git a/web/src/features/messages/composer.tsx b/web/src/features/messages/composer.tsx
new file mode 100644
index 0000000..cad38cb
--- /dev/null
+++ b/web/src/features/messages/composer.tsx
@@ -0,0 +1,217 @@
+import { useQueryClient, type InfiniteData } from "@tanstack/react-query";
+import { ImagePlus, Send, X } from "lucide-react";
+import { useEffect, useRef, useState, type ChangeEvent, type ClipboardEvent, type FormEvent } from "react";
+import { toast } from "sonner";
+import { uploadChatImage } from "@/api/chat";
+import type { ChatMessage, ChatPage } from "@/api/types";
+import {
+ Attachment,
+ AttachmentAction,
+ AttachmentActions,
+ AttachmentContent,
+ AttachmentDescription,
+ AttachmentMedia,
+ AttachmentTitle,
+} from "@/components/ui/attachment";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { useRealtime } from "@/realtime/connection";
+import { useAuthStore } from "@/stores/auth";
+
+const TYPING_THROTTLE_MS = 2_000;
+
+interface PendingImage {
+ file: File;
+ previewUrl: string;
+}
+
+/**
+ * Message composer: text + image (paste or attach). Sends are optimistic —
+ * a pending bubble goes into the ['messages', roomId] cache immediately and
+ * the RealtimeProvider swaps it for the hub echo (or we remove it on failure).
+ */
+export default function Composer({ roomId, onSent }: { roomId: string; onSent?: () => void }) {
+ const realtime = useRealtime();
+ const queryClient = useQueryClient();
+ const user = useAuthStore((s) => s.user);
+
+ const [text, setText] = useState("");
+ const [image, setImage] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const lastTypingAt = useRef(0);
+ const fileInputRef = useRef(null);
+
+ // Release the preview object URL whenever the image is replaced or cleared.
+ useEffect(() => {
+ return () => {
+ if (image) URL.revokeObjectURL(image.previewUrl);
+ };
+ }, [image]);
+
+ function onTextChange(e: ChangeEvent) {
+ setText(e.target.value);
+ const now = Date.now();
+ if (now - lastTypingAt.current >= TYPING_THROTTLE_MS) {
+ lastTypingAt.current = now;
+ realtime.typing(roomId);
+ }
+ }
+
+ function pickImage(file: File | null | undefined) {
+ if (!file || !file.type.startsWith("image/")) return;
+ setImage({ file, previewUrl: URL.createObjectURL(file) });
+ }
+
+ function onPaste(e: ClipboardEvent) {
+ const item = Array.from(e.clipboardData.items).find((i) => i.type.startsWith("image/"));
+ if (item) pickImage(item.getAsFile());
+ }
+
+ function appendOptimistic(body: { text?: string | null; imageUrl?: string | null }): ChatMessage {
+ const temp: ChatMessage = {
+ id: `temp-${crypto.randomUUID()}`,
+ roomId,
+ userId: user?.id ?? "",
+ text: body.text ?? null,
+ profilePicture: user?.profilePicture ?? null,
+ imageUrl: body.imageUrl ?? null,
+ createdOn: new Date().toISOString(),
+ pending: true,
+ };
+ queryClient.setQueryData>(["messages", roomId], (data) => {
+ if (!data || data.pages.length === 0) return data;
+ const [first, ...rest] = data.pages;
+ return {
+ ...data,
+ pages: [
+ { messages: [temp, ...first.messages], totalMessages: first.totalMessages + 1 },
+ ...rest,
+ ],
+ };
+ });
+ return temp;
+ }
+
+ function removeOptimistic(id: string) {
+ queryClient.setQueryData>(["messages", roomId], (data) => {
+ if (!data) return data;
+ return {
+ ...data,
+ pages: data.pages.map((page) =>
+ page.messages.some((m) => m.id === id)
+ ? {
+ messages: page.messages.filter((m) => m.id !== id),
+ totalMessages: page.totalMessages - 1,
+ }
+ : page,
+ ),
+ };
+ });
+ }
+
+ async function deliver(body: { text?: string | null; imageUrl?: string | null }) {
+ const temp = appendOptimistic(body);
+ onSent?.();
+ try {
+ await realtime.sendMessage({ roomId, ...body, profilePicture: user?.profilePicture ?? null });
+ } catch {
+ removeOptimistic(temp.id);
+ toast.error("Message failed to send. Try again.");
+ }
+ }
+
+ async function onSubmit(e: FormEvent) {
+ e.preventDefault();
+ if (busy) return;
+
+ const trimmed = text.trim();
+ const picked = image;
+ if (!trimmed && !picked) return;
+
+ setText("");
+ setBusy(true);
+ try {
+ // Text and image travel as two separate messages (old-app contract).
+ if (trimmed) await deliver({ text: trimmed });
+ if (picked) {
+ try {
+ const imageUrl = await uploadChatImage(picked.file);
+ setImage(null);
+ await deliver({ imageUrl });
+ } catch {
+ toast.error("Couldn't upload the image.");
+ }
+ }
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+ void onSubmit(e)} className="shrink-0 border-t p-3">
+ {image && (
+
+
+
+
+
+
+ {image.file.name || "Pasted image"}
+
+ {busy ? "Uploading…" : "Ready to send"}
+
+
+
+ setImage(null)}
+ disabled={busy}
+ >
+
+
+
+
+
+ )}
+
+ {
+ pickImage(e.target.files?.[0]);
+ e.target.value = "";
+ }}
+ />
+ fileInputRef.current?.click()}
+ >
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/features/messages/conversation-list.tsx b/web/src/features/messages/conversation-list.tsx
new file mode 100644
index 0000000..2320333
--- /dev/null
+++ b/web/src/features/messages/conversation-list.tsx
@@ -0,0 +1,261 @@
+import { Heart, Search, Users } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { NavLink } from "react-router";
+import type { Chatroom } from "@/api/types";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { Conversation } from "@/hooks/use-conversations";
+import { conversationTime } from "@/lib/dates";
+import { cn } from "@/lib/utils";
+import * as events from "@/realtime/events";
+import { useAuthStore } from "@/stores/auth";
+
+const TYPING_FLASH_MS = 3_000;
+
+interface ConversationListProps {
+ conversations: Conversation[];
+ chatrooms: Chatroom[];
+ search: string;
+ onSearchChange: (value: string) => void;
+ isLoading: boolean;
+ hasNextPage: boolean;
+ onLoadMore: () => void;
+}
+
+/** Left column: search, match conversations, and public "Communities" rooms. */
+export default function ConversationList({
+ conversations,
+ chatrooms,
+ search,
+ onSearchChange,
+ isLoading,
+ hasNextPage,
+ onLoadMore,
+}: ConversationListProps) {
+ const myId = useAuthStore((s) => s.user?.id);
+ const typingRooms = useTypingRooms(myId);
+ const rows = conversations.filter((c) => !!c.match.roomId);
+
+ return (
+
+
+
Messages
+
+
+ onSearchChange(e.target.value)}
+ placeholder="Search matches"
+ aria-label="Search matches"
+ className="rounded-full pl-9"
+ />
+
+
+
+
+ {isLoading ? (
+
+ ) : rows.length === 0 ? (
+
+
+ {search ? "No matches for that search." : "No conversations yet — go match with someone!"}
+
+ ) : (
+
+ {rows.map((conversation) => (
+
+
+
+ ))}
+
+ )}
+ {hasNextPage && !isLoading &&
}
+
+ {chatrooms.length > 0 && (
+ <>
+
+ Communities
+
+
+ {chatrooms.map((room) => (
+
+
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+/** Rooms with a live peerTyping signal in the last ~3s. */
+function useTypingRooms(myId: string | undefined): Record {
+ const [typingRooms, setTypingRooms] = useState>({});
+ const timers = useRef(new Map());
+
+ useEffect(() => {
+ const off = events.on("peerTyping", (payload) => {
+ if (payload.userId === myId) return;
+ setTypingRooms((prev) => (prev[payload.roomId] ? prev : { ...prev, [payload.roomId]: true }));
+
+ const existing = timers.current.get(payload.roomId);
+ if (existing) window.clearTimeout(existing);
+ timers.current.set(
+ payload.roomId,
+ window.setTimeout(() => {
+ timers.current.delete(payload.roomId);
+ setTypingRooms((prev) => {
+ const next = { ...prev };
+ delete next[payload.roomId];
+ return next;
+ });
+ }, TYPING_FLASH_MS),
+ );
+ });
+
+ const currentTimers = timers.current;
+ return () => {
+ off();
+ for (const timer of currentTimers.values()) window.clearTimeout(timer);
+ currentTimers.clear();
+ };
+ }, [myId]);
+
+ return typingRooms;
+}
+
+function MatchRow({
+ conversation,
+ typing,
+ myId,
+}: {
+ conversation: Conversation;
+ typing: boolean;
+ myId: string | undefined;
+}) {
+ const { match, summary } = conversation;
+ const picture = match.images.find((i) => i.isProfilePicture)?.url ?? match.images[0]?.url;
+ const unread = summary?.unreadCount ?? 0;
+
+ const mine = !!summary?.lastMessageSenderId && summary.lastMessageSenderId === myId;
+ const body = summary?.lastMessageText ?? (summary?.lastMessageImageUrl ? "📷 Photo" : null);
+ const preview = typing ? "typing…" : body ? (mine ? `You: ${body}` : body) : "You matched — say hi!";
+
+ return (
+
+
+
+ {match.userName[0]?.toUpperCase()}
+
+
+
+ {match.userName}
+ {summary?.lastMessageAt && (
+
+ {conversationTime(summary.lastMessageAt)}
+
+ )}
+
+
+ 0
+ ? "text-foreground font-medium"
+ : "text-muted-foreground",
+ )}
+ >
+ {preview}
+
+ {unread > 0 && (
+
+ {unread > 9 ? "9+" : unread}
+
+ )}
+
+
+
+ );
+}
+
+function ChatroomRow({ room, typing }: { room: Chatroom; typing: boolean }) {
+ return (
+
+
+
+
+
+
+
+
+ {room.title}
+
+ {typing ? "typing…" : "Public room"}
+
+
+
+ );
+}
+
+function rowClass({ isActive }: { isActive: boolean }) {
+ return cn(
+ "flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors",
+ isActive ? "bg-primary/10" : "hover:bg-accent",
+ );
+}
+
+function LoadMoreSentinel({ onLoadMore }: { onLoadMore: () => void }) {
+ const ref = useRef(null);
+ const onLoadMoreRef = useRef(onLoadMore);
+ onLoadMoreRef.current = onLoadMore;
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) onLoadMoreRef.current();
+ },
+ { rootMargin: "120px 0px" },
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ Loading more…
+
+ );
+}
+
+function RowSkeletons() {
+ return (
+
+ {Array.from({ length: 6 }, (_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/web/src/features/messages/members-panel.tsx b/web/src/features/messages/members-panel.tsx
new file mode 100644
index 0000000..743ee90
--- /dev/null
+++ b/web/src/features/messages/members-panel.tsx
@@ -0,0 +1,38 @@
+import type { UserInRoom } from "@/api/types";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { cn } from "@/lib/utils";
+
+/** Presence list for public chatrooms, fed by RefreshUsersList hub events. */
+export default function MembersPanel({
+ users,
+ className,
+}: {
+ users: UserInRoom[];
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/web/src/features/messages/messages-page.tsx b/web/src/features/messages/messages-page.tsx
new file mode 100644
index 0000000..dc6f668
--- /dev/null
+++ b/web/src/features/messages/messages-page.tsx
@@ -0,0 +1,60 @@
+import { MessagesSquare } from "lucide-react";
+import { useState } from "react";
+import { useParams } from "react-router";
+import { useConversations } from "@/hooks/use-conversations";
+import { cn } from "@/lib/utils";
+import ConversationList from "./conversation-list";
+import Thread from "./thread";
+
+/**
+ * Messenger split-pane. Desktop: list (w-80) + thread side by side.
+ * Mobile: /messages shows the list, /messages/:roomId shows the thread.
+ */
+export default function Page() {
+ const { roomId } = useParams();
+ const [search, setSearch] = useState("");
+ const list = useConversations(search);
+
+ return (
+
+
+ void list.fetchNextPage()}
+ />
+
+
+
+
+ );
+}
+
+function EmptyThread() {
+ return (
+
+
+
+
+
+
Pick a conversation
+
+ Choose a match on the left and start something lovely.
+
+
+
+ );
+}
diff --git a/web/src/features/messages/thread.tsx b/web/src/features/messages/thread.tsx
new file mode 100644
index 0000000..90873fe
--- /dev/null
+++ b/web/src/features/messages/thread.tsx
@@ -0,0 +1,552 @@
+import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
+import { ChevronLeft, Clock3, Users } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Link } from "react-router";
+import { getChat } from "@/api/chat";
+import type { ChatMessage, RoomSummary, UserInRoom } from "@/api/types";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Bubble, BubbleContent } from "@/components/ui/bubble";
+import { Marker, MarkerContent } from "@/components/ui/marker";
+import {
+ Message,
+ MessageAvatar,
+ MessageContent,
+ MessageFooter,
+} from "@/components/ui/message";
+import {
+ MessageScroller,
+ MessageScrollerButton,
+ MessageScrollerContent,
+ MessageScrollerItem,
+ MessageScrollerProvider,
+ MessageScrollerViewport,
+ useMessageScroller,
+} from "@/components/ui/message-scroller";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useConversations } from "@/hooks/use-conversations";
+import { dayLabel, messageTime } from "@/lib/dates";
+import { cn } from "@/lib/utils";
+import { useRealtime } from "@/realtime/connection";
+import * as events from "@/realtime/events";
+import { useAuthStore } from "@/stores/auth";
+import Composer from "./composer";
+import MembersPanel from "./members-panel";
+import TypingDots from "./typing-dots";
+
+/** Messages closer together than this collapse into one visual group. */
+const GROUP_WINDOW_MS = 5 * 60_000;
+const TYPING_FLASH_MS = 3_000;
+const MARK_READ_DEBOUNCE_MS = 500;
+
+interface SystemNote {
+ id: string;
+ text: string;
+ at: string;
+}
+
+type ThreadEntry =
+ | { kind: "day"; id: string; label: string }
+ | { kind: "note"; id: string; text: string }
+ | {
+ kind: "message";
+ message: ChatMessage;
+ isOwn: boolean;
+ groupEnd: boolean;
+ showAvatar: boolean;
+ };
+
+/**
+ * A single room's thread. Mounted with key={roomId} so all transient state
+ * (typing, presence, live receipts) resets naturally on room change.
+ */
+export default function Thread({ roomId }: { roomId: string }) {
+ const realtime = useRealtime();
+ const queryClient = useQueryClient();
+ const user = useAuthStore((s) => s.user);
+ const myId = user?.id;
+ const myPfp = user?.profilePicture ?? null;
+
+ const { conversations, chatrooms, isLoading: listLoading } = useConversations();
+ const conversation = conversations.find((c) => c.match.roomId === roomId);
+ const chatroom = chatrooms.find((room) => room.id === roomId);
+ const isPublic = !conversation && !!chatroom;
+
+ // ---- history (pages arrive newest-first; we render oldest -> newest) ----
+ const query = useInfiniteQuery({
+ queryKey: ["messages", roomId],
+ initialPageParam: 1,
+ queryFn: ({ pageParam }) => getChat(roomId, pageParam),
+ getNextPageParam: (last, pages) => {
+ const loaded = pages.reduce((n, page) => n + page.messages.length, 0);
+ return loaded < last.totalMessages ? pages.length + 1 : undefined;
+ },
+ });
+
+ const messages = useMemo(() => {
+ const flat = (query.data?.pages ?? []).flatMap((page) => page.messages);
+ const seen = new Set();
+ const unique: ChatMessage[] = [];
+ for (const message of flat) {
+ if (!seen.has(message.id)) {
+ seen.add(message.id);
+ unique.push(message);
+ }
+ }
+ return unique.reverse();
+ }, [query.data]);
+
+ // The summaries cache only refetches every 60s; zero the badge locally
+ // whenever we tell the server this room is read.
+ const clearUnread = useCallback(() => {
+ queryClient.setQueryData(["summaries"], (summaries) =>
+ summaries?.map((s) => (s.roomId === roomId ? { ...s, unreadCount: 0 } : s)),
+ );
+ }, [queryClient, roomId]);
+
+ // ---- room lifecycle: active room, join, mark read, reconnect recovery ----
+ useEffect(() => {
+ realtime.setActiveRoom(roomId);
+ realtime
+ .joinRoom(roomId, myPfp)
+ .then(() => realtime.markRead(roomId))
+ .then(clearUnread)
+ .catch((error) => console.error("Failed to join room", error));
+
+ const offReconnected = events.on("reconnected", () => {
+ realtime
+ .joinRoom(roomId, myPfp)
+ .then(() => realtime.markRead(roomId))
+ .then(clearUnread)
+ .catch((error) => console.error("Failed to re-join room", error));
+ void queryClient.invalidateQueries({ queryKey: ["messages", roomId] });
+ });
+
+ return () => {
+ offReconnected();
+ realtime.setActiveRoom(null);
+ void realtime.leaveRoom(roomId);
+ };
+ }, [roomId, myPfp, realtime, queryClient, clearUnread]);
+
+ // ---- typing indicator (transient signal, ~3s flash reset per ping) ----
+ const [peerTypingName, setPeerTypingName] = useState(null);
+ const typingTimer = useRef(null);
+ useEffect(() => {
+ const off = events.on("peerTyping", (payload) => {
+ if (payload.roomId !== roomId || payload.userId === myId) return;
+ setPeerTypingName(payload.userName || "Someone");
+ if (typingTimer.current) window.clearTimeout(typingTimer.current);
+ typingTimer.current = window.setTimeout(() => setPeerTypingName(null), TYPING_FLASH_MS);
+ });
+ return () => {
+ off();
+ if (typingTimer.current) window.clearTimeout(typingTimer.current);
+ };
+ }, [roomId, myId]);
+
+ // ---- read receipts: live event beats the (also patched) summaries cache ----
+ const [liveReadAt, setLiveReadAt] = useState(null);
+ useEffect(() => {
+ return events.on("readReceipt", (receipt) => {
+ if (receipt.roomId === roomId) setLiveReadAt(receipt.readAt);
+ });
+ }, [roomId]);
+
+ const summaryReadAt = conversation?.summary?.peerLastReadAt ?? null;
+ const peerReadAt = useMemo(() => {
+ const candidates = [summaryReadAt, liveReadAt].filter((v): v is string => !!v);
+ if (candidates.length === 0) return null;
+ return candidates.reduce((a, b) => (Date.parse(a) >= Date.parse(b) ? a : b));
+ }, [summaryReadAt, liveReadAt]);
+
+ // ---- presence + join/leave notes (public rooms) ----
+ const [usersInRoom, setUsersInRoom] = useState([]);
+ const [notes, setNotes] = useState([]);
+ const usersRef = useRef([]);
+ useEffect(() => {
+ return events.on("refreshUsersList", (payload) => {
+ if (payload.roomId && payload.roomId !== roomId) return;
+ const changed = findChangedUser(usersRef.current, payload.users, payload.hasLeft);
+ usersRef.current = payload.users;
+ setUsersInRoom(payload.users);
+
+ if (isPublic && changed) {
+ const name = changed.id === myId ? "You" : (changed.userName ?? "Someone");
+ const text = payload.hasLeft
+ ? `${name} left the chat.`
+ : `${name} joined the chat — say hi!`;
+ setNotes((prev) => [
+ ...prev,
+ { id: crypto.randomUUID(), text, at: new Date().toISOString() },
+ ]);
+ }
+ });
+ }, [roomId, myId, isPublic]);
+
+ // ---- keep unread at 0 while the thread is visible (debounced re-markRead) ----
+ const latestPeerMessageId = useMemo(() => {
+ const last = messages[messages.length - 1];
+ return last && !last.pending && last.userId !== myId ? last.id : null;
+ }, [messages, myId]);
+
+ useEffect(() => {
+ if (!latestPeerMessageId) return;
+ setPeerTypingName(null); // they just spoke; retire the dots early
+ const timer = window.setTimeout(
+ () => void realtime.markRead(roomId).then(clearUnread),
+ MARK_READ_DEBOUNCE_MS,
+ );
+ return () => window.clearTimeout(timer);
+ }, [latestPeerMessageId, roomId, realtime, clearUnread]);
+
+ // ---- flatten into render entries: day markers, notes, grouped messages ----
+ const entries = useMemo(
+ () => buildEntries(messages, notes, myId),
+ [messages, notes, myId],
+ );
+
+ const lastOwnId = useMemo(() => {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].userId === myId) return messages[i].id;
+ }
+ return null;
+ }, [messages, myId]);
+
+ const peerName = conversation?.match.userName ?? null;
+ const peerMessagePicture = useMemo(() => {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const m = messages[i];
+ if (m.userId !== myId && m.profilePicture) return m.profilePicture;
+ }
+ return null;
+ }, [messages, myId]);
+ const peerAvatarUrl =
+ conversation?.match.images.find((i) => i.isProfilePicture)?.url ??
+ conversation?.match.images[0]?.url ??
+ peerMessagePicture;
+
+ const title = peerName ?? chatroom?.title ?? (listLoading ? null : "Conversation");
+ const headerImage = conversation ? peerAvatarUrl : chatroom?.url;
+
+ const [membersOpen, setMembersOpen] = useState(false);
+ const [sentTick, setSentTick] = useState(0);
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Thread + composer share the scroller context so sends can jump to the end. */}
+
+
+
+
+ {query.isLoading ? (
+
+ ) : (
+
+
+
+ {query.hasNextPage && (
+ {
+ if (!query.isFetchingNextPage) void query.fetchNextPage();
+ }}
+ />
+ )}
+
+ {entries.length === 0 && (
+
+ No messages yet — break the ice!
+
+ )}
+
+ {entries.map((entry) => {
+ if (entry.kind === "day") {
+ return (
+
+
+ {entry.label}
+
+
+ );
+ }
+ if (entry.kind === "note") {
+ return (
+
+
+
+ {entry.text}
+
+
+
+ );
+ }
+
+ const m = entry.message;
+ const isLastOwn = entry.isOwn && !!conversation && m.id === lastOwnId;
+ return (
+
+
+ {!entry.isOwn &&
+ (entry.showAvatar ? (
+
+
+
+
+ {(peerName?.[0] ?? "?").toUpperCase()}
+
+
+
+ ) : (
+
+ ))}
+
+
+
+ {m.imageUrl ? (
+
+ ) : (
+ m.text
+ )}
+
+
+ {m.pending ? (
+
+
+ Sending…
+
+ ) : isLastOwn ? (
+
+ {peerReadAt &&
+ Date.parse(peerReadAt) >= Date.parse(m.createdOn)
+ ? `Seen ${messageTime(peerReadAt)}`
+ : "Delivered"}
+
+ ) : entry.groupEnd ? (
+ {messageTime(m.createdOn)}
+ ) : null}
+
+
+
+ );
+ })}
+
+ {peerTypingName && (
+
+ {conversation && (
+
+
+
+ {(peerName?.[0] ?? "?").toUpperCase()}
+
+
+ )}
+
+
+ )}
+
+
+
+
+ )}
+
+
+
setSentTick((tick) => tick + 1)} />
+
+
+ {isPublic && membersOpen && (
+
+ )}
+
+
+
+ );
+}
+
+function buildEntries(
+ messages: ChatMessage[],
+ notes: SystemNote[],
+ myId: string | undefined,
+): ThreadEntry[] {
+ type Merged = { at: number } & (
+ | { type: "message"; message: ChatMessage }
+ | { type: "note"; note: SystemNote }
+ );
+
+ const merged: Merged[] = [
+ ...messages.map((message) => ({
+ type: "message" as const,
+ at: Date.parse(message.createdOn),
+ message,
+ })),
+ ...notes.map((note) => ({ type: "note" as const, at: Date.parse(note.at), note })),
+ ].sort((a, b) => a.at - b.at);
+
+ const entries: ThreadEntry[] = [];
+ let prevDay = "";
+
+ for (let i = 0; i < merged.length; i++) {
+ const entry = merged[i];
+ const iso = entry.type === "message" ? entry.message.createdOn : entry.note.at;
+ const label = dayLabel(iso);
+ if (label !== prevDay) {
+ entries.push({ kind: "day", id: `day-${label}`, label });
+ prevDay = label;
+ }
+
+ if (entry.type === "note") {
+ entries.push({ kind: "note", id: entry.note.id, text: entry.note.text });
+ continue;
+ }
+
+ const m = entry.message;
+ const next = merged[i + 1];
+ const groupEnd =
+ !next ||
+ next.type !== "message" ||
+ next.message.userId !== m.userId ||
+ next.at - entry.at > GROUP_WINDOW_MS ||
+ dayLabel(next.message.createdOn) !== label;
+
+ entries.push({
+ kind: "message",
+ message: m,
+ isOwn: m.userId === myId,
+ groupEnd,
+ showAvatar: m.userId !== myId && groupEnd,
+ });
+ }
+
+ return entries;
+}
+
+/** Old-app diffing: the changed user is the one missing from the other list. */
+function findChangedUser(
+ prev: UserInRoom[],
+ next: UserInRoom[],
+ hasLeft: boolean,
+): UserInRoom | null {
+ if (prev.length === 0) return null;
+ const source = hasLeft ? prev : next;
+ const target = new Set((hasLeft ? next : prev).map((u) => u.id));
+ return source.find((u) => !target.has(u.id)) ?? null;
+}
+
+/** Top sentinel: pulls the next (older) page while it stays in view. */
+function LoadEarlier({ onLoad, loading }: { onLoad: () => void; loading: boolean }) {
+ const ref = useRef(null);
+ const onLoadRef = useRef(onLoad);
+ onLoadRef.current = onLoad;
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const observer = new IntersectionObserver(
+ (observed) => {
+ if (observed.some((entry) => entry.isIntersecting)) onLoadRef.current();
+ },
+ { rootMargin: "200px 0px" },
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+
+ {loading ? "Loading earlier messages…" : "Load earlier messages"}
+
+
+ );
+}
+
+/** Lives inside the scroller provider; jumps to the newest message on send. */
+function ScrollToEndOnSend({ tick }: { tick: number }) {
+ const { scrollToEnd } = useMessageScroller();
+ const lastTick = useRef(tick);
+
+ useEffect(() => {
+ if (tick !== lastTick.current) {
+ lastTick.current = tick;
+ scrollToEnd({ behavior: "smooth" });
+ }
+ }, [tick, scrollToEnd]);
+
+ return null;
+}
+
+function ThreadSkeleton() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/features/messages/typing-dots.tsx b/web/src/features/messages/typing-dots.tsx
new file mode 100644
index 0000000..cbe263c
--- /dev/null
+++ b/web/src/features/messages/typing-dots.tsx
@@ -0,0 +1,14 @@
+/** Animated three-dot "peer is typing" bubble. */
+export default function TypingDots() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/web/src/features/profile/photo-manager.tsx b/web/src/features/profile/photo-manager.tsx
new file mode 100644
index 0000000..f9781b5
--- /dev/null
+++ b/web/src/features/profile/photo-manager.tsx
@@ -0,0 +1,121 @@
+import { ImagePlus, Star, Trash2, X } from "lucide-react";
+import type { ImageModel } from "@/api/types";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+
+export interface NewPhoto {
+ file: File;
+ /** Object URL used for the preview; revoke it when the photo is dropped. */
+ url: string;
+}
+
+interface PhotoManagerProps {
+ images: ImageModel[];
+ newPhotos: NewPhoto[];
+ onMakeProfilePicture: (id: number) => void;
+ onRemoveImage: (id: number) => void;
+ onAddFiles: (files: FileList | null) => void;
+ onRemoveNewPhoto: (url: string) => void;
+}
+
+export default function PhotoManager({
+ images,
+ newPhotos,
+ onMakeProfilePicture,
+ onRemoveImage,
+ onAddFiles,
+ onRemoveNewPhoto,
+}: PhotoManagerProps) {
+ return (
+
+ {images.map((image) => (
+
onMakeProfilePicture(image.id)}
+ onRemove={() => onRemoveImage(image.id)}
+ />
+ ))}
+
+ {newPhotos.map((photo) => (
+
+
+
+ New
+
+
onRemoveNewPhoto(photo.url)}
+ >
+
+
+
+ ))}
+
+
+
+
+ Add photos
+
+ {
+ onAddFiles(e.target.files);
+ e.target.value = "";
+ }}
+ />
+
+
+ );
+}
+
+function PhotoTile({
+ image,
+ onMakeProfilePicture,
+ onRemove,
+}: {
+ image: ImageModel;
+ onMakeProfilePicture: () => void;
+ onRemove: () => void;
+}) {
+ return (
+
+
+
+ {image.isProfilePicture ? (
+
+ Profile
+
+ ) : (
+ // Visible on touch; revealed on hover/focus for pointer devices.
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/features/profile/profile-page.tsx b/web/src/features/profile/profile-page.tsx
new file mode 100644
index 0000000..bf07192
--- /dev/null
+++ b/web/src/features/profile/profile-page.tsx
@@ -0,0 +1,436 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import dayjs from "dayjs";
+import { useEffect, useRef, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { getCitiesByCountry, getCountries, getGenders } from "@/api/geo";
+import { editAccount, getAccount } from "@/api/profile";
+import type { AccountDetails, ImageModel } from "@/api/types";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Textarea } from "@/components/ui/textarea";
+import { validation } from "@/lib/constants";
+import { ageFrom, latestLegalBirthdate } from "@/lib/dates";
+import { errorMessages } from "@/lib/errors";
+import { useAuthStore } from "@/stores/auth";
+import PhotoManager, { type NewPhoto } from "./photo-manager";
+
+const schema = z.object({
+ userName: z
+ .string()
+ .min(1, "Username is required")
+ .max(validation.USERNAME_MAX_LENGTH, `Username must be at most ${validation.USERNAME_MAX_LENGTH} characters`),
+ bio: z
+ .string()
+ .max(validation.BIO_MAX_LENGTH, `Bio must be at most ${validation.BIO_MAX_LENGTH} characters`),
+ birthdate: z
+ .string()
+ .min(1, "Birthdate is required")
+ .refine(
+ (value) => ageFrom(value) >= validation.MINIMAL_AGE,
+ `You must be at least ${validation.MINIMAL_AGE} years old`,
+ ),
+ genderId: z.string().refine((value) => Number(value) > 0, "Pick a gender"),
+ countryId: z.string().refine((value) => Number(value) > 0, "Pick a country"),
+ cityId: z.string().refine((value) => Number(value) > 0, "Pick a city"),
+});
+
+type FormValues = z.infer;
+
+function toFormValues(account: AccountDetails): FormValues {
+ return {
+ userName: account.userName,
+ bio: account.bio ?? "",
+ birthdate: dayjs(account.birthdate).format("YYYY-MM-DD"),
+ genderId: account.genderId > 0 ? String(account.genderId) : "",
+ countryId: account.countryId > 0 ? String(account.countryId) : "",
+ cityId: account.cityId > 0 ? String(account.cityId) : "",
+ };
+}
+
+export default function ProfilePage() {
+ const myId = useAuthStore((s) => s.user?.id);
+
+ const accountQuery = useQuery({
+ queryKey: ["account", myId],
+ queryFn: () => getAccount(myId!),
+ enabled: !!myId,
+ });
+
+ if (accountQuery.isPending) return ;
+
+ if (accountQuery.isError) {
+ return (
+
+ {errorMessages(accountQuery.error).map((line) => (
+
{line}
+ ))}
+
+ );
+ }
+
+ return ;
+}
+
+function ProfileEditor({ account }: { account: AccountDetails }) {
+ const queryClient = useQueryClient();
+
+ // Photo state: kept images (with editable isProfilePicture flags) + new files.
+ const [keptImages, setKeptImages] = useState(account.images);
+ const [newPhotos, setNewPhotos] = useState([]);
+
+ // Revoke outstanding preview URLs on unmount.
+ const newPhotosRef = useRef(newPhotos);
+ newPhotosRef.current = newPhotos;
+ useEffect(
+ () => () => {
+ for (const photo of newPhotosRef.current) URL.revokeObjectURL(photo.url);
+ },
+ [],
+ );
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: toFormValues(account),
+ });
+
+ const countryId = Number(form.watch("countryId"));
+ const bioLength = form.watch("bio").length;
+
+ const gendersQuery = useQuery({ queryKey: ["genders"], queryFn: getGenders });
+ const countriesQuery = useQuery({ queryKey: ["countries"], queryFn: getCountries });
+ const citiesQuery = useQuery({
+ queryKey: ["cities", countryId],
+ queryFn: () => getCitiesByCountry(countryId),
+ enabled: countryId > 0,
+ });
+
+ // Placeholder entries with id 0 are skipped.
+ const genders = (gendersQuery.data ?? []).filter((g) => g.id !== 0);
+ const countries = (countriesQuery.data ?? []).filter((c) => c.countryId !== 0);
+ const cities = (citiesQuery.data?.cities ?? []).filter((c) => c.cityId !== 0);
+
+ const mutation = useMutation({
+ mutationFn: (values: FormValues) =>
+ editAccount({
+ id: account.id,
+ email: account.email,
+ userName: values.userName,
+ bio: values.bio,
+ birthdate: values.birthdate,
+ countryId: Number(values.countryId),
+ genderId: Number(values.genderId),
+ cityId: Number(values.cityId),
+ images: keptImages,
+ newImages: newPhotos.map((photo) => photo.file),
+ }),
+ onSuccess: (data) => {
+ queryClient.setQueryData(["account", account.id], data);
+ setKeptImages(data.images);
+ for (const photo of newPhotos) URL.revokeObjectURL(photo.url);
+ setNewPhotos([]);
+ form.reset(toFormValues(data));
+ toast.success("Profile updated");
+ },
+ });
+
+ function makeProfilePicture(id: number) {
+ setKeptImages((images) =>
+ images.map((image) => ({ ...image, isProfilePicture: image.id === id })),
+ );
+ }
+
+ function removeImage(id: number) {
+ setKeptImages((images) => {
+ const rest = images.filter((image) => image.id !== id);
+ // Keep exactly one profile picture flag alive when images remain.
+ if (rest.length > 0 && !rest.some((image) => image.isProfilePicture)) {
+ return rest.map((image, index) => ({ ...image, isProfilePicture: index === 0 }));
+ }
+ return rest;
+ });
+ }
+
+ function addFiles(files: FileList | null) {
+ if (!files) return;
+ const added = Array.from(files).map((file) => ({
+ file,
+ url: URL.createObjectURL(file),
+ }));
+ setNewPhotos((photos) => [...photos, ...added]);
+ }
+
+ function removeNewPhoto(url: string) {
+ URL.revokeObjectURL(url);
+ setNewPhotos((photos) => photos.filter((photo) => photo.url !== url));
+ }
+
+ function onSubmit(values: FormValues) {
+ if (keptImages.length + newPhotos.length === 0) {
+ toast.error("Keep or add at least one photo");
+ return;
+ }
+ mutation.mutate(values);
+ }
+
+ return (
+
+
Your profile
+
+ Keep your photos and details fresh — they are what others swipe on.
+
+
+
+
+
+ {/* Photos */}
+
+
+ Photos
+
+ Star a photo to make it your profile picture. Changes apply when you save.
+
+
+
+
+
+
+
+ {/* Details */}
+
+
+ Details
+ Tell the world who you are.
+
+
+
+ Email
+
+
+
+ (
+
+ Username
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Bio
+
+ {bioLength}/{validation.BIO_MAX_LENGTH}
+
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+ (
+
+ Country
+ {
+ field.onChange(value);
+ form.setValue("cityId", "");
+ }}
+ >
+
+
+
+
+
+
+ {countries.map((country) => (
+
+ {country.countryName}
+
+ ))}
+
+
+
+
+ )}
+ />
+
+ (
+
+ City
+
+
+
+
+
+
+
+ {cities.map((city) => (
+
+ {city.cityName}
+
+ ))}
+
+
+
+
+ )}
+ />
+
+
+ {mutation.isError && (
+
+ {errorMessages(mutation.error).map((line) => (
+
{line}
+ ))}
+
+ )}
+
+
+ {mutation.isPending ? "Saving…" : "Save changes"}
+
+
+
+
+
+
+
+ );
+}
+
+function ProfileSkeleton() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+ ))}
+
+
+
+
+
+ {Array.from({ length: 5 }, (_, index) => (
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/web/src/hooks/use-conversations.ts b/web/src/hooks/use-conversations.ts
new file mode 100644
index 0000000..57681e1
--- /dev/null
+++ b/web/src/hooks/use-conversations.ts
@@ -0,0 +1,70 @@
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import { getMatches } from "@/api/dating";
+import { getChatrooms, getSummaries } from "@/api/chat";
+import type { RoomSummary, UserCard } from "@/api/types";
+import { PAGE_SIZE } from "@/lib/constants";
+import { useAuthStore } from "@/stores/auth";
+
+export interface Conversation {
+ match: UserCard;
+ summary: RoomSummary | null;
+}
+
+/**
+ * Conversation-list data shared by the Messages page and the nav unread badge:
+ * matches (paged) joined with the Chat service's room summaries.
+ */
+export function useConversations(search = "") {
+ const userId = useAuthStore((s) => s.user?.id);
+
+ const matchesQuery = useInfiniteQuery({
+ queryKey: ["matches", search],
+ enabled: !!userId,
+ initialPageParam: 1,
+ queryFn: ({ pageParam }) =>
+ getMatches({ userId: userId!, page: pageParam, search: search || undefined }),
+ getNextPageParam: (last, pages) => {
+ const loaded = pages.reduce((n, p) => n + p.matches.length, 0);
+ return loaded < last.totalMatches ? pages.length + 1 : undefined;
+ },
+ });
+
+ const matches = (matchesQuery.data?.pages ?? [])
+ .flatMap((p) => p.matches)
+ .filter((m, i, all) => all.findIndex((x) => x.id === m.id) === i);
+
+ const roomIds = matches.map((m) => m.roomId).filter((id): id is string => !!id);
+
+ const summariesQuery = useQuery({
+ queryKey: ["summaries"],
+ enabled: roomIds.length > 0,
+ queryFn: () => getSummaries(roomIds),
+ refetchInterval: 60_000,
+ });
+
+ const chatroomsQuery = useQuery({
+ queryKey: ["chatrooms"],
+ enabled: !!userId,
+ queryFn: getChatrooms,
+ staleTime: 5 * 60_000,
+ });
+
+ const summariesByRoom = new Map((summariesQuery.data ?? []).map((s) => [s.roomId, s]));
+
+ const conversations: Conversation[] = matches.map((match) => ({
+ match,
+ summary: match.roomId ? (summariesByRoom.get(match.roomId) ?? null) : null,
+ }));
+
+ const totalUnread = (summariesQuery.data ?? []).reduce((n, s) => n + s.unreadCount, 0);
+
+ return {
+ conversations,
+ chatrooms: chatroomsQuery.data ?? [],
+ totalUnread,
+ isLoading: matchesQuery.isLoading,
+ fetchNextPage: matchesQuery.fetchNextPage,
+ hasNextPage: matchesQuery.hasNextPage ?? false,
+ pageSize: PAGE_SIZE,
+ };
+}
diff --git a/web/src/index.css b/web/src/index.css
new file mode 100644
index 0000000..9f94e60
--- /dev/null
+++ b/web/src/index.css
@@ -0,0 +1,127 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+/* LOVE.NET theme — dark-first with a rose→amber "sunset" accent. */
+:root {
+ --background: oklch(0.985 0.002 90);
+ --foreground: oklch(0.2 0.02 20);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.2 0.02 20);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.2 0.02 20);
+ --primary: oklch(0.62 0.24 12);
+ --primary-foreground: oklch(0.99 0.01 20);
+ --secondary: oklch(0.95 0.01 60);
+ --secondary-foreground: oklch(0.3 0.03 20);
+ --muted: oklch(0.955 0.008 80);
+ --muted-foreground: oklch(0.5 0.02 40);
+ --accent: oklch(0.94 0.03 40);
+ --accent-foreground: oklch(0.3 0.04 20);
+ --destructive: oklch(0.55 0.22 27);
+ --destructive-foreground: oklch(0.99 0.01 20);
+ --border: oklch(0.91 0.008 60);
+ --input: oklch(0.91 0.008 60);
+ --ring: oklch(0.62 0.24 12);
+ --radius: 0.75rem;
+
+ --gradient-from: oklch(0.62 0.24 12);
+ --gradient-to: oklch(0.75 0.16 55);
+
+ --chart-1: oklch(0.62 0.24 12);
+ --chart-2: oklch(0.75 0.16 55);
+ --chart-3: oklch(0.6 0.15 250);
+ --chart-4: oklch(0.7 0.15 150);
+ --chart-5: oklch(0.65 0.2 300);
+}
+
+.dark {
+ --background: oklch(0.16 0.015 285);
+ --foreground: oklch(0.93 0.005 60);
+ --card: oklch(0.2 0.018 285);
+ --card-foreground: oklch(0.93 0.005 60);
+ --popover: oklch(0.19 0.018 285);
+ --popover-foreground: oklch(0.93 0.005 60);
+ --primary: oklch(0.66 0.23 12);
+ --primary-foreground: oklch(0.99 0.01 20);
+ --secondary: oklch(0.26 0.02 285);
+ --secondary-foreground: oklch(0.9 0.01 60);
+ --muted: oklch(0.24 0.018 285);
+ --muted-foreground: oklch(0.65 0.015 60);
+ --accent: oklch(0.28 0.03 20);
+ --accent-foreground: oklch(0.93 0.01 40);
+ --destructive: oklch(0.6 0.2 27);
+ --destructive-foreground: oklch(0.99 0.01 20);
+ --border: oklch(0.28 0.02 285);
+ --input: oklch(0.3 0.02 285);
+ --ring: oklch(0.66 0.23 12);
+
+ --gradient-from: oklch(0.66 0.23 12);
+ --gradient-to: oklch(0.78 0.15 55);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-gradient-from: var(--gradient-from);
+ --color-gradient-to: var(--gradient-to);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --font-sans: "Inter", "Inter Fallback", system-ui, sans-serif;
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+
+ html,
+ body,
+ #root {
+ height: 100dvh;
+ }
+
+ body {
+ @apply bg-background text-foreground font-sans antialiased;
+ }
+}
+
+@utility gradient-brand {
+ background-image: linear-gradient(135deg, var(--gradient-from), var(--gradient-to));
+}
+
+@utility text-gradient-brand {
+ background-image: linear-gradient(135deg, var(--gradient-from), var(--gradient-to));
+ background-clip: text;
+ -webkit-background-clip: text;
+ color: transparent;
+}
+
+@utility safe-bottom {
+ padding-bottom: env(safe-area-inset-bottom);
+}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
new file mode 100644
index 0000000..c77ab55
--- /dev/null
+++ b/web/src/lib/constants.ts
@@ -0,0 +1,13 @@
+// The gateway keeps the monolith's old origin, so localhost:8080 is the
+// default everywhere; override via Vite env for other deployments.
+export const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
+export const API_URL = `${BASE_URL}/api`;
+
+export const validation = {
+ PASSWORD_MIN_LENGTH: 5,
+ BIO_MAX_LENGTH: 255,
+ USERNAME_MAX_LENGTH: 100,
+ MINIMAL_AGE: 18,
+} as const;
+
+export const PAGE_SIZE = 10;
diff --git a/web/src/lib/dates.ts b/web/src/lib/dates.ts
new file mode 100644
index 0000000..3b692ba
--- /dev/null
+++ b/web/src/lib/dates.ts
@@ -0,0 +1,36 @@
+import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
+
+dayjs.extend(relativeTime);
+
+export function ageFrom(birthdate: string): number {
+ return dayjs().diff(dayjs(birthdate), "year");
+}
+
+/** Latest birthdate that still satisfies the 18+ rule. */
+export function latestLegalBirthdate(): string {
+ return dayjs().subtract(18, "year").format("YYYY-MM-DD");
+}
+
+/** Conversation-list style timestamp: time today, weekday this week, date otherwise. */
+export function conversationTime(iso: string): string {
+ const value = dayjs(iso);
+ if (value.isSame(dayjs(), "day")) return value.format("HH:mm");
+ if (value.isAfter(dayjs().subtract(7, "day"))) return value.format("ddd");
+ return value.format("D MMM");
+}
+
+export function messageTime(iso: string): string {
+ return dayjs(iso).format("HH:mm");
+}
+
+export function dayLabel(iso: string): string {
+ const value = dayjs(iso);
+ if (value.isSame(dayjs(), "day")) return "Today";
+ if (value.isSame(dayjs().subtract(1, "day"), "day")) return "Yesterday";
+ return value.format("D MMMM YYYY");
+}
+
+export function fromNow(iso: string): string {
+ return dayjs(iso).fromNow();
+}
diff --git a/web/src/lib/distance.ts b/web/src/lib/distance.ts
new file mode 100644
index 0000000..92b0f3c
--- /dev/null
+++ b/web/src/lib/distance.ts
@@ -0,0 +1,15 @@
+/** Haversine distance, ceiled to whole km (ported from the old app). */
+export function distanceInKms(lat1: number, lon1: number, lat2: number, lon2: number): number {
+ const R = 6371;
+ const dLat = toRad(lat2 - lat1);
+ const dLon = toRad(lon2 - lon1);
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return Math.ceil(R * c);
+}
+
+function toRad(value: number): number {
+ return (value * Math.PI) / 180;
+}
diff --git a/web/src/lib/errors.ts b/web/src/lib/errors.ts
new file mode 100644
index 0000000..90c38d9
--- /dev/null
+++ b/web/src/lib/errors.ts
@@ -0,0 +1,25 @@
+import { AxiosError } from "axios";
+
+/** Flattens the API's assorted error shapes into displayable lines (ported behavior). */
+export function errorMessages(error: unknown): string[] {
+ if (error instanceof AxiosError && error.response?.data) {
+ const data = error.response.data as Record;
+
+ if (Array.isArray(data)) return data.map(String);
+ if (typeof data === "string") return [data];
+
+ const lines: string[] = [];
+ if (typeof data.Error === "string") lines.push(data.Error);
+ if (Array.isArray(data.errors)) lines.push(...data.errors.map(String));
+ else if (data.errors && typeof data.errors === "object") {
+ for (const value of Object.values(data.errors as Record)) {
+ if (Array.isArray(value)) lines.push(...value.map(String));
+ else lines.push(String(value));
+ }
+ }
+ if (lines.length > 0) return lines.flatMap((l) => l.split("\n"));
+ }
+
+ if (error instanceof Error && error.message) return error.message.split("\n");
+ return ["Something went wrong"];
+}
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/web/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..96d0814
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,32 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { RouterProvider } from "react-router/dom";
+import { ThemeProvider } from "@/components/theme-provider";
+import { Toaster } from "@/components/ui/sonner";
+import { RealtimeProvider } from "@/realtime/connection";
+import { router } from "@/router";
+import "./index.css";
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+
+
+ ,
+);
diff --git a/web/src/realtime/connection.tsx b/web/src/realtime/connection.tsx
new file mode 100644
index 0000000..09cc511
--- /dev/null
+++ b/web/src/realtime/connection.tsx
@@ -0,0 +1,207 @@
+import {
+ HubConnectionBuilder,
+ HubConnectionState,
+ LogLevel,
+ type HubConnection,
+} from "@microsoft/signalr";
+import { useQueryClient, type InfiniteData } from "@tanstack/react-query";
+import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
+import { toast } from "sonner";
+import type {
+ ChatMessage,
+ ChatPage,
+ MatchReceived,
+ MessageNotification,
+ PeerTyping,
+ ReadReceipt,
+ RefreshUsersList,
+ RoomSummary,
+} from "@/api/types";
+import { BASE_URL } from "@/lib/constants";
+import { MatchToast, MessageToast } from "@/realtime/toasts";
+import * as events from "@/realtime/events";
+import { isLogged, useAuthStore } from "@/stores/auth";
+
+interface RealtimeApi {
+ joinRoom: (roomId: string, profilePictureUrl?: string | null) => Promise;
+ leaveRoom: (roomId: string) => Promise;
+ sendMessage: (message: {
+ roomId: string;
+ text?: string | null;
+ imageUrl?: string | null;
+ profilePicture?: string | null;
+ }) => Promise;
+ typing: (roomId: string) => void;
+ markRead: (roomId: string) => Promise;
+ /** The room whose thread is on screen; message toasts are suppressed for it. */
+ setActiveRoom: (roomId: string | null) => void;
+}
+
+const RealtimeContext = createContext(null);
+
+export function useRealtime(): RealtimeApi {
+ const api = useContext(RealtimeContext);
+ if (!api) throw new Error("useRealtime must be used inside ");
+ return api;
+}
+
+export function RealtimeProvider({ children }: { children: ReactNode }) {
+ const logged = useAuthStore(isLogged);
+ const queryClient = useQueryClient();
+ const connectionRef = useRef(null);
+ const activeRoomRef = useRef(null);
+
+ useEffect(() => {
+ if (!logged) return;
+
+ const connection = new HubConnectionBuilder()
+ .withUrl(`${BASE_URL}/chat`, {
+ accessTokenFactory: () => useAuthStore.getState().user?.token ?? "",
+ })
+ .configureLogging(LogLevel.Warning)
+ .withAutomaticReconnect()
+ .build();
+
+ const myId = useAuthStore.getState().user?.id;
+
+ connection.on("ReceiveMessage", (message: ChatMessage) => {
+ appendToThread(message);
+ void queryClient.invalidateQueries({ queryKey: ["summaries"] });
+ });
+
+ connection.on("MessageNotification", (notification: MessageNotification) => {
+ void queryClient.invalidateQueries({ queryKey: ["summaries"] });
+
+ if (notification.userId !== myId && activeRoomRef.current !== notification.roomId) {
+ toast.custom((id) => , {
+ id: `msg-${notification.roomId}`,
+ });
+ }
+ });
+
+ connection.on("MatchReceived", (match: MatchReceived) => {
+ void queryClient.invalidateQueries({ queryKey: ["matches"] });
+ toast.custom((id) => , {
+ id: `match-${match.matchId}`,
+ duration: 10_000,
+ });
+ });
+
+ connection.on("PeerTyping", (payload: PeerTyping) => events.emit("peerTyping", payload));
+
+ connection.on("ReadReceipt", (receipt: ReadReceipt) => {
+ events.emit("readReceipt", receipt);
+ queryClient.setQueryData(["summaries"], (summaries) =>
+ summaries?.map((s) =>
+ s.roomId === receipt.roomId ? { ...s, peerLastReadAt: receipt.readAt } : s,
+ ),
+ );
+ });
+
+ connection.on("RefreshUsersList", (payload: RefreshUsersList) =>
+ events.emit("refreshUsersList", payload),
+ );
+
+ connection.onreconnecting(() => {
+ toast.loading("Reconnecting…", { id: "hub-reconnect" });
+ });
+ connection.onreconnected(() => {
+ toast.dismiss("hub-reconnect");
+ events.emit("reconnected", undefined);
+ });
+
+ connection.start().catch((error) => {
+ console.error("Realtime connection failed", error);
+ });
+ connectionRef.current = connection;
+
+ return () => {
+ connectionRef.current = null;
+ void connection.stop();
+ };
+ }, [logged, queryClient]);
+
+ function appendToThread(message: ChatMessage) {
+ queryClient.setQueryData>(["messages", message.roomId], (data) => {
+ if (!data || data.pages.length === 0) return data;
+
+ const already = data.pages.some((p) => p.messages.some((m) => m.id === message.id));
+ if (already) return data;
+
+ const [first, ...rest] = data.pages;
+
+ // Replace the matching optimistic bubble (same author + body) if present.
+ const pendingIndex = first.messages.findIndex(
+ (m) => m.pending && m.userId === message.userId &&
+ m.text === message.text && m.imageUrl === message.imageUrl,
+ );
+
+ const messages =
+ pendingIndex >= 0
+ ? first.messages.map((m, i) => (i === pendingIndex ? { ...message } : m))
+ : [message, ...first.messages];
+
+ return {
+ ...data,
+ pages: [
+ { messages, totalMessages: first.totalMessages + (pendingIndex >= 0 ? 0 : 1) },
+ ...rest,
+ ],
+ };
+ });
+ }
+
+ const api = useMemo(
+ () => ({
+ joinRoom: async (roomId, profilePictureUrl = null) => {
+ await ready();
+ await connectionRef.current!.invoke("JoinRoom", { roomId, profilePictureUrl });
+ },
+ leaveRoom: async (roomId) => {
+ if (connectionRef.current?.state === HubConnectionState.Connected) {
+ await connectionRef.current.invoke("LeaveRoom", { roomId, profilePictureUrl: null });
+ }
+ },
+ sendMessage: async (message) => {
+ await ready();
+ await connectionRef.current!.invoke("SendMessage", {
+ roomId: message.roomId,
+ text: message.text ?? null,
+ imageUrl: message.imageUrl ?? null,
+ profilePicture: message.profilePicture ?? null,
+ });
+ },
+ typing: (roomId) => {
+ if (connectionRef.current?.state === HubConnectionState.Connected) {
+ void connectionRef.current.send("Typing", roomId);
+ }
+ },
+ markRead: async (roomId) => {
+ if (connectionRef.current?.state === HubConnectionState.Connected) {
+ await connectionRef.current.invoke("MarkRead", roomId);
+ }
+ },
+ setActiveRoom: (roomId) => {
+ activeRoomRef.current = roomId;
+ },
+ }),
+ [],
+ );
+
+ async function ready(): Promise {
+ const connection = connectionRef.current;
+ if (!connection) throw new Error("Realtime connection is not available");
+
+ if (connection.state === HubConnectionState.Disconnected) {
+ await connection.start();
+ }
+
+ // Wait out an in-flight (re)connect instead of failing the invoke.
+ const deadline = Date.now() + 10_000;
+ while (connection.state !== HubConnectionState.Connected && Date.now() < deadline) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ }
+
+ return {children} ;
+}
diff --git a/web/src/realtime/events.ts b/web/src/realtime/events.ts
new file mode 100644
index 0000000..1be7726
--- /dev/null
+++ b/web/src/realtime/events.ts
@@ -0,0 +1,30 @@
+import type { PeerTyping, ReadReceipt, RefreshUsersList } from "@/api/types";
+
+// Transient hub signals that shouldn't live in the query cache: thread
+// components subscribe directly (typing dots, presence, seen ticks).
+type EventMap = {
+ peerTyping: PeerTyping;
+ readReceipt: ReadReceipt;
+ refreshUsersList: RefreshUsersList & { roomId?: string };
+ reconnected: void;
+};
+
+type Handler = (payload: T) => void;
+
+const handlers: { [K in keyof EventMap]: Set> } = {
+ peerTyping: new Set(),
+ readReceipt: new Set(),
+ refreshUsersList: new Set(),
+ reconnected: new Set(),
+};
+
+export function on(event: K, handler: Handler): () => void {
+ handlers[event].add(handler);
+ return () => handlers[event].delete(handler);
+}
+
+export function emit(event: K, payload: EventMap[K]): void {
+ for (const handler of handlers[event]) {
+ handler(payload);
+ }
+}
diff --git a/web/src/realtime/toasts.tsx b/web/src/realtime/toasts.tsx
new file mode 100644
index 0000000..851e33e
--- /dev/null
+++ b/web/src/realtime/toasts.tsx
@@ -0,0 +1,63 @@
+import { Heart, MessageCircle } from "lucide-react";
+import { toast } from "sonner";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import type { MatchReceived, MessageNotification } from "@/api/types";
+import { router } from "@/router";
+
+export function MatchToast({ toastId, match }: { toastId: string | number; match: MatchReceived }) {
+ return (
+
+
+
+
+ {match.user.userName[0]?.toUpperCase()}
+
+
+
+
+
+
+
It's a match!
+
You and {match.user.userName} liked each other.
+
+
{
+ toast.dismiss(toastId);
+ void router.navigate(`/messages/${match.roomId}`);
+ }}
+ >
+ Say hi
+
+
+ );
+}
+
+export function MessageToast({
+ toastId,
+ notification,
+}: {
+ toastId: string | number;
+ notification: MessageNotification;
+}) {
+ return (
+ {
+ toast.dismiss(toastId);
+ void router.navigate(`/messages/${notification.roomId}`);
+ }}
+ >
+
+
+
+
+ {notification.senderUserName}
+
+ {notification.text ?? "Sent a photo"}
+
+
+
+ );
+}
diff --git a/web/src/router.tsx b/web/src/router.tsx
new file mode 100644
index 0000000..ceb24c3
--- /dev/null
+++ b/web/src/router.tsx
@@ -0,0 +1,51 @@
+import { createBrowserRouter, Navigate } from "react-router";
+import AppShell from "@/components/app-shell";
+import { AdminGuard, AuthGuard, NoAuthGuard } from "@/components/guards";
+import DashboardPage from "@/features/admin/dashboard-page";
+import UsersPage from "@/features/admin/users-page";
+import LoginPage from "@/features/auth/login-page";
+import RegisterPage from "@/features/auth/register-page";
+import ResetPage from "@/features/auth/reset-page";
+import VerifyPage from "@/features/auth/verify-page";
+import DiscoverPage from "@/features/discover/discover-page";
+import LandingPage from "@/features/landing/landing-page";
+import MessagesPage from "@/features/messages/messages-page";
+import ProfilePage from "@/features/profile/profile-page";
+
+// Exported instance (not just JSX) so non-component code — e.g. toast
+// actions — can navigate via router.navigate().
+export const router = createBrowserRouter([
+ {
+ element: ,
+ children: [
+ { path: "/welcome", element: },
+ { path: "/login", element: },
+ { path: "/register", element: },
+ { path: "/verify", element: },
+ ],
+ },
+ // Reachable both logged in and out (old-app behavior).
+ { path: "/resetPassword", element: },
+ {
+ element: ,
+ children: [
+ {
+ element: ,
+ children: [
+ { path: "/", element: },
+ { path: "/messages", element: },
+ { path: "/messages/:roomId", element: },
+ { path: "/profile", element: },
+ {
+ element: ,
+ children: [
+ { path: "/admin", element: },
+ { path: "/admin/users", element: },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ { path: "*", element: },
+]);
diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts
new file mode 100644
index 0000000..d84ef79
--- /dev/null
+++ b/web/src/stores/auth.ts
@@ -0,0 +1,72 @@
+import { create } from "zustand";
+import type { LoginResponse } from "@/api/types";
+
+// localStorage keys are load-bearing legacy contracts: the axios interceptor
+// and the SignalR accessTokenFactory read "auth" directly.
+const AUTH_KEY = "auth";
+const LOCATION_KEY = "location";
+
+export interface GeoLocation {
+ latitude: number;
+ longitude: number;
+}
+
+interface AuthState {
+ user: LoginResponse | null;
+ location: GeoLocation | null;
+ login: (data: LoginResponse) => void;
+ logout: () => void;
+ patchToken: (token: string) => void;
+ setLocation: (location: GeoLocation) => void;
+}
+
+function read(key: string): T | null {
+ try {
+ const raw = localStorage.getItem(key);
+ return raw ? (JSON.parse(raw) as T) : null;
+ } catch {
+ return null;
+ }
+}
+
+export const useAuthStore = create()((set) => ({
+ user: read(AUTH_KEY),
+ location: read(LOCATION_KEY),
+
+ login: (data) => {
+ localStorage.setItem(AUTH_KEY, JSON.stringify(data));
+ const location = { latitude: data.latitude, longitude: data.longitude };
+ localStorage.setItem(LOCATION_KEY, JSON.stringify(location));
+ set({ user: data, location });
+ },
+
+ logout: () => {
+ localStorage.removeItem(AUTH_KEY);
+ localStorage.removeItem(LOCATION_KEY);
+ set({ user: null, location: null });
+ },
+
+ patchToken: (token) =>
+ set((state) => {
+ if (!state.user) return state;
+ const user = { ...state.user, token };
+ localStorage.setItem(AUTH_KEY, JSON.stringify(user));
+ return { user };
+ }),
+
+ setLocation: (location) => {
+ localStorage.setItem(LOCATION_KEY, JSON.stringify(location));
+ set({ location });
+ },
+}));
+
+export const isLogged = (state: { user: LoginResponse | null }) => !!state.user?.token;
+
+/** Non-hook accessors for the axios/SignalR layers. */
+export function currentToken(): string | undefined {
+ return useAuthStore.getState().user?.token;
+}
+
+export function currentUserId(): string | undefined {
+ return useAuthStore.getState().user?.id;
+}
diff --git a/web/src/stores/preferences.ts b/web/src/stores/preferences.ts
new file mode 100644
index 0000000..5fd98f2
--- /dev/null
+++ b/web/src/stores/preferences.ts
@@ -0,0 +1,33 @@
+import { create } from "zustand";
+import { persist, createJSONStorage } from "zustand/middleware";
+
+export interface Preferences {
+ maxAge: number;
+ maxDistance: number;
+ aroundTheWorld: boolean;
+ gender: number; // -1 = all
+}
+
+interface PreferencesState extends Preferences {
+ set: (patch: Partial) => void;
+}
+
+export const defaultPreferences: Preferences = {
+ maxAge: 100,
+ maxDistance: 600,
+ aroundTheWorld: false,
+ gender: -1,
+};
+
+export const usePreferencesStore = create()(
+ persist(
+ (set) => ({
+ ...defaultPreferences,
+ set: (patch) => set(patch),
+ }),
+ {
+ name: "preferences",
+ storage: createJSONStorage(() => localStorage),
+ },
+ ),
+);
diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json
new file mode 100644
index 0000000..32f1723
--- /dev/null
+++ b/web/tsconfig.app.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "allowArbitraryExtensions": true,
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..c36d52a
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
new file mode 100644
index 0000000..8455dcb
--- /dev/null
+++ b/web/tsconfig.node.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "module": "nodenext",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..b2fcb18
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,23 @@
+import path from "node:path";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+// Port 3000 is load-bearing: the gateway CORS policy and the JWT
+// issuer/audience are configured for http://localhost:3000.
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ port: 3000,
+ strictPort: true,
+ },
+ preview: {
+ port: 3000,
+ strictPort: true,
+ },
+});