diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 695c426..f2c6766 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -27,6 +27,7 @@ export default function TabsLayout() { > + diff --git a/app/(tabs)/culture.tsx b/app/(tabs)/culture.tsx new file mode 100644 index 0000000..0fe78e3 --- /dev/null +++ b/app/(tabs)/culture.tsx @@ -0,0 +1,91 @@ +import { StatusBar } from "expo-status-bar"; +import { useEffect } from "react"; +import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { CultureEmpty } from "@/components/culture/culture-empty"; +import { CultureFeed } from "@/components/culture/culture-feed"; +import { ExploreError } from "@/components/explore/explore-error"; +import { useCultureFeedStore } from "@/store/use-culture-feed-store"; + +export default function CultureScreen() { + const insets = useSafeAreaInsets(); + const countries = useCultureFeedStore((s) => s.countries); + const selectedRegion = useCultureFeedStore((s) => s.selectedRegion); + const hasLoadedOnce = useCultureFeedStore((s) => s.hasLoadedOnce); + const status = useCultureFeedStore((s) => s.status); + const error = useCultureFeedStore((s) => s.error); + const loadInitialFeed = useCultureFeedStore((s) => s.loadInitialFeed); + const setRegionFilter = useCultureFeedStore((s) => s.setRegionFilter); + + useEffect(() => { + if (!hasLoadedOnce && status === "idle") { + void loadInitialFeed(); + } + }, [hasLoadedOnce, loadInitialFeed, status]); + + const showLoading = + !hasLoadedOnce || + (countries.length === 0 && + (status === "loading" || status === "loadingMore")); + const showError = + countries.length === 0 && + status === "error" && + (selectedRegion !== null || hasLoadedOnce); + const showEmpty = + hasLoadedOnce && countries.length === 0 && status === "idle"; + + return ( + + + + {showError ? ( + { + if (selectedRegion !== null) { + void setRegionFilter(selectedRegion); + return; + } + void loadInitialFeed({ force: true }); + }} + /> + ) : showLoading ? ( + + + Loading culture clips… + + ) : showEmpty ? ( + { + if (selectedRegion !== null) { + void setRegionFilter(selectedRegion); + return; + } + void loadInitialFeed({ force: true }); + }} + /> + ) : ( + + )} + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: "#0b132b", + }, + loading: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 16, + }, + loadingText: { + fontSize: 14, + fontFamily: "Poppins-Regular", + color: "rgba(255, 255, 255, 0.8)", + }, +}); diff --git a/backend/README.md b/backend/README.md index 3611736..4ed65d4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -72,6 +72,19 @@ Results are **cached per country** in Redis (`images:{country}`, 30-day TTL). Re Consumers should mirror the same provider fallback order and country-level caching so image behavior stays consistent with the backend. +## Video API (Feature 15) + +Configure `PEXELS_API_KEY` in `backend/.env` only (same key as images). When unset or when Pexels returns no match, endpoints return `videos: []` with HTTP 200. + +- **Provider:** Pexels Video Search API (`{country} landscape`, fallback `{country} travel`) +- **Cache:** Redis key `videos:{country}` (30-day TTL) +- **Shape:** At most one `CountryVideo` per country — HTTPS MP4 `url`, optional `poster`, `provider`, `duration` + +```bash +curl -s "http://localhost:3001/country/Japan/profile" | jq '.data.country.videos' +curl -s "http://localhost:3001/feed/countries?limit=3" | jq '.data[] | {name, videos: .videos | length}' +``` + ## Endpoints | Method | Path | Description | diff --git a/backend/src/controllers/country.controller.ts b/backend/src/controllers/country.controller.ts index 9244505..a676eda 100644 --- a/backend/src/controllers/country.controller.ts +++ b/backend/src/controllers/country.controller.ts @@ -4,6 +4,7 @@ import { parseCountryName } from "../lib/validation.js"; import { enrichCountryWithAi } from "../services/ai.service.js"; import { getCountryByName } from "../services/country.service.js"; import { enrichCountryWithImages } from "../services/image.service.js"; +import { enrichCountryWithVideos } from "../services/video.service.js"; export async function getCountry( req: Request, @@ -15,7 +16,8 @@ export async function getCountry( const country = await getCountryByName(name); const withImages = await enrichCountryWithImages(country); - const withAi = await enrichCountryWithAi(withImages); + const withVideos = await enrichCountryWithVideos(withImages); + const withAi = await enrichCountryWithAi(withVideos); res.json({ data: withAi }); } catch (error) { next(error); diff --git a/backend/src/controllers/profile.controller.ts b/backend/src/controllers/profile.controller.ts index a954d29..9db93e5 100644 --- a/backend/src/controllers/profile.controller.ts +++ b/backend/src/controllers/profile.controller.ts @@ -5,6 +5,7 @@ import { enrichCountryWithAi } from "../services/ai.service.js"; import { getCountryByName } from "../services/country.service.js"; import { enrichCountryWithImages } from "../services/image.service.js"; import { getLandmarksForCountry } from "../services/landmarks.service.js"; +import { enrichCountryWithVideos } from "../services/video.service.js"; import { getWikipediaForCountry } from "../services/wikipedia.service.js"; /** Country profile for AI explorer — images + Wikipedia, no news. */ @@ -18,7 +19,8 @@ export async function getCountryProfile( const country = await getCountryByName(name); const withImages = await enrichCountryWithImages(country); - const withAi = await enrichCountryWithAi(withImages); + const withVideos = await enrichCountryWithVideos(withImages); + const withAi = await enrichCountryWithAi(withVideos); const [wikipedia, landmarks] = await Promise.all([ getWikipediaForCountry(withAi.name), getLandmarksForCountry(withAi.name, withAi.images ?? [], { diff --git a/backend/src/lib/upstream-validation.ts b/backend/src/lib/upstream-validation.ts index 9b57d86..b3c2fee 100644 --- a/backend/src/lib/upstream-validation.ts +++ b/backend/src/lib/upstream-validation.ts @@ -39,6 +39,108 @@ type PexelsSearchResponse = { photos?: unknown; }; +type PexelsVideoSearchResponse = { + videos?: unknown; +}; + +type PexelsVideoFileCandidate = { + link?: unknown; + file_type?: unknown; + width?: unknown; +}; + +type PexelsVideoCandidate = { + image?: unknown; + duration?: unknown; + video_files?: unknown; +}; + +export type PexelsVideoHit = { + url: string; + poster?: string; + duration?: number; + /** Selected MP4 width in pixels (used to rank clips). */ + width?: number; +}; + +function isValidHttpsMp4Url(value: string): boolean { + try { + const url = new URL(value.trim()); + if (url.protocol !== "https:") return false; + return ( + url.pathname.endsWith(".mp4") || + url.hostname.toLowerCase().includes("pexels.com") + ); + } catch { + return false; + } +} + +const PREFERRED_MAX_VIDEO_WIDTH = 1920; +const MIN_FALLBACK_VIDEO_WIDTH = 720; +const MIN_ACCEPTABLE_VIDEO_WIDTH = 640; + +function selectBestMp4File( + files: PexelsVideoFileCandidate[], +): { link: string; width: number } | null { + const mp4Files = files + .map((file) => ({ + link: typeof file.link === "string" ? file.link.trim() : "", + fileType: typeof file.file_type === "string" ? file.file_type.trim() : "", + width: typeof file.width === "number" ? file.width : 0, + })) + .filter( + (file) => + file.fileType === "video/mp4" && + file.link && + isValidHttpsMp4Url(file.link), + ); + + if (mp4Files.length === 0) return null; + + const byWidthDesc = [...mp4Files].sort((a, b) => b.width - a.width); + + const upTo1080p = byWidthDesc.filter( + (file) => file.width <= PREFERRED_MAX_VIDEO_WIDTH, + ); + if (upTo1080p.length > 0) { + return { link: upTo1080p[0].link, width: upTo1080p[0].width }; + } + + const above1080p = [...mp4Files] + .filter((file) => file.width > PREFERRED_MAX_VIDEO_WIDTH) + .sort((a, b) => a.width - b.width); + if (above1080p.length > 0) { + return { link: above1080p[0].link, width: above1080p[0].width }; + } + + const above720 = byWidthDesc.filter( + (file) => file.width >= MIN_FALLBACK_VIDEO_WIDTH, + ); + if (above720.length > 0) { + return { link: above720[0].link, width: above720[0].width }; + } + + const above640 = byWidthDesc.filter( + (file) => file.width >= MIN_ACCEPTABLE_VIDEO_WIDTH, + ); + if (above640.length > 0) { + return { link: above640[0].link, width: above640[0].width }; + } + + return null; +} + +export function pickBestPexelsVideoHit( + hits: PexelsVideoHit[], +): PexelsVideoHit | null { + if (hits.length === 0) return null; + + return hits.reduce((best, hit) => + (hit.width ?? 0) > (best.width ?? 0) ? hit : best, + ); +} + export function parseUnsplashResults(data: unknown): string[] { if (!data || typeof data !== "object") { throw new HttpError("Invalid Unsplash response", 502, "UPSTREAM_INVALID"); @@ -58,6 +160,48 @@ export function parseUnsplashResults(data: unknown): string[] { .filter((url): url is string => Boolean(url)); } +export function parsePexelsVideoResults(data: unknown): PexelsVideoHit[] { + if (!data || typeof data !== "object") { + throw new HttpError("Invalid Pexels response", 502, "UPSTREAM_INVALID"); + } + + const videos = (data as PexelsVideoSearchResponse).videos; + if (videos !== undefined && !Array.isArray(videos)) { + throw new HttpError("Invalid Pexels response", 502, "UPSTREAM_INVALID"); + } + + const hits: PexelsVideoHit[] = []; + + for (const item of videos ?? []) { + if (!item || typeof item !== "object") continue; + + const video = item as PexelsVideoCandidate; + const files = Array.isArray(video.video_files) + ? (video.video_files as PexelsVideoFileCandidate[]) + : []; + const selected = selectBestMp4File(files); + if (!selected) continue; + + const poster = + typeof video.image === "string" && video.image.trim() + ? video.image.trim() + : undefined; + const duration = + typeof video.duration === "number" && video.duration > 0 + ? Math.round(video.duration) + : undefined; + + hits.push({ + url: selected.link, + poster, + duration, + width: selected.width, + }); + } + + return hits; +} + export function parsePexelsResults(data: unknown): string[] { if (!data || typeof data !== "object") { throw new HttpError("Invalid Pexels response", 502, "UPSTREAM_INVALID"); diff --git a/backend/src/services/cache.service.ts b/backend/src/services/cache.service.ts index a45bee6..9123788 100644 --- a/backend/src/services/cache.service.ts +++ b/backend/src/services/cache.service.ts @@ -12,6 +12,7 @@ export const CACHE_TTL = { discover: 24 * 60 * 60, ai: 7 * 24 * 60 * 60, images: 30 * 24 * 60 * 60, + videos: 30 * 24 * 60 * 60, news: 4 * 60 * 60, wikipedia: 30 * 24 * 60 * 60, landmarks: 30 * 24 * 60 * 60, @@ -48,6 +49,7 @@ export const cacheKeys = { return `discover:${bboxPart}:${centerPart}:${regionPart}:${params.limit}:${params.cursor}`; }, images: (name: string) => `images:${name.trim().toLowerCase()}`, + videos: (name: string) => `videos:${name.trim().toLowerCase()}`, ai: (name: string) => `ai:${name.toLowerCase()}`, news: (name: string) => `news:${name.trim().toLowerCase()}`, wikipedia: (name: string) => `wikipedia:${name.trim().toLowerCase()}`, diff --git a/backend/src/services/feed.service.ts b/backend/src/services/feed.service.ts index 3facb6a..99c075d 100644 --- a/backend/src/services/feed.service.ts +++ b/backend/src/services/feed.service.ts @@ -5,6 +5,7 @@ import { enrichCountryWithAi } from "./ai.service.js"; import { CACHE_TTL, cacheKeys, getOrSet } from "./cache.service.js"; import { getFeedCountries } from "./country.service.js"; import { enrichCountryWithImages } from "./image.service.js"; +import { enrichCountryWithVideos } from "./video.service.js"; export type FeedBatchResponse = { data: Country[]; @@ -50,7 +51,8 @@ function parseCursor(cursor?: string): number { async function enrichCountry(country: CountryBasic): Promise { const withImages = await enrichCountryWithImages(country); - return enrichCountryWithAi(withImages); + const withVideos = await enrichCountryWithVideos(withImages); + return enrichCountryWithAi(withVideos); } async function buildFeedBatch( diff --git a/backend/src/services/search.service.ts b/backend/src/services/search.service.ts index ee28882..a28013d 100644 --- a/backend/src/services/search.service.ts +++ b/backend/src/services/search.service.ts @@ -5,6 +5,7 @@ import { enrichCountryWithAi } from "./ai.service.js"; import { CACHE_TTL, cacheKeys, getOrSet } from "./cache.service.js"; import { getFeedCountries } from "./country.service.js"; import { enrichCountryWithImages } from "./image.service.js"; +import { enrichCountryWithVideos } from "./video.service.js"; export type SearchResponse = { data: Country[]; @@ -17,7 +18,8 @@ export type SearchResponse = { async function enrichCountry(country: CountryBasic): Promise { const withImages = await enrichCountryWithImages(country); - return enrichCountryWithAi(withImages); + const withVideos = await enrichCountryWithVideos(withImages); + return enrichCountryWithAi(withVideos); } const SUBSTRING_MIN_LEN = 3; diff --git a/backend/src/services/video.service.ts b/backend/src/services/video.service.ts new file mode 100644 index 0000000..8115503 --- /dev/null +++ b/backend/src/services/video.service.ts @@ -0,0 +1,143 @@ +import { env } from "../config/env.js"; +import { + parsePexelsVideoResults, + pickBestPexelsVideoHit, + type PexelsVideoHit, +} from "../lib/upstream-validation.js"; +import type { CountryBasic, CountryVideo } from "../types/country.js"; +import { logger } from "../utils/logger.js"; +import { CACHE_TTL, cacheKeys, getOrSet } from "./cache.service.js"; +import { getImagesForCountry } from "./image.service.js"; + +const PEXELS_VIDEO_SEARCH = "https://api.pexels.com/videos/search"; +const PER_PAGE = 5; + +let loggedMissingPexelsKey = false; + +export type CountryWithVideos = CountryBasic & { videos: CountryVideo[] }; + +async function searchPexelsVideos(query: string): Promise { + const url = new URL(PEXELS_VIDEO_SEARCH); + url.searchParams.set("query", query); + url.searchParams.set("per_page", String(PER_PAGE)); + url.searchParams.set("orientation", "landscape"); + + const response = await fetch(url.toString(), { + headers: { Authorization: env.pexelsApiKey }, + }); + + if (!response.ok) { + logger.warn("Pexels Video API error", { + status: response.status, + country: query, + }); + return []; + } + + try { + const data = await response.json(); + return parsePexelsVideoResults(data); + } catch (error) { + logger.warn("Pexels Video API parse failed", { + country: query, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } +} + +async function resolvePoster( + countryName: string, + hit: PexelsVideoHit, + existingImages?: string[], +): Promise { + if (hit.poster) return hit.poster; + + const images = + existingImages && existingImages.length > 0 + ? existingImages + : await getImagesForCountry(countryName); + + return images[0]; +} + +function toCountryVideo(hit: PexelsVideoHit, poster?: string): CountryVideo { + const video: CountryVideo = { + url: hit.url, + provider: "pexels", + }; + + if (poster) video.poster = poster; + if (hit.duration !== undefined) video.duration = hit.duration; + + return video; +} + +async function fetchVideosFromApis( + countryName: string, + existingImages?: string[], +): Promise { + if (!env.pexelsApiKey) { + if (!loggedMissingPexelsKey) { + logger.debug("PEXELS_API_KEY not set — skipping video fetch"); + loggedMissingPexelsKey = true; + } + return []; + } + + const query = countryName.trim(); + const querySuffixes = ["culture", "landscape", "travel"] as const; + let hits: PexelsVideoHit[] = []; + + for (const suffix of querySuffixes) { + hits = await searchPexelsVideos(`${query} ${suffix}`); + if (hits.length > 0) break; + } + + if (hits.length === 0) { + logger.debug("No video found for country", { country: query }); + return []; + } + + const hit = pickBestPexelsVideoHit(hits); + if (!hit) { + logger.debug("No video found for country", { country: query }); + return []; + } + + const poster = await resolvePoster(query, hit, existingImages); + const video = toCountryVideo(hit, poster); + + logger.debug("Video fetched from Pexels", { + country: query, + duration: video.duration, + width: hit.width, + candidates: hits.length, + }); + + return [video]; +} + +export async function getVideosForCountry( + countryName: string, + existingImages?: string[], +): Promise { + const key = cacheKeys.videos(countryName); + + return getOrSet(key, CACHE_TTL.videos, () => + fetchVideosFromApis(countryName, existingImages), + ); +} + +export async function enrichCountryWithVideos< + T extends CountryBasic & { images?: string[] }, +>(country: T): Promise { + const videos = await getVideosForCountry(country.name, country.images); + return { ...country, videos }; +} + +export async function enrichCountriesWithVideos< + T extends CountryBasic & { images?: string[] }, +>(countries: T[]): Promise<(T & { videos: CountryVideo[] })[]> { + return Promise.all(countries.map(enrichCountryWithVideos)); +} diff --git a/backend/src/types/country.ts b/backend/src/types/country.ts index 1ec11c2..d053d47 100644 --- a/backend/src/types/country.ts +++ b/backend/src/types/country.ts @@ -1,3 +1,14 @@ +export type CountryVideo = { + /** Direct HTTPS MP4 URL (not a Pexels watch page). */ + url: string; + /** Still frame for loading / header blur backdrop. */ + poster?: string; + /** Upstream label, e.g. "pexels". */ + provider?: string; + /** Duration in whole seconds when known. */ + duration?: number; +}; + /** Full country shape (images + ai added in later prompts). */ export type Country = { name: string; @@ -19,6 +30,7 @@ export type Country = { /** Official / national language names from REST Countries. */ languages: string[]; images?: string[]; + videos?: CountryVideo[]; ai?: { /** Primary fact (same as `facts[0]`). */ fact: string; diff --git a/components/bottom-tab-bar.tsx b/components/bottom-tab-bar.tsx index a7576c1..70ceae7 100644 --- a/components/bottom-tab-bar.tsx +++ b/components/bottom-tab-bar.tsx @@ -37,6 +37,12 @@ const TAB_ITEMS: TabItem[] = [ icon: "compass-outline", iconFocused: "compass", }, + { + routeName: "culture", + label: "Culture", + icon: "film-outline", + iconFocused: "film", + }, { routeName: "map", label: "Map", diff --git a/components/culture/culture-country-page.tsx b/components/culture/culture-country-page.tsx new file mode 100644 index 0000000..2eacc01 --- /dev/null +++ b/components/culture/culture-country-page.tsx @@ -0,0 +1,48 @@ +import { useEffect } from "react"; +import { View } from "react-native"; + +import { CultureOverlay } from "@/components/culture/culture-overlay"; +import { CultureVideoSlide } from "@/components/culture/culture-video-slide"; +import { getCulturePosterUri, getCultureVideo } from "@/lib/format-country"; +import { prefetchCountryProfile } from "@/lib/prefetch-country-profiles"; +import type { Country } from "@/types/country"; + +type CultureCountryPageProps = { + country: Country; + pageHeight: number; + pageWidth: number; + isActive: boolean; +}; + +export function CultureCountryPage({ + country, + pageHeight, + pageWidth, + isActive, +}: CultureCountryPageProps) { + const video = getCultureVideo(country); + const posterUri = getCulturePosterUri(country); + + useEffect(() => { + void prefetchCountryProfile(country.name); + }, [country.name]); + + if (!video) { + return ; + } + + return ( + + + + + ); +} diff --git a/components/culture/culture-empty.tsx b/components/culture/culture-empty.tsx new file mode 100644 index 0000000..7dd7586 --- /dev/null +++ b/components/culture/culture-empty.tsx @@ -0,0 +1,93 @@ +import { router } from "expo-router"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +type CultureEmptyProps = { + onRetry: () => void; +}; + +export function CultureEmpty({ onRetry }: CultureEmptyProps) { + const insets = useSafeAreaInsets(); + + return ( + + No culture clips yet + + Countries with video clips will appear here. Make sure the backend video + service is running, or browse photo stories on Explore. + + + [styles.button, pressed && styles.pressed]} + > + Retry + + + router.push("/(tabs)/explore")} + style={({ pressed }) => [ + styles.secondaryButton, + pressed && styles.pressed, + ]} + > + Browse Explore + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 32, + gap: 12, + }, + title: { + fontSize: 20, + fontFamily: "Poppins-SemiBold", + color: "#ffffff", + textAlign: "center", + }, + message: { + fontSize: 14, + lineHeight: 20, + fontFamily: "Poppins-Regular", + color: "rgba(255, 255, 255, 0.72)", + textAlign: "center", + }, + button: { + marginTop: 8, + backgroundColor: "#fbbf24", + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 24, + minHeight: 44, + justifyContent: "center", + }, + secondaryButton: { + paddingHorizontal: 16, + paddingVertical: 10, + minHeight: 44, + justifyContent: "center", + }, + pressed: { + opacity: 0.9, + }, + buttonText: { + fontSize: 14, + fontFamily: "Poppins-SemiBold", + color: "#0b132b", + }, + secondaryButtonText: { + fontSize: 14, + fontFamily: "Poppins-Medium", + color: "rgba(255, 255, 255, 0.82)", + }, +}); diff --git a/components/culture/culture-feed-menu-sheet.tsx b/components/culture/culture-feed-menu-sheet.tsx new file mode 100644 index 0000000..e3c5f99 --- /dev/null +++ b/components/culture/culture-feed-menu-sheet.tsx @@ -0,0 +1,240 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Modal, Pressable, StyleSheet, Text, View } from "react-native"; +import Animated, { SlideInDown } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { WORLDLOOP_HEADER_ACCENT_COLOR } from "@/components/worldloop-header"; +import { + continentTabLabel, + EXPLORE_HEADER_TABS, + FOR_YOU_TAB, + isContinent, + type ExploreHeaderTab, +} from "@/constants/regions"; +import { useCultureFeedStore } from "@/store/use-culture-feed-store"; + +const SHEET_ENTER = SlideInDown.springify() + .damping(20) + .stiffness(150) + .mass(0.85); + +type CultureFeedMenuSheetProps = { + visible: boolean; + onClose: () => void; +}; + +type FeedMenuRowProps = { + label: string; + selected: boolean; + accessibilityLabel: string; + onPress: () => void; +}; + +function FeedMenuRow({ + label, + selected, + accessibilityLabel, + onPress, +}: FeedMenuRowProps) { + return ( + [styles.menuRow, pressed && styles.pressed]} + > + + {label} + + {selected ? ( + + ) : null} + + ); +} + +export function CultureFeedMenuSheet({ + visible, + onClose, +}: CultureFeedMenuSheetProps) { + const insets = useSafeAreaInsets(); + const selectedRegion = useCultureFeedStore((s) => s.selectedRegion); + const restoreForYouFeed = useCultureFeedStore((s) => s.restoreForYouFeed); + const setRegionFilter = useCultureFeedStore((s) => s.setRegionFilter); + + const selectedTab: ExploreHeaderTab = + selectedRegion === null + ? FOR_YOU_TAB + : (selectedRegion as ExploreHeaderTab); + + const onTabPress = (name: ExploreHeaderTab) => { + if (name === FOR_YOU_TAB) { + if (selectedRegion === null) { + onClose(); + return; + } + void restoreForYouFeed(); + onClose(); + return; + } + + if (!isContinent(name)) return; + + if (selectedRegion === name) { + void setRegionFilter(null); + onClose(); + return; + } + + void setRegionFilter(name); + onClose(); + }; + + return ( + + + + + + Browse feed + [ + styles.closeButton, + pressed && styles.pressed, + ]} + > + + + + + + {EXPLORE_HEADER_TABS.map((name, index) => { + const selected = name === selectedTab; + const accessibilityLabel = + name === FOR_YOU_TAB + ? "Show your personalized culture feed" + : selected + ? `Clear ${name} filter and show For You feed` + : `Show culture clips from ${name}`; + + const label = + name === FOR_YOU_TAB ? name : continentTabLabel(name, true); + + return ( + + {index > 0 ? : null} + onTabPress(name)} + /> + + ); + })} + + + + + ); +} + +const styles = StyleSheet.create({ + pressed: { + opacity: 0.85, + }, + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.55)", + justifyContent: "flex-end", + }, + sheet: { + width: "100%", + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingTop: 12, + paddingHorizontal: 16, + backgroundColor: "#111827", + borderWidth: 1, + borderBottomWidth: 0, + borderColor: "rgba(255, 255, 255, 0.14)", + gap: 4, + }, + sheetHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + marginBottom: 2, + }, + sheetTitle: { + fontSize: 16, + lineHeight: 20, + fontFamily: "Poppins-SemiBold", + color: "#ffffff", + }, + closeButton: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + }, + menuPanel: { + gap: 0, + marginTop: 4, + }, + menuRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + minHeight: 48, + paddingVertical: 6, + }, + menuLabel: { + flex: 1, + fontSize: 15, + lineHeight: 20, + fontFamily: "Poppins-Medium", + color: "#ffffff", + includeFontPadding: false, + }, + menuLabelSelected: { + fontFamily: "Poppins-SemiBold", + color: "#ffffff", + }, + menuDivider: { + height: 1, + backgroundColor: "rgba(255, 255, 255, 0.08)", + }, +}); diff --git a/components/culture/culture-feed.tsx b/components/culture/culture-feed.tsx new file mode 100644 index 0000000..f3620c2 --- /dev/null +++ b/components/culture/culture-feed.tsx @@ -0,0 +1,158 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useCallback, useRef, useState } from "react"; +import { + ActivityIndicator, + FlatList, + StyleSheet, + View, + type LayoutChangeEvent, + type ViewToken, +} from "react-native"; + +import { CultureCountryPage } from "@/components/culture/culture-country-page"; +import { CultureTopBar } from "@/components/culture/culture-top-bar"; +import { prefetchCountryProfiles } from "@/lib/prefetch-country-profiles"; +import { useCultureFeedStore } from "@/store/use-culture-feed-store"; +import { useDiscoveryProgressStore } from "@/store/use-discovery-progress-store"; +import type { Country } from "@/types/country"; + +export function CultureFeed() { + const isTabFocused = useIsFocused(); + const [pageHeight, setPageHeight] = useState(0); + const [pageWidth, setPageWidth] = useState(0); + const pageHeightRef = useRef(0); + const listRef = useRef>(null); + + const countries = useCultureFeedStore((s) => s.countries); + const selectedRegion = useCultureFeedStore((s) => s.selectedRegion); + const currentIndex = useCultureFeedStore((s) => s.currentIndex); + const status = useCultureFeedStore((s) => s.status); + const feedListKey = selectedRegion ?? "for-you"; + const setCurrentIndex = useCultureFeedStore((s) => s.setCurrentIndex); + const loadMoreFeed = useCultureFeedStore((s) => s.loadMoreFeed); + + const onViewableItemsChanged = useRef( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + const first = viewableItems[0]; + if (first?.index == null) return; + + const index = first.index; + setCurrentIndex(index); + + const state = useCultureFeedStore.getState(); + const country = state.countries[index]; + if (country) { + useDiscoveryProgressStore.getState().recordCountryVisit(country); + void prefetchCountryProfiles(state.countries, { aroundIndex: index }); + } + + if ( + state.selectedRegion === null && + state.nextCursor !== null && + index >= state.countries.length - 2 && + state.status !== "loading" && + state.status !== "loadingMore" + ) { + void loadMoreFeed(); + } + }, + ).current; + + const viewabilityConfig = useRef({ + itemVisiblePercentThreshold: 50, + }).current; + + const onFeedLayout = useCallback((event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + const nextHeight = Math.round(height); + const nextWidth = Math.round(width); + if (nextHeight > 0 && nextHeight !== pageHeightRef.current) { + pageHeightRef.current = nextHeight; + setPageHeight(nextHeight); + setPageWidth(nextWidth); + } + }, []); + + const renderItem = useCallback( + ({ item, index }: { item: Country; index: number }) => ( + + ), + [currentIndex, isTabFocused, pageHeight, pageWidth], + ); + + const keyExtractor = useCallback((item: Country) => item.name, []); + + return ( + + + {pageHeight > 0 && pageWidth > 0 ? ( + ({ + length: pageHeight, + offset: pageHeight * index, + index, + })} + initialNumToRender={2} + maxToRenderPerBatch={2} + windowSize={3} + removeClippedSubviews + onScrollToIndexFailed={(info) => { + requestAnimationFrame(() => { + listRef.current?.scrollToIndex({ + index: info.index, + animated: false, + }); + }); + }} + /> + ) : null} + + {status === "loadingMore" ? ( + + + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create({ + feed: { + flex: 1, + }, + feedBody: { + flex: 1, + backgroundColor: "#0b132b", + }, + list: { + flex: 1, + backgroundColor: "transparent", + }, + loadingMore: { + position: "absolute", + bottom: 120, + left: 0, + right: 0, + alignItems: "center", + }, +}); diff --git a/components/culture/culture-overlay.tsx b/components/culture/culture-overlay.tsx new file mode 100644 index 0000000..55d083b --- /dev/null +++ b/components/culture/culture-overlay.tsx @@ -0,0 +1,182 @@ +import { LinearGradient } from "expo-linear-gradient"; +import { useEffect, useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; + +import { TAB_BAR_CONTENT_HEIGHT } from "@/components/bottom-tab-bar"; +import { ExploreActionRail } from "@/components/explore/explore-action-rail"; +import { FlagBadge } from "@/components/explore/flag-badge"; +import { continentDisplayLabel } from "@/constants/regions"; +import { formatPopulation, getAiFact } from "@/lib/format-country"; +import { + openCountryAiExplorer, + warmCountryAiExplorer, +} from "@/lib/open-country-ai-explorer"; +import type { Country } from "@/types/country"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +/** Negative offset lets the card sit into the tab bar chrome (TikTok-style). */ +const CULTURE_OVERLAY_TAB_CLEARANCE = -70; +/** Action rail sits above the country card. */ +const CULTURE_ACTION_RAIL_CLEARANCE = -16; + +type CultureOverlayProps = { + country: Country; +}; + +export function CultureOverlay({ country }: CultureOverlayProps) { + const insets = useSafeAreaInsets(); + const [isFactExpanded, setIsFactExpanded] = useState(false); + const region = continentDisplayLabel(country.region?.trim() || "—"); + const fact = getAiFact(country); + const bottomOffset = + TAB_BAR_CONTENT_HEIGHT + insets.bottom + CULTURE_OVERLAY_TAB_CLEARANCE; + + useEffect(() => { + setIsFactExpanded(false); + }, [country.name]); + + return ( + + + + {isFactExpanded ? ( + setIsFactExpanded(false)} + style={styles.expandedScrim} + /> + ) : null} + + + + warmCountryAiExplorer(country)} + onPress={() => openCountryAiExplorer(country)} + style={({ pressed }) => [ + styles.titlePressable, + pressed && styles.pressed, + ]} + > + + + + + {country.name} + + + + + + {isFactExpanded ? ( + + + {region} · {formatPopulation(country.population)} + + + {fact} + + + ) : ( + setIsFactExpanded(true)} + style={({ pressed }) => [pressed && styles.pressed]} + > + + {fact} + + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + ...StyleSheet.absoluteFillObject, + justifyContent: "flex-end", + }, + scrim: { + ...StyleSheet.absoluteFillObject, + }, + expandedScrim: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.55)", + zIndex: 5, + }, + content: { + flexDirection: "row", + alignItems: "flex-end", + paddingLeft: 16, + paddingRight: 64, + zIndex: 6, + }, + infoBlock: { + flex: 1, + gap: 10, + }, + titlePressable: { + alignSelf: "stretch", + }, + pressed: { + opacity: 0.92, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + titleText: { + flex: 1, + justifyContent: "center", + }, + countryName: { + fontSize: 16, + lineHeight: 28, + fontFamily: "Poppins-Regular", + color: "#ffffff", + includeFontPadding: false, + }, + expandedDetails: { + gap: 6, + }, + meta: { + fontSize: 12, + lineHeight: 16, + fontFamily: "Poppins-Regular", + color: "rgba(255, 255, 255, 0.78)", + }, + fact: { + fontSize: 14, + lineHeight: 20, + fontFamily: "Poppins-Regular", + color: "rgba(255, 255, 255, 0.92)", + }, +}); diff --git a/components/culture/culture-top-bar.tsx b/components/culture/culture-top-bar.tsx new file mode 100644 index 0000000..1edad74 --- /dev/null +++ b/components/culture/culture-top-bar.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { CultureFeedMenuSheet } from "@/components/culture/culture-feed-menu-sheet"; +import { + WORLDLOOP_HEADER_TOP_PADDING, + WorldLoopHeader, +} from "@/components/worldloop-header"; +import { + CULTURE_CHROME_ICON_SIZE, + CULTURE_CHROME_TITLE_SIZE, + CULTURE_CHROME_TOUCH_SIZE, +} from "@/constants/culture-chrome"; +import { useCultureFeedStore } from "@/store/use-culture-feed-store"; +import { useSearchUiStore } from "@/store/use-search-ui-store"; + +export function CultureTopBar() { + const insets = useSafeAreaInsets(); + const [isFeedMenuOpen, setIsFeedMenuOpen] = useState(false); + const openSearch = useSearchUiStore((s) => s.openSearch); + const isSearchOpen = useSearchUiStore( + (s) => s.isOpen && s.context === "culture", + ); + const isSortSheetOpen = useCultureFeedStore((s) => s.isSortSheetOpen); + + return ( + + + setIsFeedMenuOpen(true)} + menuActive={isFeedMenuOpen || isSortSheetOpen} + menuAccessibilityLabel="Browse culture feed" + menuAccessibilityHint="Opens For You and continent filters" + onSearchPress={() => openSearch("culture")} + searchActive={isSearchOpen} + inactiveColor="#ffffff" + brandFontFamily="Poppins-Bold" + rowHeight={CULTURE_CHROME_TOUCH_SIZE} + sideSlotWidth={CULTURE_CHROME_TOUCH_SIZE} + brandFontSize={CULTURE_CHROME_TITLE_SIZE} + iconSize={CULTURE_CHROME_ICON_SIZE} + searchIconSize={CULTURE_CHROME_ICON_SIZE} + /> + + + setIsFeedMenuOpen(false)} + /> + + ); +} + +const styles = StyleSheet.create({ + overlayRoot: { + position: "absolute", + top: 0, + left: 0, + right: 0, + zIndex: 10, + }, +}); diff --git a/components/culture/culture-video-slide.tsx b/components/culture/culture-video-slide.tsx new file mode 100644 index 0000000..5449b18 --- /dev/null +++ b/components/culture/culture-video-slide.tsx @@ -0,0 +1,126 @@ +import { useEventListener } from "expo"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useEffect, useState } from "react"; +import { AppState, StyleSheet, View, type AppStateStatus } from "react-native"; + +import { CountryImage } from "@/components/explore/country-image"; +import { videos as bundledVideos } from "@/constants/videos"; +import { useCultureFeedStore } from "@/store/use-culture-feed-store"; +import type { CountryVideo } from "@/types/country"; + +type CultureVideoSlideProps = { + video: CountryVideo; + flag: string; + iso2: string; + posterUri?: string; + isActive: boolean; + width: number; + height: number; +}; + +function resolveVideoSource(video: CountryVideo) { + if (video.provider === "demo") { + return bundledVideos.onboardingHero; + } + return video.url; +} + +export function CultureVideoSlide({ + video, + flag, + iso2, + posterUri, + isActive, + width, + height, +}: CultureVideoSlideProps) { + const isMuted = useCultureFeedStore((s) => s.isMuted); + const [showPoster, setShowPoster] = useState(true); + const [hasError, setHasError] = useState(false); + const source = resolveVideoSource(video); + + const player = useVideoPlayer(source, (instance) => { + instance.loop = true; + instance.muted = isMuted; + }); + + useEventListener(player, "playingChange", ({ isPlaying }) => { + if (isPlaying) { + setShowPoster(false); + } + }); + + useEventListener(player, "statusChange", ({ status }) => { + if (status === "error") { + setHasError(true); + setShowPoster(true); + } + }); + + useEffect(() => { + if (isActive) { + player.play(); + return; + } + player.pause(); + }, [isActive, player]); + + useEffect(() => { + player.muted = isMuted; + }, [isMuted, player]); + + useEffect(() => { + const handleAppState = (nextState: AppStateStatus) => { + if (nextState === "active" && isActive) { + player.play(); + return; + } + if (nextState === "background" || nextState === "inactive") { + player.pause(); + } + }; + + const subscription = AppState.addEventListener("change", handleAppState); + return () => subscription.remove(); + }, [isActive, player]); + + useEffect(() => { + setShowPoster(true); + setHasError(false); + }, [video.url]); + + const shouldShowPoster = showPoster || hasError; + + return ( + + {shouldShowPoster ? ( + + + + ) : null} + + {!hasError ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + shell: { + overflow: "hidden", + backgroundColor: "#0b132b", + }, +}); diff --git a/components/explore/country-feed-page.tsx b/components/explore/country-feed-page.tsx index 313b668..5ea0156 100644 --- a/components/explore/country-feed-page.tsx +++ b/components/explore/country-feed-page.tsx @@ -84,12 +84,7 @@ export function CountryFeedPage({ - + s.toggleSaved); const isSaved = useSavedCountriesStore((s) => s.isSaved(country.name)); - const sortField = useCountryFeedStore((s) => s.sortField); - const sortOrder = useCountryFeedStore((s) => s.sortOrder); - const setSort = useCountryFeedStore((s) => s.setSort); + const exploreSortField = useCountryFeedStore((s) => s.sortField); + const exploreSortOrder = useCountryFeedStore((s) => s.sortOrder); + const setExploreSort = useCountryFeedStore((s) => s.setSort); + const cultureSortField = useCultureFeedStore((s) => s.sortField); + const cultureSortOrder = useCultureFeedStore((s) => s.sortOrder); + const setCultureSort = useCultureFeedStore((s) => s.setSort); + const cultureIsMuted = useCultureFeedStore((s) => s.isMuted); + const toggleCultureMuted = useCultureFeedStore((s) => s.toggleMuted); + const cultureSortSheetOpen = useCultureFeedStore((s) => s.isSortSheetOpen); + const openCultureSortSheet = useCultureFeedStore((s) => s.openSortSheet); + const closeCultureSortSheet = useCultureFeedStore((s) => s.closeSortSheet); + const usesCultureFeed = variant === "culture"; + const sortField = usesCultureFeed ? cultureSortField : exploreSortField; + const sortOrder = usesCultureFeed ? cultureSortOrder : exploreSortOrder; + const setSort = usesCultureFeed ? setCultureSort : setExploreSort; const saved = isSaved; const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false); - const [isSortModalOpen, setIsSortModalOpen] = useState(false); + const [isExploreSortModalOpen, setIsExploreSortModalOpen] = useState(false); + const isSortModalOpen = usesCultureFeed + ? cultureSortSheetOpen + : isExploreSortModalOpen; const [draftRandom, setDraftRandom] = useState(false); const [draftField, setDraftField] = useState( sortField ?? DEFAULT_FEED_SORT_FIELD, @@ -360,16 +383,28 @@ export function ExploreActionRail({ setDraftRandom(random); setDraftOrder(random ? "asc" : currentOrder); setIsMoreMenuOpen(false); - setIsSortModalOpen(true); + if (usesCultureFeed) { + openCultureSortSheet(); + return; + } + setIsExploreSortModalOpen(true); }; const handleCloseSortModal = () => { - setIsSortModalOpen(false); + if (usesCultureFeed) { + closeCultureSortSheet(); + return; + } + setIsExploreSortModalOpen(false); }; const handleApplySort = () => { setSort(draftField, draftRandom ? "random" : draftOrder); - setIsSortModalOpen(false); + if (usesCultureFeed) { + closeCultureSortSheet(); + return; + } + setIsExploreSortModalOpen(false); }; const handleJumpToMap = () => { @@ -394,12 +429,14 @@ export function ExploreActionRail({ variant === "header" ? styles.railHeaderStack : [ - styles.railOverlay, + variant === "culture" + ? styles.railCultureOverlay + : styles.railOverlay, { bottom: insets.bottom + TAB_BAR_CONTENT_HEIGHT + - EXPLORE_FLOATING_CHROME_OFFSET, + floatingChromeOffset, }, ] } @@ -451,6 +488,79 @@ export function ExploreActionRail({ accessibilityHint="Opens share, sort, and other country actions" /> + ) : variant === "culture" ? ( + + + + + + { + void handleShare(); + }} + accessibilityLabel={`Share ${country.name}`} + accessibilityHint="Opens the system share sheet" + /> + + + + + ) : ( - - - - - - More actions - [ - styles.closeButton, - pressed && styles.optionPressed, - ]} - > - - - + {variant !== "culture" ? ( + + + + + + More actions + [ + styles.closeButton, + pressed && styles.optionPressed, + ]} + > + + + - - - - - - {}} - /> - - - - + + + + + + {}} + /> + + + + + ) : null} openSearch()} searchActive={isSearchOpen} inactiveColor={EXPLORE_HEADER_INACTIVE_COLOR} + rowHeight={CULTURE_CHROME_TOUCH_SIZE} + sideSlotWidth={CULTURE_CHROME_TOUCH_SIZE} + brandFontSize={CULTURE_CHROME_TITLE_SIZE} + iconSize={CULTURE_CHROME_ICON_SIZE} + searchIconSize={CULTURE_CHROME_ICON_SIZE} /> {discoveryMode === "here" ? ( diff --git a/components/explore/glass-icon-button.tsx b/components/explore/glass-icon-button.tsx index f2a06c8..5351423 100644 --- a/components/explore/glass-icon-button.tsx +++ b/components/explore/glass-icon-button.tsx @@ -37,6 +37,8 @@ type GlassIconButtonProps = { iconOnly?: boolean; /** Compact variant only — smaller icon and touch target. */ compactSize?: "default" | "small"; + /** Override icon pixel size for plain / compact variants. */ + iconSize?: number; }; function triggerHaptic(style: HapticStyle) { @@ -62,14 +64,16 @@ export function GlassIconButton({ iconTone = "muted", iconOnly = false, compactSize = "default", + iconSize: iconSizeOverride, }: GlassIconButtonProps) { const isSmallCompact = variant === "compact" && compactSize === "small"; - const compactIconSize = isSmallCompact - ? COMPACT_ICON_SIZE_SMALL - : COMPACT_ICON_SIZE; + const compactIconSize = + iconSizeOverride ?? + (isSmallCompact ? COMPACT_ICON_SIZE_SMALL : COMPACT_ICON_SIZE); const compactTouchSize = isSmallCompact ? COMPACT_TOUCH_SIZE_SMALL : COMPACT_TOUCH_SIZE; + const plainTouchSize = iconSizeOverride != null ? 44 : GLASS_TOUCH_SIZE; const compactIconColor = iconTone === "bright" ? COMPACT_ICON_BRIGHT_COLOR : COMPACT_ICON_COLOR; @@ -80,7 +84,9 @@ export function GlassIconButton({ ? activeColor : variant === "glass" ? "#ffffff" - : compactIconColor; + : iconTone === "bright" + ? "#ffffff" + : compactIconColor; const handlePress = () => { if (disabled) return; @@ -100,10 +106,15 @@ export function GlassIconButton({ style={({ pressed }) => [ variant === "glass" ? styles.hitArea - : [ - styles.hitAreaCompact, - { minWidth: compactTouchSize, minHeight: compactTouchSize }, - ], + : variant === "plain" + ? [ + styles.hitAreaPlain, + { minWidth: plainTouchSize, minHeight: plainTouchSize }, + ] + : [ + styles.hitAreaCompact, + { minWidth: compactTouchSize, minHeight: compactTouchSize }, + ], disabled && styles.hitAreaDisabled, pressed && !disabled && styles.pressed, ]} @@ -121,7 +132,11 @@ export function GlassIconButton({ ) : ( )} @@ -153,6 +168,10 @@ const styles = StyleSheet.create({ minWidth: COMPACT_TOUCH_SIZE, minHeight: COMPACT_TOUCH_SIZE, }, + hitAreaPlain: { + alignItems: "center", + justifyContent: "center", + }, hitAreaDisabled: { opacity: 0.72, }, diff --git a/components/map/map-search-results-panel.tsx b/components/map/map-search-results-panel.tsx index 245c48e..697d029 100644 --- a/components/map/map-search-results-panel.tsx +++ b/components/map/map-search-results-panel.tsx @@ -18,6 +18,7 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { FlagBadge } from "@/components/explore/flag-badge"; import { FeedErrorBanner } from "@/components/home/feed-error-banner"; +import { WORLDLOOP_HEADER_HORIZONTAL_PADDING } from "@/components/worldloop-header"; import { MAP_SEARCH_BLUR_INTENSITY, MAP_SEARCH_OVERLAY_PANEL, @@ -335,7 +336,7 @@ const styles = StyleSheet.create({ flex: 1, alignItems: "center", justifyContent: "flex-start", - paddingHorizontal: 24, + paddingHorizontal: WORLDLOOP_HEADER_HORIZONTAL_PADDING, paddingTop: 100, }, recentRow: { diff --git a/components/search/search-overlay.tsx b/components/search/search-overlay.tsx index 597cb52..20942aa 100644 --- a/components/search/search-overlay.tsx +++ b/components/search/search-overlay.tsx @@ -34,6 +34,7 @@ import { CONTINENTS, continentDisplayLabel } from "@/constants/regions"; import { SPACE_TAB_BAR_BG } from "@/constants/space-theme"; import { useCountrySearch } from "@/hooks/use-country-search"; import { getAiFact, getCountryImages } from "@/lib/format-country"; +import { openCountryInCulture } from "@/lib/open-country-in-culture"; import { openCountryInExplore } from "@/lib/open-country-in-explore"; import { useSearchUiStore } from "@/store/use-search-ui-store"; import type { Country } from "@/types/country"; @@ -315,7 +316,11 @@ export function SearchOverlay() { renderItem={({ item }) => ( openCountryInExplore(item)} + onPress={() => + context === "culture" + ? openCountryInCulture(item) + : openCountryInExplore(item) + } /> )} keyboardShouldPersistTaps="handled" diff --git a/components/worldloop-header.tsx b/components/worldloop-header.tsx index 16979e1..886cf6a 100644 --- a/components/worldloop-header.tsx +++ b/components/worldloop-header.tsx @@ -26,6 +26,13 @@ type WorldLoopHeaderProps = { onSearchPress: () => void; searchActive?: boolean; inactiveColor?: string; + brandFontFamily?: string; + /** Row height — use 48+ on immersive overlays for 44pt touch targets. */ + rowHeight?: number; + sideSlotWidth?: number; + brandFontSize?: number; + iconSize?: number; + searchIconSize?: number; menuAccessibilityLabel?: string; menuAccessibilityHint?: string; searchAccessibilityLabel?: string; @@ -38,26 +45,37 @@ export function WorldLoopHeader({ onSearchPress, searchActive = false, inactiveColor = WORLDLOOP_HEADER_MUTED_COLOR, + brandFontFamily = "Poppins-Medium", + rowHeight = WORLDLOOP_HEADER_ROW_HEIGHT, + sideSlotWidth = WORLDLOOP_HEADER_SIDE_SLOT_WIDTH, + brandFontSize = 16, + iconSize = WORLDLOOP_HEADER_ICON_SIZE, + searchIconSize = WORLDLOOP_HEADER_SEARCH_ICON_SIZE, menuAccessibilityLabel = "Browse feed filters", menuAccessibilityHint = "Opens For You, Here, and continent filters", searchAccessibilityLabel = "Search countries", searchAccessibilityHint = "Opens country search", }: WorldLoopHeaderProps) { return ( - + {({ pressed }) => ( - + WorldLoop @@ -81,12 +109,21 @@ export function WorldLoopHeader({ accessibilityHint={searchAccessibilityHint} hitSlop={8} onPress={onSearchPress} - style={({ pressed }) => [styles.sideSlot, pressed && styles.pressed]} + style={({ pressed }) => [ + styles.sideSlot, + { width: sideSlotWidth, height: rowHeight }, + pressed && styles.pressed, + ]} > - + @@ -101,36 +138,23 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - height: WORLDLOOP_HEADER_ROW_HEIGHT, paddingHorizontal: WORLDLOOP_HEADER_HORIZONTAL_PADDING, }, sideSlot: { - width: WORLDLOOP_HEADER_SIDE_SLOT_WIDTH, - height: WORLDLOOP_HEADER_ROW_HEIGHT, alignItems: "center", justifyContent: "center", }, headerIconBox: { - width: WORLDLOOP_HEADER_ICON_SIZE, - height: WORLDLOOP_HEADER_ICON_SIZE, alignItems: "center", justifyContent: "center", }, searchIconBox: { - width: WORLDLOOP_HEADER_SEARCH_ICON_SIZE, - height: WORLDLOOP_HEADER_SEARCH_ICON_SIZE, alignItems: "center", justifyContent: "center", }, brandTitle: { position: "absolute", - left: - WORLDLOOP_HEADER_HORIZONTAL_PADDING + WORLDLOOP_HEADER_SIDE_SLOT_WIDTH, - right: - WORLDLOOP_HEADER_HORIZONTAL_PADDING + WORLDLOOP_HEADER_SIDE_SLOT_WIDTH, fontFamily: "Poppins-Medium", - fontSize: 16, - lineHeight: WORLDLOOP_HEADER_ROW_HEIGHT, letterSpacing: 0.2, textAlign: "center", includeFontPadding: false, diff --git a/constants/culture-chrome.ts b/constants/culture-chrome.ts new file mode 100644 index 0000000..b3faaaf --- /dev/null +++ b/constants/culture-chrome.ts @@ -0,0 +1,5 @@ +/** Shared Culture tab chrome — header + action rail stay visually aligned. */ +export const CULTURE_CHROME_ICON_SIZE = 26; +export const CULTURE_CHROME_TOUCH_SIZE = 44; +export const CULTURE_CHROME_TITLE_SIZE = 18; +export const CULTURE_CHROME_RAIL_GAP = 20; diff --git a/constants/explore-feed-layout.ts b/constants/explore-feed-layout.ts index 9157f73..188efa7 100644 --- a/constants/explore-feed-layout.ts +++ b/constants/explore-feed-layout.ts @@ -1,7 +1,5 @@ -import { - WORLDLOOP_HEADER_ROW_HEIGHT, - WORLDLOOP_HEADER_TOP_PADDING, -} from "@/components/worldloop-header"; +import { WORLDLOOP_HEADER_TOP_PADDING } from "@/components/worldloop-header"; +import { CULTURE_CHROME_TOUCH_SIZE } from "@/constants/culture-chrome"; /** Outer radius for the stacked hero + info card unit. */ export const EXPLORE_FEED_SURFACE_RADIUS = 14; @@ -23,7 +21,7 @@ export function getExploreHeaderContentHeight( return ( safeAreaTop + WORLDLOOP_HEADER_TOP_PADDING + - WORLDLOOP_HEADER_ROW_HEIGHT + + CULTURE_CHROME_TOUCH_SIZE + EXPLORE_HEADER_OVERLAY_BOTTOM_PADDING + (options?.hereMode ? EXPLORE_HERE_SUBTITLE_HEIGHT : 0) ); diff --git a/hooks/use-country-search.ts b/hooks/use-country-search.ts index 0ccbb75..bfb0cb4 100644 --- a/hooks/use-country-search.ts +++ b/hooks/use-country-search.ts @@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { getCachedSearchResults, getSyncLocalSearchResults, - hasLocalSearchCatalog, prefetchLocalSearchCatalog, searchCountriesWithCache, } from "@/lib/search-countries"; @@ -77,8 +76,7 @@ export function useCountrySearch(enabled: boolean) { setError(null); const localPreview = getSyncLocalSearchResults(q, r); - const hasLocalPreview = - localPreview.length > 0 || (hasLocalSearchCatalog() && (!!q || !!r)); + const hasLocalPreview = localPreview.length > 0; const cached = await getCachedSearchResults(q, r); if (requestId !== searchRequestIdRef.current) return; @@ -89,6 +87,7 @@ export function useCountrySearch(enabled: boolean) { setResults(cached); setStatus("success"); } else if (!hasLocalPreview) { + setResults([]); setStatus("loading"); } @@ -172,11 +171,10 @@ export function useCountrySearch(enabled: boolean) { return; } - if (hasLocalSearchCatalog()) { - setResults([]); - setStatus("success"); - setError(null); - } + // Partial local catalog may miss matches the server still has — wait for network. + setResults([]); + setStatus("loading"); + setError(null); }, [enabled, query, region]); useEffect(() => { diff --git a/lib/format-country.ts b/lib/format-country.ts index 0d4323f..39be342 100644 --- a/lib/format-country.ts +++ b/lib/format-country.ts @@ -2,6 +2,7 @@ import { normalizeImageUrls, stripUrlsFromText, } from "@/lib/normalize-image-url"; +import type { Country, CountryVideo } from "@/types/country"; /** Ensure landmark copy always starts with a capital letter. */ export function formatLandmarkDescription(description: string): string { @@ -112,6 +113,68 @@ export function getCountryImages(country: { images?: string[] }): string[] { return normalizeImageUrls(country.images); } +function isValidVideoUrl(url: string | undefined): boolean { + if (!url?.trim()) return false; + try { + const parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } +} + +/** Normalized, deduped video list for a country. */ +export function getCountryVideos(country: { + name?: string; + videos?: CountryVideo[]; + images?: string[]; +}): CountryVideo[] { + const seen = new Set(); + const result: CountryVideo[] = []; + + for (const item of country.videos ?? []) { + if (!isValidVideoUrl(item.url) || seen.has(item.url)) continue; + seen.add(item.url); + result.push({ + url: item.url, + ...(item.poster ? { poster: item.poster } : {}), + ...(item.provider ? { provider: item.provider } : {}), + ...(item.duration !== undefined ? { duration: item.duration } : {}), + }); + } + + if (result.length === 0 && __DEV__) { + const images = getCountryImages(country); + const poster = images[0] ?? undefined; + return [ + { + url: "demo://onboarding-hero", + poster, + provider: "demo", + }, + ]; + } + + return result; +} + +/** Primary culture clip for the Culture tab (first valid video). */ +export function getCultureVideo(country: Country): CountryVideo | null { + return getCountryVideos(country)[0] ?? null; +} + +export function hasCultureVideo(country: Country): boolean { + return getCultureVideo(country) !== null; +} + +/** Poster / still for Culture top-bar blur and loading state. */ +export function getCulturePosterUri(country: Country): string | undefined { + const video = getCultureVideo(country); + if (video?.poster) return video.poster; + const images = getCountryImages(country); + return images[0] ?? country.flag; +} + function cleanAiLine(raw: string | undefined): string { if (!raw?.trim()) return ""; return stripUrlsFromText(raw.trim()); diff --git a/lib/open-country-in-culture.ts b/lib/open-country-in-culture.ts new file mode 100644 index 0000000..7d30e68 --- /dev/null +++ b/lib/open-country-in-culture.ts @@ -0,0 +1,14 @@ +import { router } from "expo-router"; + +import { useCultureFeedStore } from "@/store/use-culture-feed-store"; +import { useRecentlyViewedStore } from "@/store/use-recently-viewed-store"; +import { useSearchUiStore } from "@/store/use-search-ui-store"; +import type { Country } from "@/types/country"; + +/** Close search, focus country in the culture feed, record view, and open Culture. */ +export function openCountryInCulture(country: Country): void { + useSearchUiStore.getState().closeSearch(); + useCultureFeedStore.getState().focusCountryInCulture(country); + useRecentlyViewedStore.getState().recordView(country); + router.push("/(tabs)/culture"); +} diff --git a/lib/search-countries.ts b/lib/search-countries.ts index 78dad24..7bf6128 100644 --- a/lib/search-countries.ts +++ b/lib/search-countries.ts @@ -12,7 +12,10 @@ import { useMapStore } from "@/store/use-map-store"; import type { Country, MapCountry } from "@/types/country"; let diskCatalogSnapshot: Country[] = []; -let diskCatalogHydratePromise: Promise | null = null; +let baseDiskHydrated = false; +let baseDiskHydratePromise: Promise | null = null; +const hydratedDiskRegions = new Set(); +const regionDiskHydratePromises = new Map>(); function countryRichness(country: Country): number { let score = 0; @@ -131,20 +134,64 @@ export function getSyncLocalSearchResults( return filterLocalCatalog(catalog, query, region); } -async function hydrateDiskCatalog(region: string): Promise { - if (diskCatalogHydratePromise) { - await diskCatalogHydratePromise; +async function hydrateBaseDiskCatalog(): Promise { + if (baseDiskHydrated) return; + + if (baseDiskHydratePromise) { + await baseDiskHydratePromise; return; } - diskCatalogHydratePromise = (async () => { - const diskCatalog = await loadDiskCatalog(region); + baseDiskHydratePromise = (async () => { + const diskCatalog = await loadDiskCatalog(""); if (diskCatalog.length > 0) { diskCatalogSnapshot = mergeCatalog([diskCatalogSnapshot, diskCatalog]); } - })(); + baseDiskHydrated = true; + })().catch((err) => { + baseDiskHydratePromise = null; + throw err; + }); + + await baseDiskHydratePromise; +} + +async function hydrateRegionDiskCatalog(region: string): Promise { + const trimmedRegion = region.trim(); + if (!trimmedRegion || hydratedDiskRegions.has(trimmedRegion)) return; + + const inFlight = regionDiskHydratePromises.get(trimmedRegion); + if (inFlight) { + await inFlight; + return; + } + + const promise = (async () => { + await hydrateBaseDiskCatalog(); + + const regionDisk = await getClientCache( + CLIENT_CACHE_KEYS.feedRegion(trimmedRegion), + ); + if (regionDisk.data && regionDisk.data.length > 0) { + diskCatalogSnapshot = mergeCatalog([ + diskCatalogSnapshot, + regionDisk.data, + ]); + } + + hydratedDiskRegions.add(trimmedRegion); + })().catch((err) => { + regionDiskHydratePromises.delete(trimmedRegion); + throw err; + }); + + regionDiskHydratePromises.set(trimmedRegion, promise); + await promise; +} - await diskCatalogHydratePromise; +async function hydrateDiskCatalog(region: string): Promise { + await hydrateBaseDiskCatalog(); + await hydrateRegionDiskCatalog(region.trim()); } /** Warm map/feed disk caches so progressive typing can filter without network. */ diff --git a/prompts-worldloop/15-video-service.md b/prompts-worldloop/15-video-service.md new file mode 100644 index 0000000..de3b66c --- /dev/null +++ b/prompts-worldloop/15-video-service.md @@ -0,0 +1,304 @@ +Read AGENTS.md first and follow it strictly. + +Reference: `prompts-worldloop/00-backend-overview.md`, `prompts-worldloop/03-image-service.md`, `prompts-worldloop/05-feed-endpoint.md`, `prompts/17-explore-video-feed.md` + +Implement **Feature 15: Video Service** — one short landscape travel clip per country for the Explore feed hero. + +## Goal + +Provide **0–1 playable MP4 URLs** per country so the mobile Explore feed can autoplay muted looping video (see `prompts/17-explore-video-feed.md`). + +Provider chain (v1): + +1. **Pexels Video API** (primary) — reuse existing `PEXELS_API_KEY` +2. **Empty array** — endpoint must still succeed when Pexels returns nothing or the key is missing + +Do **not** call video APIs from the Expo app. URLs only — no hosting or transcoding on WorldLoop servers. + +--- + +## Current state + +- `backend/src/services/image.service.ts` — images via Unsplash → Pexels → Wikipedia; Redis key `images:{country}` +- `backend/src/services/feed.service.ts` — `enrichCountry()` = images + AI +- `backend/src/types/country.ts` — no `videos` field yet +- `backend/src/lib/upstream-validation.ts` — `parsePexelsResults()` for **photos** only +- Mobile Explore hero is image-only (`HeroImagePager`) + +This prompt adds a **parallel enrichment service** for video, mirroring the image service patterns. + +--- + +## Prerequisites (all required) + +- `01-country-data-service.md` completed — country `name`, `cca2`, `capital` +- **`02-local-redis-setup.md` completed** — Redis running and verified +- `03-image-service.md` completed — recommended for poster fallback (`images[0]`) +- `05-feed-endpoint.md` completed — feed enrichment pipeline exists + +--- + +## Data model + +### `backend/src/types/country.ts` + +Add: + +```ts +export type CountryVideo = { + /** Direct HTTPS MP4 URL (not a Pexels watch page). */ + url: string; + /** Still frame for loading / header blur backdrop. */ + poster?: string; + /** Upstream label, e.g. "pexels". */ + provider?: string; + /** Duration in whole seconds when known. */ + duration?: number; +}; + +export type Country = { + // ...existing fields + images?: string[]; + videos?: CountryVideo[]; +}; +``` + +**Contract:** + +- `videos` is always an array when present (`[]` or one item in v1) +- v1 returns **at most 1** video per country +- `url` must be `https://` and end in `.mp4` (or known Pexels CDN MP4 path) + +Mirror the same types in mobile `types/country.ts` when implementing `prompts/17-explore-video-feed.md`. + +--- + +## Scope + +### 1. `backend/src/services/video.service.ts` + +Create a dedicated service (do not bolt video fetching onto `image.service.ts`). + +| Export | Purpose | +| ------------------------------------------ | ------------------------------------------- | +| `getVideosForCountry(countryName: string)` | Cache-first fetch; returns `CountryVideo[]` | +| `enrichCountryWithVideos(country)` | Merge `videos` onto a country object | +| `enrichCountriesWithVideos(countries)` | Batch helper (optional) | + +**Cache:** + +- Key: `videos:{country}` via `cacheKeys.videos(name)` — lowercase trimmed country name (same convention as `images:`) +- TTL: 30 days — add `videos: 30 * 24 * 60 * 60` to `CACHE_TTL` in `cache.service.ts` + +**Fetch when cache miss:** + +1. If `!env.pexelsApiKey` → return `[]` (log once at debug level, do not throw) +2. Call Pexels Video Search API +3. Pick the best matching clip (see selection rules) +4. Normalize to `CountryVideo[]` (0 or 1 item) +5. Store in Redis + +### 2. Pexels Video API + +**Endpoint:** `GET https://api.pexels.com/videos/search` + +**Headers:** `Authorization: {PEXELS_API_KEY}` (same as photo search in `image.service.ts`) + +**Query params (v1):** + +| Param | Value | +| ------------- | ----------------------------------------------------------------------------------------------- | +| `query` | `"{countryName} landscape"` — fallback `"{countryName} travel"` if first query returns 0 videos | +| `per_page` | `5` | +| `orientation` | `landscape` | + +**Response fields to use:** + +| Pexels field | Maps to | +| ------------------------ | -------------------- | +| `videos[].video_files[]` | Pick one MP4 `link` | +| `videos[].image` | `poster` | +| `videos[].duration` | `duration` (seconds) | +| — | `provider: "pexels"` | + +**File selection rules** (first match wins): + +1. Prefer `file_type === "video/mp4"` +2. Prefer width **≤ 1280** and **≥ 720** (balance quality vs mobile bandwidth) +3. If none in range, pick smallest width ≥ 640 +4. Reject non-HTTPS links and non-MP4 types + +Store the **direct file URL** from `video_files[].link`, not `https://www.pexels.com/video/...`. + +### 3. `backend/src/lib/upstream-validation.ts` + +Add typed parser(s), mirroring `parsePexelsResults`: + +```ts +export function parsePexelsVideoResults(data: unknown): PexelsVideoHit[]; +``` + +- Validate response shape; throw `HttpError` with code `UPSTREAM_INVALID` on malformed JSON (same as photos) +- Return normalized hits with `url`, `poster`, `duration` — service layer picks the first suitable hit +- Log and return `[]` on empty `videos` array (not an error) + +### 4. Poster fallback + +When Pexels provides no `image` on the chosen video: + +1. Call `getImagesForCountry(countryName)` and use `images[0]` as `poster` +2. If still none, omit `poster` (mobile falls back to flag placeholder) + +Avoid circular imports: video service may import `getImagesForCountry` from `image.service.ts` (images do not import videos). + +### 5. Feed integration — `feed.service.ts` + +Extend enrichment pipeline: + +```ts +async function enrichCountry(country: CountryBasic): Promise { + const withImages = await enrichCountryWithImages(country); + const withVideos = await enrichCountryWithVideos(withImages); + return enrichCountryWithAi(withVideos); +} +``` + +Order: **images → videos → AI** so poster fallback and AI facts stay independent. + +Feed batch cache keys (`feed:countries:{offset}:{limit}`) already cache fully enriched objects — no separate feed cache change required. After deploy, old feed cache entries lack `videos` until TTL expires; acceptable for dev. Optionally bump feed cache key suffix in a follow-up if you need instant invalidation. + +### 6. Other endpoints (recommended) + +Merge videos anywhere countries are fully enriched for client hero use: + +| Endpoint / service | Action | +| ------------------------------------------------------ | ------------------------------------------------------ | +| `GET /country/:name/profile` (`profile.controller.ts`) | After images, call `enrichCountryWithVideos` | +| `GET /country/:name` (`country.controller.ts`) | Same (optional but keeps parity) | +| `search.service.ts` | Same pattern as images if search cards ever show video | + +Skip `GET /map/countries` — map pins stay lightweight (`MapCountry` has no video field). + +### 7. Logging + +Match `image.service.ts` style: + +```ts +logger.debug("Video fetched from Pexels", { country, duration }); +logger.debug("No video found for country", { country }); +logger.warn("Pexels Video API error", { status, country }); +``` + +On cache hit, existing `getOrSet` logging applies. + +--- + +## Out of scope + +- Multiple videos per country (v1 = max 1) +- Unsplash video (no stable free API in scope) +- Wikipedia / Wikimedia video files +- YouTube or Vimeo embed URLs +- HLS / DASH adaptive streams +- Uploading or proxying video through WorldLoop CDN +- AI-generated video +- Video pre-generation batch jobs (`10-pregeneration-system.md`) +- Mobile playback UI (`prompts/17-explore-video-feed.md`) + +--- + +## Env vars + +| Variable | Required | Notes | +| ---------------- | ----------- | -------------------------------------------------------------------------- | +| `PEXELS_API_KEY` | Recommended | Same key as `03-image-service.md`; service returns `videos: []` when unset | + +No new env vars for v1. + +Document in `backend/README.md` under a **Video** subsection next to Images. + +--- + +## Files to create / modify + +| Path | Change | +| ----------------------------------------------- | ---------------------------------------------------- | +| `backend/src/types/country.ts` | Add `CountryVideo`, optional `videos[]` on `Country` | +| `backend/src/services/video.service.ts` | **New** — Pexels fetch + cache + enrich helpers | +| `backend/src/lib/upstream-validation.ts` | Add Pexels video response parser | +| `backend/src/services/cache.service.ts` | `CACHE_TTL.videos`, `cacheKeys.videos()` | +| `backend/src/services/feed.service.ts` | Video step in `enrichCountry` | +| `backend/src/controllers/profile.controller.ts` | Enrich with videos (recommended) | +| `backend/README.md` | Env + curl test for video field | + +--- + +## Acceptance criteria + +- `GET /feed/countries` returns countries with `videos: CountryVideo[]` when Pexels succeeds +- Each video object has a valid HTTPS MP4 `url` +- `poster` is populated when Pexels or image service provides a still +- Missing `PEXELS_API_KEY` or upstream failure → `videos: []`; HTTP **200** unchanged +- Redis key `videos:{country}` caches results; second request logs cache hit +- No Pexels key or direct MP4 URLs in the Expo app +- `npm run lint` and `npm run typecheck` pass in `backend/` +- Feed pagination (`nextCursor`) behavior unchanged + +--- + +## Testing + +```bash +# Backend running with Redis + PEXELS_API_KEY in backend/.env +cd backend && npm run dev +``` + +**1. Single country (via feed slice or profile):** + +```bash +curl -s "http://localhost:3000/country/Japan/profile" | jq '.videos' +``` + +Expect: + +```json +[ + { + "url": "https://videos.pexels.com/.../....mp4", + "poster": "https://images.pexels.com/...", + "provider": "pexels", + "duration": 14 + } +] +``` + +**2. Cache hit** — repeat request; logs should show Redis cache hit for `videos:japan`. + +**3. No API key** — unset `PEXELS_API_KEY`, restart, same endpoint → `"videos": []`, status 200. + +**4. Feed batch:** + +```bash +curl -s "http://localhost:3000/feed/countries?limit=3" | jq '.data[] | {name, videos: .videos | length}' +``` + +**5. Invalid upstream** — mock 502 from Pexels in unit test or temporary throw; endpoint still returns country without video. + +After backend is verified, implement mobile playback per `prompts/17-explore-video-feed.md`. + +--- + +## Performance notes + +- Fetch **one** video per country — Explore v1 only plays the first slide +- Video enrichment runs in parallel per country inside `Promise.all` in feed batches (same as images/AI today) +- Consider skipping video fetch when `PEXELS_API_KEY` is empty (short-circuit before HTTP) +- Do not block feed on slow Pexels responses beyond existing upstream timeout patterns — fail open to `[]` + +--- + +## Next steps + +1. `prompts/17-explore-video-feed.md` — mobile `HeroMediaPager` + `expo-video` +2. Optional: bump feed Redis cache version if you need immediate `videos` on all cached batches +3. Optional v2: second provider (Mixkit static catalog keyed by region) when Pexels has no country match diff --git a/prompts-worldloop/README.md b/prompts-worldloop/README.md index 9d12078..281533b 100644 --- a/prompts-worldloop/README.md +++ b/prompts-worldloop/README.md @@ -19,6 +19,7 @@ Incremental backend implementation prompts for the WorldLoop API. Implement in n | 12 | [12-discover-endpoint.md](./12-discover-endpoint.md) | Spatial bbox discover (pairs with `prompts/15-*`) | | 13 | [13-news-service.md](./13-news-service.md) | GNews + Currents news layer, LLM, Redis 4h TTL | | 14 | [14-landmarks-data-pipeline.md](./14-landmarks-data-pipeline.md) | Wikidata + OSM landmarks, Wikimedia images, 3–5 per country | +| 15 | [15-video-service.md](./15-video-service.md) | Pexels travel clips per country for Explore video hero | ## Dev prerequisites @@ -37,14 +38,15 @@ Incremental backend implementation prompts for the WorldLoop API. Implement in n `00` → `01` → **`02` (Redis — required)** → `08` (cache layer hardening, if needed) → `05` → `03` → `04` → `09` → `06` → `07` → `11` → `10` -| Phase | Steps | Notes | -| -------------------- | ---------------------- | ------------------------------------------------------------- | -| Foundation | `00`, `01` | Backend + country API | -| **Redis (blocking)** | **`02`** | Must pass before `03`+ | -| Cache code | `08` | Refine `cache.service.ts`; Redis must already run | -| Enrichment | `03`, `04`, `05` | Images, AI, feed | -| Hardening | `09`, `06`, `07`, `11` | Security, search, map, health | -| Optional | `10` | Pre-generation | -| Geo discovery | `12` | After client `prompts/15a`–`15d`; bbox query API | -| News / explorer | `13` | After `04`; pairs with `prompts/16-ai-content-explorer-ui.md` | -| Landmarks pipeline | `14` | After `03`; Wikidata + Overpass; extends profile endpoint | +| Phase | Steps | Notes | +| -------------------- | ---------------------- | --------------------------------------------------------------------------- | +| Foundation | `00`, `01` | Backend + country API | +| **Redis (blocking)** | **`02`** | Must pass before `03`+ | +| Cache code | `08` | Refine `cache.service.ts`; Redis must already run | +| Enrichment | `03`, `04`, `05` | Images, AI, feed | +| Hardening | `09`, `06`, `07`, `11` | Security, search, map, health | +| Optional | `10` | Pre-generation | +| Geo discovery | `12` | After client `prompts/15a`–`15d`; bbox query API | +| News / explorer | `13` | After `04`; pairs with `prompts/16-ai-content-explorer-ui.md` | +| Landmarks pipeline | `14` | After `03`; Wikidata + Overpass; extends profile endpoint | +| Explore video hero | `15` | After `03`; Pexels Video API; pairs with `prompts/17-explore-video-feed.md` | diff --git a/prompts/17-explore-video-feed.md b/prompts/17-explore-video-feed.md new file mode 100644 index 0000000..cfe7543 --- /dev/null +++ b/prompts/17-explore-video-feed.md @@ -0,0 +1,341 @@ +Read AGENTS.md first and follow it strictly. + +Reference: `AGENTS.md` (Explore Screen Rules, Image Data Rules), `prompts/10a-explore-ui.md`, `prompts/16-ai-content-explorer-ui.md`, `prompts-worldloop/03-image-service.md`, `prompts-worldloop/05-feed-endpoint.md`, `prompts-worldloop/15-video-service.md`, `components/onboarding/onboarding-background-video.tsx`, `.agents/skills/upgrading-expo/references/expo-av-to-video.md` + +Upgrade the Explore feed hero from **static image paging** to a **TikTok-style mixed media carousel** that can play **looping muted country videos** alongside existing photography. Keep the current vertical country feed, card layout, action rail, and AI fact wiring — only evolve the hero layer and data model. + +--- + +## Goal + +Each country in the vertical Explore feed should feel like a short-form video post when video is available: + +- **Vertical swipe** still changes country (`ExploreFeed` + `useCountryFeedStore` unchanged in behavior) +- **Horizontal swipe** on the hero cycles **mixed media slides** (video and/or images) +- Active slide **autoplays** when the country page is visible; pauses when off-screen or when user swipes away +- Videos are **muted by default**, loop, and use `contentFit="cover"` like the onboarding background video +- When no video exists, behavior falls back to today’s image-only hero (no regressions) +- Backend supplies optional `videos[]` per country; mobile never calls Pexels/Unsplash video APIs directly + +--- + +## Prerequisites + +- `prompts/10a-explore-ui.md` — Explore tab, `ExploreFeed`, `CountryFeedPage`, `HeroImagePager`, `MediaCarousel`, `ExploreCountryCard` +- `prompts/08-zustand.md` — `useCountryFeedStore`, `lib/api.ts`, `types/country.ts` +- `prompts-worldloop/02-local-redis-setup.md` — Redis running +- `prompts-worldloop/03-image-service.md` — `images[]` already merged into feed countries +- `prompts-worldloop/05-feed-endpoint.md` — `GET /feed/countries` pagination works +- **`prompts-worldloop/15-video-service.md` completed** — feed/profile return `videos[]` with direct MP4 URLs +- `expo-video` already installed (`package.json`); onboarding reference at `components/onboarding/onboarding-background-video.tsx` + +--- + +## Dependencies + +Use what is already installed: + +- `expo-video` (`useVideoPlayer`, `VideoView`) — **not** `expo-av` +- `expo-image` via existing `CountryImage` helper +- `react-native` `FlatList` for horizontal hero paging (same pattern as `HeroImagePager`) +- Existing feed stores and layout constants in `constants/explore-feed-layout.ts` + +Do **not** add `expo-av`, React Query, or new video SDKs without user approval. + +--- + +## Data model + +### Extend `types/country.ts` + +Add a typed media shape so the hero can distinguish video slides from images: + +```ts +export type CountryVideo = { + /** Direct MP4 (or HLS) URL from backend — never a page link. */ + url: string; + /** Optional poster for loading / ExploreTopBar backdrop. */ + poster?: string; + /** Source attribution for debug UI (e.g. "pexels"). */ + provider?: string; + /** Duration in seconds when known. */ + duration?: number; +}; + +export type CountryMediaSlide = + | { kind: "image"; uri: string } + | { kind: "video"; video: CountryVideo }; + +export type Country = { + // ...existing fields + images?: string[]; + videos?: CountryVideo[]; +}; +``` + +### Normalization helper — `lib/format-country.ts` + +Add helpers (names are suggestions; keep them teachable): + +| Function | Behavior | +| -------------------------------- | ------------------------------------------------------------------- | +| `getCountryVideos(country)` | Returns normalized `CountryVideo[]` (dedupe by `url`, drop invalid) | +| `getCountryMediaSlides(country)` | Builds ordered slide list for the hero pager | + +**Recommended slide order (v1):** + +1. If `videos[0]` exists → first slide is video (poster = `video.poster ?? images[0] ?? flag`) +2. Append all `images[]` as image slides (skip URL duplicated as poster) +3. If no videos and no images → empty list (existing flag placeholder path in `CountryImage`) + +Never expose provider API keys in the app. All URLs come from the feed/profile API. + +--- + +## Backend dependency + +Implement **`prompts-worldloop/15-video-service.md` first** (or in parallel). The mobile work assumes feed and profile responses include: + +```json +"videos": [ + { + "url": "https://videos.pexels.com/.../....mp4", + "poster": "https://images.pexels.com/...", + "provider": "pexels", + "duration": 14 + } +] +``` + +When `videos` is missing or `[]`, the app keeps today’s image-only hero with no behavior change. + +--- + +## Route & files + +| Path | Purpose | +| ------------------------------------------ | --------------------------------------------------------------------------------------------- | +| `types/country.ts` | Add `CountryVideo`, `CountryMediaSlide`, optional `videos[]` | +| `lib/format-country.ts` | `getCountryMediaSlides`, `getCountryVideos`, backdrop poster helper | +| `components/explore/hero-media-pager.tsx` | **New** — replaces `HeroImagePager` usage; mixed image + video slides | +| `components/explore/hero-video-slide.tsx` | **New** — single slide: `VideoView` + poster + loading/error fallback | +| `components/explore/country-feed-page.tsx` | Wire `HeroMediaPager`, pass `isActive`, use media slides not raw `images` | +| `components/explore/explore-feed.tsx` | Backdrop: use poster URI when active slide is video | +| `components/explore/media-carousel.tsx` | Rename props mentally to “slides”; dots count = slide count | +| `constants/videos.ts` | Optional: `exploreFallback` bundled clip **only** for dev/demo when backend returns no videos | + +Keep `HeroImagePager` exported until migration is complete, then delete or re-export from `HeroMediaPager` for a thin diff in git history. + +--- + +## Frontend implementation + +### 1. `HeroMediaPager` + +Evolve `HeroImagePager` → `HeroMediaPager`: + +- Props: `slides: CountryMediaSlide[]`, `activeIndex`, `onIndexChange`, `heroWidth`, `heroHeight`, `isPageActive`, `isMuted`, press handlers for AI explorer +- Horizontal `FlatList` with `pagingEnabled` — same scroll math as today +- **Image slide:** existing `CountryImage` inside `Pressable` +- **Video slide:** render `HeroVideoSlide` (see below) +- When `slides.length <= 1`, hide dot carousel (unchanged rule) + +### 2. `HeroVideoSlide` + +Follow `OnboardingBackgroundVideo` patterns: + +```tsx +const player = useVideoPlayer(video.url, (instance) => { + instance.loop = true; + instance.muted = isMuted; +}); + +useEffect(() => { + if (isPageActive && isSlideActive) { + player.play(); + return; + } + player.pause(); +}, [player, isPageActive, isSlideActive, isMuted]); +``` + +`HeroVideoSlide` props: + +| Prop | Purpose | +| ----------------- | ------------------------------------------------------------------------------- | +| `video` | `CountryVideo` | +| `flag`, `iso2` | Fallback placeholder via `CountryImage` | +| `isPageActive` | From `CountryFeedPage` (`isActive`) — false when user swiped to another country | +| `isSlideActive` | `activeIndex === slideIndex` — false when user swiped to another hero slide | +| `isMuted` | Default `true`; wire to optional mute toggle later | +| `width`, `height` | Hero shell dimensions | + +`VideoView` settings: + +- `contentFit="cover"` +- `nativeControls={false}` +- `allowsPictureInPicture={false}` +- Show **poster** (`CountryImage`) until `playingChange` or `statusChange` indicates ready; hide poster when playing + +On load error → fall back to poster/static image; do not crash the feed page. + +### 3. `CountryFeedPage` wiring + +Replace: + +```ts +const images = useMemo(() => getCountryImages(country), [country.name]); +``` + +With: + +```ts +const slides = useMemo(() => getCountryMediaSlides(country), [country.name]); +const heroSlides = useMemo( + () => Array.from({ length: slides.length }, (_, index) => `${index}`), + [slides.length], +); +``` + +Pass `isActive` into `HeroMediaPager` as `isPageActive`. + +Reset `heroIndex` to `0` on `country.name` change (already done). + +Prefetch: keep image prefetch; optionally `player.replace` prefetch is **not** needed in v1. + +### 4. `ExploreFeed` backdrop + +Today `backdropImageUri` uses `getCountryImages`. Update to: + +```ts +const backdropUri = getExploreBackdropUri(currentCountry, activeHeroIndex); +``` + +Logic: + +- Image slide → that image URI +- Video slide → `video.poster ?? country.images?.[0]` +- Ensures `ExploreTopBar` frosted blur still has a stable still frame while video plays + +Add `getExploreBackdropUri` to `lib/format-country.ts`. + +### 5. Playback lifecycle rules (critical) + +Only **one** video should decode/play at a time: + +| State | Video behavior | +| ------------------------------------------------ | --------------------------------- | +| Country page off-screen (vertical feed) | **Pause** all videos on that page | +| Country page active, slide index ≠ video index | **Pause** that video | +| Country page active, video slide visible | **Play** (muted, loop) | +| User opens AI explorer (`openCountryAiExplorer`) | **Pause** hero video | +| App background (`AppState` → `background`) | **Pause** all players | + +Implement `isPageActive` from existing `isActive` on `CountryFeedPage`. Do not instantiate `useVideoPlayer` for off-screen FlatList rows when avoidable — in v1 it is acceptable to mount players only for the active slide index inside the active page (lazy slide mount) to reduce memory. + +Suggested lazy pattern inside `HeroMediaPager`: + +```tsx +renderItem={({ item, index }) => { + if (item.kind === "video") { + if (!isPageActive || index !== activeIndex) { + return ; + } + return ; + } + return ; +}} +``` + +### 6. Optional UX (v1.1 — not blocking) + +- **Mute toggle** on `ExploreActionRail` when active slide is video (reuse Listen slot or add speaker icon) +- Subtle **“Video”** pill on video slides (debug/teaching label) +- Double-tap to unmute (TikTok pattern) — only if product asks + +--- + +## UI breakdown + +Match the existing Explore feed chrome — **do not redesign** the card, rail, or header: + +| Area | Video behavior | +| -------------------- | ------------------------------------------------------------------------------------- | +| Hero region | Full-bleed video with same radius (`EXPLORE_FEED_SURFACE_RADIUS`) and shell as photos | +| Dot carousel | Same pill track; one dot per **slide** (video counts as one slide) | +| `ExploreCountryCard` | Unchanged; `getAiFactByIndex(country, heroIndex)` still tracks slide index | +| `ExploreTopBar` | Blurred backdrop uses poster still, not live video texture | +| Tab bar | No overlap changes | + +Styling: + +- Prefer **StyleSheet** for `VideoView`, `FlatList`, and animated poster opacity +- NativeWind only where already used in sibling explore components +- No `className` on `SafeAreaView` + +--- + +## Performance & memory + +- `ExploreFeed` already uses `windowSize={3}`, `maxToRenderPerBatch={2}`, `removeClippedSubviews` — keep these +- Pause (don’t destroy) players when pausing — faster resume when user swipes back +- Prefer **one video per country** in backend v1 to limit bandwidth +- Do not preload videos for feed neighbors in v1 (images only, like today) +- Test on a mid-range Android device — video + vertical FlatList is the main risk + +--- + +## Out of scope + +- User-uploaded video +- Inline audio / narration playback (Listen button stays placeholder unless `expo-speech` product scope returns) +- HLS adaptive streaming (MP4 only for v1) +- Picture-in-picture +- Replacing the entire feed with video-only countries +- AI-generated video +- Persisting mute preference to AsyncStorage (optional stretch) + +--- + +## Acceptance criteria + +- Feed countries with `videos[]` show autoplaying muted loop on the first hero slide when page is active +- Swiping **up/down** pauses previous country’s video and plays the new one when applicable +- Swiping **left/right** pauses previous slide’s video and plays the new slide when it is a video +- Countries without `videos[]` behave exactly like the current image hero +- `ExploreTopBar` backdrop remains a sharp still (poster/image), not black +- No `expo-av` imports; only `expo-video` +- Video load failure shows poster/flag fallback without red screen +- `npm run lint` and `npm run typecheck` pass +- Backend returns `videos: []` gracefully when Pexels key missing (local dev without video still works) + +--- + +## Testing + +```bash +# Terminal 1 — backend + Redis +# Terminal 2 +npx expo start +``` + +1. Open **Explore** — first country with video autoplays muted on hero +2. Swipe hero horizontally to an image slide — video pauses +3. Swipe back to video slide — video resumes +4. Swipe **up** to next country — previous video stops, new country behaves correctly +5. Background the app — audio/video stops (muted anyway); foreground resumes active slide +6. Turn off backend video service / empty `videos[]` — image-only hero still works +7. Tap hero → AI explorer opens; hero video paused +8. Android: scroll 10+ countries — no OOM/crash + +**Dev without backend videos:** temporarily append a known-good MP4 in `getCountryMediaSlides` behind `__DEV__` or use a bundled asset in `constants/videos.ts` for one demo country only. + +--- + +## Next steps + +After this prompt: + +1. AI explorer hero parity — reuse `getCountryMediaSlides` in `app/country/[name]/ai-explorer.tsx` +2. Mute toggle + persist preference (optional) +3. Prefetch poster frames for next country in feed (not full video files) diff --git a/store/use-country-feed-store.ts b/store/use-country-feed-store.ts index c3c754e..2653184 100644 --- a/store/use-country-feed-store.ts +++ b/store/use-country-feed-store.ts @@ -83,9 +83,7 @@ function isLoading(status: FeedStatus): boolean { } function isFeedGenerationStale(regionGen: number, hereGen: number): boolean { - return ( - regionGen !== regionFilterGeneration || hereGen !== hereFeedGeneration - ); + return regionGen !== regionFilterGeneration || hereGen !== hereFeedGeneration; } function shuffleCountries(items: T[]): T[] { @@ -259,8 +257,7 @@ export const useCountryFeedStore = create((set, get) => ({ const guard = options?.feedGenerationGuard; const isStale = () => - guard != null && - isFeedGenerationStale(guard.regionGen, guard.hereGen); + guard != null && isFeedGenerationStale(guard.regionGen, guard.hereGen); const cacheKey = CLIENT_CACHE_KEYS.feedFirstPage; let hydratedFromDisk = false; diff --git a/store/use-culture-feed-store.ts b/store/use-culture-feed-store.ts new file mode 100644 index 0000000..d7f812a --- /dev/null +++ b/store/use-culture-feed-store.ts @@ -0,0 +1,393 @@ +import { create } from "zustand"; + +import { fetchFeedCountries } from "@/lib/api"; +import { normalizeCountriesRegions } from "@/lib/app-region"; +import { fetchExploreRegionCountries } from "@/lib/explore-region-countries"; +import { hasCultureVideo } from "@/lib/format-country"; +import { + DEFAULT_FEED_SORT_FIELD, + DEFAULT_FEED_SORT_ORDER, + type FeedSortField, + type FeedSortOrder, +} from "@/store/use-country-feed-store"; +import type { Country } from "@/types/country"; + +const DEFAULT_LIMIT = 20; + +type FeedStatus = "idle" | "loading" | "loadingMore" | "error"; + +type ForYouSnapshot = { + countries: Country[]; + nextCursor: string | null; +}; + +type CultureFeedState = { + countries: Country[]; + nextCursor: string | null; + currentIndex: number; + hasLoadedOnce: boolean; + selectedRegion: string | null; + sortField: FeedSortField | null; + sortOrder: FeedSortOrder | null; + forYouSnapshot: ForYouSnapshot | null; + regionCache: Record; + isMuted: boolean; + isSortSheetOpen: boolean; + status: FeedStatus; + error: string | null; + loadInitialFeed: (options?: { force?: boolean }) => Promise; + loadMoreFeed: (limit?: number) => Promise; + setRegionFilter: (region: string | null) => Promise; + restoreForYouFeed: () => Promise; + setCurrentIndex: (index: number) => void; + setSort: (field: FeedSortField, order: FeedSortOrder) => void; + toggleMuted: () => void; + openSortSheet: () => void; + closeSortSheet: () => void; + focusCountryInCulture: (country: Country) => void; + getCurrentCountry: () => Country | undefined; +}; + +let regionFilterGeneration = 0; + +function isLoading(status: FeedStatus): boolean { + return status === "loading" || status === "loadingMore"; +} + +function filterCultureCountries(countries: Country[]): Country[] { + return countries.filter(hasCultureVideo); +} + +function shuffleCountries(items: T[]): T[] { + const list = [...items]; + for (let i = list.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [list[i], list[j]] = [list[j], list[i]]; + } + return list; +} + +function sortCountries( + countries: Country[], + sortField: FeedSortField | null, + sortOrder: FeedSortOrder | null, +): Country[] { + const field = sortField ?? DEFAULT_FEED_SORT_FIELD; + const order = sortOrder ?? DEFAULT_FEED_SORT_ORDER; + + if (order === "random") { + return shuffleCountries(countries); + } + + const direction = order === "asc" ? 1 : -1; + return [...countries].sort((a, b) => { + if (field === "population") { + return (a.population - b.population) * direction; + } + return a.name.localeCompare(b.name) * direction; + }); +} + +function mergeUniqueCultureCountries( + existing: Country[], + incoming: Country[], +): Country[] { + const seen = new Set(existing.map((country) => country.name)); + const uniqueIncoming = incoming.filter((country) => !seen.has(country.name)); + return [...existing, ...uniqueIncoming]; +} + +function snapshotForYouIfNeeded(): void { + const { forYouSnapshot, nextCursor, selectedRegion, countries } = + useCultureFeedStore.getState(); + + if (selectedRegion !== null) return; + if (forYouSnapshot) return; + if (countries.length === 0) return; + + useCultureFeedStore.setState({ + forYouSnapshot: { countries, nextCursor }, + }); +} + +export const useCultureFeedStore = create((set, get) => ({ + countries: [], + nextCursor: null, + currentIndex: 0, + hasLoadedOnce: false, + selectedRegion: null, + sortField: DEFAULT_FEED_SORT_FIELD, + sortOrder: DEFAULT_FEED_SORT_ORDER, + forYouSnapshot: null, + regionCache: {}, + isMuted: true, + isSortSheetOpen: false, + status: "idle", + error: null, + + loadInitialFeed: async (options) => { + if (!options?.force && get().countries.length > 0) return; + if (!options?.force && isLoading(get().status)) return; + + set({ status: "loading", error: null, selectedRegion: null }); + + try { + const { data, nextCursor } = await fetchFeedCountries( + undefined, + DEFAULT_LIMIT, + ); + const normalized = normalizeCountriesRegions(data); + const cultureCountries = filterCultureCountries(normalized); + const { sortField, sortOrder } = get(); + const sorted = sortCountries(cultureCountries, sortField, sortOrder); + + set({ + countries: sorted, + nextCursor, + currentIndex: 0, + hasLoadedOnce: true, + selectedRegion: null, + forYouSnapshot: { countries: sorted, nextCursor }, + status: "idle", + error: null, + }); + } catch (err) { + set({ + hasLoadedOnce: true, + status: "error", + error: + err instanceof Error ? err.message : "Failed to load culture feed", + }); + } + }, + + setRegionFilter: async (region) => { + const requestId = ++regionFilterGeneration; + + if (region === null) { + await get().restoreForYouFeed(); + return; + } + + if (get().selectedRegion === region && get().status !== "error") return; + + snapshotForYouIfNeeded(); + + const cached = get().regionCache[region]; + if (cached) { + const { sortField, sortOrder } = get(); + const sorted = sortCountries(cached, sortField, sortOrder); + set({ + countries: sorted, + nextCursor: null, + currentIndex: 0, + selectedRegion: region, + status: "idle", + error: null, + }); + return; + } + + set({ + selectedRegion: region, + countries: [], + currentIndex: 0, + status: "loading", + error: null, + }); + + try { + const data = await fetchExploreRegionCountries(region); + if (requestId !== regionFilterGeneration) return; + + const normalized = normalizeCountriesRegions(data); + const cultureCountries = filterCultureCountries(normalized); + const { sortField, sortOrder } = get(); + const sorted = sortCountries(cultureCountries, sortField, sortOrder); + + set((state) => ({ + countries: sorted, + nextCursor: null, + currentIndex: 0, + selectedRegion: region, + regionCache: { ...state.regionCache, [region]: cultureCountries }, + status: "idle", + error: null, + })); + } catch (err) { + if (requestId !== regionFilterGeneration) return; + + set({ + status: "error", + error: + err instanceof Error + ? err.message + : `Failed to load culture clips in ${region}`, + }); + } + }, + + restoreForYouFeed: async () => { + regionFilterGeneration += 1; + + const snapshot = get().forYouSnapshot; + if (snapshot && snapshot.countries.length > 0) { + set({ + countries: snapshot.countries, + nextCursor: snapshot.nextCursor, + currentIndex: 0, + selectedRegion: null, + status: "idle", + error: null, + }); + return; + } + + set({ + selectedRegion: null, + error: null, + }); + await get().loadInitialFeed({ force: true }); + }, + + loadMoreFeed: async (limit = DEFAULT_LIMIT) => { + const { nextCursor, status, countries, selectedRegion } = get(); + if (selectedRegion !== null || nextCursor === null || isLoading(status)) { + return; + } + + set({ status: "loadingMore", error: null }); + + try { + const { data, nextCursor: newCursor } = await fetchFeedCountries( + nextCursor, + limit, + ); + const normalized = normalizeCountriesRegions(data); + const cultureCountries = filterCultureCountries(normalized); + + set((state) => { + const merged = mergeUniqueCultureCountries(countries, cultureCountries); + const nextCountries = sortCountries( + merged, + state.sortField, + state.sortOrder, + ); + const snapshotTail = state.forYouSnapshot + ? sortCountries( + mergeUniqueCultureCountries( + state.forYouSnapshot.countries, + cultureCountries, + ), + state.sortField, + state.sortOrder, + ) + : nextCountries; + + return { + countries: nextCountries, + nextCursor: newCursor, + status: "idle", + error: null, + forYouSnapshot: { + countries: snapshotTail, + nextCursor: newCursor, + }, + }; + }); + } catch (err) { + set({ + status: "error", + error: + err instanceof Error + ? err.message + : "Failed to load more culture clips", + }); + } + }, + + setSort: (field, order) => { + set((state) => ({ + sortField: field, + sortOrder: order, + countries: sortCountries(state.countries, field, order), + currentIndex: 0, + ...(state.selectedRegion === null && state.forYouSnapshot + ? { + forYouSnapshot: { + ...state.forYouSnapshot, + countries: sortCountries( + state.forYouSnapshot.countries, + field, + order, + ), + }, + } + : {}), + })); + }, + + toggleMuted: () => { + set((state) => ({ isMuted: !state.isMuted })); + }, + + openSortSheet: () => { + set({ isSortSheetOpen: true }); + }, + + closeSortSheet: () => { + set({ isSortSheetOpen: false }); + }, + + setCurrentIndex: (index) => { + const { countries } = get(); + if (countries.length === 0) { + set({ currentIndex: 0 }); + return; + } + const clamped = Math.max(0, Math.min(index, countries.length - 1)); + set({ currentIndex: clamped }); + }, + + focusCountryInCulture: (country) => { + const { countries, status, selectedRegion, forYouSnapshot, nextCursor } = + get(); + const nextStatus = status === "loading" ? "idle" : status; + const existingIndex = countries.findIndex( + (item) => item.name === country.name, + ); + + if (existingIndex >= 0) { + const reordered = [ + countries[existingIndex], + ...countries.filter((item) => item.name !== country.name), + ]; + set({ + countries: reordered, + currentIndex: 0, + status: nextStatus, + }); + return; + } + + const nextCountries = [country, ...countries]; + set({ + countries: nextCountries, + currentIndex: 0, + status: nextStatus, + ...(selectedRegion === null + ? { + forYouSnapshot: { + countries: nextCountries, + nextCursor: forYouSnapshot?.nextCursor ?? nextCursor, + }, + } + : {}), + }); + }, + + getCurrentCountry: () => { + const { countries, currentIndex } = get(); + return countries[currentIndex]; + }, +})); diff --git a/store/use-search-ui-store.ts b/store/use-search-ui-store.ts index 082e181..03ad6a3 100644 --- a/store/use-search-ui-store.ts +++ b/store/use-search-ui-store.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; -export type SearchUiContext = "default" | "map"; +export type SearchUiContext = "default" | "map" | "culture"; type SearchUiState = { isOpen: boolean; diff --git a/types/country.ts b/types/country.ts index cec7680..6ff32a7 100644 --- a/types/country.ts +++ b/types/country.ts @@ -1,3 +1,11 @@ +/** Direct MP4 (or HLS) URL from backend — never a page link. */ +export type CountryVideo = { + url: string; + poster?: string; + provider?: string; + duration?: number; +}; + /** Lightweight country shape from GET /map/countries. */ export type MapCountry = { name: string; @@ -27,6 +35,7 @@ export type Country = { /** Official language names (REST Countries). */ languages?: string[]; images?: string[]; + videos?: CountryVideo[]; ai?: { /** Primary fact (same as `facts[0]`). */ fact: string;