diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index ec502a1..c0fe1a0 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,31 +1,35 @@ import { Tabs } from "expo-router"; +import { View } from "react-native"; import { BottomTabBar } from "@/components/bottom-tab-bar"; +import { SearchOverlay } from "@/components/search/search-overlay"; export default function TabsLayout() { return ( - } - screenOptions={{ - headerShown: false, - safeAreaInsets: { bottom: 0 }, - tabBarStyle: { - position: "absolute", - left: 0, - right: 0, - bottom: 0, - backgroundColor: "transparent", - borderTopWidth: 0, - elevation: 0, - shadowOpacity: 0, - }, - }} - > - - - - - - + + } + screenOptions={{ + headerShown: false, + tabBarStyle: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + backgroundColor: "transparent", + borderTopWidth: 0, + elevation: 0, + shadowOpacity: 0, + }, + }} + > + + + + + + + + ); } diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 3c0ad6c..849d3b6 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -9,20 +9,24 @@ import { useCountryFeedStore } from "@/store/use-country-feed-store"; export default function ExploreScreen() { const countries = useCountryFeedStore((s) => s.countries); + const selectedRegion = useCountryFeedStore((s) => s.selectedRegion); const status = useCountryFeedStore((s) => s.status); const error = useCountryFeedStore((s) => s.error); const loadInitialFeed = useCountryFeedStore((s) => s.loadInitialFeed); useEffect(() => { - if (countries.length === 0 && status === "idle") { + if (countries.length === 0 && status === "idle" && selectedRegion === null) { void loadInitialFeed(); } - }, [countries.length, status, loadInitialFeed]); + }, [countries.length, selectedRegion, status, loadInitialFeed]); const showInitialLoading = + selectedRegion === null && countries.length === 0 && (status === "loading" || status === "idle" || status === "loadingMore"); - const showError = countries.length === 0 && status === "error"; + const showError = + selectedRegion === null && countries.length === 0 && status === "error"; + const showFeed = countries.length > 0 || selectedRegion !== null; return ( @@ -37,7 +41,7 @@ export default function ExploreScreen() { /> ) : showInitialLoading ? ( - ) : countries.length > 0 ? ( + ) : showFeed ? ( ) : ( diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index c8021f0..52aa6f0 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,16 +1,85 @@ -import { ScrollView, Text, View } from "react-native"; +import { StatusBar } from "expo-status-bar"; +import { useEffect } from "react"; +import { ScrollView, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { CountryOfTheDayCard } from "@/components/home/country-of-the-day-card"; +import { FeedErrorBanner } from "@/components/home/feed-error-banner"; +import { HomeSearchBar } from "@/components/home/home-search-bar"; +import { HomeStatsRow } from "@/components/home/home-stats-row"; +import { RecentlyViewedSection } from "@/components/home/recently-viewed-section"; +import { TrendingCountriesSection } from "@/components/home/trending-countries-section"; +import { useCountryFeedStore } from "@/store/use-country-feed-store"; +import { useRecentlyViewedStore } from "@/store/use-recently-viewed-store"; export default function HomeScreen() { + const countries = useCountryFeedStore((s) => s.countries); + const status = useCountryFeedStore((s) => s.status); + const error = useCountryFeedStore((s) => s.error); + const loadInitialFeed = useCountryFeedStore((s) => s.loadInitialFeed); + + const recentlyViewed = useRecentlyViewedStore((s) => s.entries ?? []); + const seedIfEmpty = useRecentlyViewedStore((s) => s.seedIfEmpty); + + useEffect(() => { + if (countries.length === 0 && status === "idle") { + void loadInitialFeed(); + } + }, [countries.length, status, loadInitialFeed]); + + useEffect(() => { + const finishHydration = useRecentlyViewedStore.persist.onFinishHydration( + () => { + seedIfEmpty(); + }, + ); + if (useRecentlyViewedStore.persist.hasHydrated()) { + seedIfEmpty(); + } + return finishHydration; + }, [seedIfEmpty]); + + const isLoading = status === "loading" && countries.length === 0; + const showError = status === "error" && countries.length === 0; + + const featuredCountry = countries[0]; + const trendingCountries = countries.slice(1, 5); + return ( - - - Home - Coming in a later lesson - - + + + + {/* */} + + + {showError ? ( + { + void loadInitialFeed(); + }} + /> + ) : null} + + + + + + + + + ); } diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx index 955e1d8..cd53495 100644 --- a/app/(tabs)/map.tsx +++ b/app/(tabs)/map.tsx @@ -1,16 +1,463 @@ -import { ScrollView, Text, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { StatusBar } from "expo-status-bar"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import type { Region } from "react-native-maps"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { MapControls } from "@/components/map/map-controls"; +import { MapCountryPreviewCard } from "@/components/map/map-country-preview-card"; +import { MapFeaturedChips } from "@/components/map/map-featured-chips"; +import { MapFilterChips } from "@/components/map/map-filter-chips"; +import { MapOnboardingSheet } from "@/components/map/map-onboarding-sheet"; +import { MapSearchRow } from "@/components/map/map-search-row"; +import { RandomCountryFab } from "@/components/map/random-country-fab"; +import { + WorldMapView, + type MapZoomTier, + type WorldMapViewHandle, +} from "@/components/map/world-map-view"; +import { regionForCountry } from "@/constants/map-regions"; +import { TRENDING_COUNTRY_NAMES } from "@/constants/trending-countries"; +import { buildMapClusters, type MapCluster } from "@/lib/map-clusters"; +import { useCountryFeedStore } from "@/store/use-country-feed-store"; +import { filterMapCountriesByChip, useMapStore } from "@/store/use-map-store"; +import { useMapUiStore } from "@/store/use-map-ui-store"; +import { useRecentlyViewedStore } from "@/store/use-recently-viewed-store"; +import type { MapCountry } from "@/types/country"; export default function MapScreen() { + const insets = useSafeAreaInsets(); + const mapRef = useRef(null); + const zoomTierRef = useRef("world"); + + const status = useMapStore((s) => s.status); + const error = useMapStore((s) => s.error); + const selectedCountry = useMapStore((s) => s.selectedCountry); + const activeChip = useMapStore((s) => s.activeChip); + const countries = useMapStore((s) => s.countries); + const loadMapCountries = useMapStore((s) => s.loadMapCountries); + const selectCountry = useMapStore((s) => s.selectCountry); + const setActiveChip = useMapStore((s) => s.setActiveChip); + + const focusedRegion = useMapUiStore((s) => s.focusedRegion); + const setDisplayMode = useMapUiStore((s) => s.setDisplayMode); + const setFocusedRegion = useMapUiStore((s) => s.setFocusedRegion); + const setFeaturedShortcut = useMapUiStore((s) => s.setFeaturedShortcut); + const resetGlobalPulse = useMapUiStore((s) => s.resetGlobalPulse); + const hasSeenMapOnboarding = useMapUiStore((s) => s.hasSeenMapOnboarding); + const dismissMapOnboarding = useMapUiStore((s) => s.dismissMapOnboarding); + + const [zoomTier, setZoomTier] = useState("world"); + const clusters = useMemo(() => buildMapClusters(countries), [countries]); + + const pinCountries = useMemo(() => { + if (zoomTier === "world") return []; + const base = focusedRegion + ? countries.filter((c) => c.region === focusedRegion) + : countries; + return filterMapCountriesByChip(base, activeChip); + }, [zoomTier, focusedRegion, countries, activeChip]); + + useEffect(() => { + if (status === "idle" && countries.length === 0) { + void loadMapCountries(); + } + }, [status, countries.length, loadMapCountries]); + + const computeZoomTier = useCallback((latitudeDelta: number): MapZoomTier => { + if (latitudeDelta > 60) return "world"; + if (latitudeDelta > 20) return "region"; + return "country"; + }, []); + + const focusCountry = useCallback( + (country: MapCountry) => { + selectCountry(country.name); + mapRef.current?.animateToRegion(regionForCountry(country.latlng), 500); + }, + [selectCountry], + ); + + const handleClusterPress = useCallback( + (cluster: MapCluster) => { + selectCountry(null); + setFeaturedShortcut(null); + setDisplayMode("explore"); + setFocusedRegion(cluster.region); + zoomTierRef.current = "region"; + setZoomTier("region"); + mapRef.current?.animateToRegion( + regionForCountry(cluster.center, 45), + 650, + ); + }, + [selectCountry, setDisplayMode, setFocusedRegion, setFeaturedShortcut], + ); + + const handleRegionChangeComplete = useCallback( + (region: Region) => { + const nextTier = computeZoomTier(region.latitudeDelta); + if (zoomTierRef.current === nextTier) return; + zoomTierRef.current = nextTier; + setZoomTier(nextTier); + + if (nextTier === "world") { + resetGlobalPulse(); + selectCountry(null); + return; + } + + setDisplayMode("explore"); + + // If user pinches in without tapping a cluster, pick the closest cluster + // so the map does not overwhelm with pins. + const currentFocused = useMapUiStore.getState().focusedRegion; + if (!currentFocused && clusters.length > 0) { + const centerLat = region.latitude; + const centerLng = region.longitude; + let nearest: (typeof clusters)[number] | null = null; + let best = Number.POSITIVE_INFINITY; + + for (const cluster of clusters) { + const dLat = cluster.center[0] - centerLat; + const dLng = cluster.center[1] - centerLng; + const d = dLat * dLat + dLng * dLng; + if (d < best) { + best = d; + nearest = cluster; + } + } + + if (nearest) setFocusedRegion(nearest.region); + } + }, + [ + clusters, + computeZoomTier, + resetGlobalPulse, + selectCountry, + setDisplayMode, + setFocusedRegion, + ], + ); + + const focusByMapCountry = useCallback( + (pick: MapCountry) => { + setActiveChip("all"); + setFeaturedShortcut(null); + setDisplayMode("explore"); + setFocusedRegion(pick.region); + zoomTierRef.current = "region"; + setZoomTier("region"); + selectCountry(pick.name); + mapRef.current?.animateToRegion(regionForCountry(pick.latlng, 45), 650); + }, + [ + selectCountry, + setActiveChip, + setDisplayMode, + setFeaturedShortcut, + setFocusedRegion, + ], + ); + + const handleTrendingPress = useCallback(() => { + if (countries.length === 0) return; + const trending = countries.filter((c) => + TRENDING_COUNTRY_NAMES.has(c.name), + ); + const pick = trending[0] ?? countries[0]; + if (!pick) return; + setActiveChip("all"); + setFeaturedShortcut("trending"); + setDisplayMode("explore"); + setFocusedRegion(pick.region); + zoomTierRef.current = "region"; + setZoomTier("region"); + selectCountry(pick.name); + mapRef.current?.animateToRegion(regionForCountry(pick.latlng, 45), 650); + }, [ + countries, + selectCountry, + setActiveChip, + setDisplayMode, + setFeaturedShortcut, + setFocusedRegion, + ]); + + const handleForYouPress = useCallback(async () => { + if (countries.length === 0) return; + + useRecentlyViewedStore.getState().seedIfEmpty(); + const entries = useRecentlyViewedStore.getState().entries; + const topName = entries[0]?.country?.name?.trim() ?? ""; + const direct = countries.find((c) => c.name === topName) ?? null; + + let pick = direct; + if (!pick) { + const feed = useCountryFeedStore.getState(); + if (feed.countries.length === 0 && feed.status === "idle") { + await feed.loadInitialFeed(); + } + const feedTop = feed.countries.slice(0, 3); + pick = + feedTop + .map((fc) => countries.find((c) => c.name === fc.name)) + .find(Boolean) ?? null; + } + + if (!pick) { + handleTrendingPress(); + return; + } + + setActiveChip("all"); + setFeaturedShortcut("forYou"); + setDisplayMode("explore"); + setFocusedRegion(pick.region); + zoomTierRef.current = "region"; + setZoomTier("region"); + selectCountry(pick.name); + mapRef.current?.animateToRegion(regionForCountry(pick.latlng, 45), 650); + }, [ + countries, + handleTrendingPress, + selectCountry, + setActiveChip, + setDisplayMode, + setFeaturedShortcut, + setFocusedRegion, + ]); + + const handleNewActivityPress = useCallback(async () => { + if (countries.length === 0) return; + + const feed = useCountryFeedStore.getState(); + if (feed.countries.length === 0 && feed.status === "idle") { + await feed.loadInitialFeed(); + } + + const feedTop = feed.countries.slice(0, 3); + const pick = + feedTop + .map((fc) => countries.find((c) => c.name === fc.name)) + .find(Boolean) ?? countries[0]; + + if (!pick) return; + + setActiveChip("all"); + setFeaturedShortcut("newActivity"); + setDisplayMode("explore"); + setFocusedRegion(pick.region); + zoomTierRef.current = "region"; + setZoomTier("region"); + selectCountry(pick.name); + mapRef.current?.animateToRegion(regionForCountry(pick.latlng, 45), 650); + }, [ + countries, + selectCountry, + setActiveChip, + setDisplayMode, + setFeaturedShortcut, + setFocusedRegion, + ]); + + const handleRandomCountry = useCallback(async () => { + if (countries.length === 0) return; + + const ui = useMapUiStore.getState(); + + if (zoomTierRef.current === "world") { + const shortcut = ui.featuredShortcut; + let pool: MapCountry[] = []; + + if (shortcut === "trending") { + pool = countries.filter((c) => TRENDING_COUNTRY_NAMES.has(c.name)); + } else if (shortcut === "forYou") { + useRecentlyViewedStore.getState().seedIfEmpty(); + const names = useRecentlyViewedStore + .getState() + .entries.slice(0, 3) + .map((e) => e.country.name); + pool = countries.filter((c) => names.includes(c.name)); + } else if (shortcut === "newActivity") { + const feed = useCountryFeedStore.getState(); + if (feed.countries.length === 0 && feed.status === "idle") { + await feed.loadInitialFeed(); + } + const names = feed.countries.slice(0, 3).map((c) => c.name); + pool = countries.filter((c) => names.includes(c.name)); + } + + if (pool.length === 0) { + const trending = countries.filter((c) => + TRENDING_COUNTRY_NAMES.has(c.name), + ); + pool = trending.length > 0 ? trending : countries; + } + + const pick = pool[Math.floor(Math.random() * pool.length)] ?? null; + if (!pick) return; + focusByMapCountry(pick); + return; + } + + const base = focusedRegion + ? countries.filter((c) => c.region === focusedRegion) + : countries; + const visible = filterMapCountriesByChip(base, activeChip); + if (visible.length === 0) return; + + const pick = visible[Math.floor(Math.random() * visible.length)] ?? null; + if (!pick) return; + + selectCountry(pick.name); + mapRef.current?.animateToRegion(regionForCountry(pick.latlng), 600); + }, [activeChip, countries, focusByMapCountry, focusedRegion, selectCountry]); + + const bottomPad = Math.max(insets.bottom, 16) + 88; + const showOnboarding = + !hasSeenMapOnboarding && + zoomTier === "world" && + status !== "loading" && + countries.length > 0 && + selectedCountry === null; + return ( - - - Map - Coming in a later lesson + + + + selectCountry(null)} + onRegionChangeComplete={handleRegionChangeComplete} + /> + + + + + + + {zoomTier === "world" ? ( + void handleForYouPress()} + onNewActivityPress={() => void handleNewActivityPress()} + /> + ) : ( + + )} + + + {status === "loading" ? ( + + + + ) : null} + + {status === "error" ? ( + + + + {error ?? "Could not load map data"} + + void loadMapCountries()} + style={({ pressed }) => [ + styles.retryButton, + pressed && styles.pressed, + ]} + > + + Retry + + + + ) : null} + + { + mapRef.current?.resetWorldView(); + resetGlobalPulse(); + setZoomTier("world"); + zoomTierRef.current = "world"; + selectCountry(null); + setActiveChip("all"); + }} + onZoomIn={() => mapRef.current?.zoomBy("in")} + onZoomOut={() => mapRef.current?.zoomBy("out")} + /> + + void handleRandomCountry()} /> + + {selectedCountry ? ( + + selectCountry(null)} + /> + + ) : showOnboarding ? ( + + dismissMapOnboarding()} /> + + ) : null} - + ); } + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: "#0b132b", + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(11, 19, 43, 0.35)", + }, + errorBanner: { + position: "absolute", + left: 16, + right: 16, + top: "42%", + flexDirection: "row", + alignItems: "center", + gap: 8, + padding: 16, + borderRadius: 16, + backgroundColor: "rgba(18, 24, 38, 0.96)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + }, + retryButton: { + paddingHorizontal: 12, + paddingVertical: 8, + }, + previewWrap: { + position: "absolute", + left: 0, + right: 0, + }, + pressed: { + opacity: 0.85, + }, +}); diff --git a/assets/images/profile-avatar.png b/assets/images/profile-avatar.png new file mode 100644 index 0000000..3d2580a Binary files /dev/null and b/assets/images/profile-avatar.png differ diff --git a/backend/src/api/map.routes.ts b/backend/src/api/map.routes.ts new file mode 100644 index 0000000..36b578f --- /dev/null +++ b/backend/src/api/map.routes.ts @@ -0,0 +1,7 @@ +import { Router } from "express"; + +import { getMapCountriesHandler } from "../controllers/map.controller.js"; + +export const mapRouter = Router(); + +mapRouter.get("/countries", getMapCountriesHandler); diff --git a/backend/src/api/search.routes.ts b/backend/src/api/search.routes.ts new file mode 100644 index 0000000..5273ded --- /dev/null +++ b/backend/src/api/search.routes.ts @@ -0,0 +1,7 @@ +import { Router } from "express"; + +import { searchCountriesHandler } from "../controllers/search.controller.js"; + +export const searchRouter = Router(); + +searchRouter.get("/", searchCountriesHandler); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 0f30348..2e6aded 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -1,7 +1,10 @@ import "dotenv/config"; export const env = { - port: Number(process.env.PORT ?? 3001), + port: (() => { + const parsed = Number(process.env.PORT); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 3001; + })(), redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379", restCountriesBaseUrl: process.env.REST_COUNTRIES_BASE_URL ?? "https://restcountries.com/v3.1", diff --git a/backend/src/controllers/ai.controller.ts b/backend/src/controllers/ai.controller.ts index 9718942..2658ed2 100644 --- a/backend/src/controllers/ai.controller.ts +++ b/backend/src/controllers/ai.controller.ts @@ -2,8 +2,8 @@ import type { NextFunction, Request, Response } from "express"; import { env } from "../config/env.js"; import { HttpError } from "../lib/http.js"; -import { getCountryByName } from "../services/country.service.js"; import { getAiForCountry } from "../services/ai.service.js"; +import { getCountryByName } from "../services/country.service.js"; type AiGenerateBody = { countryName?: string; @@ -17,14 +17,7 @@ type AiGenerateBody = { function requireInternalApiKey(req: Request): void { const provided = req.header("x-internal-api-key"); - if (!env.internalApiKey) { - throw new HttpError( - "INTERNAL_API_KEY is not configured", - 503, - "INTERNAL_AUTH_NOT_CONFIGURED", - ); - } - if (!provided || provided !== env.internalApiKey) { + if (!env.internalApiKey || !provided || provided !== env.internalApiKey) { throw new HttpError("Unauthorized", 401, "UNAUTHORIZED"); } } diff --git a/backend/src/controllers/feed.controller.ts b/backend/src/controllers/feed.controller.ts index bfcdb73..71e63ab 100644 --- a/backend/src/controllers/feed.controller.ts +++ b/backend/src/controllers/feed.controller.ts @@ -1,22 +1,26 @@ -import type { Request, Response, NextFunction } from "express"; +import type { NextFunction, Request, Response } from "express"; import { HttpError } from "../lib/http.js"; import { getFeedBatch } from "../services/feed.service.js"; -function parseOptionalInt( - value: unknown, - field: string, -): number | undefined { +function parseOptionalInt(value: unknown, field: string): number | undefined { if (value === undefined) return undefined; if (typeof value !== "string" || value.trim() === "") { - throw new HttpError(`${field} must be a number`, 400, `INVALID_${field.toUpperCase()}`); + throw new HttpError( + `${field} must be a number`, + 400, + `INVALID_${field.toUpperCase()}`, + ); } - const parsed = Number.parseInt(value, 10); - if (Number.isNaN(parsed)) { - throw new HttpError(`${field} must be a number`, 400, `INVALID_${field.toUpperCase()}`); + if (!/^\d+$/.test(value)) { + throw new HttpError( + `${field} must be a number`, + 400, + `INVALID_${field.toUpperCase()}`, + ); } - + const parsed = Number.parseInt(value, 10); return parsed; } diff --git a/backend/src/controllers/map.controller.ts b/backend/src/controllers/map.controller.ts new file mode 100644 index 0000000..a7bc35b --- /dev/null +++ b/backend/src/controllers/map.controller.ts @@ -0,0 +1,16 @@ +import type { NextFunction, Request, Response } from "express"; + +import { getMapCountries } from "../services/map.service.js"; + +export async function getMapCountriesHandler( + _req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const result = await getMapCountries(); + res.json(result); + } catch (error) { + next(error); + } +} diff --git a/backend/src/controllers/search.controller.ts b/backend/src/controllers/search.controller.ts new file mode 100644 index 0000000..19ec7c8 --- /dev/null +++ b/backend/src/controllers/search.controller.ts @@ -0,0 +1,25 @@ +import type { Request, Response, NextFunction } from "express"; + +import { searchCountries } from "../services/search.service.js"; + +function parseOptionalString(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") return undefined; + return value; +} + +export async function searchCountriesHandler( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const query = parseOptionalString(req.query.query); + const region = parseOptionalString(req.query.region); + + const result = await searchCountries(query, region); + res.json(result); + } catch (error) { + next(error); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 99fd244..cd783d5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,6 +5,8 @@ import { aiRouter } from "./api/ai.routes.js"; import { countryRouter } from "./api/country.routes.js"; import { feedRouter } from "./api/feed.routes.js"; import { healthRouter } from "./api/health.routes.js"; +import { mapRouter } from "./api/map.routes.js"; +import { searchRouter } from "./api/search.routes.js"; import { env } from "./config/env.js"; import { errorHandler, @@ -25,6 +27,8 @@ logger.info("ENV CHECK", { app.use("/health", healthRouter); app.use("/country", countryRouter); app.use("/feed", feedRouter); +app.use("/search", searchRouter); +app.use("/map", mapRouter); app.use("/ai", aiRouter); app.use(notFoundHandler); @@ -39,7 +43,9 @@ async function start() { const shutdown = async () => { logger.info("Shutting down"); - server.close(); + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); await disconnectCache(); process.exit(0); }; diff --git a/backend/src/lib/llm.ts b/backend/src/lib/llm.ts index fb5d66b..ace0a16 100644 --- a/backend/src/lib/llm.ts +++ b/backend/src/lib/llm.ts @@ -45,21 +45,33 @@ export async function createStructuredChatCompletion( ); } - const response = await fetch(`${env.openAiBaseUrl}/chat/completions`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${env.openAiApiKey}`, - }, - body: JSON.stringify({ - model: env.openAiModel, - messages, - temperature: 0.7, - response_format: { - type: "json_object", + const controller = new AbortController(); + const timeoutMs = 12_000; + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + let response: Response; + try { + response = await fetch(`${env.openAiBaseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.openAiApiKey}`, }, - }), - }); + body: JSON.stringify({ + model: env.openAiModel, + messages, + temperature: 0.7, + response_format: { + type: "json_object", + }, + }), + signal: controller.signal, + }); + } catch { + throw new HttpError("LLM provider unreachable", 502, "AI_PROVIDER_ERROR"); + } finally { + clearTimeout(timeout); + } if (!response.ok) { throw new HttpError( diff --git a/backend/src/middleware/error.middleware.ts b/backend/src/middleware/error.middleware.ts index 523602c..4147c04 100644 --- a/backend/src/middleware/error.middleware.ts +++ b/backend/src/middleware/error.middleware.ts @@ -1,4 +1,4 @@ -import type { Request, Response, NextFunction } from "express"; +import type { NextFunction, Request, Response } from "express"; import { HttpError } from "../lib/http.js"; import { logger } from "../utils/logger.js"; @@ -7,8 +7,12 @@ export function errorHandler( err: unknown, _req: Request, res: Response, - _next: NextFunction, + next: NextFunction, ): void { + if (res.headersSent) { + next(err); + return; + } if (err instanceof HttpError) { res.status(err.status).json({ error: { diff --git a/backend/src/services/cache.service.ts b/backend/src/services/cache.service.ts index 55dcede..3a30848 100644 --- a/backend/src/services/cache.service.ts +++ b/backend/src/services/cache.service.ts @@ -7,14 +7,19 @@ import { logger } from "../utils/logger.js"; export const CACHE_TTL = { country: 90 * 24 * 60 * 60, feed: 7 * 24 * 60 * 60, + search: 7 * 24 * 60 * 60, + map: 30 * 24 * 60 * 60, ai: 7 * 24 * 60 * 60, images: 30 * 24 * 60 * 60, } as const; export const cacheKeys = { - country: (name: string) => `country:${name.toLowerCase()}`, + country: (name: string) => `country:${name.trim().toLowerCase()}`, feedCountries: (cursor = "all") => `feed:countries:${cursor}`, - images: (name: string) => `images:${name.toLowerCase()}`, + search: (query: string, region: string) => + `search:${query.toLowerCase()}:${region.toLowerCase()}`, + mapCountries: () => "map:countries", + images: (name: string) => `images:${name.trim().toLowerCase()}`, ai: (name: string) => `ai:${name.toLowerCase()}`, } as const; @@ -42,15 +47,7 @@ export async function connectCache(): Promise { }); try { - await Promise.race([ - client.connect(), - new Promise((_, reject) => - setTimeout( - () => reject(new Error("Redis connection timed out")), - CONNECT_TIMEOUT_MS, - ), - ), - ]); + await client.connect(); connected = true; logger.info("Redis connected"); } catch (error) { diff --git a/backend/src/services/map.service.ts b/backend/src/services/map.service.ts new file mode 100644 index 0000000..d81ad5f --- /dev/null +++ b/backend/src/services/map.service.ts @@ -0,0 +1,35 @@ +import type { CountryBasic } from "../types/country.js"; +import type { MapCountriesResponse, MapCountry } from "../types/map.js"; +import { + CACHE_TTL, + cacheKeys, + getOrSet, +} from "./cache.service.js"; +import { getFeedCountries } from "./country.service.js"; +import { getImagesForCountry } from "./image.service.js"; + +async function toMapCountry(country: CountryBasic): Promise { + const images = await getImagesForCountry(country.name); + + return { + name: country.name, + capital: country.capital, + region: country.region, + population: country.population, + flag: country.flag, + latlng: country.latlng, + image: images[0] ?? null, + }; +} + +async function buildMapCountries(): Promise { + const countries = await getFeedCountries(); + const data = await Promise.all(countries.map(toMapCountry)); + + return { data }; +} + +/** All countries with valid coordinates, optimized for the map screen. */ +export async function getMapCountries(): Promise { + return getOrSet(cacheKeys.mapCountries(), CACHE_TTL.map, buildMapCountries); +} diff --git a/backend/src/services/search.service.ts b/backend/src/services/search.service.ts new file mode 100644 index 0000000..550429e --- /dev/null +++ b/backend/src/services/search.service.ts @@ -0,0 +1,92 @@ +import type { Country, CountryBasic } from "../types/country.js"; +import { HttpError } from "../lib/http.js"; +import { enrichCountryWithAi } from "./ai.service.js"; +import { getFeedCountries } from "./country.service.js"; +import { + CACHE_TTL, + cacheKeys, + getOrSet, +} from "./cache.service.js"; +import { enrichCountryWithImages } from "./image.service.js"; + +export type SearchResponse = { + data: Country[]; + meta: { + query: string | null; + region: string | null; + count: number; + }; +}; + +async function enrichCountry(country: CountryBasic): Promise { + const withImages = await enrichCountryWithImages(country); + return enrichCountryWithAi(withImages); +} + +function filterCountries( + countries: CountryBasic[], + query: string, + region: string, +): CountryBasic[] { + let matches = countries; + + if (region) { + const regionLower = region.toLowerCase(); + matches = matches.filter( + (c) => c.region.toLowerCase() === regionLower, + ); + } + + if (query) { + const queryLower = query.toLowerCase(); + matches = matches.filter((c) => + c.name.toLowerCase().includes(queryLower), + ); + } + + return matches.sort((a, b) => a.name.localeCompare(b.name)); +} + +async function executeSearch( + query: string, + region: string, +): Promise { + const allCountries = await getFeedCountries(); + const matches = filterCountries(allCountries, query, region); + const data = await Promise.all(matches.map(enrichCountry)); + + return { + data, + meta: { + query: query || null, + region: region || null, + count: data.length, + }, + }; +} + +/** + * Search countries by partial name and/or region. + * Requires at least one of `query` or `region`; both empty returns 400. + */ +export async function searchCountries( + query?: string, + region?: string, +): Promise { + const normalizedQuery = query?.trim() ?? ""; + const normalizedRegion = region?.trim() ?? ""; + + if (!normalizedQuery && !normalizedRegion) { + throw new HttpError( + "At least one of query or region is required", + 400, + "INVALID_SEARCH", + ); + } + + const cacheKey = cacheKeys.search(normalizedQuery, normalizedRegion); + + return getOrSet(cacheKey, CACHE_TTL.search, () => + executeSearch(normalizedQuery, normalizedRegion), + ); +} diff --git a/backend/src/types/map.ts b/backend/src/types/map.ts new file mode 100644 index 0000000..f3af143 --- /dev/null +++ b/backend/src/types/map.ts @@ -0,0 +1,15 @@ +/** Lightweight country shape for the interactive map screen. */ +export type MapCountry = { + name: string; + capital: string; + region: string; + population: number; + flag: string; + latlng: [number, number]; + /** First image URL only — null when none cached yet. */ + image: string | null; +}; + +export type MapCountriesResponse = { + data: MapCountry[]; +}; diff --git a/components/explore/country-feed-page.tsx b/components/explore/country-feed-page.tsx index d70e344..db32707 100644 --- a/components/explore/country-feed-page.tsx +++ b/components/explore/country-feed-page.tsx @@ -9,7 +9,6 @@ import { } from "@/components/explore/country-image"; import { ExploreActionRail } from "@/components/explore/explore-action-rail"; import { ExploreFooter } from "@/components/explore/explore-footer"; -import { ExploreTopBar } from "@/components/explore/explore-top-bar"; import { HeroScrims } from "@/components/explore/hero-scrims"; import { MediaCarousel } from "@/components/explore/media-carousel"; import { getAiFactByIndex, getCountryImages } from "@/lib/format-country"; @@ -30,7 +29,7 @@ export function CountryFeedPage({ country, pageHeight }: CountryFeedPageProps) { useEffect(() => { for (const imageUri of images) { - prefetchCountryImage(imageUri); + void prefetchCountryImage(imageUri); } }, [images]); @@ -52,8 +51,6 @@ export function CountryFeedPage({ country, pageHeight }: CountryFeedPageProps) { {/* pageheight here adjusts the height of the scrims to make them darker */} - - diff --git a/components/explore/country-image.tsx b/components/explore/country-image.tsx index 2da605e..6923e41 100644 --- a/components/explore/country-image.tsx +++ b/components/explore/country-image.tsx @@ -16,15 +16,37 @@ function imageSource(uri: string) { }; } -/** Warm the disk/memory cache so hero swaps do not flash. */ -export function prefetchCountryImage(uri: string | undefined) { +const warmedUris = new Set(); +const inflightPrefetches = new Map>(); + +export function isCountryImageReady(uri: string | undefined): boolean { + const normalized = uri ? normalizeImageUrl(uri) : null; + return normalized ? warmedUris.has(normalized) : false; +} + +/** Warm disk/memory cache; resolves when safe to paint without a navy flash. */ +export function prefetchCountryImage(uri: string | undefined): Promise { const normalized = uri ? normalizeImageUrl(uri) : null; - if (!normalized) return; + if (!normalized) return Promise.resolve(); - void Image.prefetch(normalized, { + if (warmedUris.has(normalized)) return Promise.resolve(); + + const existing = inflightPrefetches.get(normalized); + if (existing) return existing; + + const promise = Image.prefetch(normalized, { headers: imageSource(normalized).headers, cachePolicy: "memory-disk", - }); + }) + .then(() => { + warmedUris.add(normalized); + }) + .finally(() => { + inflightPrefetches.delete(normalized); + }); + + inflightPrefetches.set(normalized, promise); + return promise; } type CountryImageProps = { @@ -51,8 +73,10 @@ export function CountryImage({ [uri], ); - /** URI currently painted — stays on the previous photo until the next is cached. */ - const [shownUri, setShownUri] = useState(null); + const [shownUri, setShownUri] = useState(() => { + const initial = uri ? normalizeImageUrl(uri) : null; + return initial && warmedUris.has(initial) ? initial : null; + }); const [failed, setFailed] = useState(false); const requestRef = useRef(0); @@ -70,10 +94,12 @@ export function CountryImage({ const requestId = ++requestRef.current; setFailed(false); - void Image.prefetch(normalizedUri, { - headers: imageSource(normalizedUri).headers, - cachePolicy: "memory-disk", - }).then(() => { + if (warmedUris.has(normalizedUri)) { + setShownUri(normalizedUri); + return; + } + + void prefetchCountryImage(normalizedUri).then(() => { if (requestRef.current !== requestId) return; setShownUri(normalizedUri); }); diff --git a/components/explore/explore-action-rail.tsx b/components/explore/explore-action-rail.tsx index f33d2df..f7c0170 100644 --- a/components/explore/explore-action-rail.tsx +++ b/components/explore/explore-action-rail.tsx @@ -75,7 +75,7 @@ const styles = StyleSheet.create({ rail: { position: "absolute", right: 8, - top: "38%", + top: "40%", alignItems: "center", gap: 16, zIndex: 10, diff --git a/components/explore/explore-feed.tsx b/components/explore/explore-feed.tsx index 8c40f68..213180b 100644 --- a/components/explore/explore-feed.tsx +++ b/components/explore/explore-feed.tsx @@ -1,24 +1,64 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, FlatList, + Pressable, StyleSheet, + Text, View, type LayoutChangeEvent, type ViewToken, } from "react-native"; import { CountryFeedPage } from "@/components/explore/country-feed-page"; +import { ExploreTopBar } from "@/components/explore/explore-top-bar"; import { useCountryFeedStore } from "@/store/use-country-feed-store"; import type { Country } from "@/types/country"; export function ExploreFeed() { const [pageHeight, setPageHeight] = useState(0); const pageHeightRef = useRef(0); + const listRef = useRef>(null); + const skipProgrammaticScrollRef = useRef(false); + const hasSyncedInitialScrollRef = useRef(false); const countries = useCountryFeedStore((s) => s.countries); + const currentIndex = useCountryFeedStore((s) => s.currentIndex); + const selectedRegion = useCountryFeedStore((s) => s.selectedRegion); const status = useCountryFeedStore((s) => s.status); const setCurrentIndex = useCountryFeedStore((s) => s.setCurrentIndex); const loadMoreFeed = useCountryFeedStore((s) => s.loadMoreFeed); + const setRegionFilter = useCountryFeedStore((s) => s.setRegionFilter); + const error = useCountryFeedStore((s) => s.error); + + const feedListKey = selectedRegion ?? "for-you"; + + const scrollToCurrentIndex = useCallback( + (animated: boolean) => { + if (pageHeight <= 0 || countries.length === 0) return; + const index = Math.max(0, Math.min(currentIndex, countries.length - 1)); + listRef.current?.scrollToIndex({ index, animated }); + }, + [countries.length, currentIndex, pageHeight], + ); + + useEffect(() => { + hasSyncedInitialScrollRef.current = false; + }, [selectedRegion]); + + // Only scroll programmatically (region filter, focus country, layout). + // User swipes update currentIndex via onViewableItemsChanged — do not fight that scroll. + useEffect(() => { + if (pageHeight <= 0 || countries.length === 0) return; + + if (skipProgrammaticScrollRef.current) { + skipProgrammaticScrollRef.current = false; + return; + } + + const animated = hasSyncedInitialScrollRef.current; + hasSyncedInitialScrollRef.current = true; + scrollToCurrentIndex(animated); + }, [currentIndex, countries.length, pageHeight, scrollToCurrentIndex]); const onViewableItemsChanged = useRef( ({ viewableItems }: { viewableItems: ViewToken[] }) => { @@ -26,6 +66,7 @@ export function ExploreFeed() { if (first?.index == null) return; const index = first.index; + skipProgrammaticScrollRef.current = true; setCurrentIndex(index); const state = useCountryFeedStore.getState(); @@ -63,19 +104,26 @@ export function ExploreFeed() { return ( + + + {pageHeight > 0 ? ( 0 && currentIndex < countries.length + ? currentIndex + : undefined + } pagingEnabled showsVerticalScrollIndicator={false} decelerationRate="fast" - snapToAlignment="start" - snapToInterval={pageHeight} - disableIntervalMomentum onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={viewabilityConfig} getItemLayout={(_, index) => ({ @@ -87,14 +135,42 @@ export function ExploreFeed() { maxToRenderPerBatch={2} windowSize={3} removeClippedSubviews + onScrollToIndexFailed={(info) => { + const retry = () => { + listRef.current?.scrollToIndex({ + index: info.index, + animated: false, + }); + }; + requestAnimationFrame(retry); + }} /> ) : null} - {(status === "loading" || status === "loadingMore") && ( + {status === "loading" && countries.length === 0 && ( )} + + {status === "error" && countries.length === 0 && selectedRegion !== null && ( + + Couldn't load {selectedRegion} + + {error ?? "Check that the backend is running and try again."} + + { + void setRegionFilter(selectedRegion); + }} + style={({ pressed }) => [styles.retryButton, pressed && styles.retryPressed]} + > + Retry + + + )} ); } @@ -103,10 +179,54 @@ const styles = StyleSheet.create({ list: { flex: 1, }, + topBarOverlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + zIndex: 20, + }, loadingOverlay: { ...StyleSheet.absoluteFillObject, alignItems: "center", justifyContent: "center", backgroundColor: "rgba(0, 0, 0, 0.25)", }, + errorOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(11, 19, 43, 0.92)", + paddingHorizontal: 32, + gap: 12, + }, + errorTitle: { + fontSize: 18, + fontFamily: "Poppins-SemiBold", + color: "#ffffff", + textAlign: "center", + }, + errorMessage: { + fontSize: 14, + fontFamily: "Poppins-Regular", + color: "rgba(255, 255, 255, 0.7)", + textAlign: "center", + }, + retryButton: { + marginTop: 8, + backgroundColor: "#fbbf24", + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 24, + minHeight: 44, + justifyContent: "center", + }, + retryPressed: { + opacity: 0.9, + }, + retryText: { + fontSize: 14, + fontFamily: "Poppins-SemiBold", + color: "#0b132b", + }, }); diff --git a/components/explore/explore-top-bar.tsx b/components/explore/explore-top-bar.tsx index e161d43..44db961 100644 --- a/components/explore/explore-top-bar.tsx +++ b/components/explore/explore-top-bar.tsx @@ -1,43 +1,412 @@ -import { View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useCallback, useEffect, useRef } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import Animated, { + Easing, + scrollTo, + useAnimatedReaction, + useAnimatedRef, + useSharedValue, + withSpring, + withTiming, +} from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { GlassIconButton } from "@/components/explore/glass-icon-button"; -import { useSavedCountriesStore } from "@/store/use-saved-countries-store"; -import type { Country } from "@/types/country"; +import { + EXPLORE_HEADER_TABS, + FOR_YOU_TAB, + isContinent, + type ExploreHeaderTab, +} from "@/constants/regions"; +import { useCountryFeedStore } from "@/store/use-country-feed-store"; +import { useSearchUiStore } from "@/store/use-search-ui-store"; -type ExploreTopBarProps = { - country: Country; -}; +const UNDERLINE_WIDTH_RATIO = 0.55; +const UNDERLINE_MAX_WIDTH = 40; +const UNDERLINE_SPRING = { damping: 20, stiffness: 280 }; +const PROGRESSIVE_EASING = Easing.out(Easing.cubic); +const PROGRESSIVE_MIN_SCROLL_MS = 250; +const PROGRESSIVE_MAX_SCROLL_MS = 550; +const PROGRESSIVE_DISTANCE_TO_MS = 0.6; +const SCROLL_POSITION_EPSILON = 2; +/** Place selected tab center at this fraction of the viewport width. */ +const TAB_ANCHOR_RATIO = 0.5; -export function ExploreTopBar({ country }: ExploreTopBarProps) { +type TabLayout = { x: number; width: number }; + +export function ExploreTopBar() { const insets = useSafeAreaInsets(); - const toggleSaved = useSavedCountriesStore((s) => s.toggleSaved); - const isSaved = useSavedCountriesStore((s) => s.isSaved(country.name)); - const saved = isSaved; + const openSearch = useSearchUiStore((s) => s.openSearch); + const selectedRegion = useCountryFeedStore((s) => s.selectedRegion); + const setRegionFilter = useCountryFeedStore((s) => s.setRegionFilter); + + const selectedTab: ExploreHeaderTab = + selectedRegion === null + ? FOR_YOU_TAB + : (selectedRegion as ExploreHeaderTab); + const hasActiveFilter = selectedRegion !== null; + + const scrollRef = useAnimatedRef(); + const scrollViewWidthRef = useRef(0); + const contentWidthRef = useRef(0); + const tabLayoutsRef = useRef>>({}); + const previousTabRef = useRef(selectedTab); + const trackRef = useRef(null); + const tabRefs = useRef>>({}); + const underlineVisibleRef = useRef(false); + + const scrollOffsetX = useSharedValue(0); + + const underlineX = useSharedValue(0); + const underlineWidth = useSharedValue(0); + const underlineOpacity = useSharedValue(0); + + const moveUnderlineTo = useCallback( + (layout: TabLayout | undefined, animate: boolean) => { + if (!layout) { + underlineOpacity.value = withTiming(0, { duration: 180 }); + underlineVisibleRef.current = false; + return; + } + + const narrowWidth = Math.min( + layout.width * UNDERLINE_WIDTH_RATIO, + UNDERLINE_MAX_WIDTH, + ); + const targetX = layout.x + (layout.width - narrowWidth) / 2; + + underlineOpacity.value = withTiming(1, { duration: 180 }); + underlineVisibleRef.current = true; + + if (animate) { + underlineX.value = withSpring(targetX, UNDERLINE_SPRING); + underlineWidth.value = withSpring(narrowWidth, UNDERLINE_SPRING); + } else { + underlineX.value = targetX; + underlineWidth.value = narrowWidth; + } + }, + [underlineOpacity, underlineWidth, underlineX], + ); + + const measureSelectedTab = useCallback( + (animate: boolean) => { + const tab = tabRefs.current[selectedTab]; + const track = trackRef.current; + if (!tab || !track) return; + + tab.measureLayout( + track, + (x, _y, width) => { + moveUnderlineTo({ x, width }, animate); + }, + () => { + requestAnimationFrame(() => measureSelectedTab(animate)); + }, + ); + }, + [moveUnderlineTo, selectedTab], + ); + + useEffect(() => { + measureSelectedTab(underlineVisibleRef.current); + }, [measureSelectedTab, selectedTab]); + + useAnimatedReaction( + () => scrollOffsetX.value, + (x, previous) => { + if (previous === null || x !== previous) { + scrollTo(scrollRef, x, 0, false); + } + }, + ); + + const getMaxScrollX = useCallback(() => { + const viewport = scrollViewWidthRef.current; + const content = contentWidthRef.current; + if (viewport <= 0 || content <= 0) return 0; + return Math.max(0, content - viewport); + }, []); + + const clampScrollX = useCallback( + (x: number) => { + const maxX = getMaxScrollX(); + return Math.min(maxX, Math.max(0, x)); + }, + [getMaxScrollX], + ); + + const isScrollReady = useCallback(() => { + return ( + scrollViewWidthRef.current > 0 && + contentWidthRef.current > scrollViewWidthRef.current + ); + }, []); + + const computeScrollXForLayout = useCallback( + (_tabIndex: number, layout: TabLayout) => { + const viewport = scrollViewWidthRef.current; + + if (!isScrollReady()) return null; + + const tabCenter = layout.x + layout.width / 2; + + const target = tabCenter - viewport * TAB_ANCHOR_RATIO; + + return clampScrollX(target); + }, + [clampScrollX, isScrollReady], + ); + + const cacheTabLayout = useCallback((name: ExploreHeaderTab) => { + const tab = tabRefs.current[name]; + const track = trackRef.current; + if (!tab || !track) return; + + tab.measureLayout( + track, + (x, _y, width) => { + tabLayoutsRef.current[name] = { x, width }; + }, + () => {}, + ); + }, []); + + const scrollXForTab = useCallback( + (name: ExploreHeaderTab) => { + const tabIndex = EXPLORE_HEADER_TABS.indexOf(name); + const layout = tabLayoutsRef.current[name]; + if (tabIndex < 0 || !layout || !isScrollReady()) return null; + return computeScrollXForLayout(tabIndex, layout); + }, + [computeScrollXForLayout, isScrollReady], + ); + + const progressiveScrollToTab = useCallback( + (targetTab: ExploreHeaderTab, animate: boolean) => { + if (!isScrollReady()) { + requestAnimationFrame(() => progressiveScrollToTab(targetTab, animate)); + return; + } + + const targetX = scrollXForTab(targetTab); + if (targetX === null) { + cacheTabLayout(targetTab); + requestAnimationFrame(() => progressiveScrollToTab(targetTab, animate)); + return; + } + + if (!animate) { + scrollOffsetX.value = targetX; + previousTabRef.current = targetTab; + return; + } + + if (Math.abs(targetX - scrollOffsetX.value) <= SCROLL_POSITION_EPSILON) { + previousTabRef.current = targetTab; + return; + } + + // Premium-feel: do one continuous scroll animation to `targetX`. + // Distance-based duration avoids the “robotic” short-step timing. + const distance = Math.abs(targetX - scrollOffsetX.value); + const duration = Math.min( + PROGRESSIVE_MAX_SCROLL_MS, + Math.max( + PROGRESSIVE_MIN_SCROLL_MS, + distance * PROGRESSIVE_DISTANCE_TO_MS, + ), + ); + + scrollOffsetX.value = withTiming(targetX, { + duration, + easing: PROGRESSIVE_EASING, + }); + + previousTabRef.current = targetTab; + }, + [cacheTabLayout, isScrollReady, scrollOffsetX, scrollXForTab], + ); + + useEffect(() => { + progressiveScrollToTab(selectedTab, true); + }, [progressiveScrollToTab, selectedTab]); + + const onTabPress = (name: ExploreHeaderTab) => { + if (name === FOR_YOU_TAB) { + if (selectedRegion === null) return; + void setRegionFilter(null); + return; + } + + if (!isContinent(name)) return; + + if (selectedRegion === name) { + void setRegionFilter(null); + return; + } + + void setRegionFilter(name); + }; return ( - - { - console.log("Search tapped"); - }} - accessibilityLabel="Search countries" - /> - - toggleSaved(country)} - accessibilityLabel={ - saved ? `Unsave ${country.name}` : `Save ${country.name}` - } - /> + + + { + scrollViewWidthRef.current = event.nativeEvent.layout.width; + }} + > + { + contentWidthRef.current = event.nativeEvent.layout.width; + }} + > + {EXPLORE_HEADER_TABS.map((name, index) => { + const selected = + name === FOR_YOU_TAB + ? selectedRegion === null + : selectedRegion === name; + const accessibilityLabel = + name === FOR_YOU_TAB + ? "Show your personalized country feed" + : selected + ? `Clear ${name} filter and show For You feed` + : `Show countries in ${name}`; + + return ( + { + if (node) { + tabRefs.current[name] = node; + } else { + delete tabRefs.current[name]; + } + }} + style={index > 0 ? styles.continentTabSpacing : undefined} + collapsable={false} + onLayout={() => { + cacheTabLayout(name); + if (selectedTab === name) { + measureSelectedTab(underlineVisibleRef.current); + } + }} + > + onTabPress(name)} + hitSlop={8} + style={({ pressed }) => [ + styles.continentItem, + pressed && styles.pressed, + ]} + > + + {name} + + + + ); + })} + + {/* */} + + + + [ + styles.searchButton, + pressed && styles.pressed, + ]} + > + + + ); } + +const styles = StyleSheet.create({ + continentsScroll: { + flex: 1, + marginRight: 12, + }, + continentsRow: { + alignItems: "center", + }, + continentsTrack: { + flexDirection: "row", + alignItems: "center", + paddingRight: 4, + position: "relative", + minHeight: 44, + paddingBottom: 2, + }, + continentTabSpacing: { + marginLeft: 16, + }, + continentItem: { + alignItems: "center", + justifyContent: "center", + }, + continentText: { + fontSize: 15, + fontFamily: "Poppins-Medium", + paddingBottom: 0, + }, + continentTextDefault: { + color: "rgba(255, 255, 255, 0.7)", + }, + continentTextDimmed: { + color: "rgba(255, 255, 255, 0.4)", + }, + continentTextSelected: { + color: "#ffffff", + fontFamily: "Poppins-SemiBold", + }, + slidingUnderline: { + position: "absolute", + bottom: 0, + height: 2, + borderRadius: 1, + backgroundColor: "#ffffff", + }, + searchButton: { + width: 44, + height: 44, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + pressed: { + opacity: 0.85, + }, +}); diff --git a/components/explore/glass-icon-button.tsx b/components/explore/glass-icon-button.tsx index 3de28c5..fb4f7b2 100644 --- a/components/explore/glass-icon-button.tsx +++ b/components/explore/glass-icon-button.tsx @@ -8,6 +8,8 @@ type GlassIconButtonProps = { active?: boolean; activeColor?: string; accessibilityLabel?: string; + /** `plain` = icon only, no glass circle (e.g. Explore header search). */ + variant?: "glass" | "plain"; }; export function GlassIconButton({ @@ -17,7 +19,10 @@ export function GlassIconButton({ active = false, activeColor = "#fbbf24", accessibilityLabel, + variant = "glass", }: GlassIconButtonProps) { + const iconColor = active ? activeColor : "#ffffff"; + return ( [styles.hitArea, pressed && styles.pressed]} > - - - + {variant === "plain" ? ( + + ) : ( + + + + )} {label} diff --git a/components/home/country-of-the-day-card.tsx b/components/home/country-of-the-day-card.tsx new file mode 100644 index 0000000..bc7a1c2 --- /dev/null +++ b/components/home/country-of-the-day-card.tsx @@ -0,0 +1,170 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native"; + +import { FlagBadge } from "@/components/explore/flag-badge"; +import { + formatPopulation, + getAiFact, + getCountryImages, +} from "@/lib/format-country"; +import { openCountryInExplore } from "@/lib/open-country-in-explore"; +import { useSavedCountriesStore } from "@/store/use-saved-countries-store"; +import type { Country } from "@/types/country"; + +const PLACEHOLDER_DESCRIPTION = + "Land of ancient wonders, vibrant culture, and breathtaking landscapes."; + +type CountryOfTheDayCardProps = { + country?: Country; + loading?: boolean; +}; + +function truncateDescription(text: string, maxLength = 90): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trimEnd()}…`; +} + +export function CountryOfTheDayCard({ + country, + loading = false, +}: CountryOfTheDayCardProps) { + const isSaved = useSavedCountriesStore((s) => + country ? s.isSaved(country.name) : false, + ); + const toggleSaved = useSavedCountriesStore((s) => s.toggleSaved); + + if (loading) { + return ( + + + + ); + } + + const images = country ? getCountryImages(country) : []; + const heroUri = images[0]; + const description = country + ? truncateDescription( + getAiFact(country) === "Fun fact loading…" + ? PLACEHOLDER_DESCRIPTION + : getAiFact(country), + ) + : PLACEHOLDER_DESCRIPTION; + const displayName = country?.name ?? "Peru"; + + return ( + + {heroUri ? ( + + ) : ( + + )} + + + + + + + ✨ COUNTRY OF THE DAY + + + + {country ? ( + + ) : null} + + {displayName} + + + + + {description} + + + {country ? ( + + + + + + ) : null} + + + + { + if (country) openCountryInExplore(country); + }} + disabled={!country} + className="min-h-[44px] flex-row items-center gap-2 rounded-full bg-tab-active px-5 py-3" + style={({ pressed }) => [{ opacity: pressed ? 0.9 : 1 }]} + > + + Explore {displayName} → + + + + {country ? ( + toggleSaved(country)} + className="min-h-[44px] flex-row items-center gap-1 rounded-full bg-black/40 px-3 py-2" + style={({ pressed }) => [{ opacity: pressed ? 0.85 : 1 }]} + > + {isSaved ? "❤️" : "🤍"} + + 12.4K Love this place + + + ) : null} + + + + ); +} + +function StatItem({ + icon, + label, +}: { + icon: keyof typeof Ionicons.glyphMap; + label: string; +}) { + return ( + + + {label} + + ); +} + +const styles = StyleSheet.create({ + fallbackHero: { + backgroundColor: "#0b132b", + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.45)", + }, +}); diff --git a/components/home/feed-error-banner.tsx b/components/home/feed-error-banner.tsx new file mode 100644 index 0000000..3b65a10 --- /dev/null +++ b/components/home/feed-error-banner.tsx @@ -0,0 +1,24 @@ +import { Pressable, Text, View } from "react-native"; + +type FeedErrorBannerProps = { + message: string | null; + onRetry: () => void; +}; + +export function FeedErrorBanner({ message, onRetry }: FeedErrorBannerProps) { + return ( + + + {message ?? "Could not load countries."} + + + Retry + + + ); +} diff --git a/components/home/home-header.tsx b/components/home/home-header.tsx new file mode 100644 index 0000000..93749e9 --- /dev/null +++ b/components/home/home-header.tsx @@ -0,0 +1,50 @@ +import { Image } from "expo-image"; +import { router } from "expo-router"; +import { Pressable, Text, View } from "react-native"; + +import { images } from "@/constants/images"; +import { getTimeGreeting } from "@/lib/greeting"; + +const USER_NAME = "Alex"; + +type HomeHeaderProps = { + onAvatarPress?: () => void; +}; + +export function HomeHeader({ onAvatarPress }: HomeHeaderProps) { + const greeting = getTimeGreeting(); + + return ( + + + + + {greeting}, {USER_NAME} 👋 + + + Where will curiosity take you today? + + + + { + if (onAvatarPress) { + onAvatarPress(); + return; + } + router.push("/(tabs)/profile"); + }} + className="h-11 w-11 overflow-hidden rounded-full border-2 border-tab-active" + > + + + + + ); +} diff --git a/components/home/home-search-bar.tsx b/components/home/home-search-bar.tsx new file mode 100644 index 0000000..5d377f2 --- /dev/null +++ b/components/home/home-search-bar.tsx @@ -0,0 +1,26 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Pressable, Text } from "react-native"; + +import { useSearchUiStore } from "@/store/use-search-ui-store"; + +export function HomeSearchBar() { + const openSearch = useSearchUiStore((s) => s.openSearch); + + return ( + + + + Search countries, regions, cultures… + + {/* + 🔥 + Discover + */} + + ); +} diff --git a/components/home/home-stats-row.tsx b/components/home/home-stats-row.tsx new file mode 100644 index 0000000..60cb67d --- /dev/null +++ b/components/home/home-stats-row.tsx @@ -0,0 +1,164 @@ +import { Ionicons } from "@expo/vector-icons"; +import { StyleSheet, Text, View } from "react-native"; + +import { useDiscoveryProgressStore } from "@/store/use-discovery-progress-store"; + +const DAY_LABELS = ["M", "T", "W", "T", "F", "S", "S"] as const; + +export function HomeStatsRow() { + const streakDays = useDiscoveryProgressStore((s) => s.streakDays); + const weekProgress = useDiscoveryProgressStore( + (s) => s.weekProgress ?? [true, true, true, true, true, true, false], + ); + const worldProgressPercent = useDiscoveryProgressStore( + (s) => s.worldProgressPercent, + ); + const countriesExplored = useDiscoveryProgressStore( + (s) => s.countriesExplored, + ); + const quizzesCompleted = useDiscoveryProgressStore((s) => s.quizzesCompleted); + + return ( + + + + 🔥 Your Learning Streak + + + {streakDays} days + Keep it going! + + + {DAY_LABELS.map((label, index) => { + const done = weekProgress[index]; + return ( + + {label} + + {done ? ( + + ) : null} + + + ); + })} + + + + + + + + Your Progress + + + + {worldProgressPercent}%{"\n"}of the world explored + + + + + + + + + + + ); +} + +function MiniStatCard({ + icon, + value, + label, +}: { + icon: keyof typeof Ionicons.glyphMap; + value: string; + label: string; +}) { + return ( + + + + {value} + {label} + + + ); +} + +function WorldProgressRing({ percent }: { percent: number }) { + const size = 72; + const stroke = 6; + const clamped = Math.min(100, Math.max(0, percent)); + const rotation = (clamped / 100) * 360 - 90; + + return ( + + + 25 ? "#fbbf24" : "transparent", + borderBottomColor: clamped > 50 ? "#fbbf24" : "transparent", + borderLeftColor: clamped > 75 ? "#fbbf24" : "transparent", + transform: [{ rotate: `${rotation}deg` }], + }} + /> + {clamped}% + + ); +} + +const styles = StyleSheet.create({ + dayCircle: { + width: 22, + height: 22, + borderRadius: 11, + alignItems: "center", + justifyContent: "center", + }, + dayCircleDone: { + backgroundColor: "#fbbf24", + }, + dayCircleEmpty: { + borderWidth: 1.5, + borderColor: "rgba(255, 255, 255, 0.25)", + backgroundColor: "transparent", + }, +}); diff --git a/components/home/recently-viewed-section.tsx b/components/home/recently-viewed-section.tsx new file mode 100644 index 0000000..4e25482 --- /dev/null +++ b/components/home/recently-viewed-section.tsx @@ -0,0 +1,110 @@ +import { Image } from "expo-image"; +import { + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; + +import { formatRelativeTime } from "@/lib/format-relative-time"; +import { getCountryImages } from "@/lib/format-country"; +import { openCountryInExplore } from "@/lib/open-country-in-explore"; +import type { RecentlyViewedEntry } from "@/store/use-recently-viewed-store"; + +type RecentlyViewedSectionProps = { + entries: RecentlyViewedEntry[]; +}; + +const CARD_WIDTH = 120; +const CARD_HEIGHT = 96; + +export function RecentlyViewedSection({ entries }: RecentlyViewedSectionProps) { + const visibleEntries = entries.filter((entry) => entry.country?.name?.trim()); + + return ( + + + + Recently Viewed + + {}} + > + + See All > + + + + + + {visibleEntries.map((entry) => ( + + ))} + + + ); +} + +function RecentlyViewedCard({ entry }: { entry: RecentlyViewedEntry }) { + const { country, viewedAt } = entry; + const images = getCountryImages(country); + const heroUri = images[0]; + + return ( + openCountryInExplore(country)} + style={({ pressed }) => [ + styles.card, + { opacity: pressed ? 0.92 : 1 }, + ]} + > + {heroUri ? ( + + ) : ( + + )} + + + + {country.name} + + + {formatRelativeTime(viewedAt)} + + + + ); +} + +const styles = StyleSheet.create({ + scrollContent: { + gap: 12, + paddingRight: 16, + }, + card: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + borderRadius: 12, + overflow: "hidden", + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.4)", + }, +}); diff --git a/components/home/trending-countries-section.tsx b/components/home/trending-countries-section.tsx new file mode 100644 index 0000000..6ba6737 --- /dev/null +++ b/components/home/trending-countries-section.tsx @@ -0,0 +1,121 @@ +import { Image } from "expo-image"; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; + +import { FlagBadge } from "@/components/explore/flag-badge"; +import { getCountryImages } from "@/lib/format-country"; +import { openCountryInExplore } from "@/lib/open-country-in-explore"; +import type { Country } from "@/types/country"; + +type TrendingCountriesSectionProps = { + countries: Country[]; + loading?: boolean; +}; + +const CARD_WIDTH = 136; +const CARD_HEIGHT = 188; + +export function TrendingCountriesSection({ + countries, + loading = false, +}: TrendingCountriesSectionProps) { + return ( + + + + Trending Countries + + {}} + > + + View All > + + + + + {loading ? ( + + + + ) : ( + + {countries.map((country) => ( + + ))} + + )} + + ); +} + +function TrendingCountryCard({ country }: { country: Country }) { + const images = getCountryImages(country); + const heroUri = images[0]; + + return ( + openCountryInExplore(country)} + style={({ pressed }) => [styles.card, { opacity: pressed ? 0.92 : 1 }]} + > + {heroUri ? ( + + ) : ( + + )} + + + + + + {country.name} + + + {/* 🔥 Trending */} + + + ); +} + +const styles = StyleSheet.create({ + scrollContent: { + gap: 12, + paddingRight: 16, + }, + card: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + borderRadius: 16, + overflow: "hidden", + }, + cardOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.35)", + }, +}); diff --git a/components/map/map-controls.tsx b/components/map/map-controls.tsx new file mode 100644 index 0000000..2fba274 --- /dev/null +++ b/components/map/map-controls.tsx @@ -0,0 +1,120 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useState } from "react"; +import { Pressable, StyleSheet, View } from "react-native"; + +type MapControlsProps = { + onReset: () => void; + onZoomIn: () => void; + onZoomOut: () => void; +}; + +export function MapControls({ + onReset, + onZoomIn, + onZoomOut, +}: MapControlsProps) { + const [showNavZoomControls, setShowNavZoomControls] = useState(true); + + return ( + + {showNavZoomControls ? ( + + [styles.control, pressed && styles.pressed]} + > + + + + [styles.control, pressed && styles.pressed]} + > + + + + [styles.control, pressed && styles.pressed]} + > + + + + ) : null} + + { + setShowNavZoomControls((prev) => !prev); + }} + style={({ pressed }) => [ + styles.legendButton, + pressed && styles.pressed, + ]} + > + + {/* Legend */} + {/* */} + + + ); +} + +const styles = StyleSheet.create({ + column: { + position: "absolute", + left: 16, + bottom: 200, + gap: 4, + zIndex: 5, + }, + stack: { + borderRadius: 32, + backgroundColor: "#101828", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + overflow: "hidden", + }, + control: { + width: 40, + height: 40, + alignItems: "center", + justifyContent: "center", + }, + divider: { + height: 1, + backgroundColor: "rgba(255, 255, 255, 0.1)", + }, + legendButton: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 12, + backgroundColor: "#101828", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + }, + legendText: { + fontSize: 13, + fontFamily: "Poppins-Medium", + color: "#ffffff", + }, + pressed: { + opacity: 0.95, + // golden color #FBBF24 + backgroundColor: "#29303C", + }, +}); diff --git a/components/map/map-country-marker.tsx b/components/map/map-country-marker.tsx new file mode 100644 index 0000000..d8dc572 --- /dev/null +++ b/components/map/map-country-marker.tsx @@ -0,0 +1,124 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { StyleSheet, Text, View } from "react-native"; +import { Marker } from "react-native-maps"; + +import { isTrendingCountry } from "@/constants/trending-countries"; +import { resolveFlagCdnUrl } from "@/lib/flag-url"; +import { cca2FromFlagUrl } from "@/lib/map-country"; +import type { MapCountry } from "@/types/country"; + +type MapCountryMarkerProps = { + country: MapCountry; + selected: boolean; + onPress: () => void; +}; + +export function MapCountryMarker({ + country, + selected, + onPress, +}: MapCountryMarkerProps) { + const [latitude, longitude] = country.latlng; + const trending = isTrendingCountry(country.name); + const flagUri = resolveFlagCdnUrl(country.flag, cca2FromFlagUrl(country.flag)); + + return ( + { + event.stopPropagation?.(); + onPress(); + }} + tracksViewChanges={selected} + > + + + {flagUri ? ( + + ) : ( + 🏳️ + )} + + + + {country.name} + + {trending ? ( + + + Trending + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + alignItems: "center", + maxWidth: 120, + }, + pin: { + width: 40, + height: 40, + borderRadius: 20, + borderWidth: 2, + borderColor: "rgba(255, 255, 255, 0.35)", + backgroundColor: "#1a1f2e", + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }, + pinSelected: { + borderColor: "#fbbf24", + borderWidth: 3, + shadowColor: "#fbbf24", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.8, + shadowRadius: 8, + elevation: 6, + }, + flag: { + width: 32, + height: 32, + borderRadius: 16, + }, + flagEmoji: { + fontSize: 20, + }, + labelRow: { + marginTop: 4, + alignItems: "center", + gap: 2, + }, + countryName: { + fontSize: 12, + fontFamily: "Poppins-SemiBold", + color: "#ffffff", + textAlign: "center", + textShadowColor: "rgba(0, 0, 0, 0.75)", + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, + }, + trendingBadge: { + flexDirection: "row", + alignItems: "center", + gap: 2, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 8, + backgroundColor: "rgba(251, 146, 60, 0.2)", + }, + trendingText: { + fontSize: 9, + fontFamily: "Poppins-Medium", + color: "#fb923c", + }, +}); diff --git a/components/map/map-country-preview-card.tsx b/components/map/map-country-preview-card.tsx new file mode 100644 index 0000000..3e292f7 --- /dev/null +++ b/components/map/map-country-preview-card.tsx @@ -0,0 +1,411 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { useEffect, useMemo, useState } from "react"; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; + +import { prefetchCountryImage } from "@/components/explore/country-image"; +import { FlagBadge } from "@/components/explore/flag-badge"; +import { isTrendingCountry } from "@/constants/trending-countries"; +import { fetchCountryByName } from "@/lib/api"; +import { formatPopulation, getCountryImages } from "@/lib/format-country"; +import { mapCountryToCountry } from "@/lib/map-country"; +import { openCountryInExplore } from "@/lib/open-country-in-explore"; +import { useSavedCountriesStore } from "@/store/use-saved-countries-store"; +import type { Country, MapCountry } from "@/types/country"; + +type MapCountryPreviewCardProps = { + country: MapCountry; + onDismiss?: () => void; +}; + +export function MapCountryPreviewCard({ + country, + onDismiss, +}: MapCountryPreviewCardProps) { + const toggleSaved = useSavedCountriesStore((s) => s.toggleSaved); + const isSaved = useSavedCountriesStore((s) => s.isSaved(country.name)); + + const [detail, setDetail] = useState(null); + const [detailStatus, setDetailStatus] = useState< + "idle" | "loading" | "error" + >("idle"); + const [detailError, setDetailError] = useState(null); + + useEffect(() => { + let cancelled = false; + setDetail(null); + setDetailStatus("loading"); + setDetailError(null); + + void fetchCountryByName(country.name) + .then((data) => { + if (cancelled) return; + setDetail(data); + setDetailStatus("idle"); + }) + .catch((err) => { + if (cancelled) return; + setDetailStatus("error"); + setDetailError( + err instanceof Error ? err.message : "Could not load fun fact", + ); + }); + + return () => { + cancelled = true; + }; + }, [country.name]); + + const trending = isTrendingCountry(country.name); + const thumbnailUri = + detail?.images?.[0] ?? country.image ?? country.flag ?? null; + const countryForActions = mapCountryToCountry(country, detail); + + const previewImages = useMemo(() => { + if (!detail) return []; + return getCountryImages(detail).slice(0, 3); + }, [detail]); + + useEffect(() => { + if (previewImages.length === 0) return; + for (const uri of previewImages) { + void prefetchCountryImage(uri); + } + }, [previewImages]); + + const aiFact = + detail?.ai?.fact?.trim() || + (detailStatus === "loading" + ? "Fun fact loading…" + : detailStatus === "error" + ? (detailError ?? "Fun fact unavailable") + : "Fun fact loading…"); + + return ( + + + {/* */} + {/* */} + + + + + {thumbnailUri ? ( + + ) : ( + + + + )} + + + + + + + + {country.name} + + {/* {trending ? ( + + + Trending + + ) : null} */} + + + {/* toggleSaved(countryForActions)} + hitSlop={8} + style={({ pressed }) => [ + styles.saveButton, + pressed && styles.pressed, + ]} + > + + */} + + + + + + + + + + + + + + + AI Fun Fact + + {detailStatus === "loading" ? ( + + ) : null} + + {aiFact} + + + {/* + Top 3 posts + {detailStatus === "loading" && previewImages.length === 0 ? ( + + {[0, 1, 2].map((i) => ( + + ))} + + ) : null} + {previewImages.length > 0 ? ( + openCountryInExplore(countryForActions)} + /> + ) : null} + */} + + openCountryInExplore(countryForActions)} + style={({ pressed }) => [ + styles.exploreButton, + pressed && styles.pressed, + ]} + > + View full feed + {/* */} + + + ); +} + +function StatItem({ + icon, + label, + caption, +}: { + icon?: keyof typeof Ionicons.glyphMap; + label: string; + caption: string; +}) { + return ( + + {icon ? : null} + + {label} + + {caption} + + ); +} + +const styles = StyleSheet.create({ + card: { + marginHorizontal: 32, + marginBottom: 136, + padding: 16, + gap: 16, + borderRadius: 24, + backgroundColor: "rgba(18, 24, 38, 0.8)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.08)", + }, + dismissHandle: { + position: "absolute", + top: 0, + right: 0, + alignItems: "center", + justifyContent: "center", + padding: 8, + }, + + thumbnailWrap: { + width: 88, + height: 88, + borderRadius: 16, + overflow: "hidden", + }, + thumbnail: { + width: 88, + height: 88, + }, + thumbnailFallback: { + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(255, 255, 255, 0.06)", + }, + trendingPill: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + backgroundColor: "rgba(251, 146, 60, 0.15)", + }, + trendingPillText: { + fontSize: 11, + fontFamily: "Poppins-Medium", + color: "#fb923c", + }, + saveButton: { + width: 44, + height: 44, + alignItems: "center", + justifyContent: "center", + }, + statItem: { + flex: 1, + minWidth: 0, + }, + statLabel: { + fontSize: 13, + fontFamily: "Poppins-Medium", + color: "#ffffff", + }, + statCaption: { + fontSize: 11, + fontFamily: "Poppins-Regular", + color: "#94a3b8", + }, + aiBlock: { + gap: 8, + padding: 12, + borderRadius: 16, + backgroundColor: "rgba(255, 255, 255, 0.04)", + }, + exploreButton: { + height: 52, + borderRadius: 16, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + backgroundColor: "#fbbf24", + }, + exploreLabel: { + fontSize: 16, + fontFamily: "Poppins-SemiBold", + color: "#0b132b", + }, + pressed: { + opacity: 0.88, + }, + previewBlock: { + gap: 10, + }, + previewTitle: { + fontSize: 13, + fontFamily: "Poppins-SemiBold", + color: "rgba(255,255,255,0.9)", + }, + previewSkeletonRow: { + flexDirection: "row", + gap: 10, + }, + previewSkeleton: { + width: 72, + height: 72, + borderRadius: 16, + backgroundColor: "rgba(255,255,255,0.06)", + }, + previewRow: { + gap: 10, + alignItems: "center", + paddingVertical: 2, + }, + previewThumbWrap: { + width: 72, + height: 72, + borderRadius: 16, + overflow: "hidden", + backgroundColor: "rgba(255,255,255,0.04)", + }, + previewThumb: { + width: "100%", + height: "100%", + }, + previewTrailingPad: { + width: 8, + }, +}); + +function ScrollRowPreview({ + images, + onPress, +}: { + images: string[]; + onPress: () => void; +}) { + return ( + + {images.map((uri) => ( + [ + styles.previewThumbWrap, + pressed && styles.pressed, + ]} + > + + + ))} + + + ); +} diff --git a/components/map/map-featured-chips.tsx b/components/map/map-featured-chips.tsx new file mode 100644 index 0000000..cb22c17 --- /dev/null +++ b/components/map/map-featured-chips.tsx @@ -0,0 +1,142 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; + +import type { FeaturedShortcut } from "@/store/use-map-ui-store"; +import { useMapUiStore } from "@/store/use-map-ui-store"; + +type FeaturedChipsProps = { + onTrendingPress: () => void; + onForYouPress: () => void; + onNewActivityPress: () => void; +}; + +type ChipConfig = { + id: Exclude; + label: string; + icon: keyof typeof Ionicons.glyphMap; +}; + +const CHIPS: ChipConfig[] = [ + { id: "trending", label: "Trending Now", icon: "flame" }, + { id: "forYou", label: "For You World", icon: "globe-outline" }, + { id: "newActivity", label: "New Activity", icon: "sparkles" }, +]; + +export function MapFeaturedChips({ + onTrendingPress, + onForYouPress, + onNewActivityPress, +}: FeaturedChipsProps) { + const featuredShortcut = useMapUiStore((s) => s.featuredShortcut); + + return ( + + {CHIPS.map((chip, index) => { + const selected = featuredShortcut === chip.id; + return ( + [ + styles.chip, + index > 0 && styles.chipSpacing, + selected && styles.chipSelected, + pressed && styles.pressed, + ]} + > + + + {chip.label} + + + ); + })} + + [ + styles.chip, + styles.chipSpacing, + styles.nearYouChip, + pressed && styles.pressed, + ]} + > + + Near You + + + + + ); +} + +const styles = StyleSheet.create({ + row: { + paddingHorizontal: 16, + paddingVertical: 8, + alignItems: "center", + }, + chip: { + flexDirection: "row", + alignItems: "center", + gap: 6, + height: 40, + paddingHorizontal: 16, + borderRadius: 20, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.12)", + backgroundColor: "rgba(255, 255, 255, 0.06)", + maxWidth: 220, + }, + chipSpacing: { + marginLeft: 8, + }, + chipSelected: { + borderColor: "#fbbf24", + backgroundColor: "rgba(251, 191, 36, 0.08)", + }, + chipLabel: { + fontSize: 14, + fontFamily: "Poppins-Medium", + color: "#94a3b8", + }, + chipLabelSelected: { + color: "#fbbf24", + fontFamily: "Poppins-SemiBold", + }, + nearYouChip: { + opacity: 0.45, + }, + pressed: { + opacity: 0.88, + }, + trailingPad: { + width: 8, + }, +}); + diff --git a/components/map/map-filter-chips.tsx b/components/map/map-filter-chips.tsx new file mode 100644 index 0000000..7d41fe5 --- /dev/null +++ b/components/map/map-filter-chips.tsx @@ -0,0 +1,107 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; + +import type { MapFilterChip } from "@/store/use-map-store"; +import { useMapStore } from "@/store/use-map-store"; + +type ChipConfig = { + id: MapFilterChip; + label: string; + icon: keyof typeof Ionicons.glyphMap; +}; + +const CHIPS: ChipConfig[] = [ + { id: "all", label: "All", icon: "globe-outline" }, + { id: "population", label: "Population", icon: "people-outline" }, + { id: "culture", label: "Culture", icon: "color-palette-outline" }, + { id: "nature", label: "Nature", icon: "leaf-outline" }, + { id: "history", label: "History", icon: "library-outline" }, +]; + +export function MapFilterChips() { + const activeChip = useMapStore((s) => s.activeChip); + const setActiveChip = useMapStore((s) => s.setActiveChip); + + return ( + + {CHIPS.map((chip, index) => { + const selected = activeChip === chip.id; + return ( + setActiveChip(chip.id)} + style={({ pressed }) => [ + styles.chip, + index > 0 && styles.chipSpacing, + selected && styles.chipSelected, + pressed && styles.pressed, + ]} + > + + + {chip.label} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + row: { + paddingHorizontal: 16, + paddingVertical: 8, + alignItems: "center", + }, + chip: { + flexDirection: "row", + alignItems: "center", + gap: 6, + height: 40, + paddingHorizontal: 16, + borderRadius: 20, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.12)", + backgroundColor: "rgba(255, 255, 255, 0.06)", + }, + chipSpacing: { + marginLeft: 8, + }, + chipSelected: { + borderColor: "#fbbf24", + backgroundColor: "rgba(251, 191, 36, 0.08)", + }, + chipLabel: { + fontSize: 14, + fontFamily: "Poppins-Medium", + color: "#94a3b8", + }, + chipLabelSelected: { + color: "#fbbf24", + fontFamily: "Poppins-SemiBold", + }, + pressed: { + opacity: 0.88, + }, + trailingPad: { + width: 8, + }, +}); diff --git a/components/map/map-header.tsx b/components/map/map-header.tsx new file mode 100644 index 0000000..387b604 --- /dev/null +++ b/components/map/map-header.tsx @@ -0,0 +1,70 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { router } from "expo-router"; +import { Pressable, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { images } from "@/constants/images"; + +type MapHeaderProps = { + onGlobePress?: () => void; +}; + +export function MapHeader({ onGlobePress }: MapHeaderProps) { + const insets = useSafeAreaInsets(); + + return ( + + + [ + styles.iconButton, + pressed && styles.pressed, + ]} + > + + + + {/* */} + + router.push("/(tabs)/profile")} + className="h-11 w-11 overflow-hidden rounded-full border-2 border-tab-active" + > + + + + + ); +} + +const styles = StyleSheet.create({ + iconButton: { + width: 44, + height: 44, + alignItems: "center", + justifyContent: "center", + }, + pressed: { + opacity: 0.85, + }, + logo: { + width: 120, + height: 32, + }, +}); diff --git a/components/map/map-onboarding-sheet.tsx b/components/map/map-onboarding-sheet.tsx new file mode 100644 index 0000000..e9ff425 --- /dev/null +++ b/components/map/map-onboarding-sheet.tsx @@ -0,0 +1,170 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useCallback, useMemo, useRef } from "react"; +import { + Animated, + PanResponder, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; + +type MapOnboardingSheetProps = { + onDismiss: () => void; +}; + +export function MapOnboardingSheet({ onDismiss }: MapOnboardingSheetProps) { + const translateY = useRef(new Animated.Value(0)).current; + + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gestureState) => { + // Only respond to downward dragging for a subtle "swipe away" affordance. + return gestureState.dy > 6; + }, + onPanResponderMove: (_, gestureState) => { + const next = Math.max(0, gestureState.dy); + translateY.setValue(next); + }, + onPanResponderRelease: (_, gestureState) => { + const shouldDismiss = gestureState.dy > 90; + if (!shouldDismiss) { + Animated.spring(translateY, { + toValue: 0, + useNativeDriver: true, + friction: 7, + }).start(); + return; + } + + Animated.timing(translateY, { + toValue: 220, + duration: 180, + useNativeDriver: true, + }).start(() => onDismiss()); + }, + }), + [onDismiss, translateY], + ); + + const dismiss = useCallback(() => { + Animated.timing(translateY, { + toValue: 220, + duration: 180, + useNativeDriver: true, + }).start(() => onDismiss()); + }, [onDismiss, translateY]); + + return ( + + + + + + + + + + Explore the World + + + Tap a country to see trending content from that region. + + + + + + + + + ); +} + +function Hint({ icon, label }: { icon: keyof typeof Ionicons.glyphMap; label: string }) { + return ( + + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + sheet: { + marginHorizontal: 16, + borderRadius: 24, + backgroundColor: "rgba(18, 24, 38, 0.98)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.08)", + padding: 16, + gap: 10, + }, + topRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + handleBar: { + width: 48, + height: 4, + borderRadius: 2, + backgroundColor: "rgba(255, 255, 255, 0.2)", + alignSelf: "center", + }, + closeButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(255, 255, 255, 0.08)", + }, + title: { + fontFamily: "Poppins-SemiBold", + fontSize: 18, + color: "#ffffff", + }, + subtitle: { + fontFamily: "Poppins-Regular", + fontSize: 13, + lineHeight: 18, + color: "rgba(255,255,255,0.78)", + }, + hintsRow: { + flexDirection: "row", + gap: 10, + }, + hint: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 16, + backgroundColor: "rgba(255,255,255,0.05)", + }, + hintLabel: { + fontFamily: "Poppins-Medium", + fontSize: 12, + color: "rgba(255,255,255,0.75)", + }, +}); + diff --git a/components/map/map-pulse-cluster-marker.tsx b/components/map/map-pulse-cluster-marker.tsx new file mode 100644 index 0000000..acb7455 --- /dev/null +++ b/components/map/map-pulse-cluster-marker.tsx @@ -0,0 +1,120 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useEffect } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { Marker } from "react-native-maps"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; + +import { getActivityVisual } from "@/constants/map-activity"; +import type { MapCluster } from "@/lib/map-clusters"; + +type MapPulseClusterMarkerProps = { + cluster: MapCluster; + selected: boolean; + onPress: (cluster: MapCluster) => void; +}; + +export function MapPulseClusterMarker({ + cluster, + selected, + onPress, +}: MapPulseClusterMarkerProps) { + const [latitude, longitude] = cluster.center; + const visual = getActivityVisual(cluster.activity); + + const pulse = useSharedValue(1); + + // "Alive" feel: a gentle breathing loop on bubble markers. + useEffect(() => { + pulse.value = withRepeat( + withSequence( + withTiming(1.08, { duration: 1000 }), + withTiming(1, { duration: 1000 }), + ), + -1, + false, + ); + }, [pulse]); + + const animatedStyle = useAnimatedStyle(() => { + const scale = selected ? 1.12 : pulse.value; + return { + transform: [{ scale }], + }; + }); + + return ( + { + event.stopPropagation?.(); + onPress(cluster); + }} + > + + + + + {cluster.countryCount} + + + + + ); +} + +const styles = StyleSheet.create({ + bubble: { + width: 58, + height: 58, + borderRadius: 29, + borderWidth: 2, + alignItems: "center", + justifyContent: "center", + shadowColor: "#000", + shadowOpacity: 0.3, + shadowRadius: 10, + shadowOffset: { width: 0, height: 2 }, + elevation: 6, + }, + bubbleSelected: { + borderColor: "#fbbf24", + shadowColor: "#fbbf24", + shadowOpacity: 0.6, + }, + inner: { + alignItems: "center", + justifyContent: "center", + gap: 2, + }, + count: { + fontFamily: "Poppins-SemiBold", + fontSize: 12, + color: "#ffffff", + }, + countSelected: { + color: "#fbbf24", + }, +}); + diff --git a/components/map/map-search-row.tsx b/components/map/map-search-row.tsx new file mode 100644 index 0000000..39c2e50 --- /dev/null +++ b/components/map/map-search-row.tsx @@ -0,0 +1,55 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; + +import { useSearchUiStore } from "@/store/use-search-ui-store"; + +export function MapSearchRow() { + const openSearch = useSearchUiStore((s) => s.openSearch); + + return ( + + + + + Search countries, regions, cultures… + + + + { + Alert.alert( + "Filters", + "Advanced map filters are coming in a later lesson.", + ); + }} + style={({ pressed }) => [ + styles.filterButton, + pressed && styles.pressed, + ]} + > + + + + ); +} + +const styles = StyleSheet.create({ + filterButton: { + width: 48, + height: 48, + borderRadius: 16, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(255, 255, 255, 0.08)", + }, + pressed: { + opacity: 0.85, + }, +}); diff --git a/components/map/random-country-fab.tsx b/components/map/random-country-fab.tsx new file mode 100644 index 0000000..c0c2fe6 --- /dev/null +++ b/components/map/random-country-fab.tsx @@ -0,0 +1,56 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Pressable, StyleSheet, View } from "react-native"; + +type RandomCountryFabProps = { + onPress: () => void; +}; + +export function RandomCountryFab({ onPress }: RandomCountryFabProps) { + return ( + + [styles.fab, pressed && styles.pressed]} + > + + + {/* Random Country */} + + ); +} + +const styles = StyleSheet.create({ + wrap: { + position: "absolute", + left: 16, + bottom: 158, + alignItems: "center", + gap: 8, + zIndex: 6, + }, + fab: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#fbbf24", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.35, + shadowRadius: 8, + elevation: 8, + }, + label: { + fontSize: 12, + fontFamily: "Poppins-Medium", + color: "#ffffff", + textAlign: "center", + }, + pressed: { + opacity: 0.9, + transform: [{ scale: 0.96 }], + }, +}); diff --git a/components/map/world-map-view.tsx b/components/map/world-map-view.tsx new file mode 100644 index 0000000..46a5e9a --- /dev/null +++ b/components/map/world-map-view.tsx @@ -0,0 +1,127 @@ +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { Platform, StyleSheet } from "react-native"; +import MapView, { PROVIDER_DEFAULT, type Region } from "react-native-maps"; + +import { MAP_DARK_STYLE } from "@/constants/map-dark-style"; +import { WORLD_INITIAL_REGION } from "@/constants/map-regions"; +import { MapCountryMarker } from "@/components/map/map-country-marker"; +import { MapPulseClusterMarker } from "@/components/map/map-pulse-cluster-marker"; +import type { MapCluster } from "@/lib/map-clusters"; +import type { MapCountry } from "@/types/country"; + +export type WorldMapViewHandle = { + animateToRegion: (region: Region, duration?: number) => void; + resetWorldView: () => void; + zoomBy: (direction: "in" | "out") => void; +}; + +export type MapZoomTier = "world" | "region" | "country"; + +type WorldMapViewProps = { + countries: MapCountry[]; + clusters: MapCluster[]; + selectedName: string | null; + focusedRegion: string | null; + zoomTier: MapZoomTier; + onCountryPress: (country: MapCountry) => void; + onClusterPress: (cluster: MapCluster) => void; + onMapPress: () => void; + onRegionChangeComplete?: (region: Region) => void; +}; + +export const WorldMapView = forwardRef( + function WorldMapView( + { + countries, + clusters, + selectedName, + focusedRegion, + zoomTier, + onCountryPress, + onClusterPress, + onMapPress, + onRegionChangeComplete, + }, + ref, + ) { + const mapRef = useRef(null); + const regionRef = useRef(WORLD_INITIAL_REGION); + + const animateToRegion = useCallback((region: Region, duration = 500) => { + regionRef.current = region; + mapRef.current?.animateToRegion(region, duration); + }, []); + + useImperativeHandle( + ref, + () => ({ + animateToRegion, + resetWorldView: () => animateToRegion(WORLD_INITIAL_REGION), + zoomBy: (direction) => { + const current = regionRef.current; + const scale = direction === "in" ? 0.65 : 1.45; + const next: Region = { + ...current, + latitudeDelta: Math.min( + 120, + Math.max(2, current.latitudeDelta * scale), + ), + longitudeDelta: Math.min( + 120, + Math.max(2, current.longitudeDelta * scale), + ), + }; + animateToRegion(next, 300); + }, + }), + [animateToRegion], + ); + + return ( + { + regionRef.current = region; + onRegionChangeComplete?.(region); + }} + rotateEnabled={false} + pitchEnabled={false} + toolbarEnabled={false} + showsCompass={false} + showsScale={false} + showsUserLocation={false} + showsMyLocationButton={false} + mapType={Platform.OS === "android" ? "standard" : "mutedStandard"} + > + {zoomTier === "world" + ? clusters.map((cluster) => ( + + )) + : countries.map((country) => ( + onCountryPress(country)} + /> + ))} + + ); + }, +); + +const styles = StyleSheet.create({ + map: { + ...StyleSheet.absoluteFillObject, + }, +}); diff --git a/components/search/search-overlay.tsx b/components/search/search-overlay.tsx new file mode 100644 index 0000000..f43a353 --- /dev/null +++ b/components/search/search-overlay.tsx @@ -0,0 +1,550 @@ +import { Ionicons } from "@expo/vector-icons"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Image } from "expo-image"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + FlatList, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { FlagBadge } from "@/components/explore/flag-badge"; +import { FeedErrorBanner } from "@/components/home/feed-error-banner"; +import { CONTINENTS } from "@/constants/regions"; +import { fetchSearchCountries } from "@/lib/api"; +import { getAiFact, getCountryImages } from "@/lib/format-country"; +import { openCountryInExplore } from "@/lib/open-country-in-explore"; +import { useSearchUiStore } from "@/store/use-search-ui-store"; +import type { Country } from "@/types/country"; + +const DEBOUNCE_MS = 300; +const RECENT_SEARCHES_KEY = "worldloop-recent-searches"; +const MAX_RECENT_SEARCHES = 8; + +type SearchStatus = "idle" | "loading" | "success" | "error"; + +export function SearchOverlay() { + const insets = useSafeAreaInsets(); + const isOpen = useSearchUiStore((s) => s.isOpen); + const closeSearch = useSearchUiStore((s) => s.closeSearch); + + const [query, setQuery] = useState(""); + const [region, setRegion] = useState(null); + const [results, setResults] = useState([]); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [recentSearches, setRecentSearches] = useState([]); + + const inputRef = useRef(null); + const debounceRef = useRef | null>(null); + const lastRequestRef = useRef({ query: "", region: "" }); + + const loadRecentSearches = useCallback(async () => { + try { + const raw = await AsyncStorage.getItem(RECENT_SEARCHES_KEY); + if (raw) { + const parsed = JSON.parse(raw) as string[]; + if (Array.isArray(parsed)) { + setRecentSearches(parsed.slice(0, MAX_RECENT_SEARCHES)); + } + } + } catch { + // ignore corrupt storage + } + }, []); + + const saveRecentSearch = useCallback(async (term: string) => { + const trimmed = term.trim(); + if (!trimmed) return; + setRecentSearches((prev) => { + const next = [ + trimmed, + ...prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase()), + ].slice(0, MAX_RECENT_SEARCHES); + void AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(next)); + return next; + }); + }, []); + + const runSearch = useCallback( + async (searchQuery: string, searchRegion: string | null) => { + const q = searchQuery.trim(); + const r = searchRegion?.trim() ?? ""; + + if (!q && !r) { + setResults([]); + setStatus("idle"); + setError(null); + return; + } + + lastRequestRef.current = { query: q, region: r }; + setStatus("loading"); + setError(null); + + try { + const { data } = await fetchSearchCountries( + q || undefined, + r || undefined, + ); + setResults(data); + setStatus("success"); + if (q) void saveRecentSearch(q); + } catch (err) { + setResults([]); + setStatus("error"); + setError( + err instanceof Error ? err.message : "Search failed. Try again.", + ); + } + }, + [saveRecentSearch], + ); + + const scheduleSearch = useCallback( + (searchQuery: string, searchRegion: string | null, immediate = false) => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + + const q = searchQuery.trim(); + const r = searchRegion?.trim() ?? ""; + if (!q && !r) { + setResults([]); + setStatus("idle"); + setError(null); + return; + } + + if (immediate) { + void runSearch(searchQuery, searchRegion); + return; + } + + debounceRef.current = setTimeout(() => { + void runSearch(searchQuery, searchRegion); + }, DEBOUNCE_MS); + }, + [runSearch], + ); + + const handleRetry = useCallback(() => { + const { query: q, region: r } = lastRequestRef.current; + void runSearch(q, r || null); + }, [runSearch]); + + useEffect(() => { + if (!isOpen) return; + void loadRecentSearches(); + const focusTimer = setTimeout(() => inputRef.current?.focus(), 100); + return () => clearTimeout(focusTimer); + }, [isOpen, loadRecentSearches]); + + useEffect(() => { + if (!isOpen) return; + scheduleSearch(query, region); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, region, isOpen, scheduleSearch]); + + const handleClose = useCallback(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + closeSearch(); + }, [closeSearch]); + + const toggleRegion = useCallback((next: string) => { + setRegion((current) => (current === next ? null : next)); + }, []); + + const handleSubmit = useCallback(() => { + scheduleSearch(query, region, true); + }, [query, region, scheduleSearch]); + + const handleRecentTap = useCallback( + (term: string) => { + setQuery(term); + scheduleSearch(term, region, true); + }, + [region, scheduleSearch], + ); + + const showIdle = + status === "idle" && results.length === 0 && !query.trim() && !region; + const showEmpty = + status === "success" && + results.length === 0 && + (!!query.trim() || !!region); + + return ( + + + + + + + + + + + Search + + + + + + + + + {query.length > 0 ? ( + setQuery("")} + hitSlop={8} + > + + + ) : null} + + + + + {CONTINENTS.map((name) => { + const selected = region === name; + return ( + toggleRegion(name)} + hitSlop={4} + style={({ pressed }) => [ + styles.chip, + selected && styles.chipSelected, + pressed && { opacity: 0.85 }, + ]} + > + + {name} + + {/* {selected ? ( + + ) : null} */} + + ); + })} + + + {status === "error" && error ? ( + + + + ) : null} + + {showIdle ? ( + + {recentSearches.length > 0 ? ( + + + Recent searches + + + {recentSearches.map((term) => ( + handleRecentTap(term)} + style={({ pressed }) => [ + styles.recentChip, + pressed && { opacity: 0.85 }, + ]} + > + + {term} + + ))} + + + ) : null} + + ) : status === "loading" ? ( + + + + ) : showEmpty ? ( + + + No countries found + + + Try another spelling or pick a different region. + + + ) : ( + + item.name} + renderItem={({ item }) => ( + openCountryInExplore(item)} + /> + )} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + style={styles.resultsList} + /> + + )} + + + + ); +} + +type SearchResultRowProps = { + country: Country; + onPress: () => void; +}; + +function SearchResultRow({ country, onPress }: SearchResultRowProps) { + const images = getCountryImages(country); + const heroUri = images[0]; + const fact = getAiFact(country); + + return ( + [styles.resultRow, pressed && { opacity: 0.9 }]} + > + + {heroUri ? ( + + ) : ( + + 🌍 + + )} + + + + + + + {country.name} + + + + {country.capital} · {country.region} + + {fact !== "Fun fact loading…" ? ( + + {fact} + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, + scrim: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.6)", + }, + panel: { + marginTop: "8%", + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + input: { + flex: 1, + fontSize: 14, + color: "#fff", + paddingVertical: 0, + }, + chipsScroll: { + flexGrow: 0, + flexShrink: 0, + }, + chipsRow: { + paddingHorizontal: 16, + alignItems: "center", + gap: 8, + }, + chip: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 999, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.2)", + justifyContent: "center", + }, + chipClearIcon: { + marginLeft: 2, + }, + chipSelected: { + borderColor: "#fbbf24", + }, + idleScroll: { + flex: 1, + }, + idleScrollContent: { + flexGrow: 1, + paddingHorizontal: 16, + }, + centeredScroll: { + flex: 1, + }, + centeredScrollContent: { + flexGrow: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 24, + }, + recentChip: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: "rgba(255, 255, 255, 0.08)", + }, + resultsList: { + flex: 1, + }, + listContent: { + paddingHorizontal: 16, + paddingBottom: 24, + gap: 8, + }, + resultRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingVertical: 12, + minHeight: 72, + }, + thumb: { + width: 56, + height: 56, + borderRadius: 12, + overflow: "hidden", + backgroundColor: "rgba(255, 255, 255, 0.06)", + }, +}); diff --git a/constants/images.ts b/constants/images.ts index 0d32a32..a2b5bc5 100644 --- a/constants/images.ts +++ b/constants/images.ts @@ -1,5 +1,7 @@ +import profileAvatar from "@/assets/images/profile-avatar.png"; import worldloopIcon from "@/assets/images/worldloop-icon.png"; export const images = { worldloopIcon, + profileAvatar, }; diff --git a/constants/map-activity.ts b/constants/map-activity.ts new file mode 100644 index 0000000..2b660c9 --- /dev/null +++ b/constants/map-activity.ts @@ -0,0 +1,53 @@ +import type { MapCountry } from "@/types/country"; + +import { TRENDING_COUNTRY_NAMES } from "@/constants/trending-countries"; + +export type MapClusterActivity = "trending" | "rising" | "quiet"; + +export function getClusterActivity(countries: MapCountry[]): MapClusterActivity { + if (countries.some((c) => TRENDING_COUNTRY_NAMES.has(c.name))) { + return "trending"; + } + + // Rising: cluster contains any country in the top 20% by population (within cluster). + const sorted = [...countries].sort((a, b) => b.population - a.population); + const topCount = Math.max(1, Math.ceil(sorted.length * 0.2)); + const topNames = new Set(sorted.slice(0, topCount).map((c) => c.name)); + + if (countries.some((c) => topNames.has(c.name))) { + return "rising"; + } + + return "quiet"; +} + +export function getActivityVisual(activity: MapClusterActivity) { + switch (activity) { + case "trending": + return { + borderColor: "rgba(239,68,68,0.75)", + bgColor: "rgba(239,68,68,0.16)", + textColor: "#fb7185", + ringColor: "rgba(239,68,68,0.9)", + icon: "flame" as const, + }; + case "rising": + return { + borderColor: "rgba(251,191,36,0.8)", + bgColor: "rgba(251,191,36,0.14)", + textColor: "#fbbf24", + ringColor: "rgba(251,191,36,0.95)", + icon: "sparkles" as const, + }; + case "quiet": + default: + return { + borderColor: "rgba(255,255,255,0.28)", + bgColor: "rgba(255,255,255,0.06)", + textColor: "#94a3b8", + ringColor: "rgba(148,163,184,0.25)", + icon: "globe-outline" as const, + }; + } +} + diff --git a/constants/map-dark-style.ts b/constants/map-dark-style.ts new file mode 100644 index 0000000..505d50f --- /dev/null +++ b/constants/map-dark-style.ts @@ -0,0 +1,87 @@ +import type { MapStyleElement } from "react-native-maps"; + +/** Dark map palette matching map-screen-ui.png (muted land, darker water). */ +export const MAP_DARK_STYLE: MapStyleElement[] = [ + { elementType: "geometry", stylers: [{ color: "#1a1f2e" }] }, + { elementType: "labels.text.fill", stylers: [{ color: "#6b7280" }] }, + { elementType: "labels.text.stroke", stylers: [{ color: "#0b132b" }] }, + { + featureType: "administrative", + elementType: "geometry.stroke", + stylers: [{ color: "#2d3748" }], + }, + { + featureType: "administrative.country", + elementType: "labels.text.fill", + stylers: [{ color: "#9ca3af" }], + }, + { + featureType: "administrative.land_parcel", + stylers: [{ visibility: "off" }], + }, + { + featureType: "administrative.locality", + elementType: "labels.text.fill", + stylers: [{ color: "#9ca3af" }], + }, + { + featureType: "poi", + elementType: "labels.text.fill", + stylers: [{ color: "#6b7280" }], + }, + { + featureType: "poi.park", + elementType: "geometry", + stylers: [{ color: "#1e293b" }], + }, + { + featureType: "poi.park", + elementType: "labels.text.fill", + stylers: [{ color: "#6b7280" }], + }, + { + featureType: "road", + elementType: "geometry", + stylers: [{ color: "#2d3748" }], + }, + { + featureType: "road", + elementType: "geometry.stroke", + stylers: [{ color: "#1a1f2e" }], + }, + { + featureType: "road.highway", + elementType: "geometry", + stylers: [{ color: "#374151" }], + }, + { + featureType: "road.highway", + elementType: "geometry.stroke", + stylers: [{ color: "#1a1f2e" }], + }, + { + featureType: "road.highway", + elementType: "labels.text.fill", + stylers: [{ color: "#9ca3af" }], + }, + { + featureType: "transit", + elementType: "geometry", + stylers: [{ color: "#1e293b" }], + }, + { + featureType: "transit.station", + elementType: "labels.text.fill", + stylers: [{ color: "#9ca3af" }], + }, + { + featureType: "water", + elementType: "geometry", + stylers: [{ color: "#0b132b" }], + }, + { + featureType: "water", + elementType: "labels.text.fill", + stylers: [{ color: "#4b5563" }], + }, +]; diff --git a/constants/map-regions.ts b/constants/map-regions.ts new file mode 100644 index 0000000..b268abe --- /dev/null +++ b/constants/map-regions.ts @@ -0,0 +1,20 @@ +import type { Region } from "react-native-maps"; + +export const WORLD_INITIAL_REGION: Region = { + latitude: 20, + longitude: 0, + latitudeDelta: 120, + longitudeDelta: 120, +}; + +export function regionForCountry( + latlng: [number, number], + delta = 18, +): Region { + return { + latitude: latlng[0], + longitude: latlng[1], + latitudeDelta: delta, + longitudeDelta: delta, + }; +} diff --git a/constants/regions.ts b/constants/regions.ts new file mode 100644 index 0000000..4d1435a --- /dev/null +++ b/constants/regions.ts @@ -0,0 +1,22 @@ +/** Default Explore header tab — mixed paginated feed, no continent filter. */ +export const FOR_YOU_TAB = "For You" as const; + +/** REST Countries `region` values (continents) used for browse filters. */ +export const CONTINENTS = [ + "Africa", + "Americas", + "Asia", + "Europe", + "Oceania", +] as const; + +export type Continent = (typeof CONTINENTS)[number]; + +/** Explore top bar tabs: For You first, then continents. */ +export const EXPLORE_HEADER_TABS = [FOR_YOU_TAB, ...CONTINENTS] as const; + +export type ExploreHeaderTab = (typeof EXPLORE_HEADER_TABS)[number]; + +export function isContinent(value: string): value is Continent { + return (CONTINENTS as readonly string[]).includes(value); +} diff --git a/constants/trending-countries.ts b/constants/trending-countries.ts new file mode 100644 index 0000000..96f221b --- /dev/null +++ b/constants/trending-countries.ts @@ -0,0 +1,13 @@ +/** Hardcoded trending markers for map v1 (no backend field). */ +export const TRENDING_COUNTRY_NAMES = new Set([ + "Japan", + "Brazil", + "Iceland", + "Canada", + "South Africa", + "Peru", +]); + +export function isTrendingCountry(name: string): boolean { + return TRENDING_COUNTRY_NAMES.has(name); +} diff --git a/lib/api.ts b/lib/api.ts index 85de269..cbe97cf 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@/constants/api"; -import type { Country } from "@/types/country"; +import type { Country, MapCountry } from "@/types/country"; export type FeedCountriesResponse = { data: Country[]; @@ -27,3 +27,66 @@ export async function fetchFeedCountries( return response.json() as Promise; } + +export type SearchCountriesResponse = { + data: Country[]; + meta: { + query: string | null; + region: string | null; + count: number; + }; +}; + +export async function fetchSearchCountries( + query?: string, + region?: string, +): Promise { + const url = new URL(`${API_BASE_URL}/search`); + const q = query?.trim() ?? ""; + const r = region?.trim() ?? ""; + + if (!q && !r) { + throw new Error("At least one of query or region is required"); + } + + if (q) url.searchParams.set("query", q); + if (r) url.searchParams.set("region", r); + + const response = await fetch(url.toString()); + + if (!response.ok) { + throw new Error(`Search request failed (${response.status})`); + } + + return response.json() as Promise; +} + +export type MapCountriesResponse = { + data: MapCountry[]; +}; + +export async function fetchMapCountries(): Promise { + const response = await fetch(`${API_BASE_URL}/map/countries`); + + if (!response.ok) { + throw new Error(`Map countries request failed (${response.status})`); + } + + return response.json() as Promise; +} + +type CountryDetailResponse = { + data: Country; +}; + +export async function fetchCountryByName(name: string): Promise { + const encoded = encodeURIComponent(name.trim()); + const response = await fetch(`${API_BASE_URL}/country/${encoded}`); + + if (!response.ok) { + throw new Error(`Country request failed (${response.status})`); + } + + const payload = (await response.json()) as CountryDetailResponse; + return payload.data; +} diff --git a/lib/flag-url.ts b/lib/flag-url.ts index 498bdc1..948cedb 100644 --- a/lib/flag-url.ts +++ b/lib/flag-url.ts @@ -12,7 +12,8 @@ export function resolveFlagCdnUrl(flag: string, iso2?: string): string | null { try { const url = new URL(value.startsWith("http") ? value : `https://${value}`); - if (!url.hostname.toLowerCase().includes("flagcdn.com")) { + const host = url.hostname.toLowerCase(); + if (!(host === "flagcdn.com" || host.endsWith(".flagcdn.com"))) { return null; } url.protocol = "https:"; diff --git a/lib/format-country.ts b/lib/format-country.ts index f3d0394..4b361d2 100644 --- a/lib/format-country.ts +++ b/lib/format-country.ts @@ -5,6 +5,10 @@ import { /** Compact population label (e.g. 33.7M). */ export function formatPopulation(population: number): string { + // Runtime safety: backend responses can occasionally miss population, + // and we must not crash the feed renderer. + if (!Number.isFinite(population)) return "—"; + if (population >= 1_000_000_000) { return `${(population / 1_000_000_000).toFixed(1).replace(/\.0$/, "")}B`; } diff --git a/lib/format-relative-time.ts b/lib/format-relative-time.ts new file mode 100644 index 0000000..af01a7b --- /dev/null +++ b/lib/format-relative-time.ts @@ -0,0 +1,20 @@ +/** Human-readable relative time (e.g. "Just now", "2h ago", "Yesterday"). */ +export function formatRelativeTime(viewedAt: number): string { + const diffMs = Date.now() - viewedAt; + if (diffMs < 60_000) return "Just now"; + + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + if (days === 1) return "Yesterday"; + if (days < 7) return `${days}d ago`; + + return new Date(viewedAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} diff --git a/lib/greeting.ts b/lib/greeting.ts new file mode 100644 index 0000000..d40576f --- /dev/null +++ b/lib/greeting.ts @@ -0,0 +1,7 @@ +/** Time-of-day greeting prefix (e.g. "Good morning"). */ +export function getTimeGreeting(): string { + const hour = new Date().getHours(); + if (hour < 12) return "Good morning"; + if (hour < 17) return "Good afternoon"; + return "Good evening"; +} diff --git a/lib/map-clusters.ts b/lib/map-clusters.ts new file mode 100644 index 0000000..1007849 --- /dev/null +++ b/lib/map-clusters.ts @@ -0,0 +1,59 @@ +import { CONTINENTS } from "@/constants/regions"; +import { getClusterActivity } from "@/constants/map-activity"; +import type { MapCountry } from "@/types/country"; + +export type MapCluster = { + id: string; + region: string; + center: [number, number]; + countryCount: number; + activity: ReturnType; +}; + +function weightedCenter(countries: MapCountry[]): [number, number] { + let totalWeight = 0; + let sumLat = 0; + let sumLng = 0; + + for (const c of countries) { + const [lat, lng] = c.latlng; + const w = Number.isFinite(c.population) && c.population > 0 ? c.population : 1; + totalWeight += w; + sumLat += lat * w; + sumLng += lng * w; + } + + if (totalWeight <= 0) { + const first = countries[0]; + return first ? [first.latlng[0], first.latlng[1]] : [0, 0]; + } + + return [sumLat / totalWeight, sumLng / totalWeight]; +} + +export function buildMapClusters(countries: MapCountry[]): MapCluster[] { + const byRegion = new Map(); + for (const c of countries) { + const arr = byRegion.get(c.region) ?? []; + arr.push(c); + byRegion.set(c.region, arr); + } + + // Preserve a stable order so the map feels consistent. + const orderedRegions = CONTINENTS.filter((r) => byRegion.has(r)); + + return orderedRegions.map((region) => { + const clusterCountries = byRegion.get(region) ?? []; + const center = weightedCenter(clusterCountries); + const activity = getClusterActivity(clusterCountries); + + return { + id: `cluster:${region}`, + region, + center, + countryCount: clusterCountries.length, + activity, + }; + }); +} + diff --git a/lib/map-country.ts b/lib/map-country.ts new file mode 100644 index 0000000..228a55e --- /dev/null +++ b/lib/map-country.ts @@ -0,0 +1,49 @@ +import type { Country, MapCountry } from "@/types/country"; + +/** Extract ISO alpha-2 from a flagcdn URL when present. */ +export function cca2FromFlagUrl(flag: string): string { + const match = flag.match(/flagcdn\.com\/w\d+\/([a-z]{2})\.png/i); + return match?.[1]?.toUpperCase() ?? ""; +} + +/** Minimal `Country` for save / Explore when full detail is not loaded yet. */ +export function mapCountryToCountry( + map: MapCountry, + detail?: Country | null, +): Country { + // Merge detail + map so we never lose required fields like `population`. + // Some backend payloads can be partial during async loading. + const merged = detail ? { ...detail } : null; + const fallbackPopulation = map.population; + const safePopulation = + merged && Number.isFinite(merged.population) + ? merged.population + : fallbackPopulation; + + return { + name: merged?.name ?? map.name, + capital: merged?.capital ?? map.capital, + region: merged?.region ?? map.region, + population: safePopulation, + cca2: merged?.cca2 ?? cca2FromFlagUrl(map.flag), + flag: merged?.flag ?? map.flag, + latlng: merged?.latlng ?? map.latlng, + images: merged?.images ?? (map.image ? [map.image] : undefined), + ai: merged?.ai, + }; +} + +export function isValidLatLng( + latlng: [number, number] | undefined | null, +): latlng is [number, number] { + if (!latlng || latlng.length < 2) return false; + const [lat, lng] = latlng; + return ( + Number.isFinite(lat) && + Number.isFinite(lng) && + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180 + ); +} diff --git a/lib/open-country-in-explore.ts b/lib/open-country-in-explore.ts new file mode 100644 index 0000000..635de96 --- /dev/null +++ b/lib/open-country-in-explore.ts @@ -0,0 +1,14 @@ +import { router } from "expo-router"; + +import { useCountryFeedStore } from "@/store/use-country-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 feed, record view, and open Explore. */ +export function openCountryInExplore(country: Country): void { + useSearchUiStore.getState().closeSearch(); + useCountryFeedStore.getState().focusCountryInFeed(country); + useRecentlyViewedStore.getState().recordView(country); + router.push("/(tabs)/explore"); +} diff --git a/lib/prefetch-feed-heroes.ts b/lib/prefetch-feed-heroes.ts new file mode 100644 index 0000000..f0080f9 --- /dev/null +++ b/lib/prefetch-feed-heroes.ts @@ -0,0 +1,17 @@ +import { prefetchCountryImage } from "@/components/explore/country-image"; +import { getCountryImages } from "@/lib/format-country"; +import type { Country } from "@/types/country"; + +/** First N feed cards — prefetch heroes before swapping lists to avoid navy flash. */ +const FEED_HERO_PREFETCH_COUNT = 2; + +export async function prefetchFeedHeroImages( + countries: Country[], +): Promise { + const heroes = countries + .slice(0, FEED_HERO_PREFETCH_COUNT) + .map((country) => getCountryImages(country)[0]) + .filter((uri): uri is string => Boolean(uri)); + + await Promise.all(heroes.map((uri) => prefetchCountryImage(uri))); +} diff --git a/package-lock.json b/package-lock.json index a814a77..5aa1f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "react-native": "0.81.5", "react-native-css": "^3.0.7", "react-native-gesture-handler": "~2.28.0", + "react-native-maps": "1.20.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", @@ -4181,6 +4182,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -11573,6 +11580,28 @@ "react-native": "*" } }, + "node_modules/react-native-maps": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.20.1.tgz", + "integrity": "sha512-NZI3B5Z6kxAb8gzb2Wxzu/+P2SlFIg1waHGIpQmazDSCRkNoHNY4g96g+xS0QPSaG/9xRBbDNnd2f2/OW6t6LQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.13" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">= 17.0.1", + "react-native": ">= 0.64.3", + "react-native-web": ">= 0.11" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/react-native-reanimated": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", diff --git a/package.json b/package.json index 17935c5..49b1adf 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-native": "0.81.5", "react-native-css": "^3.0.7", "react-native-gesture-handler": "~2.28.0", + "react-native-maps": "1.20.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", diff --git a/prompts-worldloop/00-backend-overview.md b/prompts-worldloop/00-backend-overview.md index 372892a..052824c 100644 --- a/prompts-worldloop/00-backend-overview.md +++ b/prompts-worldloop/00-backend-overview.md @@ -21,7 +21,7 @@ It should: ## Architecture -``` +```text Mobile App (Expo) │ ▼ @@ -37,6 +37,7 @@ External APIs Cache storage - REST Countries - World Bank (optional) - Unsplash / Pexels + ``` --- @@ -71,7 +72,7 @@ Graceful degradation (serve without cache when Redis is down) may exist in code ## Folder structure -``` +```text backend/ src/ api/ @@ -98,6 +99,7 @@ backend/ utils/ logger.ts docker-compose.yml # Redis — added in prompt 02 + ``` --- @@ -139,12 +141,12 @@ Early features may return partial objects (e.g. no `images` or `ai` until those ## Cache keys and TTLs -| Key pattern | TTL | -|-------------|-----| -| `country:{name}` | 90 days | -| `feed:countries:{cursor}` | 7 days | -| `ai:{country}` | 7 days | -| `images:{country}` | 30 days | +| Key pattern | TTL | +| ------------------------- | ------- | +| `country:{name}` | 90 days | +| `feed:countries:{cursor}` | 7 days | +| `ai:{country}` | 7 days | +| `images:{country}` | 30 days | --- diff --git a/prompts-worldloop/06-search-and-explore.md b/prompts-worldloop/06-search-and-explore.md index fb8fa89..541b19d 100644 --- a/prompts-worldloop/06-search-and-explore.md +++ b/prompts-worldloop/06-search-and-explore.md @@ -20,7 +20,7 @@ Let users search countries by name and filter by region (optional population ran - Search by country name (partial match, case-insensitive) - Optional `region` filter (e.g. Europe, Asia) - Return enriched country objects (metadata + images + ai) using cache when possible -- Cache popular search results in Redis (e.g. `search:{query}:{region}`, TTL 7 days) +- Cache popular search results in Redis (e.g. `search:{normalizedQuery}:{normalizedRregion}`, TTL 7 days) ## Out of scope diff --git a/prompts-worldloop/07-map-endpoint.md b/prompts-worldloop/07-map-endpoint.md index 9e714d3..53e9f36 100644 --- a/prompts-worldloop/07-map-endpoint.md +++ b/prompts-worldloop/07-map-endpoint.md @@ -12,7 +12,7 @@ Serve lightweight country data optimized for the interactive world map screen. - `01-country-data-service.md` completed - **`02-local-redis-setup.md` completed** — Redis running and verified -- `03-image-service.md` recommended (first image for map markers) +- `03-image-service.md` (first image for map markers) ## Scope diff --git a/prompts-worldloop/README.md b/prompts-worldloop/README.md index 0a6519e..0f4c501 100644 --- a/prompts-worldloop/README.md +++ b/prompts-worldloop/README.md @@ -2,20 +2,20 @@ Incremental backend implementation prompts for the WorldLoop API. Implement in numeric order unless a prompt says otherwise. -| # | Prompt | Description | -|---|--------|-------------| -| 00 | [00-backend-overview.md](./00-backend-overview.md) | Shared architecture, types, cache keys (read first) | -| 01 | [01-country-data-service.md](./01-country-data-service.md) | REST Countries metadata + basic feed | -| 02 | [02-local-redis-setup.md](./02-local-redis-setup.md) | **Required** — install & run Redis, verify cache hits | -| 03 | [03-image-service.md](./03-image-service.md) | Unsplash / Pexels images per country | -| 04 | [04-ai-content-service.md](./04-ai-content-service.md) | AI facts, captions, narration | -| 05 | [05-feed-endpoint.md](./05-feed-endpoint.md) | TikTok-style paginated feed | -| 06 | [06-search-and-explore.md](./06-search-and-explore.md) | Search and filter by name / region | -| 07 | [07-map-endpoint.md](./07-map-endpoint.md) | Map view country data | -| 08 | [08-redis-caching-layer.md](./08-redis-caching-layer.md) | Central Redis cache service (code hardening) | -| 09 | [09-backend-security.md](./09-backend-security.md) | Secrets, validation, rate limits | -| 10 | [10-pregeneration-system.md](./10-pregeneration-system.md) | Background AI pre-generation (optional) | -| 11 | [11-health-and-monitoring.md](./11-health-and-monitoring.md) | Health check and logging | +| # | Prompt | Description | +| --- | ------------------------------------------------------------ | ----------------------------------------------------- | +| 00 | [00-backend-overview.md](./00-backend-overview.md) | Shared architecture, types, cache keys (read first) | +| 01 | [01-country-data-service.md](./01-country-data-service.md) | REST Countries metadata + basic feed | +| 02 | [02-local-redis-setup.md](./02-local-redis-setup.md) | **Required** — install & run Redis, verify cache hits | +| 03 | [03-image-service.md](./03-image-service.md) | Unsplash / Pexels images per country | +| 04 | [04-ai-content-service.md](./04-ai-content-service.md) | AI facts, captions, narration | +| 05 | [05-feed-endpoint.md](./05-feed-endpoint.md) | TikTok-style paginated feed | +| 06 | [06-search-and-explore.md](./06-search-and-explore.md) | Search and filter by name / region | +| 07 | [07-map-endpoint.md](./07-map-endpoint.md) | Map view country data | +| 08 | [08-redis-caching-layer.md](./08-redis-caching-layer.md) | Central Redis cache service (code hardening) | +| 09 | [09-backend-security.md](./09-backend-security.md) | Secrets, validation, rate limits | +| 10 | [10-pregeneration-system.md](./10-pregeneration-system.md) | Background AI pre-generation (optional) | +| 11 | [11-health-and-monitoring.md](./11-health-and-monitoring.md) | Health check and logging | ## Dev prerequisites @@ -25,7 +25,7 @@ Incremental backend implementation prompts for the WorldLoop API. Implement in n ## Usage in Cursor -``` +```text @prompts-worldloop/01-country-data-service.md implement it @prompts-worldloop/02-local-redis-setup.md implement it ``` @@ -34,11 +34,11 @@ 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 | +| 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 | diff --git a/prompts/06-content-worldoop-system.md b/prompts/06-content-worldoop-system.md index 3171e0a..5b6ce47 100644 --- a/prompts/06-content-worldoop-system.md +++ b/prompts/06-content-worldoop-system.md @@ -2,7 +2,7 @@ Start with: -``` +```text @prompts-worldloop/00-backend-overview.md @prompts-worldloop/01-country-data-service.md implement it @prompts-worldloop/02-local-redis-setup.md implement it diff --git a/prompts/10b-home-ui.md b/prompts/10b-home-ui.md index 5cbac24..27538e8 100644 --- a/prompts/10b-home-ui.md +++ b/prompts/10b-home-ui.md @@ -1,7 +1,242 @@ Read AGENTS.md first and follow it strictly. -Implement the Home screen UI exactly as shown in the attached design with precise 8-point spacing throughout. +Reference: `prompt_material/home-screen-ui.png`, `AGENTS.md` (app structure, data model), `prompts/08-zustand.md`, `prompts/09-bottom-tab-nav.md`, `prompts/10a-explore-ui.md` -Use assets from the assets folder via the centralized images import +Implement the **Home** tab dashboard on `app/(tabs)/index.tsx` exactly as shown in the attached design. Use precise **8-point spacing** throughout (8, 16, 24, 32, …). Match layout, typography, colors, radii, and card treatments pixel-for-pixel — do not simplify the design. + +Use assets from `assets/` via the centralized `constants/images.ts` import (`import { images } from "@/constants/images"`). Add any new image assets there before using them in screens or components. @prompt_material/home-screen-ui.png + +## Goal + +Replace the Home placeholder with the **WorldLoop discovery dashboard**: + +- Personalized greeting header with profile avatar +- Search bar with **Discover** affordance +- **Country of the Day** hero card (featured country) +- **Trending Countries** horizontal carousel +- **Learning streak** + **world progress** stats row +- **Recently Viewed** horizontal list +- Scrollable content above the existing custom tab bar + +Wire real country data from the feed store where possible. Use local Zustand + AsyncStorage for engagement stats (streak, progress, recently viewed) — no new backend endpoints for v1. + +## Prerequisites + +- `prompts/09-bottom-tab-nav.md` — `(tabs)` routes and `components/bottom-tab-bar.tsx` exist; Home tab is the default landing tab +- `prompts/08-zustand.md` — `store/use-country-feed-store.ts`, `store/use-saved-countries-store.ts`, `lib/api.ts`, `types/country.ts` +- `prompts/01-nativewind.md` and `prompts/02-design-theme.md` — theme tokens in `global.css`, Poppins fonts loaded +- Backend feed running (`GET /feed/countries`) with enriched countries (`images`, `ai.fact` when available) +- `prompts/10a-explore-ui.md` recommended (Explore feed UI) so “Explore country” CTAs have a meaningful destination — Home can still ship with navigation stubs if Explore is not done yet + +## Dependencies + +Use what is already installed: + +- `expo-router`, `expo-image` +- `zustand`, `@react-native-async-storage/async-storage` +- `@expo/vector-icons` +- `react-native-safe-area-context` + +Do **not** add React Query, axios, chart libraries, or new navigation libraries without user approval. + +## Route & files + +| Path | Purpose | +| ---- | ------- | +| `app/(tabs)/index.tsx` | Home screen — composes dashboard sections, loads feed on mount | +| `components/home/` (optional) | Extract when it keeps `index.tsx` readable: e.g. `HomeHeader`, `HomeSearchBar`, `CountryOfTheDayCard`, `TrendingCountryCard`, `LearningStreakCard`, `WorldProgressCard`, `RecentlyViewedCard` | + +Keep business logic in stores/hooks; the screen orchestrates layout and navigation. + +## Data wiring + +### Feed store (`useCountryFeedStore`) + +On mount (when `countries` is empty): + +- Call `loadInitialFeed()` +- Show a skeleton or subtle loading state on cards while `status === "loading"` +- On error, show inline retry (banner or card-level) using `error` — do not crash the whole screen + +**Country of the Day:** use `countries[0]` when the feed has loaded. If the feed is empty, show a placeholder card with static copy from the design. + +**Trending Countries:** use `countries.slice(1, 5)` (or the next 4–6 items after the featured country). If fewer countries exist, show what is available. + +Reuse helpers from `lib/format-country.ts`: + +- `formatPopulation(country.population)` → e.g. `33.7M` +- `getCountryImages(country)` for hero/thumbnail URLs +- `getAiFact(country)` or a short trimmed line for the featured card description when `ai.fact` is long + +Use `FlagBadge` from `components/explore/flag-badge.tsx` (or the same flagcdn pattern) — do not render flag URLs as plain text. + +### Saved store (`useSavedCountriesStore`) + +- **Love this place** pill on the Country of the Day card: reflect saved state via `isSaved(country.name)`; tap toggles `toggleSaved(country)` +- Heart count (`12.4K`) is **static design copy** for v1 unless a likes API exists later + +### Local engagement store (add for this prompt) + +Create `store/use-discovery-progress-store.ts` (name can vary; keep it teachable): + +| Field | v1 behavior | +| ----- | ----------- | +| `streakDays` | Default `12` (match design); persist with AsyncStorage | +| `weekProgress` | Boolean[7] for Mon–Sun checkmarks (design shows M–S checked, Sun empty) | +| `countriesExplored` | Default `28` | +| `quizzesCompleted` | Default `56` | +| `worldProgressPercent` | Default `28` (drives the circular ring) | + +Persist on change. No backend — this teaches local gamification state. + +### Recently viewed store (add for this prompt) + +Create `store/use-recently-viewed-store.ts`: + +- Keep last **4–8** countries the user opened (name + viewed-at timestamp) +- Persist with AsyncStorage +- Seed with mock entries matching the design (`Peru` / `Just now`, `Italy` / `2h ago`, etc.) **only when the list is empty** so first launch matches the reference +- When the user opens a country from Home or Explore (future hook), call `recordView(country)` — for v1, recording on **Explore Peru** / trending card tap is enough + +## UI breakdown (match `home-screen-ui.png`) + +### Screen shell + +- **Background:** dark immersive (`bg-midnight-navy` or design-matched `#121212`-style surface) +- **ScrollView** (or `FlashList` with header sections) with bottom padding (`pb-28` or safe area) so content clears the custom tab bar from `(tabs)/_layout.tsx` +- **Do not rebuild** the bottom tab bar — it already lives in `components/bottom-tab-bar.tsx` + +### Header + +| Element | Spec | +| ------- | ---- | +| Logo | Centered **WorldLoop** wordmark — use `images.worldloopIcon` or a dedicated logo asset in `constants/images.ts` | +| Greeting | `Good morning, Alex 👋` — time-based greeting (`morning` / `afternoon` / `evening`); name is placeholder `Alex` until Clerk profile is wired | +| Subtitle | `Where will curiosity take you today?` — muted gray | +| Avatar | Circular profile image top-right — use a bundled placeholder in `constants/images.ts` for v1 | + +### Search bar + +- Full-width rounded dark field +- Left: magnifying glass + placeholder `Search countries, regions, cultures…` +- Right: gold **Discover** label with fire icon +- v1 tap: `console.log` or `Alert` — full search is a later prompt (`prompts-worldloop/06-search-and-explore.md`) + +### Country of the Day card + +Large rounded card with background image (`getCountryImages(country)[0]` or gradient fallback): + +| Area | Content | +| ---- | ------- | +| Tag | `✨ COUNTRY OF THE DAY` — gold, small caps | +| Title | `FlagBadge` + country **name** — large white bold | +| Body | Short line from `ai.fact` (truncate ~2 lines) or design fallback copy | +| Stats row | Population · Capital · Region with icons (reuse Explore iconography) | +| CTA | Gold pill **Explore {Country} →** — navigate to `/(tabs)/explore` and set feed `currentIndex` to that country if possible | +| Social pill | `❤️ 12.4K Love this place` — translucent; wired to saved toggle | + +### Trending Countries + +- Section title **Trending Countries** + gold **View All >** (v1: no-op or scroll to end) +- Horizontal `ScrollView` / `FlatList` with `horizontal` +- Cards: vertical image, flag + name, gold **🔥 Trending** sublabel +- Tap card → Explore tab focused on that country (or stub alert) + +### Stats row (two columns) + +**Left — Learning streak** + +- Title: `🔥 Your Learning Streak` (gold) +- `12 days` large white + `Keep it going!` muted +- Row of 7 day circles (M–S) from `weekProgress` in the discovery store + +**Right — Your Progress** + +- Circular progress ring with `{worldProgressPercent}%` center label +- Subtext: `of the world explored` +- Stacked mini stat cards on the far right: + - Globe + `{countriesExplored} Countries Explored` + - Quiz icon + `{quizzesCompleted} Quizzes Completed` + +Implement the ring with `react-native-svg` **only if already installed**; otherwise use a simple teachable workaround (conic gradient View, partial border, or static ring image) without adding new libraries unless the user approves. + +### Recently Viewed + +- Section title **Recently Viewed** + gold **See All >** +- Horizontal smaller cards: image, country name, relative time (`Just now`, `2h ago`, `Yesterday`) +- Use `use-recently-viewed-store` timestamps; format with a small helper in `lib/` if needed + +### Spacing & typography + +- **8pt grid** for padding, gaps, and touch targets (minimum 44×44) +- White primary text on dark surfaces; gold `text-tab-active` / `#fbbf24` for accents and CTAs +- Reuse utilities from `global.css` (`h2`, `h3`, `h4`, `body-md`, `caption`, etc.) where they match the design + +### Styling rules + +- Prefer **NativeWind** `className` for static layout and typography +- Use **StyleSheet** / inline styles for: circular progress, horizontal lists’ `contentContainerStyle`, shadows, `Pressable` pressed states, and `AGENTS.md` exceptions +- Do **not** put `className` on `SafeAreaView` from `react-native-safe-area-context` + +## Images + +- Country heroes/thumbnails: remote URLs via `expo-image` with `contentFit="cover"` +- Bundled assets (logo, avatar, placeholders): `constants/images.ts` only +- Do not `require()` images directly inside `index.tsx` unless there is a strong reason + +## Navigation (v1) + +| Action | Behavior | +| ------ | -------- | +| Explore Peru / trending tap | `router.push("/(tabs)/explore")` + `setCurrentIndex` for that country in `useCountryFeedStore` | +| View All / See All | No-op or `console.log` until list screens exist | +| Discover (search) | Stub | +| Profile avatar | Stub or `/(tabs)/profile` | + +## Out of scope + +- Full **Search** screen and backend search +- **Map** and **Saved** tab UIs (`prompt_material/map-screen-ui.png`, `saved-screen-ui.png`) +- Real like counts, social graph, or quiz backend +- Clerk auth / real user name and photo (use placeholders) +- TikTok-style Explore feed implementation (that is `prompts/10a-explore-ui.md`) +- Backend changes unless required to fix missing `images` / `ai` in feed + +## Acceptance criteria + +- Home tab shows the full dashboard layout from the design (not placeholder “Coming in a later lesson”) +- Feed loads and powers **Country of the Day** + **Trending** with real API data when the backend is up +- Streak and progress sections render from the local discovery store (persisted) +- Recently viewed section renders from its store (seeded on first launch, then real entries after taps) +- **Explore** CTA navigates to the Explore tab +- Save/Love pill toggles bookmark state via `useSavedCountriesStore` +- Loading and error states for feed data do not crash the screen +- `npm run lint` passes (run `npm run typecheck` if available) +- No new major dependencies without user approval + +## Testing + +```bash +# Terminal 1 — backend + Redis per prompts-worldloop +# Terminal 2 +npx expo start +``` + +1. Launch app — lands on **Home** with dashboard UI +2. Confirm Country of the Day shows feed data (name, flag, stats, image) +3. Scroll trending row — cards match design proportions +4. Tap **Explore Peru** (or featured CTA) — Explore tab opens +5. Tap **Love this place** — saved state toggles; verify on `/dev` or Saved count if surfaced +6. Kill backend and relaunch — Home shows retry or graceful empty states +7. Restart app — streak/progress values persist from AsyncStorage + +## Next steps + +After this prompt: + +1. Search UI + `GET /search` integration +2. Wire Clerk profile into greeting and avatar +3. Map and Saved screens per their design references +4. Record recently viewed automatically when swiping the Explore feed diff --git a/prompts/10c-search-ui.md b/prompts/10c-search-ui.md new file mode 100644 index 0000000..4e5b2bf --- /dev/null +++ b/prompts/10c-search-ui.md @@ -0,0 +1,393 @@ +Read AGENTS.md first and follow it strictly. + +Reference: `prompt_material/home-screen-ui.png` (search bar styling), `prompt_material/full-design-system.png`, `AGENTS.md` (app structure, data model), `prompts/08-zustand.md`, `prompts/09-bottom-tab-nav.md`, `prompts/10b-home-ui.md`, `prompts/10a-explore-ui.md`, `prompts-worldloop/06-search-and-explore.md` + +Implement **Search as an overlay** on top of the current tab (Home or Explore), wired to `GET /search`. The user should never feel like they left the app for a separate “search app” — Home or Explore stays visible underneath (dimmed), then the overlay dismisses and they land on the chosen country in Explore. + +Match the **dark immersive** visual language (midnight surfaces, gold accents, rounded cards, 8-point spacing). Reuse the Home search field and trending/recently-viewed row patterns inside the overlay panel. There is no separate search mockup. + +## UX mental model (read this first) + +Search is **not** a separate screen or product — it is a **temporary layer** and a **shortcut into the main country experience** (the TikTok-style Explore card). + +When a user opens search, they expect: + +> “I’m still in WorldLoop — just finding a country quickly.” + +When they tap a result, they expect: + +> “I chose Japan — the search layer goes away and I am **in** Japan now (hero, fact, carousel). I can keep swiping to discover more.” + +They do **not** expect: + +- A hard navigation jump to a new route that hides the tab bar and breaks context +- To land on Explore still showing whatever country was in the paginated feed before + +Therefore: + +- **Overlay, not stack push** — open/close search without leaving the current tab’s world +- **Text search** = find mode (partial name match, debounced) +- **Region chips** = browse mode (works with an empty text field) +- **Tap result** = dismiss overlay → Explore **focused on that country** (required) +- Do **not** replace the entire feed with search hits — inject/focus the one country and keep the rest of the feed for vertical swiping + +## Goal + +Let users find countries by **name** (partial match) and **region**, then open a result in the **correct** Explore feed position. + +- Tap Home `HomeSearchBar` or Explore top-bar search → **open search overlay** (keyboard focused); current tab remains mounted underneath +- Type to search (debounced API calls); region chips for browse-by-region +- Results list: flag, name, capital, region, thumbnail +- Tap result → Explore shows **that** country immediately (`focusCountryInFeed` + scroll sync) +- Swipe up from there continues the normal feed (discovery does not stop) +- Loading, empty, and error states that do not crash the screen + +## Prerequisites + +- `prompts/10b-home-ui.md` — `HomeSearchBar` exists on `app/(tabs)/index.tsx` +- `prompts/10a-explore-ui.md` — Explore feed + `ExploreTopBar` search stub +- `prompts/08-zustand.md` — `types/country.ts`, `constants/api.ts`, feed + saved stores +- `prompts-worldloop/06-search-and-explore.md` — backend `GET /search` deployed and reachable from the device/emulator (`EXPO_PUBLIC_API_URL`) + +## Dependencies + +Use what is already installed: + +- `expo-router`, `expo-image` +- `zustand`, `@react-native-async-storage/async-storage` (optional for recent searches only) +- `@expo/vector-icons` +- `react-native-safe-area-context` + +Do **not** add React Query, axios, Algolia, or new navigation libraries without user approval. + +## Backend contract (already implemented) + +``` +GET /search?query=®ion= +``` + +| Case | Behavior | +| ---- | -------- | +| `query=jap` | Partial, case-insensitive name match (e.g. Japan) | +| `region=Europe` | All countries in that region (case-insensitive) | +| Both set | Intersection of name + region filters | +| Neither set | `400` — `INVALID_SEARCH` | + +Response shape: + +```ts +type SearchResponse = { + data: Country[]; + meta: { + query: string | null; + region: string | null; + count: number; + }; +}; +``` + +`Country` matches `types/country.ts` (same as feed). Search returns **enriched** countries (images + `ai`) — pass the object through to the feed; no second fetch on tap. + +## Route & files + +| Path | Purpose | +| ---- | ------- | +| `components/search/search-overlay.tsx` | Full-screen overlay UI (scrim + search panel + results) | +| `store/use-search-ui-store.ts` | `isOpen`, `openSearch()`, `closeSearch()` — tiny Zustand store | +| `app/(tabs)/_layout.tsx` | Mount `` once so Home and Explore can open it | +| `lib/api.ts` | Add `fetchSearchCountries(query?, region?)` | +| `store/use-country-feed-store.ts` | Add `focusCountryInFeed(country)` (see below) | +| `lib/open-country-in-explore.ts` | `closeSearch()` → `focusCountryInFeed` → navigate to Explore | +| `components/explore/explore-feed.tsx` | Sync `FlatList` scroll when `currentIndex` changes programmatically | +| `components/search/` (optional) | Split overlay into `SearchInput`, `RegionChips`, `SearchResultRow`, `SearchEmptyState` | +| `components/home/home-search-bar.tsx` | Replace Alert stub → `openSearch()` | +| `components/explore/explore-top-bar.tsx` | Replace `console.log` → `openSearch()` | + +**Do not** add `app/search.tsx` as a stack route for v1 — that breaks tab context and feels like leaving the immersive app. + +Keep fetch/debounce logic in the overlay or a small hook under `hooks/`; do not put API calls inside presentational row components. + +### Overlay architecture (required) + +``` +(tabs)/_layout.tsx + ├── Tabs (Home | Explore | …) + └── SearchOverlay ← visible when useSearchUiStore.isOpen + ├── Pressable scrim (dimmed, tap to dismiss) + └── Panel (search input, chips, results FlatList) +``` + +Use React Native **`Modal`** with `transparent`, `animationType="fade"` (or slide from top), **or** an absolutely positioned full-screen `View` with high `zIndex` in `(tabs)/_layout.tsx`. `Modal` is fine per `AGENTS.md` style exceptions. + +Mounting in `(tabs)/_layout.tsx` keeps the overlay available on **Home and Explore** without registering a root stack screen. + +## Search → Explore (core UX — required) + +### `focusCountryInFeed` in `useCountryFeedStore` + +Add a single action used whenever opening a country from Search, Home trending, or Country of the Day: + +```ts +focusCountryInFeed: (country: Country) => void; +``` + +**Algorithm:** + +1. If `country.name` already exists in `countries` → `setCurrentIndex(existingIndex)`. +2. Else → **prepend** `country` to `countries`, then `setCurrentIndex(0)`. +3. Never duplicate by `name`. + +**Why prepend at index 0?** The user explicitly chose that country; they should see it first. Swipe up continues the rest of the feed. Do not replace the whole feed with search results. + +**Pagination:** Do not reset `nextCursor`. `loadMoreFeed` still appends at the end. + +**Dedupe on load more (recommended):** When appending feed pages, filter out countries whose `name` is already in `countries` (avoids Japan appearing twice if it was prepended from search and later appears in a feed page). + +**Empty feed:** If `countries.length === 0`, set `countries: [country]` and `currentIndex: 0`. Ensure `loadInitialFeed` on Explore does not wipe a non-empty list (skip initial load when countries already exist, or merge safely). + +### `openCountryInExplore` + +Update `lib/open-country-in-explore.ts`: + +```ts +useSearchUiStore.getState().closeSearch(); +focusCountryInFeed(country); +recordView(country); +router.push("/(tabs)/explore"); +``` + +Remove the old `findIndex`-only logic — focus must work when the country is **not** in the current feed batch. + +If the user was already on Explore when they searched, closing the overlay + `focusCountryInFeed` is enough; `router.push` to the same tab is harmless. + +### `FlatList` scroll sync in `ExploreFeed` + +`initialScrollIndex` only applies on first mount. Bottom tabs keep Explore mounted, so changing `currentIndex` in Zustand alone will **not** scroll the list. + +In `components/explore/explore-feed.tsx`: + +- Hold a `ref` on the vertical `FlatList` +- `useEffect` when `currentIndex` or `countries` change: call `scrollToIndex({ index: currentIndex, animated: false })` when the list is laid out +- Handle `onScrollToIndexFailed` (retry after layout / `requestAnimationFrame`) — common RN pattern + +Without this, search → Explore can show the wrong country on screen even when the store index is correct. + +## API helper (`lib/api.ts`) + +Add: + +```ts +export type SearchCountriesResponse = { + data: Country[]; + meta: { + query: string | null; + region: string | null; + count: number; + }; +}; + +export async function fetchSearchCountries( + query?: string, + region?: string, +): Promise { + const url = new URL(`${API_BASE_URL}/search`); + const q = query?.trim() ?? ""; + const r = region?.trim() ?? ""; + + if (!q && !r) { + throw new Error("At least one of query or region is required"); + } + + if (q) url.searchParams.set("query", q); + if (r) url.searchParams.set("region", r); + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Search request failed (${response.status})`); + } + return response.json() as Promise; +} +``` + +Do not call the API with both params empty — the backend returns `400`. + +## Search overlay UX + +### Shell (layered, not a new route) + +| Layer | Spec | +| ----- | ---- | +| **Scrim** | Full-screen `rgba(0,0,0,0.6)` (or similar) over the **current tab** — user still senses Home/Explore underneath | +| **Dismiss scrim** | Tap outside panel closes overlay (`closeSearch()`) | +| **Panel** | Occupies most of the screen (e.g. top ~92% or full height with safe area) — `bg-midnight-navy` / `#121212`, rounded top corners optional (`rounded-t-3xl`) for sheet-like feel | +| **Safe area** | Respect top inset for input; bottom padding so results clear home indicator | +| **Tab bar** | Stays mounted; may be covered by scrim — do not navigate away from `(tabs)` | + +Top row inside panel: + +- **Cancel** or ✕ on the left (not `router.back()` — there is no search route) +- Optional muted title **Search** centered or omit title for a cleaner look + +**Android hardware back:** closes overlay when open (`Modal onRequestClose` or `BackHandler`). + +### Open / close behavior + +| Action | Behavior | +| ------ | -------- | +| `openSearch()` | `isOpen: true`, focus `TextInput` after short delay (Modal mount) | +| Cancel / ✕ / scrim / back | `closeSearch()`, clear transient error if desired (optional: keep last query for next open) | +| Result tap | `closeSearch()` then `openCountryInExplore(country)` | + +### Search input + +Reuse the Home search field look: + +- Rounded `bg-white/8` container, magnifying glass, placeholder `Search countries, regions, cultures…` +- `TextInput` with `autoFocus`, `autoCorrect={false}`, `autoCapitalize="none"`, `returnKeyType="search"` +- Clear (×) button when text is non-empty +- **Debounce** input by ~300ms before calling the API (teachable `useEffect` + timer — no lodash) +- Results update as the user types (submit key flushes debounce immediately) + +### Region filter chips (horizontal scroll) + +Use REST Countries region names consistent with feed data: + +`Africa`, `Americas`, `Asia`, `Europe`, `Oceania` + +- Single-select: tapping an active chip clears the region filter; tapping inactive sets region and triggers search +- Chip style: pill, muted border when idle, gold border/text when selected (`text-tab-active`) +- Region-only search is valid — call API even when the text field is empty if a region is selected (browse mode) + +### When to search + +| User action | API call | +| ----------- | -------- | +| Debounced text ≥ 1 character | `fetchSearchCountries(query, selectedRegion)` | +| Region chip toggled | Same, using current text + region | +| Submit / search key | Immediate search (flush debounce) | +| Overlay opens, no text, no region | **Do not** call API — show idle state (see below) | + +### Idle state (no query, no region) + +Before the user types or picks a region: + +- Short helper copy: e.g. `Search by country name or pick a region` +- Show region chips (no spinner) +- Optional v1: **Recent searches** — last 5–8 strings in AsyncStorage; tap re-runs search + +### Results list + +- `FlatList` of rows (vertical), scannable at a glance +- Each row: + +| Element | Source | +| ------- | ------ | +| Thumbnail | `getCountryImages(country)[0]` or neutral placeholder | +| Flag + name | `FlagBadge` + bold white name | +| Subtitle | `{capital} · {region}` muted | +| Optional line | Truncated `getAiFact(country)` (~1 line) | + +- Sort: stable order from API (backend returns alphabetical); no client-side re-ranking required for v1 +- Tap row → `openCountryInExplore(country)` (must focus in feed — see above) + +### Empty & error states + +| State | UI | +| ----- | --- | +| Loading | Centered `ActivityIndicator` (gold) over list area | +| Zero results | `No countries found` + hint to try another spelling or region | +| Network / 400 / 500 | Inline banner + **Retry** (re-run last query) | +| Backend down | Same as error; input stays usable | + +### Styling rules + +- Prefer **NativeWind** `className` for layout and typography +- Use **StyleSheet** / inline for: `FlatList` `contentContainerStyle`, `TextInput`, shadows, `Pressable` pressed states, and `AGENTS.md` exceptions +- Reuse utilities from `global.css` (`body-md`, `caption`, etc.) +- **8pt grid** for padding and gaps; min touch target 44×44 + +## Navigation + +| Entry | Action | +| ----- | ------ | +| `HomeSearchBar` press | `openSearch()` — overlay on Home | +| `ExploreTopBar` search | `openSearch()` — overlay on Explore (feed still mounted under scrim) | +| Result row tap | `openCountryInExplore(country)` → overlay closes, Explore shows that country | +| Cancel / scrim / back | `closeSearch()` — return to same tab, same scroll position as before | + +## Data & stores + +- **`useSearchUiStore`:** only `isOpen` + open/close — keep visibility global, search field state local to the overlay +- **Search overlay:** local `useState` for `query`, `region`, `status`, `results`, `error` +- **Feed:** `focusCountryInFeed` in `useCountryFeedStore` — shared by Search, Home CTAs, trending cards +- **Recently viewed:** via `openCountryInExplore` → `useRecentlyViewedStore.recordView` +- Do not duplicate feed loading inside the overlay +- Do not maintain a second “selected country” outside the feed store — Explore `FlatList` reads `countries[currentIndex]` only + +Optional: persist recent search strings only (not full `Country` objects). + +## Images + +- Result thumbnails: remote URLs via `expo-image`, `contentFit="cover"` +- Bundled assets: `constants/images.ts` only if you add placeholders +- Use `FlagBadge` — do not render raw flag URLs as text + +## Out of scope + +- Population range filters (backend stretch goal) +- Search on Map or Saved tabs +- Voice search, fuzzy transliteration, or Elasticsearch +- A separate vertical “search results feed” (only hit countries) — breaks the one-feed mental model +- A root stack search route (`app/search.tsx`) — use overlay for v1 +- Clerk profile / personalized suggestions +- New backend endpoints + +## Acceptance criteria + +- `GET /search` works for name-only, region-only, and combined queries +- Home and Explore search entry points open the **overlay** (no route change until a result is chosen) +- Cancel/scrim dismisses overlay; user remains on the tab they started from +- From Explore, opening search does not reset feed position; closing without a selection leaves the same country visible +- Debounced typing does not spam the API +- Results render with flag, name, metadata, and thumbnail when available +- **Tap result → Explore shows that country** (hero + metadata), including when it was **not** in the loaded feed batch +- **Swipe up** from a search-opened country continues the normal feed (not a dead end) +- `focusCountryInFeed` does not create duplicate countries by `name` +- `FlatList` scroll position matches `currentIndex` after opening from search (tab already mounted) +- Recently viewed updates after opening a country +- Idle, loading, empty, and error states are handled +- `npm run lint` passes (run `npm run typecheck` if available) +- No new major dependencies without user approval + +## Testing + +```bash +# Terminal 1 — backend + Redis (see prompts-worldloop/02) +cd backend && npm run dev + +# Terminal 2 +npx expo start +``` + +Use `EXPO_PUBLIC_API_URL` pointing at your machine (LAN IP on a physical device). + +1. Home → tap search bar → overlay opens (Home dimmed underneath), keyboard visible +2. Type `jap` → Japan in results → tap Japan → **Explore shows Japan** (not another country) +3. **Critical:** Pick a country unlikely to be in the first feed page (e.g. search `japan` after feed loaded with other countries) → still shows Japan on screen +4. Swipe up from Japan → next feed country appears +5. Type `united` → multiple matches; tap one → correct country in Explore +6. Clear text, tap **Europe** → European list loads; tap a country → Explore shows it +7. Explore → search icon → overlay opens over current country → Cancel → same country still visible, overlay gone +8. Home → open overlay → tap scrim → overlay closes, still on Home +9. Home **Recently Viewed** updates after opening a country from search +10. Stop backend → error + retry works +11. Repeat identical query → faster response (optional: backend Redis cache hit in logs) + +## Next steps + +After this prompt: + +1. Wire Clerk name/avatar into Home header (`10b` follow-up) +2. Map screen search/filter (`prompt_material/map-screen-ui.png`) +3. Saved tab UI +4. Optional: swipe down from search-inserted country returns to previous Explore position (polish, not required for v1) diff --git a/prompts/11-explore-header-upgrade.md b/prompts/11-explore-header-upgrade.md new file mode 100644 index 0000000..60f9938 --- /dev/null +++ b/prompts/11-explore-header-upgrade.md @@ -0,0 +1,187 @@ +Read AGENTS.md first and follow it strictly. + +Reference: `prompts/10a-explore-ui.md`, `prompts/10c-search-ui.md`, `AGENTS.md` (Explore Screen Rules), `prompt_material/explore-screen-ui.png`, `prompt_material/full-design-system.png` + +Upgrade the **Explore top bar** so users can browse by continent without opening search, while keeping search one tap away on the right. + +## Goal + +Replace the old Explore header layout (search left, save right) with a **minimal text header**: + +| Position | Element | +| ---------- | ----------------------------------------------------------------------- | +| **Center** | Horizontally scrollable tabs: **For You** + continent labels (no pills) | +| **Right** | Search icon only — no glass background | + +**Do not** put Save in the header; bookmark remains on the **right action rail** (`ExploreActionRail`). + +**Tab order:** `For You | Africa | Americas | Asia | Europe | Oceania` + +**Tab behavior:** + +- **For You** (default): mixed paginated feed (`GET /feed/countries`); `selectedRegion === null`; underline on **For You** on launch. +- Tap a **continent** → feed shows only countries in that continent (REST Countries `region`: Africa, Americas, Asia, Europe, Oceania). +- Tap **For You** → clear continent filter and restore the default feed (`setRegionFilter(null)` → `loadInitialFeed({ force: true })`). +- Re-tapping the active continent does **nothing** (no toggle-off); use **For You** to return to the mixed feed. +- **Selected** tab: full **white** text (`#ffffff`) with a **narrow white underline** that **slides** from the previous tab (`react-native-reanimated` spring). +- When a continent filter is active, **non-selected** tabs (including **For You**) are **dimmed** (`rgba(255,255,255,0.35)`). +- On **For You**, continent labels use muted white (`rgba(255,255,255,0.7)`). +- **No layout shift** on selection: labels use `paddingBottom: 0`; one shared underline bar moves horizontally (not per-label underlines). + +Vertical swiping still works inside the filtered list. Search overlay region chips stay independent but use `CONTINENTS` only from `constants/regions.ts` (no **For You** in search). + +## Prerequisites + +- `prompts/10a-explore-ui.md` — `ExploreFeed`, `ExploreTopBar`, `CountryFeedPage` +- `prompts/10c-search-ui.md` — `SearchOverlay`, `useSearchUiStore`, `GET /search` +- `prompts/08-zustand.md` — `useCountryFeedStore`, `lib/api.ts` +- Backend `GET /search?region=` deployed and reachable + +## Dependencies + +Use what is already installed: + +- `expo-router`, `zustand`, `@expo/vector-icons` +- `react-native-safe-area-context` +- `react-native-reanimated` (sliding underline) + +Do **not** add new libraries without user approval. + +## Data model note + +REST Countries exposes continents as **`region`** on each country (`Africa`, `Americas`, `Asia`, `Europe`, `Oceania`). The UI label is “continent”; the API/store field remains `region` for consistency with search and the backend. + +**For You** is a UI-only tab label (`FOR_YOU_TAB` in `constants/regions.ts`). It is **not** sent to the API. Store uses `selectedRegion: null` for the mixed feed. + +## Files + +| Path | Purpose | +| ------------------------------------------ | ----------------------------------------------------------------- | +| `constants/regions.ts` | `FOR_YOU_TAB`, `CONTINENTS`, `EXPLORE_HEADER_TABS`, `isContinent` | +| `components/explore/explore-top-bar.tsx` | Tab scroll + plain search icon (single aligned row) | +| `components/explore/glass-icon-button.tsx` | `variant="glass"` (default) for action rail; optional `plain` | +| `store/use-country-feed-store.ts` | `selectedRegion`, `setRegionFilter`, `loadInitialFeed({ force })` | +| `components/search/search-overlay.tsx` | Import `CONTINENTS` only (continents, not For You) | + +## Store behavior (`useCountryFeedStore`) + +### State + +```ts +selectedRegion: string | null; // null = For You (mixed feed) +``` + +### `setRegionFilter(region: string | null)` + +| `region` | Behavior | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `string` (e.g. `"Europe"`) | `GET /search?region=Europe` via `fetchSearchCountries(undefined, region)` → replace `countries`, set `nextCursor: null`, `currentIndex: 0`, `selectedRegion: region` | +| `null` | `resetFeed()` then `loadInitialFeed(undefined, { force: true })` → default shuffled paginated feed (For You) | + +Only pass real continent names from `CONTINENTS` — never `"For You"`. + +### `loadMoreFeed` + +No-op while `selectedRegion !== null` (region browse returns the full set; no cursor). + +### `loadInitialFeed(limit?, options?: { force?: boolean })` + +When `force: true`, reload even if `countries.length > 0` (used when switching back to **For You**). + +## UI spec (`ExploreTopBar`) + +### Layout + +- Single row inside safe area (`paddingTop: insets.top + 8`). +- `flex-row` + `items-center` so tabs and the search icon share the **same vertical centerline**. +- **Tabs**: `ScrollView` horizontal, `flex: 1`, `marginRight: 12`, `showsHorizontalScrollIndicator={false}`. +- **Search**: `Pressable` 44×44, icon only — **no** circle background, border, or scrim. + +### Tab labels (not chips) + +- **No** pill background, **no** border on items. +- Font: `15px`; unselected tabs use `Poppins-Medium`, **selected** tab uses `Poppins-SemiBold`. +- Spacing between labels: `marginLeft: 16` on tab wrappers after the first (**For You** has no left margin). +- Touch target: `minHeight: 44`, `hitSlop: 8`. +- **All labels**: `paddingBottom: 0` (fixed; text never jumps; underline sits flush under the label). +- **Track**: `paddingBottom: 2` on `continentsTrack` (fits the 2px underline; touch target via `minHeight: 44`). +- **Selected**: text `#ffffff`, `Poppins-SemiBold`. +- **Unselected with continent filter active**: text `rgba(255, 255, 255, 0.35)`. +- **For You selected** (`selectedRegion === null`): **For You** white; continents `rgba(255, 255, 255, 0.7)`. + +### Sliding underline (single indicator) + +- One `Animated.View` inside the tab **track** (`continentsTrack` has `position: "relative"`, `collapsable={false}`). +- Underline: `position: "absolute"`, `bottom: 0`, animated **`left`** + `width` (do **not** use `translateX` from `0` — that makes the bar appear to fly in from the screen edge). +- **Selected tab key**: `selectedRegion ?? FOR_YOU_TAB` — underline always tracks the active tab (including **For You** on launch). +- **Measure with `measureLayout`**, not child `onLayout` `x` alone: + - `trackRef` on the row container. + - Each tab wrapped in a `View` with a ref (`tabRefs[name]`). + - On selection (and when the selected tab re-layouts), call `tab.measureLayout(track, (x, _y, width) => …)` so `x` is relative to the track. + - Retry once on the next frame if layout is not ready. +- Tab spacing: `marginLeft: 16` on wrappers (avoid `gap` for measurement quirks). +- Underline width: `min(tabWidth * 0.55, 40)` — **narrower than the label**, centered: `left = x + (tabWidth - narrowWidth) / 2`. +- **First show**: set `left` / `width` immediately, then fade opacity in. +- **Switch tab**: spring `left` + `width` with `withSpring({ damping: 20, stiffness: 280 })`. +- Color: `#ffffff`, height `2px`, `borderRadius: 1`. + +### Search control + +- `Ionicons` `search`, size `24`, white — not `GlassIconButton` with glass styling (use a plain `Pressable` or `GlassIconButton` with `variant="plain"` elsewhere if reused). + +### Accessibility + +- Search: `Search countries` +- **For You**: `Show your personalized country feed` +- Each continent: `Show countries in {continent}`, `accessibilityState.selected` when active + +## User flows + +### Default For You feed + +1. Open Explore tab → mixed feed loads; **For You** selected with underline. +2. Swipe vertically through countries; `loadMoreFeed` paginates. + +### Browse Europe in Explore + +1. Tap **Europe** → feed reloads to European countries only; underline slides to **Europe**; **For You** and other continents dimmed. +2. Tap **Asia** → underline animates from Europe to Asia without shifting label text. +3. Swipe vertically through filtered countries. +4. Tap **For You** → filter clears; default feed reloads; underline slides back to **For You**. + +### Search still available + +1. Tap search (right) → overlay opens (unchanged from `10c-search-ui.md`). +2. Pick a result → `focusCountryInExplore` (unchanged). + +### Save still available + +- Use the **action rail** Save control on the current country card — not the header. + +## Error & empty states + +- If `setRegionFilter` fails → `status: "error"`, `error` message; existing `ExploreError` on explore screen when `countries.length === 0`. +- If search returns zero countries for a region → consider a friendly empty message in a follow-up (optional v1.1). + +## Testing checklist + +- [ ] Explore opens with **For You** selected and underline visible +- [ ] Each continent label filters feed to matching `country.region` +- [ ] Tap **For You** restores default mixed feed +- [ ] Re-tapping active continent does not clear filter +- [ ] Selected tab shows white text; underline is narrow and centered under label +- [ ] Underline slides tab-to-tab (not from screen left); position comes from `measureLayout` relative to track +- [ ] Non-selected tabs dim when a continent filter is active +- [ ] No background on tab labels or search icon +- [ ] Tabs and search icon vertically aligned on one row +- [ ] Header has no Save icon; rail Save still works +- [ ] Search (right) opens overlay; does not break continent filter state +- [ ] `loadMoreFeed` does not fire when a continent is selected +- [ ] `npm run lint` passes + +## Out of scope (later prompts) + +- Persist last-selected tab across app restarts +- Backend `GET /feed/countries?region=` (v1 uses search endpoint for region browse) +- Auto-scroll horizontal list to center selected tab +- True personalization for **For You** (v1 is the existing shuffled paginated feed) diff --git a/prompts/11-lesson-ui.md b/prompts/11-lesson-ui.md deleted file mode 100644 index 37b4040..0000000 --- a/prompts/11-lesson-ui.md +++ /dev/null @@ -1,14 +0,0 @@ -Read AGENTS.md first and follow it strictly. - -Implement the Lessons screen exactly as shown in the attached design. Use the selected language from Zustand + AsyncStorage and display the matching units/lessons from the hardcoded learning data. - -Allow users to select/open any lesson (no locking logic for now). If a selected language has fewer than 2 lessons in the dataset, extend the data by adding at least 5 more lessons in the same structure and style. - -Use assets from the assets folder and for each lesson selection use its respective image. If any image is missing, use a suitable placeholder from Unsplash or Picsum. - -Each lesson card should still visually indicate status (completed, in progress, etc.) based on local state or mock data. - -@prompt_material/lesson/lesson-screen-ui.png -@prompt_material/lesson/mascot-lesson-banner.png -@prompt_material/lesson/mascot-lesson-full-banner.png -@prompt_material/lesson/mascot-lesson-table.png diff --git a/prompts/12-audio-lesson-ui.md b/prompts/12-audio-lesson-ui.md deleted file mode 100644 index 76de1e9..0000000 --- a/prompts/12-audio-lesson-ui.md +++ /dev/null @@ -1,9 +0,0 @@ -Read AGENTS.md first and follow it strictly. - -Implement the AI Teacher audio lesson screen exactly as shown in the attached design. When the user taps any lesson from the Learn/Lessons screen, open this screen with the selected lesson id and display the selected lesson’s language, title, goal, phrases, and AI teacher context from the hardcoded learning data. - -This should be an audio-only experience. Do not implement video calling. Keep the camera area as a visual teacher preview/placeholder only if needed, and focus on audio lesson controls such as mic, subtitles, end call, lesson feedback, teacher response bubble, and session status. - -Use assets from the assets folder via the centralized images import and keep everything consistent with the existing design system and bottom tab navigation. - -@prompt_material/07-audio-lesson-screen.png \ No newline at end of file diff --git a/prompts/12-map-ui.md b/prompts/12-map-ui.md new file mode 100644 index 0000000..f237117 --- /dev/null +++ b/prompts/12-map-ui.md @@ -0,0 +1,284 @@ +Read AGENTS.md first and follow it strictly. + +Reference: `prompt_material/map-screen-ui.png`, `AGENTS.md` (Map Screen Rules), `prompts/08-zustand.md`, `prompts/09-bottom-tab-nav.md`, `prompts/10a-explore-ui.md`, `prompts/10c-search-ui.md`, `prompts-worldloop/07-map-endpoint.md` + +Implement the **Map** tab UI on `app/(tabs)/map.tsx` exactly as shown in the attached design. Use precise **8-point spacing** throughout (8, 16, 24, 32, …). Match layout, typography, colors, radii, map chrome, and the bottom country preview card pixel-for-pixel — do not simplify the design. + +Use assets from `assets/` via the centralized `constants/images.ts` import (`import { images } from "@/constants/images"`). Add any new image assets there before using them in screens or components. + +@prompt_material/map-screen-ui.png + +## Goal + +Replace the Map placeholder with an **interactive world map** discovery screen: + +- Full-screen dark map with gold-accent markers and selected-country highlight +- Header: globe affordance, WorldLoop logo, profile avatar +- Title block: **World Map** + subtitle (“Explore the world, one loop at a time.”) +- Search bar + filter icon (reuse existing search overlay pattern) +- Horizontal filter chips (All, Population, Culture, Nature, History) +- Tap a country marker → bottom **country preview card** (flag, stats, AI fun fact, Save, **Explore Country**) +- Map controls: location/compass, zoom +/-, legend affordance +- **Random Country** FAB (dice icon) — picks a random marker and selects it +- Wire to `GET /map/countries` for markers; load full `Country` (images + AI) on selection when needed + +## Prerequisites + +- `prompts/09-bottom-tab-nav.md` — `(tabs)` routes and custom `components/bottom-tab-bar.tsx`; center **Map** globe tab works +- `prompts/08-zustand.md` — `types/country.ts`, `constants/api.ts`, `lib/api.ts`, saved countries store +- `prompts/10a-explore-ui.md` — Explore feed exists so **Explore Country** has a real destination +- `prompts/10c-search-ui.md` — `SearchOverlay`, `useSearchUiStore`, `openCountryInExplore` (reuse for search + CTA) +- `prompts-worldloop/07-map-endpoint.md` — backend `GET /map/countries` running; `lib/api.ts` exports `fetchMapCountries` +- `prompts/01-nativewind.md` and `prompts/02-design-theme.md` — theme tokens in `global.css`, Poppins fonts loaded + +## Dependencies + +### Already installed (use these) + +- `expo-router`, `expo-image` +- `zustand`, `@react-native-async-storage/async-storage` +- `@expo/vector-icons` +- `react-native-safe-area-context`, `react-native-gesture-handler`, `react-native-reanimated` + +### Map library (required — ask user first) + +The design requires a **real interactive map** (pan, zoom, markers). This is not in `package.json` yet. + +**Ask the user for approval**, then install: + +```bash +npx expo install react-native-maps +``` + +Use a **dark custom map style** (land muted, water darker) to match `map-screen-ui.png`. Follow Expo’s dev-client / simulator notes if the map does not render on web. + +Do **not** add Mapbox, Google Maps SDK keys in the client, React Query, axios, or clustering libraries without user approval. + +## Route & files + +| Path | Purpose | +| ---- | ------- | +| `app/(tabs)/map.tsx` | Map screen — composes map, header, chips, preview card, FAB | +| `store/use-map-store.ts` | Map countries list, load status, `selectedCountry`, selection helpers | +| `lib/api.ts` | Keep `fetchMapCountries`; add `fetchCountryByName(name)` → `Country` for bottom-sheet AI | +| `components/map/` (optional) | Extract when it keeps `map.tsx` readable: e.g. `MapHeader`, `MapFilterChips`, `MapCountryMarker`, `MapCountryPreviewCard`, `MapControls`, `RandomCountryFab` | + +Keep business logic in the store and `lib/api.ts`; the screen orchestrates layout and gestures. + +## Data wiring + +### Map list (`GET /map/countries`) + +On mount, call `fetchMapCountries()` from `lib/api.ts`. + +Response shape (already typed as `MapCountry` in `types/country.ts`): + +```ts +type MapCountry = { + name: string; + capital: string; + region: string; + population: number; + flag: string; + latlng: [number, number]; + image: string | null; +}; +``` + +Store in `useMapStore`: + +- `countries: MapCountry[]` +- `status: "idle" | "loading" | "error"` +- `error: string | null` +- `selectedCountry: MapCountry | null` +- `loadMapCountries()` — fetches once (or refetch on retry) +- `selectCountry(name: string | null)` — sets selection; animates map region optional +- `selectRandomCountry()` — random item from `countries` + +Show a subtle loading state over the map while `status === "loading"`. On error, show retry UI with `error` message. + +### Full country detail (`GET /country/:name`) + +The map list is **lightweight** (no `ai` in the list). When the user selects a country for the bottom card: + +1. Show immediately from `MapCountry`: flag, name, capital, region, population, `image` thumbnail +2. Fetch full `Country` in the background via `fetchCountryByName(name)` for `ai.fact` (and richer `images` if needed) +3. While detail loads, show placeholder: “Fun fact loading…” +4. On failure, keep stats visible; show a short inline error for the fact only + +Add to `lib/api.ts`: + +```ts +export async function fetchCountryByName(name: string): Promise { + const encoded = encodeURIComponent(name.trim()); + const response = await fetch(`${API_BASE_URL}/country/${encoded}`); + if (!response.ok) { + throw new Error(`Country request failed (${response.status})`); + } + return response.json() as Promise; +} +``` + +Never call OpenAI from the app — AI text comes from the backend only. + +### Saved countries (`useSavedCountriesStore`) + +- Preview card bookmark icon → `toggleSaved(country)` + Map selection may only have `MapCountry`; when saving, pass a `Country`-shaped object (minimal fields: `name`, `capital`, `region`, `population`, `flag`, `latlng`, optional `images` from detail fetch) or fetch detail first — match how Home/Explore save works +- Reflect `isSaved(country.name)` (outline vs filled bookmark, gold when saved) + +### Explore Country CTA + +Reuse `openCountryInExplore` from `lib/open-country-in-explore.ts`: + +- Requires a full `Country` — use the fetched detail object when available; otherwise build a minimal `Country` from `MapCountry` + `image` as `images: [image]` so Explore can open immediately +- Closes search if open, focuses country in feed, records recently viewed, navigates to `/(tabs)/explore` + +### Search on Map + +- Tapping the search bar opens the same **search overlay** as Home/Explore (`useSearchUiStore` + `SearchOverlay`) +- Do not duplicate search UI inside `map.tsx` +- Filter icon: v1 → `Alert` or no-op; advanced map filters are out of scope + +## UI breakdown (match `map-screen-ui.png`) + +### Layout + +- **Full-screen** dark immersive screen; map fills the area behind header and bottom card +- Respect safe area for header; bottom preview card sits **above** the custom tab bar (`pb-28` or safe-area inset) +- Tab bar comes from `(tabs)/_layout.tsx` — do not rebuild + +### Header + +| Position | Element | +| -------- | ------- | +| Left | Globe icon (decorative or opens map region reset) | +| Center | WorldLoop wordmark — `images.worldloopIcon` from `constants/images.ts` | +| Right | Profile avatar — `images.profileAvatar`; tap → `router.push("/(tabs)/profile")` (same as Home) | + +Below header: + +- **World Map** — large white bold title (`h2` or design-matched size) +- Subtitle — light gray: “Explore the world, one loop at a time.” + +### Search & filters + +- Rounded dark search field: magnifying glass + placeholder “Search countries, regions, cultures…” +- Square filter button to the right (sliders icon) +- Horizontal **scrollable chips**: All (selected = gold border + globe), Population, Culture, Nature, History — each with a small icon + +**v1 chip behavior:** + +| Chip | Behavior | +| ---- | -------- | +| All | Show all map markers | +| Population / Culture / Nature / History | Visual selection only **or** simple client filter (e.g. Population = top 20% by `population`) — document choice in PR; backend has no map filter params | + +### Interactive map + +- `react-native-maps` `MapView` with `customMapStyle` for dark/gold aesthetic +- One `Marker` per `MapCountry` with valid `latlng` +- **Selected marker:** gold ring / highlighted callout (match Peru treatment in design) +- **Trending badge** on some markers (flame + “Trending”): v1 use a small hardcoded name list in `constants/trending-countries.ts` (e.g. Japan, Brazil, Iceland, Canada, South Africa, Peru) — no backend field required +- Optional: show country name + flag in a compact callout when zoomed in; hide when crowded + +### Map controls (overlay on map) + +| Control | v1 behavior | +| ------- | ----------- | +| Compass / location | Re-center map on user location **or** reset to world view (no GPS permission required for v1 — default world view is fine) | +| Zoom +/- | `animateToRegion` with adjusted latitudeDelta | +| Legend | Bottom sheet stub or `Alert` (“Gold markers = countries”) | + +Use **StyleSheet** / inline styles for `MapView`, `Marker`, and animated region — per `AGENTS.md` map exception. + +### Country preview card (bottom sheet style) + +Shown when `selectedCountry` is set; rounded top corners, dark card over map: + +| Area | Content | +| ---- | ------- | +| Left | Square thumbnail — `image` via `expo-image`, `contentFit="cover"`; flag fallback if null | +| Header row | Flag + **country name** + Trending pill if in trending list | +| Top right | Save / bookmark (wired to `useSavedCountriesStore`) | +| Stats row | Population (compact, e.g. `33.7M`), Capital, Region — three columns with icons | +| AI block | Sparkle + “AI Fun Fact” label + `countryDetail.ai?.fact` or loading placeholder | +| CTA | Full-width gold **Explore Country** button with arrow → `openCountryInExplore` | + +Dismiss selection: tap map away from markers or swipe card down (optional stretch). + +### Random Country FAB + +- Circular gold button, bottom-right above tab bar, dice icon +- Label below: “Random Country” +- `selectRandomCountry()` then animate map to that `latlng` + +### Spacing & typography + +- **8pt grid** for padding, gaps, and 44×44 touch targets +- White primary text; gold `#fbbf24` / `text-tab-active` for accents, selected chip, CTA, FAB +- Reuse utilities from `global.css` where they match the design + +### Styling rules + +- Prefer **NativeWind** `className` for header, chips, preview card, FAB +- Use **StyleSheet** / inline for: `MapView`, `Marker`, `MapView` region animations, shadows, `Pressable` pressed states, and `AGENTS.md` exceptions +- Do **not** put `className` on `SafeAreaView` from `react-native-safe-area-context` + +## Images + +- Marker thumbnails and preview card: `MapCountry.image` or full `Country.images[0]` after detail fetch +- Bundled assets (logo, avatar) via `constants/images.ts` only +- Flags: use `country.flag` URL from API (`flagcdn`); helper `lib/flag-url.ts` if normalizing + +## Out of scope + +- Server-side marker clustering (`prompts-worldloop/07-map-endpoint.md` optional stretch) +- Real-time trending scores from backend +- Clerk auth changes +- **Saved** tab grid UI (`saved-screen-ui.png`) +- Replacing Explore or Home screens +- Map search filters backed by new API endpoints +- 3D globe / WebGL map engines +- Persisting last map camera position (optional stretch) + +## Acceptance criteria + +- Map tab shows the full design layout (not placeholder copy) when `GET /map/countries` succeeds +- All countries with valid coordinates render as tappable markers +- Tapping a marker opens the bottom preview card with correct stats +- AI fun fact loads from `GET /country/:name` after selection (with loading + error handling) +- Save toggles bookmark state via `useSavedCountriesStore` +- **Explore Country** opens Explore focused on that country (`openCountryInExplore`) +- Search bar opens the shared search overlay; choosing a result can land on Explore (existing behavior) +- Random Country FAB selects and focuses a random marker +- Loading and error states do not crash the screen +- `npm run lint` passes (run `npm run typecheck` if available) +- `react-native-maps` only added after user approval + +## Testing + +```bash +# Terminal 1 — backend + Redis per prompts-worldloop +# Terminal 2 +npx expo start +``` + +1. Open **Map** tab (center globe) — markers load after API responds +2. Pan and zoom the map — controls work; dark style visible +3. Tap **Japan** (or any marker) — preview card shows; AI fact appears after detail fetch +4. Tap **Save** — country appears in saved store; icon updates +5. Tap **Explore Country** — app switches to Explore with that country focused +6. Tap search — overlay opens; pick a country — lands in Explore (existing search flow) +7. Tap **Random Country** — map animates to a new selection +8. Stop backend — error/retry UI on map load; recovery when backend returns + +## Next steps + +After this prompt: + +1. `prompts/13-map-v2-3d-globe.md` — upgrade map canvas to interactive 3D globe +2. Saved countries screen (`prompt_material/saved-screen-ui.png`) +3. Country detail route (if separate from Explore) +4. Map chip filters backed by search/stats APIs (optional) +5. Persist map camera + last selection in AsyncStorage (optional) diff --git a/prompts/13-map-v2-3d-globe.md b/prompts/13-map-v2-3d-globe.md new file mode 100644 index 0000000..f868bee --- /dev/null +++ b/prompts/13-map-v2-3d-globe.md @@ -0,0 +1,257 @@ +Read AGENTS.md first and follow it strictly. + +Reference: `prompt_material/map-screen-ui.png` (chrome + bottom card — reuse from v1), `prompts/12-map-ui.md`, `AGENTS.md` (Map Screen Rules), `prompts-worldloop/07-map-endpoint.md` + +Implement **Map v2**: replace the **2D map canvas** from `prompts/12-map-ui.md` with an **interactive 3D globe** while keeping the same header, search, filter chips, country preview card, FAB, and data wiring. Visual language stays **dark + gold** (WorldLoop brand). + +This is an **upgrade prompt**, not a greenfield Map screen. Do not rebuild Explore, Home, or backend endpoints. + +## Goal + +Give the Map tab a **rotatable 3D world globe** with country discovery on the sphere: + +- Drag to spin the globe; pinch (or double-tap controls) to zoom +- Country pins at `MapCountry.latlng` on the sphere surface +- Tap a pin → same bottom **country preview card** as v1 (stats, AI fact, Save, Explore Country) +- **Selected country** — gold highlight / pulse on the pin (match Peru emphasis from the 2D design, adapted to 3D) +- **Random Country** FAB — camera animates to face a random pin +- Optional: header **globe icon** toggles **2D ↔ 3D** if `react-native-maps` from v1 is kept (recommended for teaching and fallback) + +Reuse `useMapStore`, `fetchMapCountries`, `fetchCountryByName`, `openCountryInExplore`, `SearchOverlay`, and `useSavedCountriesStore` — no duplicate state. + +## Prerequisites (all required) + +- `prompts/12-map-ui.md` **completed** — Map tab ships with header, chips, preview card, FAB, `use-map-store.ts`, `fetchCountryByName`, `react-native-maps` 2D canvas +- `prompts/10c-search-ui.md` — search overlay + `openCountryInExplore` +- `prompts-worldloop/07-map-endpoint.md` — `GET /map/countries` stable +- Backend + Redis running locally for map + country detail calls + +## UX mental model + +v1 Map = **flat atlas** (pan north/south, markers on a plane). + +v2 Map = **planet in your hands** (spin the world, tap a glowing pin, same bottom card answers “what is this country?”). + +Users should not lose any v1 actions: search, save, explore, random country, trending badge on select pins. + +## Dependencies + +### Already in the project (reuse) + +- `expo-router`, `expo-image`, `zustand`, `@expo/vector-icons` +- `react-native-gesture-handler`, `react-native-reanimated`, `react-native-safe-area-context` +- `react-native-maps` (from v1 — keep for 2D mode or remove only if you drop the toggle) + +### 3D stack (required — ask user first) + +**Ask the user for approval** before installing. Prefer the **teachable** stack (Three.js concepts map cleanly to coursework): + +```bash +npx expo install expo-gl three expo-three +npx expo install @react-three/fiber @react-three/drei +``` + +| Package | Role | +| -------------------- | ----------------------------------------- | +| `expo-gl` | Native `GLView` / WebGL context | +| `three` | 3D math, sphere, materials, camera | +| `expo-three` | Bridges `GLView` → `THREE.WebGLRenderer` | +| `@react-three/fiber` | Declarative React renderer for Three.js | +| `@react-three/drei` | Helpers (`OrbitControls`, `Sphere`, etc.) | + +**Alternative (only with explicit user approval):** [`@aeryflux/globe`](https://github.com/aeryflux/globe) — prebuilt globe meshes + RN helpers; faster visually, less transparent for teaching. Document why you chose it if used. + +Do **not** add Mapbox GL, Cesium, Unity, or native AR modules without approval. + +### Platform notes + +- Test on a **physical device** when possible — iOS Simulator / Android emulator WebGL can be flaky with Three.js + EXGL +- **Web** is optional for this prompt; if the globe does not run on web, show a friendly fallback message on web only (do not block native) +- Use **development builds** if Expo Go limits GL features in your SDK version + +## Route & files + +| Path | Purpose | +| -------------------------------------- | ------------------------------------------------------------------------------ | +| `components/map/globe-view.tsx` | 3D globe canvas (`Canvas` + sphere + pins); owns GL lifecycle | +| `components/map/globe-country-pin.tsx` | Single country marker (mesh/sprite + press target) | +| `components/map/map-canvas.tsx` | Thin wrapper: renders `GlobeView` or v1 `MapView` based on `mapMode` | +| `lib/latlng-to-sphere.ts` | `latLngToVector3(lat, lng, radius)` + inverse helpers (unit tests optional) | +| `store/use-map-store.ts` | Extend with `mapMode: "2d" \| "3d"`, `setMapMode`, `focusCountryOnGlobe(name)` | +| `app/(tabs)/map.tsx` | Swap 2D map section for `MapCanvas`; wire globe icon → toggle mode | + +Keep existing v1 components (`MapHeader`, `MapFilterChips`, `MapCountryPreviewCard`, `RandomCountryFab`, etc.) — only replace the map **viewport**. + +## Coordinate math + +Backend sends WGS84-style `[lat, lng]` on `MapCountry.latlng`. + +Convert to a point on a unit sphere (radius `R`, e.g. `1`): + +```ts +// lib/latlng-to-sphere.ts — illustrative; implement and export +export function latLngToVector3( + lat: number, + lng: number, + radius: number, +): [number, number, number] { + const phi = (90 - lat) * (Math.PI / 180); + const theta = (lng + 180) * (Math.PI / 180); + const x = -(radius * Math.sin(phi) * Math.cos(theta)); + const z = radius * Math.sin(phi) * Math.sin(theta); + const y = radius * Math.cos(phi); + return [x, y, z]; +} +``` + +- Place each pin slightly **above** the surface (`radius * 1.02`) so markers don’t z-fight with the globe mesh +- `focusCountryOnGlobe` — animate camera / controls target so the selected pin faces the user (use `OrbitControls` `target` + optional `camera.lookAt`) + +## Data wiring (unchanged from v1) + +### Map list + +- `loadMapCountries()` → `fetchMapCountries()` → `countries: MapCountry[]` +- Skip entries with invalid `latlng` + +### Selection + detail + +- `selectCountry(name)` — sets `selectedCountry`, triggers `fetchCountryByName` for preview card AI block (same as v1) +- Preview card UI — **copy from v1**; do not redesign unless fixing a bug + +### Integrations + +| Action | Implementation | +| --------------- | --------------------------------------------------- | +| Save | `useSavedCountriesStore.toggleSaved` | +| Explore Country | `openCountryInExplore(detailOrMinimalCountry)` | +| Search bar | `useSearchUiStore` + `SearchOverlay` | +| Random Country | `selectRandomCountry()` + `focusCountryOnGlobe` | +| Trending badge | `constants/trending-countries.ts` (same list as v1) | + +No new backend routes for v2. + +## 3D globe implementation + +### Globe mesh + +- Base: `Sphere` (or `mesh` + `SphereGeometry`) with **dark** material — deep gray `#1a1a2e` / near-black, subtle emissive gold rim optional +- Optional: very low-poly wireframe overlay for “tech” look (keep performant) +- **Do not** ship 20MB GLB country meshes in v2 — use simple pins on a smooth sphere (teachable + fast) + +### Country pins + +- One pin per `MapCountry` (or capped subset — see performance) +- Default: small gold sphere or billboard sprite +- **Selected:** scale up + brighter emissive gold + optional ring +- **Trending:** small flame badge as 2D overlay in preview card only, or a second material on pin — avoid DOM inside `Canvas` + +### Interaction + +- `OrbitControls` from `@react-three/drei` — rotate globe, damped inertia +- Pin `onPress` → `selectCountry(name)` (use R3F pointer events / `Pressable` wrapper pattern supported on native) +- Tap empty space → optional `selectCountry(null)` to dismiss card + +### 2D ↔ 3D toggle (recommended) + +- `useMapStore.mapMode`: `"2d" | "3d"` (default `"3d"` after v2 ships, or `"2d"` until user flips — document choice) +- Header left **globe icon** toggles mode +- `components/map/map-canvas.tsx` renders: + - `"2d"` → existing `react-native-maps` `MapView` from v1 + - `"3d"` → `GlobeView` +- Persist `mapMode` in AsyncStorage (optional stretch) + +### Map controls (adapt v1 overlays) + +| Control | 3D behavior | +| --------------- | ----------------------------------------------------- | +| Zoom +/- | Adjust `OrbitControls` min/max distance or camera FOV | +| Compass / reset | Reset camera to default world-facing pose | +| Legend | Same stub as v1 (“Gold pins = countries”) | + +Position controls over the `GLView` with absolute layout (NativeWind on wrapper `View`, not on `GLView`). + +## Performance guidelines + +Rendering ~195 pins is acceptable as **simple meshes**; avoid 195 text labels in 3D. + +- Prefer **instanced** or shared geometry for pins +- Debounce globe rotation callbacks +- Do not load full `Country` objects for every pin — only for `selectedCountry` +- If frame rate drops on low-end devices: show pins for trending + region filter only, or reduce pin count with a “Show all” chip (document in PR) + +## UI chrome (reuse v1 — do not redesign) + +Match `map-screen-ui.png` for everything **except** the map viewport: + +- Header, title, subtitle, search, filter chips, preview card, FAB, tab bar spacing +- 8pt grid, gold `#fbbf24` accents, dark backgrounds +- `constants/images.ts` for logo and avatar + +## Styling rules + +- **NativeWind** on layout wrappers around the globe +- **StyleSheet / inline** for `GLView`, R3F `Canvas`, and anything in `AGENTS.md` map exceptions +- No `className` on `SafeAreaView` + +## Out of scope + +- Country border polygons / choropleth extrusion on the globe +- Real-time trending from backend +- Server-side clustering (still N/A) +- AR / camera passthrough globe +- Music-reactive or weather globe shaders +- Replacing Explore feed or Home dashboard +- New `GET` endpoints or map-specific filters on the API +- Shipping large CDN GLB assets (>2MB) without user approval +- Removing v1 2D map entirely unless product explicitly wants 3D-only (prefer toggle) + +## Acceptance criteria + +- Map tab renders an interactive **3D globe** in `"3d"` mode (spin + zoom) +- Pins align with correct lat/lng (spot-check: Japan, Brazil, Iceland) +- Tapping a pin opens the **same** preview card as v1 with correct stats +- AI fun fact still loads via `GET /country/:name` after selection +- Save, Explore Country, Search overlay, and Random Country FAB behave like v1 +- Selected pin has visible gold emphasis +- 2D ↔ 3D toggle works if implemented (both modes usable) +- Loading / error states for map countries do not crash the GL view +- `npm run lint` passes +- New 3D dependencies only added after user approval +- Tested on at least one physical device OR documented simulator limitation + +## Testing + +```bash +# Terminal 1 — backend + Redis +# Terminal 2 +npx expo start +``` + +1. Open **Map** tab — globe visible in 3D mode; countries loaded +2. Spin globe — smooth rotation; release — inertia stops +3. Pinch / zoom controls — camera distance changes +4. Tap **Japan** (or known coords) — preview card + AI fact; pin highlighted +5. Tap **Explore Country** — Explore opens on that country +6. Tap **Random Country** — new selection + camera faces pin +7. Toggle **2D** (if built) — flat map works; toggle back to 3D +8. Open search → pick country → Explore (unchanged) +9. Stop backend — map error UI; GL does not white-screen + +## Teaching notes (for students) + +Document in code comments or README snippet: + +1. Why `latlng` becomes a 3D vector (spherical coordinates) +2. What `expo-gl` provides vs DOM WebGL +3. How R3F’s `Canvas` maps to React Native +4. Tradeoff: 2D maps (`react-native-maps`) vs 3D globe (Three.js) + +## Next steps + +After this prompt: + +1. Persist `mapMode` + last camera pose in AsyncStorage +2. Region chip filters that hide/show pin subsets on the globe +3. Saved countries screen (`prompt_material/saved-screen-ui.png`) +4. Optional: custom globe texture (night lights) with user-approved asset in `assets/` diff --git a/prompts/13-stream-integration.md b/prompts/13-stream-integration.md deleted file mode 100644 index 1de9bcf..0000000 --- a/prompts/13-stream-integration.md +++ /dev/null @@ -1,7 +0,0 @@ -Read AGENTS.md first and follow it strictly. - -Use the installed GetStream agent skills and the Stream docs to implement Stream audio call setup for the selected lesson flow. When a user taps a lesson, keep the existing Audio Lesson screen UI and add the ability to start, join, mute/unmute, and end an audio-only Stream call. - -Use Expo API routes for Stream token generation and call creation. Do not expose Stream secrets in the Expo app. Use the selected lesson, selected language, and logged-in Clerk user when creating the call/session. - -Preserve the existing UI and lesson data. Add clear loading, joined, error, muted, connecting, ended states and user info on audio ui. diff --git a/prompts/14-vision-agents.md b/prompts/14-vision-agents.md deleted file mode 100644 index 896d783..0000000 --- a/prompts/14-vision-agents.md +++ /dev/null @@ -1,7 +0,0 @@ -Read AGENTS.md first and follow it strictly. - -Use the installed Vision Agents skill to create a Python service at vision-agent/ inside this repo. It is the AI language teacher, voice only, using OpenAI Realtime as the LLM and Stream Edge for transport. - -Reuse STREAM_API_KEY/STREAM_API_SECRET from the parent .env and add OPENAI_API_KEY. By default the teacher always speaks English and teaches the selected language through English. - -Before writing any lifecycle code, verify the join and lifecycle method shapes against the installed SDK in this repo and confirm it starts cleanly. \ No newline at end of file diff --git a/prompts/15-connection-to-ui.md b/prompts/15-connection-to-ui.md deleted file mode 100644 index b6bc9ac..0000000 --- a/prompts/15-connection-to-ui.md +++ /dev/null @@ -1,9 +0,0 @@ -Read AGENTS.md first and follow it strictly. - -Use the installed Vision Agents skill and Stream skills to connect the Audio Lesson screen to the Vision Agent so the AI teacher joins the same Stream call as the user. - -Add Expo API routes to start and stop the agent that proxy to the Vision Agent server, and pack the selected lesson, language, goals, vocabulary, phrases, and AI teacher prompt into the Stream call's custom data so the agent can read it on join. Update the Python agent to actually consume those fields. - -Make sure the agent has permission to publish audio in audio_room (admin role + goLive). Clean up the agent session both when the user ends the call and when the screen unmounts. - -Do not expose any secrets in the mobile app. Keep the existing Stream audio flow intact. Show the agent connection status with idle, connecting, connected, and failed states. \ No newline at end of file diff --git a/prompts/16-ai-teacher-improvements.md b/prompts/16-ai-teacher-improvements.md deleted file mode 100644 index 65736eb..0000000 --- a/prompts/16-ai-teacher-improvements.md +++ /dev/null @@ -1,7 +0,0 @@ -Read AGENTS.md first and follow it strictly. - -Improve the AI teacher’s spoken output so it feels warm, human, energetic, and lesson-focused instead of robotic. Update only the Vision Agent kickoff prompt and the per-lesson entries in `data/lessons.ts`. - -The AI teacher should act like a real-world language teacher for the currently selected language and lesson only. It should stay strictly within that lesson’s goal, vocabulary, phrases, and context, and should not teach unrelated topics or switch to other languages. - -The teacher should mostly speak English, introduce target-language words slowly with translations, use short natural sentences with contractions and gentle encouragement, listen to the user’s response, adapt the next explanation accordingly, and ask the student to repeat or try again. Keep replies to one or two conversational sentences. \ No newline at end of file diff --git a/prompts/17-live-captions.md b/prompts/17-live-captions.md deleted file mode 100644 index 58f9f41..0000000 --- a/prompts/17-live-captions.md +++ /dev/null @@ -1,3 +0,0 @@ -Read AGENTS.md first and follow it strictly. - -Use the installed skills for stream and vision agents and implement realtime live captions in the Audio Lesson screen for both the AI teacher's speech and the user's speech, as they happen. \ No newline at end of file diff --git a/prompts/18-more-posthog.md b/prompts/18-more-posthog.md deleted file mode 100644 index fa6b9a9..0000000 --- a/prompts/18-more-posthog.md +++ /dev/null @@ -1,26 +0,0 @@ -Read AGENTS.md first and follow it strictly. - -Add PostHog event tracking to the existing app using the PostHog instance already initialized in lib/posthog.ts. Do not reinitialize PostHog and do not change the existing PostHogProvider setup. - -User identification: - -- After Clerk authentication completes (sign-in or sign-up), call posthog.identify() with the Clerk user's id as distinctId. -- On the first identify call after sign-up, set user properties: signup_date (current ISO date, via $set_once) and preferred_language (the language the user selected during onboarding, or null if not yet selected). -- On every subsequent identify, update preferred_language if it has changed. - -Three custom events, captured at these moments: - -1. language_selected — fires when the user confirms their language on the language selection screen. - Properties: { language_code: string, language_name: string } - -2. lesson_started — fires when the lesson screen mounts and the user begins the lesson. - Properties: { lesson_id: string, language: string, lesson_number: number } - -4. lesson_abandoned — fires when the user exits a lesson before lesson_completed fires (back navigation, screen unmount before completion). - Properties: { lesson_id: string, time_into_lesson_seconds: number, last_question_index: number } - - -Implementation rules: -- Track lesson start time with a ref captured on mount so duration_seconds is accurate. -- Do not modify any UI. -- Do not expose any keys; PostHog is already configured via environment variables. diff --git a/store/index.ts b/store/index.ts index 7d5e537..fef9e90 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,2 +1,4 @@ export { useCountryFeedStore } from "./use-country-feed-store"; +export { useMapStore } from "./use-map-store"; export { useSavedCountriesStore } from "./use-saved-countries-store"; +export { useSearchUiStore } from "./use-search-ui-store"; diff --git a/store/use-country-feed-store.ts b/store/use-country-feed-store.ts index 5d13869..a996a69 100644 --- a/store/use-country-feed-store.ts +++ b/store/use-country-feed-store.ts @@ -1,21 +1,40 @@ import { create } from "zustand"; -import { fetchFeedCountries } from "@/lib/api"; +import { CONTINENTS } from "@/constants/regions"; +import { fetchFeedCountries, fetchSearchCountries } from "@/lib/api"; +import { prefetchFeedHeroImages } from "@/lib/prefetch-feed-heroes"; import type { Country } from "@/types/country"; +let regionFilterGeneration = 0; +let regionPrefetchGeneration = 0; +const regionFetchPromises = new Map>(); + const DEFAULT_LIMIT = 20; type FeedStatus = "idle" | "loading" | "loadingMore" | "error"; +type ForYouSnapshot = { + countries: Country[]; + nextCursor: string | null; +}; + type CountryFeedState = { countries: Country[]; nextCursor: string | null; currentIndex: number; + selectedRegion: string | null; + regionCache: Record; + forYouSnapshot: ForYouSnapshot | null; status: FeedStatus; error: string | null; - loadInitialFeed: (limit?: number) => Promise; + loadInitialFeed: ( + limit?: number, + options?: { force?: boolean }, + ) => Promise; loadMoreFeed: (limit?: number) => Promise; + setRegionFilter: (region: string | null) => Promise; setCurrentIndex: (index: number) => void; + focusCountryInFeed: (country: Country) => void; getCurrentCountry: () => Country | undefined; resetFeed: () => void; }; @@ -24,27 +43,87 @@ function isLoading(status: FeedStatus): boolean { return status === "loading" || status === "loadingMore"; } +function cancelRegionPrefetch(): void { + regionPrefetchGeneration += 1; +} + +async function ensureRegionCountries(region: string): Promise { + const cached = useCountryFeedStore.getState().regionCache[region]; + if (cached) return cached; + + const inFlight = regionFetchPromises.get(region); + if (inFlight) return inFlight; + + const promise = fetchSearchCountries(undefined, region) + .then(({ data }) => { + useCountryFeedStore.setState((state) => ({ + regionCache: { ...state.regionCache, [region]: data }, + })); + return data; + }) + .finally(() => { + regionFetchPromises.delete(region); + }); + + regionFetchPromises.set(region, promise); + return promise; +} + +function prefetchRegionsSequentially(excludeRegion?: string | null): void { + const generation = ++regionPrefetchGeneration; + + void (async () => { + for (const continent of CONTINENTS) { + if (generation !== regionPrefetchGeneration) return; + if (continent === excludeRegion) continue; + + const { regionCache } = useCountryFeedStore.getState(); + if (regionCache[continent]) continue; + + try { + const data = await ensureRegionCountries(continent); + if (generation !== regionPrefetchGeneration) return; + void prefetchFeedHeroImages(data.slice(0, 2)); + } catch { + // Background prefetch — ignore failures. + } + } + })(); +} + export const useCountryFeedStore = create((set, get) => ({ countries: [], nextCursor: null, currentIndex: 0, + selectedRegion: null, + regionCache: {}, + forYouSnapshot: null, status: "idle", error: null, - loadInitialFeed: async (limit = DEFAULT_LIMIT) => { - if (isLoading(get().status)) return; + loadInitialFeed: async (limit = DEFAULT_LIMIT, options) => { + if (!options?.force && get().countries.length > 0) return; + if (!options?.force && isLoading(get().status)) return; - set({ status: "loading", error: null }); + const showBlockingLoad = get().countries.length === 0; + if (showBlockingLoad) { + set({ status: "loading", error: null }); + } try { const { data, nextCursor } = await fetchFeedCountries(undefined, limit); + await prefetchFeedHeroImages(data); set({ countries: data, nextCursor, currentIndex: 0, + selectedRegion: null, + forYouSnapshot: { countries: data, nextCursor }, status: "idle", error: null, }); + void prefetchFeedHeroImages(data.slice(2)); + prefetchRegionsSequentially(null); } catch (err) { set({ status: "error", @@ -54,9 +133,101 @@ export const useCountryFeedStore = create((set, get) => ({ } }, + setRegionFilter: async (region) => { + const requestId = ++regionFilterGeneration; + + if (region === null) { + const snapshot = get().forYouSnapshot; + if (snapshot && snapshot.countries.length > 0) { + await prefetchFeedHeroImages(snapshot.countries); + if (requestId !== regionFilterGeneration) return; + + set({ + countries: snapshot.countries, + nextCursor: snapshot.nextCursor, + currentIndex: 0, + selectedRegion: null, + status: "idle", + error: null, + }); + prefetchRegionsSequentially(null); + return; + } + + set({ selectedRegion: null, error: null }); + await get().loadInitialFeed(undefined, { force: true }); + return; + } + + if (get().selectedRegion === region) return; + + if (get().selectedRegion === null && get().countries.length > 0) { + const { countries, nextCursor } = get(); + set({ + forYouSnapshot: { countries, nextCursor }, + }); + } + + const cached = get().regionCache[region]; + if (cached) { + await prefetchFeedHeroImages(cached); + if (requestId !== regionFilterGeneration) return; + + set({ + countries: cached, + nextCursor: null, + currentIndex: 0, + selectedRegion: region, + status: "idle", + error: null, + }); + prefetchRegionsSequentially(region); + return; + } + + set({ + selectedRegion: region, + countries: [], + currentIndex: 0, + status: "loading", + error: null, + }); + + try { + const data = await ensureRegionCountries(region); + if (requestId !== regionFilterGeneration) return; + + await prefetchFeedHeroImages(data); + if (requestId !== regionFilterGeneration) return; + + set({ + countries: data, + nextCursor: null, + currentIndex: 0, + selectedRegion: region, + status: "idle", + error: null, + }); + void prefetchFeedHeroImages(data.slice(2, 6)); + prefetchRegionsSequentially(region); + } catch (err) { + if (requestId !== regionFilterGeneration) return; + + set({ + status: "error", + error: + err instanceof Error + ? err.message + : `Failed to load countries in ${region}`, + }); + } + }, + loadMoreFeed: async (limit = DEFAULT_LIMIT) => { - const { nextCursor, status } = get(); - if (nextCursor === null || isLoading(status)) return; + const { nextCursor, status, selectedRegion } = get(); + if (selectedRegion !== null || nextCursor === null || isLoading(status)) { + return; + } set({ status: "loadingMore", error: null }); @@ -66,11 +237,24 @@ export const useCountryFeedStore = create((set, get) => ({ limit, ); const { countries } = get(); - set({ - countries: [...countries, ...data], - nextCursor: newCursor, - status: "idle", - error: null, + const existingNames = new Set(countries.map((c) => c.name)); + const uniqueNew = data.filter((c) => !existingNames.has(c.name)); + set((state) => { + const nextCountries = [...state.countries, ...uniqueNew]; + return { + countries: nextCountries, + nextCursor: newCursor, + status: "idle", + error: null, + ...(state.selectedRegion === null + ? { + forYouSnapshot: { + countries: nextCountries, + nextCursor: newCursor, + }, + } + : {}), + }; }); } catch (err) { set({ @@ -91,16 +275,32 @@ export const useCountryFeedStore = create((set, get) => ({ set({ currentIndex: clamped }); }, + focusCountryInFeed: (country: Country) => { + const { countries } = get(); + const existingIndex = countries.findIndex((c) => c.name === country.name); + if (existingIndex >= 0) { + set({ currentIndex: existingIndex }); + return; + } + set({ countries: [country, ...countries], currentIndex: 0 }); + }, + getCurrentCountry: () => { const { countries, currentIndex } = get(); return countries[currentIndex]; }, resetFeed: () => { + regionFilterGeneration += 1; + cancelRegionPrefetch(); + regionFetchPromises.clear(); set({ countries: [], nextCursor: null, currentIndex: 0, + selectedRegion: null, + regionCache: {}, + forYouSnapshot: null, status: "idle", error: null, }); diff --git a/store/use-discovery-progress-store.ts b/store/use-discovery-progress-store.ts new file mode 100644 index 0000000..e922d4c --- /dev/null +++ b/store/use-discovery-progress-store.ts @@ -0,0 +1,48 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +/** Mon–Sun completion flags (index 0 = Monday). */ +export type WeekProgress = [ + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, +]; + +type DiscoveryProgressState = { + streakDays: number; + weekProgress: WeekProgress; + countriesExplored: number; + quizzesCompleted: number; + worldProgressPercent: number; +}; + +const DEFAULT_WEEK_PROGRESS: WeekProgress = [ + true, + true, + true, + true, + true, + true, + false, +]; + +export const useDiscoveryProgressStore = create()( + persist( + () => ({ + streakDays: 12, + weekProgress: DEFAULT_WEEK_PROGRESS, + countriesExplored: 28, + quizzesCompleted: 56, + worldProgressPercent: 28, + }), + { + name: "worldloop-discovery-progress", + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); diff --git a/store/use-map-store.ts b/store/use-map-store.ts new file mode 100644 index 0000000..bf3626e --- /dev/null +++ b/store/use-map-store.ts @@ -0,0 +1,100 @@ +import { create } from "zustand"; + +import { fetchMapCountries } from "@/lib/api"; +import { isValidLatLng } from "@/lib/map-country"; +import type { MapCountry } from "@/types/country"; + +export type MapFilterChip = + | "all" + | "population" + | "culture" + | "nature" + | "history"; + +type MapStatus = "idle" | "loading" | "error"; + +type MapState = { + countries: MapCountry[]; + status: MapStatus; + error: string | null; + selectedCountry: MapCountry | null; + activeChip: MapFilterChip; + loadMapCountries: () => Promise; + selectCountry: (name: string | null) => void; + selectRandomCountry: () => MapCountry | null; + setActiveChip: (chip: MapFilterChip) => void; + getVisibleCountries: () => MapCountry[]; +}; + +function withValidCoordinates(countries: MapCountry[]): MapCountry[] { + return countries.filter((c) => isValidLatLng(c.latlng)); +} + +/** Population chip: top 20% by population. Other chips are visual-only in v1. */ +export function filterMapCountriesByChip( + countries: MapCountry[], + chip: MapFilterChip, +): MapCountry[] { + if (chip !== "population") return countries; + + const sorted = [...countries].sort((a, b) => b.population - a.population); + const topCount = Math.max(1, Math.ceil(sorted.length * 0.2)); + const topNames = new Set(sorted.slice(0, topCount).map((c) => c.name)); + return countries.filter((c) => topNames.has(c.name)); +} + +export const useMapStore = create((set, get) => ({ + countries: [], + status: "idle", + error: null, + selectedCountry: null, + activeChip: "all", + + loadMapCountries: async () => { + const { status } = get(); + if (status === "loading") return; + + set({ status: "loading", error: null }); + + try { + const { data } = await fetchMapCountries(); + set({ + countries: withValidCoordinates(data), + status: "idle", + error: null, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to load map countries"; + set({ status: "error", error: message }); + } + }, + + selectCountry: (name) => { + if (!name) { + set({ selectedCountry: null }); + return; + } + + const country = get().countries.find((c) => c.name === name) ?? null; + set({ selectedCountry: country }); + }, + + selectRandomCountry: () => { + const visible = get().getVisibleCountries(); + if (visible.length === 0) return null; + + const pick = visible[Math.floor(Math.random() * visible.length)] ?? null; + if (pick) { + set({ selectedCountry: pick }); + } + return pick; + }, + + setActiveChip: (chip) => set({ activeChip: chip }), + + getVisibleCountries: () => { + const { countries, activeChip } = get(); + return filterMapCountriesByChip(countries, activeChip); + }, +})); diff --git a/store/use-map-ui-store.ts b/store/use-map-ui-store.ts new file mode 100644 index 0000000..e03f254 --- /dev/null +++ b/store/use-map-ui-store.ts @@ -0,0 +1,46 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export type MapDisplayMode = "globalPulse" | "explore"; +export type FeaturedShortcut = "trending" | "forYou" | "newActivity"; + +type MapUiState = { + hasSeenMapOnboarding: boolean; + displayMode: MapDisplayMode; + focusedRegion: string | null; + featuredShortcut: FeaturedShortcut | null; + dismissMapOnboarding: () => void; + setDisplayMode: (mode: MapDisplayMode) => void; + setFocusedRegion: (region: string | null) => void; + setFeaturedShortcut: (shortcut: FeaturedShortcut | null) => void; + resetGlobalPulse: () => void; +}; + +export const useMapUiStore = create()( + persist( + (set) => ({ + hasSeenMapOnboarding: false, + displayMode: "globalPulse", + focusedRegion: null, + featuredShortcut: null, + + dismissMapOnboarding: () => set({ hasSeenMapOnboarding: true }), + setDisplayMode: (mode) => set({ displayMode: mode }), + setFocusedRegion: (region) => set({ focusedRegion: region }), + setFeaturedShortcut: (shortcut) => set({ featuredShortcut: shortcut }), + + resetGlobalPulse: () => + set({ + displayMode: "globalPulse", + focusedRegion: null, + featuredShortcut: null, + }), + }), + { + name: "worldloop-map-ui", + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); + diff --git a/store/use-recently-viewed-store.ts b/store/use-recently-viewed-store.ts new file mode 100644 index 0000000..0cbfd20 --- /dev/null +++ b/store/use-recently-viewed-store.ts @@ -0,0 +1,153 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { buildFlagCdnUrl } from "@/lib/flag-url"; +import type { Country } from "@/types/country"; + +const MAX_RECENT = 8; + +export type RecentlyViewedEntry = { + country: Country; + viewedAt: number; +}; + +type RecentlyViewedState = { + entries: RecentlyViewedEntry[]; + recordView: (country: Country) => void; + seedIfEmpty: () => void; + clearRecentlyViewed: () => void; +}; + +function normalizeRecordedCountry(country: Country): Country | null { + const wrapped = country as Country & { data?: Country }; + const normalized = wrapped.data ?? country; + const name = normalized.name?.trim(); + + if (!name) return null; + return normalized; +} + +function sanitizeEntries(entries: RecentlyViewedEntry[]): RecentlyViewedEntry[] { + const seen = new Set(); + const cleaned: RecentlyViewedEntry[] = []; + + for (const entry of entries) { + const normalized = normalizeRecordedCountry(entry.country); + if (!normalized || seen.has(normalized.name)) continue; + + seen.add(normalized.name); + cleaned.push({ ...entry, country: normalized }); + } + + return cleaned.slice(0, MAX_RECENT); +} + +function seedCountry( + name: string, + cca2: string, + capital: string, + region: string, + population: number, + latlng: [number, number], + images?: string[], +): Country { + return { + name, + cca2, + capital, + region, + population, + flag: buildFlagCdnUrl(cca2), + latlng, + images, + }; +} + +/** Design-matched seed entries for first launch only. */ +function buildSeedEntries(): RecentlyViewedEntry[] { + const now = Date.now(); + return [ + { + country: seedCountry( + "Peru", + "PE", + "Lima", + "Americas", + 33_715_471, + [-9.19, -75.0152], + [ + "https://images.unsplash.com/photo-1526392060635-9d59825da76e?w=800", + ], + ), + viewedAt: now, + }, + { + country: seedCountry( + "Italy", + "IT", + "Rome", + "Europe", + 58_853_482, + [41.8719, 12.5674], + [ + "https://images.unsplash.com/photo-1515542622106-78bda8ba0e5b?w=800", + ], + ), + viewedAt: now - 2 * 60 * 60 * 1000, + }, + { + country: seedCountry( + "Japan", + "JP", + "Tokyo", + "Asia", + 125_584_838, + [36.2048, 138.2529], + [ + "https://images.unsplash.com/photo-1493976040374-85c8e12f0c0e?w=800", + ], + ), + viewedAt: now - 24 * 60 * 60 * 1000, + }, + ]; +} + +export const useRecentlyViewedStore = create()( + persist( + (set, get) => ({ + entries: [], + + recordView: (country: Country) => { + const normalized = normalizeRecordedCountry(country); + if (!normalized) return; + + const { entries } = get(); + const without = entries.filter( + (e) => e.country.name !== normalized.name, + ); + const next: RecentlyViewedEntry[] = [ + { country: normalized, viewedAt: Date.now() }, + ...without, + ].slice(0, MAX_RECENT); + set({ entries: next }); + }, + + seedIfEmpty: () => { + if (get().entries.length === 0) { + set({ entries: buildSeedEntries() }); + } + }, + + clearRecentlyViewed: () => set({ entries: [] }), + }), + { + name: "worldloop-recently-viewed", + storage: createJSONStorage(() => AsyncStorage), + onRehydrateStorage: () => (state) => { + if (!state) return; + state.entries = sanitizeEntries(state.entries); + }, + }, + ), +); diff --git a/store/use-search-ui-store.ts b/store/use-search-ui-store.ts new file mode 100644 index 0000000..97c073a --- /dev/null +++ b/store/use-search-ui-store.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +type SearchUiState = { + isOpen: boolean; + openSearch: () => void; + closeSearch: () => void; +}; + +export const useSearchUiStore = create((set) => ({ + isOpen: false, + openSearch: () => set({ isOpen: true }), + closeSearch: () => set({ isOpen: false }), +})); diff --git a/types/country.ts b/types/country.ts index 448dd38..7dc60ee 100644 --- a/types/country.ts +++ b/types/country.ts @@ -1,3 +1,14 @@ +/** Lightweight country shape from GET /map/countries. */ +export type MapCountry = { + name: string; + capital: string; + region: string; + population: number; + flag: string; + latlng: [number, number]; + image: string | null; +}; + /** Country shape aligned with the backend feed API. */ export type Country = { name: string;