const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; const TOKEN_KEY = "tasteby_token"; export function getToken(): string | null { if (typeof window === "undefined") return null; return localStorage.getItem(TOKEN_KEY); } export function setToken(token: string) { localStorage.setItem(TOKEN_KEY, token); } export function removeToken() { localStorage.removeItem(TOKEN_KEY); } async function fetchApi(path: string, init?: RequestInit): Promise { const token = getToken(); const headers: Record = { "Content-Type": "application/json", ...((init?.headers as Record) || {}), }; if (token) { headers["Authorization"] = `Bearer ${token}`; } const res = await fetch(`${API_BASE}${path}`, { ...init, headers, }); if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`); return res.json(); } export interface Restaurant { id: string; name: string; address: string | null; region: string | null; latitude: number; longitude: number; cuisine_type: string | null; price_range: string | null; google_place_id: string | null; tabling_url: string | null; catchtable_url: string | null; business_status: string | null; rating: number | null; rating_count: number | null; phone: string | null; website: string | null; channels?: string[]; foods_mentioned?: string[]; } export interface VideoLink { video_id: string; title: string; url: string; published_at: string | null; foods_mentioned: string[]; evaluation: Record; guests: string[]; channel_name: string | null; channel_id: string | null; } export interface Channel { id: string; channel_id: string; channel_name: string; title_filter: string | null; video_count: number; last_scanned_at: string | null; } export interface Video { id: string; video_id: string; title: string; url: string; status: string; published_at: string | null; channel_name: string; has_transcript: boolean; has_llm: boolean; restaurant_count: number; matched_count: number; } export interface VideoRestaurant { restaurant_id: string; name: string; address: string | null; cuisine_type: string | null; price_range: string | null; region: string | null; foods_mentioned: string[]; evaluation: Record; guests: string[]; google_place_id: string | null; has_location: boolean; } export interface VideoDetail extends Video { transcript: string | null; restaurants: VideoRestaurant[]; } export interface User { id: string; email: string | null; nickname: string | null; avatar_url: string | null; is_admin?: boolean; } export interface Review { id: string; user_id: string; rating: number; review_text: string | null; visited_at: string | null; created_at: string; user_nickname: string | null; user_avatar_url: string | null; } export interface DaemonConfig { scan_enabled: boolean; scan_interval_min: number; process_enabled: boolean; process_interval_min: number; process_limit: number; last_scan_at: string | null; last_process_at: string | null; updated_at: string | null; } export interface ReviewsResponse { reviews: Review[]; avg_rating: number | null; review_count: number; } export const api = { getRestaurants(params?: { cuisine?: string; region?: string; channel?: string; limit?: number; offset?: number; }) { const sp = new URLSearchParams(); if (params?.cuisine) sp.set("cuisine", params.cuisine); if (params?.region) sp.set("region", params.region); if (params?.channel) sp.set("channel", params.channel); if (params?.limit) sp.set("limit", String(params.limit)); if (params?.offset) sp.set("offset", String(params.offset)); const qs = sp.toString(); return fetchApi(`/api/restaurants${qs ? `?${qs}` : ""}`); }, getRestaurant(id: string) { return fetchApi(`/api/restaurants/${id}`); }, updateRestaurant(id: string, data: Partial) { return fetchApi<{ ok: boolean }>(`/api/restaurants/${id}`, { method: "PUT", body: JSON.stringify(data), }); }, deleteRestaurant(id: string) { return fetchApi<{ ok: boolean }>(`/api/restaurants/${id}`, { method: "DELETE", }); }, getRestaurantVideos(id: string) { return fetchApi(`/api/restaurants/${id}/videos`); }, search(q: string, mode: "keyword" | "semantic" | "hybrid" = "keyword") { return fetchApi( `/api/search?q=${encodeURIComponent(q)}&mode=${mode}` ); }, getChannels() { return fetchApi("/api/channels"); }, loginWithGoogle(idToken: string) { return fetchApi<{ access_token: string; user: User }>("/api/auth/google", { method: "POST", body: JSON.stringify({ id_token: idToken }), }); }, getMe() { return fetchApi("/api/auth/me"); }, getFavoriteStatus(restaurantId: string) { return fetchApi<{ favorited: boolean }>(`/api/restaurants/${restaurantId}/favorite`); }, toggleFavorite(restaurantId: string) { return fetchApi<{ favorited: boolean }>(`/api/restaurants/${restaurantId}/favorite`, { method: "POST", }); }, getMyFavorites() { return fetchApi("/api/users/me/favorites"); }, getReviews(restaurantId: string) { return fetchApi(`/api/restaurants/${restaurantId}/reviews`); }, createReview( restaurantId: string, data: { rating: number; review_text?: string; visited_at?: string } ) { return fetchApi(`/api/restaurants/${restaurantId}/reviews`, { method: "POST", body: JSON.stringify(data), }); }, updateReview( reviewId: string, data: { rating: number; review_text?: string; visited_at?: string } ) { return fetchApi(`/api/reviews/${reviewId}`, { method: "PUT", body: JSON.stringify(data), }); }, deleteReview(reviewId: string) { return fetchApi(`/api/reviews/${reviewId}`, { method: "DELETE", }); }, getMyReviews() { return fetchApi<(Review & { restaurant_id: string; restaurant_name: string | null })[]>( "/api/users/me/reviews?limit=100" ); }, // Stats recordVisit() { return fetchApi<{ ok: boolean }>("/api/stats/visit", { method: "POST" }); }, getVisits() { return fetchApi<{ today: number; total: number }>("/api/stats/visits"); }, // Admin - Users getAdminUsers(params?: { limit?: number; offset?: number }) { const sp = new URLSearchParams(); if (params?.limit) sp.set("limit", String(params.limit)); if (params?.offset) sp.set("offset", String(params.offset)); const qs = sp.toString(); return fetchApi<{ users: { id: string; email: string | null; nickname: string | null; avatar_url: string | null; provider: string | null; created_at: string | null; favorite_count: number; review_count: number; }[]; total: number; }>(`/api/admin/users${qs ? `?${qs}` : ""}`); }, getAdminUserFavorites(userId: string) { return fetchApi< { id: string; name: string; address: string | null; region: string | null; cuisine_type: string | null; rating: number | null; business_status: string | null; created_at: string | null; }[] >(`/api/admin/users/${userId}/favorites`); }, getAdminUserReviews(userId: string) { return fetchApi< { id: string; restaurant_id: string; rating: number; review_text: string | null; visited_at: string | null; created_at: string | null; restaurant_name: string | null; }[] >(`/api/admin/users/${userId}/reviews`); }, // Admin addChannel(channelId: string, channelName: string, titleFilter?: string) { return fetchApi<{ id: string; channel_id: string }>("/api/channels", { method: "POST", body: JSON.stringify({ channel_id: channelId, channel_name: channelName, title_filter: titleFilter || null, }), }); }, searchTabling(restaurantId: string) { return fetchApi<{ title: string; url: string }[]>( `/api/restaurants/${restaurantId}/tabling-search` ); }, setTablingUrl(restaurantId: string, tablingUrl: string) { return fetchApi<{ ok: boolean }>( `/api/restaurants/${restaurantId}/tabling-url`, { method: "PUT", body: JSON.stringify({ tabling_url: tablingUrl }) } ); }, searchCatchtable(restaurantId: string) { return fetchApi<{ title: string; url: string }[]>( `/api/restaurants/${restaurantId}/catchtable-search` ); }, setCatchtableUrl(restaurantId: string, catchtableUrl: string) { return fetchApi<{ ok: boolean }>( `/api/restaurants/${restaurantId}/catchtable-url`, { method: "PUT", body: JSON.stringify({ catchtable_url: catchtableUrl }) } ); }, deleteChannel(channelId: string) { return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, { method: "DELETE", }); }, scanChannel(channelId: string, full: boolean = false) { return fetchApi<{ total_fetched: number; new_videos: number }>( `/api/channels/${channelId}/scan${full ? "?full=true" : ""}`, { method: "POST" } ); }, getVideos(params?: { status?: string; limit?: number; offset?: number }) { const sp = new URLSearchParams(); if (params?.status) sp.set("status", params.status); if (params?.limit) sp.set("limit", String(params.limit)); if (params?.offset) sp.set("offset", String(params.offset)); const qs = sp.toString(); return fetchApi(`/api/videos${qs ? `?${qs}` : ""}`); }, getVideoDetail(videoDbId: string) { return fetchApi(`/api/videos/${videoDbId}`); }, skipVideo(videoDbId: string) { return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}/skip`, { method: "POST", }); }, deleteVideo(videoDbId: string) { return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}`, { method: "DELETE", }); }, getExtractPrompt() { return fetchApi<{ prompt: string }>("/api/videos/extract/prompt"); }, extractRestaurants(videoDbId: string, customPrompt?: string) { return fetchApi<{ ok: boolean; restaurants_extracted: number }>( `/api/videos/${videoDbId}/extract`, { method: "POST", body: JSON.stringify(customPrompt ? { prompt: customPrompt } : {}), } ); }, fetchTranscript(videoDbId: string, mode: "auto" | "manual" | "generated" = "auto") { return fetchApi<{ ok: boolean; length: number; source: string }>( `/api/videos/${videoDbId}/fetch-transcript?mode=${mode}`, { method: "POST" } ); }, uploadTranscript(videoDbId: string, text: string, source: string = "browser") { return fetchApi<{ ok: boolean; length: number; source: string }>( `/api/videos/${videoDbId}/upload-transcript`, { method: "POST", body: JSON.stringify({ text, source }) } ); }, triggerProcessing(limit: number = 5) { return fetchApi<{ restaurants_extracted: number }>( `/api/videos/process?limit=${limit}`, { method: "POST" } ); }, getBulkExtractPending() { return fetchApi<{ count: number; videos: { id: string; title: string }[] }>( "/api/videos/bulk-extract/pending" ); }, getBulkTranscriptPending() { return fetchApi<{ count: number; videos: { id: string; title: string }[] }>( "/api/videos/bulk-transcript/pending" ); }, updateVideo(videoDbId: string, data: { title: string }) { return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}`, { method: "PUT", body: JSON.stringify(data), }); }, addManualRestaurant( videoDbId: string, data: { name: string; address?: string; region?: string; cuisine_type?: string; price_range?: string; foods_mentioned?: string[]; evaluation?: string; guests?: string[]; } ) { return fetchApi<{ ok: boolean; restaurant_id: string; link_id: string }>( `/api/videos/${videoDbId}/restaurants/manual`, { method: "POST", body: JSON.stringify(data) } ); }, deleteVideoRestaurant(videoDbId: string, restaurantId: string) { return fetchApi<{ ok: boolean }>( `/api/videos/${videoDbId}/restaurants/${restaurantId}`, { method: "DELETE" } ); }, updateVideoRestaurant( videoDbId: string, restaurantId: string, data: Partial ) { return fetchApi<{ ok: boolean }>( `/api/videos/${videoDbId}/restaurants/${restaurantId}`, { method: "PUT", body: JSON.stringify(data) } ); }, // Daemon config getDaemonConfig() { return fetchApi("/api/daemon/config"); }, updateDaemonConfig(data: Partial) { return fetchApi<{ ok: boolean }>("/api/daemon/config", { method: "PUT", body: JSON.stringify(data), }); }, runDaemonScan() { return fetchApi<{ ok: boolean; new_videos: number }>("/api/daemon/run/scan", { method: "POST", }); }, runDaemonProcess(limit: number = 10) { return fetchApi<{ ok: boolean; restaurants_extracted: number }>( `/api/daemon/run/process?limit=${limit}`, { method: "POST" } ); }, };