diff --git a/apps/api/src/modules/tmdb/router.ts b/apps/api/src/modules/tmdb/router.ts index 624dc08..cbe84fd 100644 --- a/apps/api/src/modules/tmdb/router.ts +++ b/apps/api/src/modules/tmdb/router.ts @@ -5,6 +5,7 @@ import { TmdbModel } from "./model"; import { discoverFeed, getMediaDetail, + getMediaRecommendations, getTvEpisodeDetail, getTvSeasonDetail, getWatchProviders, @@ -66,6 +67,18 @@ export const tmdbController = new Elysia({ }, }, ) + .get( + "/:mediaType/:tmdbId/recommendations", + ({ params, query }) => + getMediaRecommendations(params.mediaType, params.tmdbId, query.language ?? DEFAULT_LANGUAGE), + { + params: "tmdb.MediaParams", + query: "tmdb.LanguageQuery", + response: { + 200: "tmdb.SummaryList", + }, + }, + ) .get( "/tv/:seriesId/season/:seasonNumber", ({ params, query }) => diff --git a/apps/api/src/modules/whats-new/data.ts b/apps/api/src/modules/whats-new/data.ts index 4f9a096..2819929 100644 --- a/apps/api/src/modules/whats-new/data.ts +++ b/apps/api/src/modules/whats-new/data.ts @@ -20,6 +20,19 @@ interface WhatsNewRelease { } export const WHATS_NEW_RELEASES: WhatsNewRelease[] = [ + { + id: "you-may-also-like", + features: [ + { + icon: "rectangle.stack", + title: { en: "You May Also Like", fr: "À voir aussi" }, + description: { + en: "Discover related titles right on any movie or series page.", + fr: "Trouvez des titres similaires directement sur chaque film ou série.", + }, + }, + ], + }, { id: "letterboxd-account", features: [ diff --git a/apps/mobile/src/components/screens/media-detail/index.tsx b/apps/mobile/src/components/screens/media-detail/index.tsx index ba8132a..e7d0836 100644 --- a/apps/mobile/src/components/screens/media-detail/index.tsx +++ b/apps/mobile/src/components/screens/media-detail/index.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ScreenHeader, ScreenToolbar, type ScreenAction } from "@/components/navigation"; import { Text } from "@/components/ui/text"; +import { useMediaRouteBase } from "@/hooks/use-media-route-base"; import { useTheme } from "@/hooks/use-theme"; import { useWatchAction } from "@/hooks/watch-sessions/use-watch-action"; @@ -17,6 +18,7 @@ import { MediaParallaxHeader } from "./media-parallax-header"; import { MediaSummary } from "./media-summary"; import { OverviewSection } from "./overview-section"; import { RatingsSection } from "./ratings-section"; +import { RecommendationsSection } from "./recommendations-section"; import { useMediaDetailViewModel } from "./use-media-detail-view-model"; import { WatchProvidersSection } from "./watch-providers-section"; @@ -24,6 +26,7 @@ export function MediaDetail() { const theme = useTheme(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); + const base = useMediaRouteBase(); const vm = useMediaDetailViewModel(); const handleRate = useCallback(() => vm.openReview(vm.myStars || undefined), [vm]); @@ -148,6 +151,8 @@ export function MediaDetail() { onOpenReviews={vm.openReviews} /> + + {vm.isLoading && !vm.detail ? ( Loading… diff --git a/apps/mobile/src/components/screens/media-detail/recommendations-section.tsx b/apps/mobile/src/components/screens/media-detail/recommendations-section.tsx new file mode 100644 index 0000000..5b80969 --- /dev/null +++ b/apps/mobile/src/components/screens/media-detail/recommendations-section.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from "react-i18next"; +import { StyleSheet, View } from "react-native"; + +import { PosterCard } from "@/components/discover/poster-card"; +import { Shelf } from "@/components/discover/shelf"; +import { SPACING } from "@/constants/design-tokens"; +import { useMediaRecommendations } from "@/hooks/tmdb/use-media-recommendations"; +import type { MediaType } from "@/lib/tmdb"; +import type { MediaRouteBase } from "@/lib/navigation"; + +import { DetailSection } from "./detail-section"; + +export function RecommendationsSection({ + tmdbId, + mediaType, + base, +}: { + tmdbId: number; + mediaType: MediaType; + base: MediaRouteBase; +}) { + const { t } = useTranslation(); + const { media } = useMediaRecommendations(tmdbId, mediaType); + + if (media.length === 0) return null; + + return ( + + + `${item.media_type}-${item.id}`} + renderItem={(item, _index, cardWidth) => ( + + )} + visibleCards={3} + /> + + + ); +} + +const styles = StyleSheet.create({ + // The parallax body pads horizontally by SPACING.MD; break out so the shelf + // spans edge-to-edge like the cast row, letting Shelf own its own padding. + shelf: { + marginHorizontal: -SPACING.MD, + }, +}); diff --git a/apps/mobile/src/hooks/tmdb/use-media-recommendations.ts b/apps/mobile/src/hooks/tmdb/use-media-recommendations.ts new file mode 100644 index 0000000..938efad --- /dev/null +++ b/apps/mobile/src/hooks/tmdb/use-media-recommendations.ts @@ -0,0 +1,33 @@ +import { useQuery } from "@tanstack/react-query"; +import { tmdbKeys } from "@seen/shared"; +import { useTranslation } from "react-i18next"; + +import { + getMediaRecommendations, + type MediaType, + type TmdbMovieSummary, + tmdbLanguage, +} from "@/lib/tmdb"; + +interface MediaRecommendationsState { + media: TmdbMovieSummary[]; + isLoading: boolean; +} + +export function useMediaRecommendations( + tmdbId: number, + mediaType: MediaType, +): MediaRecommendationsState { + const { i18n } = useTranslation(); + const language = tmdbLanguage(i18n.language); + const query = useQuery({ + queryKey: tmdbKeys.recommendations(mediaType, tmdbId, language), + queryFn: () => getMediaRecommendations(tmdbId, mediaType, language), + enabled: Number.isFinite(tmdbId) && tmdbId > 0, + }); + + return { + media: query.data ?? [], + isLoading: query.isLoading, + }; +} diff --git a/apps/mobile/src/lib/i18n/locales/en.ts b/apps/mobile/src/lib/i18n/locales/en.ts index a15d6eb..43137e5 100644 --- a/apps/mobile/src/lib/i18n/locales/en.ts +++ b/apps/mobile/src/lib/i18n/locales/en.ts @@ -170,6 +170,7 @@ export const en = { noReviewsHint: "Be the first to share what you thought.", retry: "Retry", whereToWatch: "Where to watch", + youMayAlsoLike: "You may also like", }, likes: { like: "Like", diff --git a/apps/mobile/src/lib/i18n/locales/fr.ts b/apps/mobile/src/lib/i18n/locales/fr.ts index 0b6965c..30a4e35 100644 --- a/apps/mobile/src/lib/i18n/locales/fr.ts +++ b/apps/mobile/src/lib/i18n/locales/fr.ts @@ -174,6 +174,7 @@ export const fr: typeof en = { noReviewsHint: "Sois le premier à partager ton avis.", retry: "Réessayer", whereToWatch: "Où regarder", + youMayAlsoLike: "À voir aussi", }, likes: { like: "J'aime", diff --git a/apps/mobile/src/lib/tmdb/index.ts b/apps/mobile/src/lib/tmdb/index.ts index 4d1706c..f0043a4 100644 --- a/apps/mobile/src/lib/tmdb/index.ts +++ b/apps/mobile/src/lib/tmdb/index.ts @@ -21,6 +21,7 @@ export { getDiscoverFeed } from "./discover"; export { trendingMedia } from "./trending"; export { findByExternalId } from "./find"; export { getMovieDetail } from "./movie"; +export { getMediaRecommendations } from "./recommendations"; export { getTvEpisodeDetail, getTvSeasonDetail } from "./tv"; export { getWatchProviders } from "./watch-providers"; export { tmdbLanguage } from "./client"; diff --git a/apps/mobile/src/lib/tmdb/recommendations.ts b/apps/mobile/src/lib/tmdb/recommendations.ts new file mode 100644 index 0000000..3a4f190 --- /dev/null +++ b/apps/mobile/src/lib/tmdb/recommendations.ts @@ -0,0 +1,16 @@ +import { eden, unwrapEden } from "@/lib/eden"; + +import { tmdbLanguage } from "./client"; +import type { MediaType, TmdbMovieSummary } from "./types"; + +export async function getMediaRecommendations( + tmdbId: number, + mediaType: MediaType, + language = tmdbLanguage(), +): Promise { + return unwrapEden( + eden.tmdb({ mediaType })({ tmdbId }).recommendations.get({ + query: { language }, + }), + ); +} diff --git a/packages/shared/src/query-keys.ts b/packages/shared/src/query-keys.ts index 762758d..2a4dc0b 100644 --- a/packages/shared/src/query-keys.ts +++ b/packages/shared/src/query-keys.ts @@ -22,6 +22,9 @@ export const discoverKeys = { export const tmdbKeys = { detail: (mediaType: MediaType, tmdbId: number, locale: string) => ["tmdb", "detail", mediaType, tmdbId, locale] as const, + recommendations: (mediaType: MediaType, tmdbId: number, locale: string) => + ["tmdb", "recommendations", mediaType, tmdbId, locale] as const, + person: (personId: number, locale: string) => ["tmdb", "person", personId, locale] as const, }; export const watchProviderKeys = {