From bdfaef6397a05011455cb3aefc3b5437824dfc6d Mon Sep 17 00:00:00 2001 From: miicolas Date: Mon, 8 Jun 2026 23:08:29 +0200 Subject: [PATCH] feat: add taste preferences onboarding --- apps/api/package.json | 4 + apps/api/src/modules/preferences/index.ts | 8 + apps/api/src/modules/preferences/model.ts | 59 + .../modules/preferences/mutations/index.ts | 2 + .../preferences/mutations/record-swipes.ts | 53 + .../modules/preferences/mutations/set-me.ts | 29 + .../src/modules/preferences/queries/get-me.ts | 15 + .../src/modules/preferences/queries/index.ts | 2 + .../preferences/queries/onboarding-seed.ts | 75 + apps/api/src/modules/preferences/router.ts | 31 + apps/api/src/modules/preferences/seed.ts | 31 + apps/api/src/modules/preferences/shared.ts | 61 + apps/api/src/modules/router.ts | 2 + apps/mobile/src/app/(setup)/taste.tsx | 5 + .../mobile/src/app/(tabs)/profile/_layout.tsx | 8 + .../app/(tabs)/profile/taste-preferences.tsx | 5 + apps/mobile/src/app/_layout.tsx | 1 + .../screens/letterboxd-import/index.tsx | 8 +- .../screens/platforms-picker/index.tsx | 8 +- .../profile/account-settings/index.tsx | 30 +- .../linked-accounts-section.tsx | 4 +- .../account-row.tsx => settings-row.tsx} | 10 +- .../profile/taste-preferences/index.tsx | 84 + .../taste-preferences/selectable-section.tsx | 47 + .../taste-preferences/use-preference-draft.ts | 69 + .../components/screens/taste-swipe/index.tsx | 114 + .../screens/taste-swipe/swipe-actions.tsx | 98 + .../screens/taste-swipe/swipe-card.tsx | 73 + .../screens/taste-swipe/swipe-deck.tsx | 185 ++ .../screens/taste-swipe/use-taste-swipe.ts | 93 + apps/mobile/src/constants/movie-genres.ts | 26 +- .../hooks/preferences/use-my-preferences.ts | 19 + .../hooks/preferences/use-set-preferences.ts | 21 + apps/mobile/src/lib/i18n/locales/en.ts | 22 + apps/mobile/src/lib/i18n/locales/fr.ts | 22 + apps/mobile/src/lib/set.ts | 15 + .../handlers/get-my-preferences.ts | 7 + .../handlers/get-onboarding-seed.ts | 7 + .../handlers/record-onboarding-swipes.ts | 7 + .../preferences/handlers/set-preferences.ts | 7 + apps/mobile/src/services/preferences/index.ts | 12 + apps/mobile/src/services/preferences/types.ts | 7 + apps/mobile/src/store/use-onboarding-store.ts | 6 + packages/db/drizzle/0008_far_sunspot.sql | 9 + packages/db/drizzle/meta/0008_snapshot.json | 2194 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 1 + packages/db/src/schema/preferences/index.ts | 1 + packages/db/src/schema/preferences/schema.ts | 29 + packages/shared/package.json | 4 +- packages/shared/src/genres.ts | 38 + packages/shared/src/index.ts | 2 + packages/shared/src/moods.ts | 19 + packages/shared/src/query-keys.ts | 5 + 54 files changed, 3656 insertions(+), 45 deletions(-) create mode 100644 apps/api/src/modules/preferences/index.ts create mode 100644 apps/api/src/modules/preferences/model.ts create mode 100644 apps/api/src/modules/preferences/mutations/index.ts create mode 100644 apps/api/src/modules/preferences/mutations/record-swipes.ts create mode 100644 apps/api/src/modules/preferences/mutations/set-me.ts create mode 100644 apps/api/src/modules/preferences/queries/get-me.ts create mode 100644 apps/api/src/modules/preferences/queries/index.ts create mode 100644 apps/api/src/modules/preferences/queries/onboarding-seed.ts create mode 100644 apps/api/src/modules/preferences/router.ts create mode 100644 apps/api/src/modules/preferences/seed.ts create mode 100644 apps/api/src/modules/preferences/shared.ts create mode 100644 apps/mobile/src/app/(setup)/taste.tsx create mode 100644 apps/mobile/src/app/(tabs)/profile/taste-preferences.tsx rename apps/mobile/src/components/screens/profile/{account-settings/account-row.tsx => settings-row.tsx} (59%) create mode 100644 apps/mobile/src/components/screens/profile/taste-preferences/index.tsx create mode 100644 apps/mobile/src/components/screens/profile/taste-preferences/selectable-section.tsx create mode 100644 apps/mobile/src/components/screens/profile/taste-preferences/use-preference-draft.ts create mode 100644 apps/mobile/src/components/screens/taste-swipe/index.tsx create mode 100644 apps/mobile/src/components/screens/taste-swipe/swipe-actions.tsx create mode 100644 apps/mobile/src/components/screens/taste-swipe/swipe-card.tsx create mode 100644 apps/mobile/src/components/screens/taste-swipe/swipe-deck.tsx create mode 100644 apps/mobile/src/components/screens/taste-swipe/use-taste-swipe.ts create mode 100644 apps/mobile/src/hooks/preferences/use-my-preferences.ts create mode 100644 apps/mobile/src/hooks/preferences/use-set-preferences.ts create mode 100644 apps/mobile/src/lib/set.ts create mode 100644 apps/mobile/src/services/preferences/handlers/get-my-preferences.ts create mode 100644 apps/mobile/src/services/preferences/handlers/get-onboarding-seed.ts create mode 100644 apps/mobile/src/services/preferences/handlers/record-onboarding-swipes.ts create mode 100644 apps/mobile/src/services/preferences/handlers/set-preferences.ts create mode 100644 apps/mobile/src/services/preferences/index.ts create mode 100644 apps/mobile/src/services/preferences/types.ts create mode 100644 packages/db/drizzle/0008_far_sunspot.sql create mode 100644 packages/db/drizzle/meta/0008_snapshot.json create mode 100644 packages/db/src/schema/preferences/index.ts create mode 100644 packages/db/src/schema/preferences/schema.ts create mode 100644 packages/shared/src/genres.ts create mode 100644 packages/shared/src/moods.ts diff --git a/apps/api/package.json b/apps/api/package.json index 7bdf10b..bc29a4a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,10 @@ "types": "./src/modules/platforms/model.ts", "default": "./src/modules/platforms/model.ts" }, + "./preferences": { + "types": "./src/modules/preferences/model.ts", + "default": "./src/modules/preferences/model.ts" + }, "./recommendations": { "types": "./src/modules/recommendations/model.ts", "default": "./src/modules/recommendations/model.ts" diff --git a/apps/api/src/modules/preferences/index.ts b/apps/api/src/modules/preferences/index.ts new file mode 100644 index 0000000..3bc9595 --- /dev/null +++ b/apps/api/src/modules/preferences/index.ts @@ -0,0 +1,8 @@ +export { preferencesController } from "./router"; +export type { + PreferencesMeDto, + PreferencesInputDto, + SeedItemDto, + SwipeItemDto, + SwipeResultDto, +} from "./model"; diff --git a/apps/api/src/modules/preferences/model.ts b/apps/api/src/modules/preferences/model.ts new file mode 100644 index 0000000..5322a0e --- /dev/null +++ b/apps/api/src/modules/preferences/model.ts @@ -0,0 +1,59 @@ +import { Elysia, t } from "elysia"; +import type { Static } from "@sinclair/typebox"; + +const mediaType = t.Union([t.Literal("movie"), t.Literal("tv")]); + +const input = t.Object({ + favorite_genres: t.Array(t.Integer()), + disliked_genres: t.Array(t.Integer()), + moods: t.Array(t.String()), +}); + +// The stored shape is the input plus the server-managed timestamp. +const me = t.Composite([input, t.Object({ updated_at: t.Nullable(t.String()) })]); + +// Card payload for the onboarding swipe deck — a subset of the TMDB summary +// shape so the client can render it with its existing poster components. +const seedItem = t.Object({ + id: t.Number(), + media_type: mediaType, + title: t.Optional(t.String()), + original_title: t.Optional(t.String()), + overview: t.Optional(t.String()), + release_date: t.Optional(t.String()), + poster_path: t.Optional(t.Nullable(t.String())), + backdrop_path: t.Optional(t.Nullable(t.String())), + vote_average: t.Optional(t.Number()), + genre_ids: t.Optional(t.Array(t.Number())), +}); + +const seedList = t.Array(seedItem); + +const swipeItem = t.Object({ + tmdb_id: t.Number(), + media_type: mediaType, + choice: t.Union([t.Literal("like"), t.Literal("dislike")]), +}); + +const swipeBatch = t.Object({ + items: t.Array(swipeItem, { maxItems: 100 }), +}); + +const swipeResult = t.Object({ + liked: t.Number(), + disliked: t.Number(), +}); + +export const PreferencesModel = new Elysia({ name: "Preferences.Model" }).model({ + "preferences.Me": me, + "preferences.Input": input, + "preferences.SeedList": seedList, + "preferences.SwipeBatch": swipeBatch, + "preferences.SwipeResult": swipeResult, +}); + +export type PreferencesMeDto = Static; +export type PreferencesInputDto = Static; +export type SeedItemDto = Static; +export type SwipeItemDto = Static; +export type SwipeResultDto = Static; diff --git a/apps/api/src/modules/preferences/mutations/index.ts b/apps/api/src/modules/preferences/mutations/index.ts new file mode 100644 index 0000000..ae34c32 --- /dev/null +++ b/apps/api/src/modules/preferences/mutations/index.ts @@ -0,0 +1,2 @@ +export { setMyPreferences } from "./set-me"; +export { recordOnboardingSwipes } from "./record-swipes"; diff --git a/apps/api/src/modules/preferences/mutations/record-swipes.ts b/apps/api/src/modules/preferences/mutations/record-swipes.ts new file mode 100644 index 0000000..57ce8f4 --- /dev/null +++ b/apps/api/src/modules/preferences/mutations/record-swipes.ts @@ -0,0 +1,53 @@ +import { recordInteractions } from "../../events/mutations"; +import type { InteractionEventInput } from "../../events/shared"; +import { addLike } from "../../likes/mutations"; +import { dismiss } from "../../not-interested/mutations"; +import type { MediaType } from "../../tmdb"; + +export type OnboardingSwipe = { + tmdb_id: number; + media_type: MediaType; + choice: "like" | "dislike"; +}; + +// Collapses repeated swipes on the same title (last choice wins) so counts and +// interaction_events aren't double-written when the client sends duplicates. +function dedupeSwipes(items: OnboardingSwipe[]): OnboardingSwipe[] { + const byKey = new Map(); + for (const item of items) byKey.set(`${item.media_type}:${item.tmdb_id}`, item); + return [...byKey.values()]; +} + +// One write path for an onboarding swipe batch: likes → likes table, dislikes → +// not-interested (reason "onboarding"), and every choice → interaction_events +// (source "onboarding") so #12 can weight it. Skips never reach here. +// +// Per-item writes run concurrently and a single failed title (e.g. a TMDB +// lookup error) is skipped rather than aborting the batch — so one bad id can't +// leave likes/dismisses persisted with their interaction_events dropped. Each +// event is emitted only for a write that succeeded, keeping the two consistent. +export async function recordOnboardingSwipes(userId: string, items: OnboardingSwipe[]) { + const results = await Promise.all( + dedupeSwipes(items).map(async (item): Promise => { + const ref = { tmdb_id: item.tmdb_id, media_type: item.media_type }; + try { + if (item.choice === "like") { + await addLike(userId, { ...ref, kind: "like" }); + return { type: "liked", ...ref, metadata: { source: "onboarding" } }; + } + await dismiss(userId, { ...ref, reason: "onboarding" }); + return { type: "not_interested", ...ref, metadata: { source: "onboarding" } }; + } catch { + return null; + } + }), + ); + + const events = results.filter((event): event is InteractionEventInput => event !== null); + await recordInteractions(userId, events); + + return { + liked: events.filter((event) => event.type === "liked").length, + disliked: events.filter((event) => event.type === "not_interested").length, + }; +} diff --git a/apps/api/src/modules/preferences/mutations/set-me.ts b/apps/api/src/modules/preferences/mutations/set-me.ts new file mode 100644 index 0000000..31818f0 --- /dev/null +++ b/apps/api/src/modules/preferences/mutations/set-me.ts @@ -0,0 +1,29 @@ +import { db } from "@seen/db"; +import { userPreferences } from "@seen/db/schema"; + +import { type PreferencesInput, toPreferences, validatePreferences } from "../shared"; + +export async function setMyPreferences(userId: string, input: PreferencesInput) { + const clean = validatePreferences(input); + + const [row] = await db + .insert(userPreferences) + .values({ + userId, + favoriteGenres: clean.favorite_genres, + dislikedGenres: clean.disliked_genres, + moods: clean.moods, + }) + .onConflictDoUpdate({ + target: userPreferences.userId, + set: { + favoriteGenres: clean.favorite_genres, + dislikedGenres: clean.disliked_genres, + moods: clean.moods, + updatedAt: new Date(), + }, + }) + .returning(); + + return toPreferences(row); +} diff --git a/apps/api/src/modules/preferences/queries/get-me.ts b/apps/api/src/modules/preferences/queries/get-me.ts new file mode 100644 index 0000000..968e905 --- /dev/null +++ b/apps/api/src/modules/preferences/queries/get-me.ts @@ -0,0 +1,15 @@ +import { db } from "@seen/db"; +import { userPreferences } from "@seen/db/schema"; +import { eq } from "@seen/db/orm"; + +import { toPreferences } from "../shared"; + +export async function getMyPreferences(userId: string) { + const [row] = await db + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, userId)) + .limit(1); + + return toPreferences(row); +} diff --git a/apps/api/src/modules/preferences/queries/index.ts b/apps/api/src/modules/preferences/queries/index.ts new file mode 100644 index 0000000..0baeb90 --- /dev/null +++ b/apps/api/src/modules/preferences/queries/index.ts @@ -0,0 +1,2 @@ +export { getMyPreferences } from "./get-me"; +export { getOnboardingSeed } from "./onboarding-seed"; diff --git a/apps/api/src/modules/preferences/queries/onboarding-seed.ts b/apps/api/src/modules/preferences/queries/onboarding-seed.ts new file mode 100644 index 0000000..87dc1b7 --- /dev/null +++ b/apps/api/src/modules/preferences/queries/onboarding-seed.ts @@ -0,0 +1,75 @@ +import { db } from "@seen/db"; +import { likes, notInterested, reviews, watchlist } from "@seen/db/schema"; +import { and, eq, inArray } from "@seen/db/orm"; + +import { getMediaDetail } from "../../tmdb"; +import type { MovieDetailDto } from "../../tmdb/model"; +import type { SeedItemDto } from "../model"; +import { SEED_TITLES } from "../seed"; + +// Titles the user has already acted on — we never ask "seen this?" about them. +async function getSeenKeys(userId: string, tmdbIds: number[]): Promise> { + if (tmdbIds.length === 0) return new Set(); + + const [reviewed, listed, liked, dismissed] = await Promise.all([ + db + .select({ tmdbId: reviews.tmdbId, mediaType: reviews.mediaType }) + .from(reviews) + .where(and(eq(reviews.userId, userId), inArray(reviews.tmdbId, tmdbIds))), + db + .select({ tmdbId: watchlist.tmdbId, mediaType: watchlist.mediaType }) + .from(watchlist) + .where(and(eq(watchlist.userId, userId), inArray(watchlist.tmdbId, tmdbIds))), + db + .select({ tmdbId: likes.tmdbId, mediaType: likes.mediaType }) + .from(likes) + .where(and(eq(likes.userId, userId), inArray(likes.tmdbId, tmdbIds))), + db + .select({ tmdbId: notInterested.tmdbId, mediaType: notInterested.mediaType }) + .from(notInterested) + .where(and(eq(notInterested.userId, userId), inArray(notInterested.tmdbId, tmdbIds))), + ]); + + const seen = new Set(); + for (const rows of [reviewed, listed, liked, dismissed]) { + for (const row of rows) seen.add(`${row.mediaType}:${row.tmdbId}`); + } + return seen; +} + +function toSeedItem(detail: MovieDetailDto): SeedItemDto { + return { + id: detail.id, + media_type: detail.media_type, + title: detail.title, + original_title: detail.original_title, + overview: detail.overview, + release_date: detail.release_date, + poster_path: detail.poster_path ?? null, + backdrop_path: detail.backdrop_path ?? null, + vote_average: detail.vote_average, + genre_ids: detail.genre_ids ?? detail.genres?.map((genre) => genre.id), + }; +} + +// Returns the curated seed (already-seen titles removed), resolved to cards via +// the TMDB cache, in the diverse round-robin order. The client slices ~8/~18. +export async function getOnboardingSeed(userId: string): Promise { + const seen = await getSeenKeys( + userId, + SEED_TITLES.map((entry) => entry.tmdbId), + ); + const remaining = SEED_TITLES.filter((entry) => !seen.has(`${entry.mediaType}:${entry.tmdbId}`)); + + const items = await Promise.all( + remaining.map(async (entry) => { + try { + return toSeedItem(await getMediaDetail(entry.mediaType, entry.tmdbId)); + } catch { + return null; + } + }), + ); + + return items.filter((item): item is SeedItemDto => item !== null); +} diff --git a/apps/api/src/modules/preferences/router.ts b/apps/api/src/modules/preferences/router.ts new file mode 100644 index 0000000..08837d6 --- /dev/null +++ b/apps/api/src/modules/preferences/router.ts @@ -0,0 +1,31 @@ +import { Elysia } from "elysia"; + +import { authGuard } from "../../auth-plugin"; +import { PreferencesModel } from "./model"; +import { recordOnboardingSwipes, setMyPreferences } from "./mutations"; +import { getMyPreferences, getOnboardingSeed } from "./queries"; + +export const preferencesController = new Elysia({ + name: "Preferences.Controller", + prefix: "/preferences", +}) + .use(authGuard) + .use(PreferencesModel) + .get("/me", ({ user }) => getMyPreferences(user.id), { + auth: true, + response: { 200: "preferences.Me" }, + }) + .put("/me", ({ user, body }) => setMyPreferences(user.id, body), { + auth: true, + body: "preferences.Input", + response: { 200: "preferences.Me" }, + }) + .get("/onboarding-seed", ({ user }) => getOnboardingSeed(user.id), { + auth: true, + response: { 200: "preferences.SeedList" }, + }) + .post("/onboarding-swipes", ({ user, body }) => recordOnboardingSwipes(user.id, body.items), { + auth: true, + body: "preferences.SwipeBatch", + response: { 200: "preferences.SwipeResult" }, + }); diff --git a/apps/api/src/modules/preferences/seed.ts b/apps/api/src/modules/preferences/seed.ts new file mode 100644 index 0000000..870f07c --- /dev/null +++ b/apps/api/src/modules/preferences/seed.ts @@ -0,0 +1,31 @@ +import type { MediaType } from "../tmdb"; + +export type SeedEntry = { + tmdbId: number; + mediaType: MediaType; +}; + +// Fixed, hand-curated diverse seed (#10 v1 — adaptive selection is out of scope). +// Ordered round-robin so any prefix (the importer-shortened ~8 or the full ~18) +// already spans many genres and decades. The genre/decade of each title is noted +// in the trailing comment so the diversity intent stays readable. +export const SEED_TITLES: SeedEntry[] = [ + { tmdbId: 278, mediaType: "movie" }, // The Shawshank Redemption — Drama, 1990s + { tmdbId: 76341, mediaType: "movie" }, // Mad Max: Fury Road — Action, 2010s + { tmdbId: 129, mediaType: "movie" }, // Spirited Away — Animation, 2000s + { tmdbId: 238, mediaType: "movie" }, // The Godfather — Crime, 1970s + { tmdbId: 603, mediaType: "movie" }, // The Matrix — Sci-Fi, 1990s + { tmdbId: 419430, mediaType: "movie" }, // Get Out — Horror, 2010s + { tmdbId: 1399, mediaType: "tv" }, // Game of Thrones — Fantasy, 2010s + { tmdbId: 289, mediaType: "movie" }, // Casablanca — Romance, 1940s + { tmdbId: 680, mediaType: "movie" }, // Pulp Fiction — Thriller, 1990s + { tmdbId: 105, mediaType: "movie" }, // Back to the Future — Adventure, 1980s + { tmdbId: 244786, mediaType: "movie" }, // Whiplash — Music, 2010s + { tmdbId: 1396, mediaType: "tv" }, // Breaking Bad — Crime, 2000s + { tmdbId: 62, mediaType: "movie" }, // 2001: A Space Odyssey — Sci-Fi, 1960s + { tmdbId: 539, mediaType: "movie" }, // Psycho — Horror, 1960s + { tmdbId: 120467, mediaType: "movie" }, // The Grand Budapest Hotel — Comedy, 2010s + { tmdbId: 120, mediaType: "movie" }, // LOTR: Fellowship of the Ring — Adventure, 2000s + { tmdbId: 510, mediaType: "movie" }, // One Flew Over the Cuckoo's Nest — Drama, 1970s + { tmdbId: 66732, mediaType: "tv" }, // Stranger Things — Sci-Fi, 2010s +]; diff --git a/apps/api/src/modules/preferences/shared.ts b/apps/api/src/modules/preferences/shared.ts new file mode 100644 index 0000000..23d6b76 --- /dev/null +++ b/apps/api/src/modules/preferences/shared.ts @@ -0,0 +1,61 @@ +import { isKnownGenreId, isKnownMood } from "@seen/shared"; +import type { userPreferences } from "@seen/db/schema"; + +import { HttpError } from "../../lib/http-error"; + +export type PreferencesInput = { + favorite_genres: number[]; + disliked_genres: number[]; + moods: string[]; +}; + +function unique(values: T[]): T[] { + return [...new Set(values)]; +} + +function sharedValues(left: T[], right: T[]): T[] { + const rightValues = new Set(right); + return left.filter((value) => rightValues.has(value)); +} + +function assertKnownGenreIds(ids: number[]) { + for (const id of ids) { + if (!isKnownGenreId(id)) throw new HttpError(400, `Unknown genre id: ${id}`); + } +} + +function assertKnownMoods(moods: string[]) { + for (const mood of moods) { + if (!isKnownMood(mood)) throw new HttpError(400, `Unknown mood: ${mood}`); + } +} + +export function toPreferences(row: typeof userPreferences.$inferSelect | undefined) { + if (!row) { + return { favorite_genres: [], disliked_genres: [], moods: [], updated_at: null }; + } + return { + favorite_genres: row.favoriteGenres, + disliked_genres: row.dislikedGenres, + moods: row.moods, + updated_at: row.updatedAt.toISOString(), + }; +} + +// Dedupes, then enforces: known genre ids, known moods, and that no genre is +// both a favorite and a dislike. Returns the cleaned input. +export function validatePreferences(input: PreferencesInput): PreferencesInput { + const favorite_genres = unique(input.favorite_genres); + const disliked_genres = unique(input.disliked_genres); + const moods = unique(input.moods); + + assertKnownGenreIds([...favorite_genres, ...disliked_genres]); + assertKnownMoods(moods); + + const overlap = sharedValues(favorite_genres, disliked_genres); + if (overlap.length > 0) { + throw new HttpError(400, `Genres cannot be both favorite and disliked: ${overlap.join(", ")}`); + } + + return { favorite_genres, disliked_genres, moods }; +} diff --git a/apps/api/src/modules/router.ts b/apps/api/src/modules/router.ts index dfdf5bf..8ffcbec 100644 --- a/apps/api/src/modules/router.ts +++ b/apps/api/src/modules/router.ts @@ -7,6 +7,7 @@ import { importController } from "./import"; import { likesController } from "./likes"; import { notInterestedController } from "./not-interested"; import { platformsController } from "./platforms"; +import { preferencesController } from "./preferences"; import { profileController } from "./profiles"; import { recommendationsController } from "./recommendations"; import { reviewController } from "./reviews"; @@ -27,5 +28,6 @@ export const apiRouter = new Elysia({ name: "api.router" }) .use(importController) .use(accountController) .use(platformsController) + .use(preferencesController) .use(recommendationsController) .use(whatsNewController); diff --git a/apps/mobile/src/app/(setup)/taste.tsx b/apps/mobile/src/app/(setup)/taste.tsx new file mode 100644 index 0000000..a7a9768 --- /dev/null +++ b/apps/mobile/src/app/(setup)/taste.tsx @@ -0,0 +1,5 @@ +import { TasteSwipe } from "@/components/screens/taste-swipe"; + +export default function SetupTasteRoute() { + return ; +} diff --git a/apps/mobile/src/app/(tabs)/profile/_layout.tsx b/apps/mobile/src/app/(tabs)/profile/_layout.tsx index 4d96a48..24da451 100644 --- a/apps/mobile/src/app/(tabs)/profile/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/profile/_layout.tsx @@ -23,6 +23,14 @@ export default function ProfileLayout() { headerBackButtonDisplayMode: "minimal", }} /> + ; +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index bc89a66..655c914 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -33,6 +33,7 @@ function RootNavigator() { + diff --git a/apps/mobile/src/components/screens/letterboxd-import/index.tsx b/apps/mobile/src/components/screens/letterboxd-import/index.tsx index 32f49b3..6733550 100644 --- a/apps/mobile/src/components/screens/letterboxd-import/index.tsx +++ b/apps/mobile/src/components/screens/letterboxd-import/index.tsx @@ -98,7 +98,7 @@ export function LetterboxdImport({ mode }: LetterboxdImportProps) { hapticTap(); if (mode === "onboarding") { if (!summary) markImportSkipped(); - router.replace("/platforms"); + router.replace("/taste"); } else { router.back(); } @@ -111,7 +111,11 @@ export function LetterboxdImport({ mode }: LetterboxdImportProps) { : null; return ( - +
{ - const next = new Set(prev); - if (next.has(providerId)) next.delete(providerId); - else next.add(providerId); - return next; - }); + setSelected((prev) => toggleInSet(prev, providerId)); } async function save({ skipped }: { skipped: boolean }) { diff --git a/apps/mobile/src/components/screens/profile/account-settings/index.tsx b/apps/mobile/src/components/screens/profile/account-settings/index.tsx index bb6528d..b5f4278 100644 --- a/apps/mobile/src/components/screens/profile/account-settings/index.tsx +++ b/apps/mobile/src/components/screens/profile/account-settings/index.tsx @@ -11,7 +11,7 @@ import { useSessions } from "@/hooks/account/use-sessions"; import { hapticError, hapticSuccess, hapticTap } from "@/lib/haptics"; import { signOut } from "@/services/account"; -import { AccountRow } from "./account-row"; +import { SettingsRow } from "../settings-row"; import { LinkedAccountsSection } from "./linked-accounts-section"; import { SessionsSection } from "./sessions-section"; @@ -43,6 +43,11 @@ export function AccountSettingsSheet() { router.push("/profile/platforms"); }, [router]); + const openTastePreferences = useCallback(() => { + hapticTap(); + router.push("/profile/taste-preferences"); + }, [router]); + const openWhatsNew = useCallback(() => { hapticTap(); router.push("/whats-new"); @@ -70,21 +75,30 @@ export function AccountSettingsSheet() { - +
- - - - + + +
@@ -92,7 +106,7 @@ export function AccountSettingsSheet() {
-
- ; @@ -49,7 +49,7 @@ export function LinkedAccountsSection({ linked }: { linked: LinkedAccounts }) { ) : linked.accounts.length > 0 ? ( linked.accounts.map((account) => ( - void; destructive?: boolean; } -// A settings row. The icon color comes from the parent Form's `tint` (the app -// accent) — a Form Button's local tint only colors the label, not the SF Symbol. -// So here the label is tinted to the primary text color while the Form keeps the -// accent on the icon; destructive rows stay fully red via `role`. -export function AccountRow({ icon, label, onPress, destructive }: AccountRowProps) { +// A native settings row. The icon color comes from the parent Form's `tint` +// while non-destructive labels use the primary text color. +export function SettingsRow({ icon, label, onPress, destructive }: SettingsRowProps) { const theme = useTheme(); if (destructive) { diff --git a/apps/mobile/src/components/screens/profile/taste-preferences/index.tsx b/apps/mobile/src/components/screens/profile/taste-preferences/index.tsx new file mode 100644 index 0000000..44f00ca --- /dev/null +++ b/apps/mobile/src/components/screens/profile/taste-preferences/index.tsx @@ -0,0 +1,84 @@ +import { Form, Host, Text as SwiftUIText } from "@expo/ui/swift-ui"; +import { tint } from "@expo/ui/swift-ui/modifiers"; +import { MOODS, MOVIE_GENRES_LIST } from "@seen/shared"; +import { Stack, useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; + +import { useMyPreferences } from "@/hooks/preferences/use-my-preferences"; +import { useSetPreferences } from "@/hooks/preferences/use-set-preferences"; +import { useAccentColor } from "@/hooks/use-accent-color"; +import { hapticTap } from "@/lib/haptics"; + +import { SelectableSection, type SelectableItem } from "./selectable-section"; +import { usePreferenceDraft } from "./use-preference-draft"; + +const GENRE_ITEMS: SelectableItem[] = MOVIE_GENRES_LIST.map((genre) => ({ + value: genre.id, + label: genre.name, +})); +const MOOD_ITEMS: SelectableItem[] = MOODS.map((mood) => ({ value: mood, label: mood })); + +export function TastePreferences() { + const { t } = useTranslation(); + const router = useRouter(); + const { accentHex } = useAccentColor(); + + const { data } = useMyPreferences(); + const setMutation = useSetPreferences(); + const draft = usePreferenceDraft(data); + + async function save() { + if (setMutation.isPending) return; + hapticTap(); + try { + await setMutation.mutateAsync(draft.input); + router.back(); + } catch { + // hapticError fires in the mutation; stay on screen so the user can retry. + } + } + + return ( + <> + + + {setMutation.isPending ? t("taste.saving") : t("taste.save")} + + + + + + + + + + {t("taste.settingsSubtitle")}} + /> + + + + ); +} diff --git a/apps/mobile/src/components/screens/profile/taste-preferences/selectable-section.tsx b/apps/mobile/src/components/screens/profile/taste-preferences/selectable-section.tsx new file mode 100644 index 0000000..a6bb0e2 --- /dev/null +++ b/apps/mobile/src/components/screens/profile/taste-preferences/selectable-section.tsx @@ -0,0 +1,47 @@ +import { Section } from "@expo/ui/swift-ui"; +import type { ReactElement } from "react"; + +import { hapticSelection } from "@/lib/haptics"; + +import { SettingsRow } from "../settings-row"; + +export type SelectableItem = { + value: T; + label: string; +}; + +type Props = { + title: string; + items: SelectableItem[]; + selected: Set; + onToggle: (value: T) => void; + footer?: ReactElement; +}; + +// A Form section of toggleable rows backed by a Set — the shared shape behind +// the favorite-genres, disliked-genres, and moods pickers. +export function SelectableSection({ + title, + items, + selected, + onToggle, + footer, +}: Props) { + function select(value: T) { + hapticSelection(); + onToggle(value); + } + + return ( +
+ {items.map((item) => ( + select(item.value)} + /> + ))} +
+ ); +} diff --git a/apps/mobile/src/components/screens/profile/taste-preferences/use-preference-draft.ts b/apps/mobile/src/components/screens/profile/taste-preferences/use-preference-draft.ts new file mode 100644 index 0000000..27b9974 --- /dev/null +++ b/apps/mobile/src/components/screens/profile/taste-preferences/use-preference-draft.ts @@ -0,0 +1,69 @@ +import { useMemo, useState } from "react"; + +import { removeFromSet, toggleInSet } from "@/lib/set"; +import type { Preferences, PreferencesInput } from "@/services/preferences"; + +type PreferenceDraft = { + favorite: Set; + disliked: Set; + moods: Set; +}; + +function toDraft(preferences?: Preferences | null): PreferenceDraft { + return { + favorite: new Set(preferences?.favorite_genres ?? []), + disliked: new Set(preferences?.disliked_genres ?? []), + moods: new Set(preferences?.moods ?? []), + }; +} + +export function usePreferenceDraft(preferences: Preferences | null) { + const [draft, setDraft] = useState(() => toDraft()); + const [applied, setApplied] = useState(null); + + // Seed local selections from the server once it loads (render-phase sync, the + // same pattern the platforms picker uses). + if (preferences && preferences !== applied) { + setApplied(preferences); + setDraft(toDraft(preferences)); + } + + const input = useMemo( + () => ({ + favorite_genres: [...draft.favorite], + disliked_genres: [...draft.disliked], + moods: [...draft.moods], + }), + [draft.favorite, draft.disliked, draft.moods], + ); + + function toggleFavorite(id: number) { + setDraft((prev) => ({ + ...prev, + favorite: toggleInSet(prev.favorite, id), + disliked: removeFromSet(prev.disliked, id), + })); + } + + function toggleDisliked(id: number) { + setDraft((prev) => ({ + ...prev, + disliked: toggleInSet(prev.disliked, id), + favorite: removeFromSet(prev.favorite, id), + })); + } + + function toggleMood(mood: string) { + setDraft((prev) => ({ ...prev, moods: toggleInSet(prev.moods, mood) })); + } + + return { + favorite: draft.favorite, + disliked: draft.disliked, + moods: draft.moods, + input, + toggleFavorite, + toggleDisliked, + toggleMood, + }; +} diff --git a/apps/mobile/src/components/screens/taste-swipe/index.tsx b/apps/mobile/src/components/screens/taste-swipe/index.tsx new file mode 100644 index 0000000..e5f3982 --- /dev/null +++ b/apps/mobile/src/components/screens/taste-swipe/index.tsx @@ -0,0 +1,114 @@ +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { Button } from "@/components/ui/button/button"; +import { Text } from "@/components/ui/text"; +import { SPACING } from "@/constants/design-tokens"; +import { useTheme } from "@/hooks/use-theme"; + +import { SwipeDeck } from "./swipe-deck"; +import { useTasteSwipe } from "./use-taste-swipe"; + +export function TasteSwipe() { + const { t } = useTranslation(); + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const swipe = useTasteSwipe(); + + const showContinue = !swipe.isLoading && (swipe.hasError || swipe.total === 0); + + return ( + + + + {t("taste.onboardingTitle")} + + + {t("taste.onboardingSubtitle")} + + + + + {swipe.isLoading ? ( + + + + ) : swipe.hasError ? ( + + + {t("taste.loadError")} + + + ) : swipe.total === 0 ? ( + + + {t("taste.empty")} + + + ) : swipe.currentCard ? ( + <> + + {t("taste.progress", { current: swipe.progress, total: swipe.total }).toUpperCase()} + + + + ) : ( + + + + {t("taste.finishing")} + + + )} + + + +