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;