Compare commits
No commits in common. "4c2e97b66d0ed0a277e9e14381945a16a8e163fc" and "871f33bf5a5f4ca350858dd4239b63539211bf2b" have entirely different histories.
4c2e97b66d
...
871f33bf5a
Binary file not shown.
@ -1,156 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
|
||||||
import { getUserMembershipContext } from "@/lib/membership/access";
|
|
||||||
import log from "@/lib/logger";
|
|
||||||
|
|
||||||
interface OpenFoodFactsProduct {
|
|
||||||
product_name?: string;
|
|
||||||
product_name_en?: string;
|
|
||||||
brands?: string;
|
|
||||||
image_url?: string;
|
|
||||||
image_front_url?: string;
|
|
||||||
serving_size?: string;
|
|
||||||
nutriments?: {
|
|
||||||
[key: string]: number | string | undefined;
|
|
||||||
"energy-kcal_serving"?: number;
|
|
||||||
"energy-kcal_100g"?: number;
|
|
||||||
proteins_serving?: number;
|
|
||||||
proteins_100g?: number;
|
|
||||||
carbohydrates_serving?: number;
|
|
||||||
carbohydrates_100g?: number;
|
|
||||||
fat_serving?: number;
|
|
||||||
fat_100g?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OpenFoodFactsResponse {
|
|
||||||
status: number;
|
|
||||||
code: string;
|
|
||||||
product?: OpenFoodFactsProduct;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBarcode(rawCode: string): string {
|
|
||||||
return rawCode.replace(/\D/g, "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSupportedBarcode(code: string): boolean {
|
|
||||||
return [8, 12, 13].includes(code.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNumber(value: unknown): number | undefined {
|
|
||||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (Number.isFinite(parsed)) return parsed;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProductPayload(code: string, product: OpenFoodFactsProduct) {
|
|
||||||
const caloriesPerServing =
|
|
||||||
getNumber(product.nutriments?.["energy-kcal_serving"]) ??
|
|
||||||
getNumber(product.nutriments?.["energy-kcal_100g"]) ??
|
|
||||||
0;
|
|
||||||
|
|
||||||
const protein =
|
|
||||||
getNumber(product.nutriments?.proteins_serving) ??
|
|
||||||
getNumber(product.nutriments?.proteins_100g);
|
|
||||||
const carbs =
|
|
||||||
getNumber(product.nutriments?.carbohydrates_serving) ??
|
|
||||||
getNumber(product.nutriments?.carbohydrates_100g);
|
|
||||||
const fat =
|
|
||||||
getNumber(product.nutriments?.fat_serving) ??
|
|
||||||
getNumber(product.nutriments?.fat_100g);
|
|
||||||
|
|
||||||
return {
|
|
||||||
barcode: code,
|
|
||||||
name: product.product_name || product.product_name_en || "Unknown Product",
|
|
||||||
brand: product.brands || null,
|
|
||||||
imageUrl: product.image_url || product.image_front_url || null,
|
|
||||||
servingSize: product.serving_size || "1 serving",
|
|
||||||
caloriesPerServing: Math.max(0, Math.round(caloriesPerServing)),
|
|
||||||
macros: {
|
|
||||||
protein: protein ?? null,
|
|
||||||
carbs: carbs ?? null,
|
|
||||||
fat: fat ?? null,
|
|
||||||
},
|
|
||||||
source: "openfoodfacts" as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: Request,
|
|
||||||
{ params }: { params: Promise<{ code: string }> },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { userId } = await auth();
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { features, membershipType } = await getUserMembershipContext(userId);
|
|
||||||
if (!features.nutritionTracking) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
"Barcode food scan is available on Premium and VIP memberships",
|
|
||||||
membershipType,
|
|
||||||
},
|
|
||||||
{ status: 403 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { code: rawCode } = await params;
|
|
||||||
const code = normalizeBarcode(rawCode);
|
|
||||||
|
|
||||||
if (!isSupportedBarcode(code)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid barcode. Use EAN-8, UPC-A, or EAN-13 formats." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`https://world.openfoodfacts.org/api/v2/product/${code}.json`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"User-Agent": "FitAI/1.0 (fitai.app)",
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
log.warn("OpenFoodFacts lookup failed", {
|
|
||||||
status: response.status,
|
|
||||||
barcode: code,
|
|
||||||
});
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Food lookup service unavailable. Please try again." },
|
|
||||||
{ status: 503 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await response.json()) as OpenFoodFactsResponse;
|
|
||||||
if (payload.status !== 1 || !payload.product) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Product not found in OpenFoodFacts" },
|
|
||||||
{ status: 404 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: buildProductPayload(code, payload.product),
|
|
||||||
meta: {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed barcode food lookup", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to lookup food barcode" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import { apiClient, withAuth } from "./client";
|
|
||||||
import { API_ENDPOINTS } from "../config/api";
|
|
||||||
|
|
||||||
export interface ScannedFoodProduct {
|
|
||||||
barcode: string;
|
|
||||||
name: string;
|
|
||||||
brand: string | null;
|
|
||||||
imageUrl: string | null;
|
|
||||||
servingSize: string;
|
|
||||||
caloriesPerServing: number;
|
|
||||||
macros: {
|
|
||||||
protein: number | null;
|
|
||||||
carbs: number | null;
|
|
||||||
fat: number | null;
|
|
||||||
};
|
|
||||||
source: "openfoodfacts";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FoodLookupResponse {
|
|
||||||
success: boolean;
|
|
||||||
data: ScannedFoodProduct;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function lookupFoodByBarcode(
|
|
||||||
barcode: string,
|
|
||||||
token: string | null,
|
|
||||||
): Promise<ScannedFoodProduct> {
|
|
||||||
const normalized = barcode.replace(/\D/g, "");
|
|
||||||
const response = await apiClient.get<FoodLookupResponse>(
|
|
||||||
API_ENDPOINTS.FOOD.LOOKUP_BARCODE(normalized),
|
|
||||||
withAuth(token),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
@ -15,5 +15,4 @@ export * from "./hydration";
|
|||||||
export * from "./client";
|
export * from "./client";
|
||||||
export * from "./helpers";
|
export * from "./helpers";
|
||||||
export * from "./membership";
|
export * from "./membership";
|
||||||
export * from "./food";
|
|
||||||
export * from "./gyms";
|
export * from "./gyms";
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Alert,
|
Alert,
|
||||||
AppState,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useUser } from "@clerk/clerk-expo";
|
import { useUser } from "@clerk/clerk-expo";
|
||||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
@ -44,22 +43,6 @@ const CALORIE_GOAL = 2000;
|
|||||||
const WATER_GOAL = 2000;
|
const WATER_GOAL = 2000;
|
||||||
const WORKOUT_GOAL = 3;
|
const WORKOUT_GOAL = 3;
|
||||||
const MOTIVATION_KEY_PREFIX = "home-motivation";
|
const MOTIVATION_KEY_PREFIX = "home-motivation";
|
||||||
const HOME_METRICS_KEY_PREFIX = "home-metrics";
|
|
||||||
|
|
||||||
const getLocalDateKey = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(now.getDate()).padStart(2, "0");
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMillisecondsUntilNextMidnight = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const nextMidnight = new Date(now);
|
|
||||||
nextMidnight.setHours(24, 0, 0, 0);
|
|
||||||
return Math.max(1000, nextMidnight.getTime() - now.getTime());
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRandomMotivation = () => {
|
const getRandomMotivation = () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
@ -91,102 +74,12 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
const caloriesBounce = useRef(new Animated.Value(1)).current;
|
const caloriesBounce = useRef(new Animated.Value(1)).current;
|
||||||
const waterBounce = useRef(new Animated.Value(1)).current;
|
const waterBounce = useRef(new Animated.Value(1)).current;
|
||||||
const caloriesRef = useRef(0);
|
|
||||||
const waterRef = useRef(0);
|
|
||||||
const currentDateRef = useRef(getLocalDateKey());
|
|
||||||
const midnightResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
caloriesRef.current = calories;
|
|
||||||
}, [calories]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
waterRef.current = waterIntake;
|
|
||||||
}, [waterIntake]);
|
|
||||||
|
|
||||||
const getMetricsStorageKey = useCallback(
|
|
||||||
() => `${HOME_METRICS_KEY_PREFIX}_${user?.id || "guest"}`,
|
|
||||||
[user?.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const persistDailyMetrics = useCallback(
|
|
||||||
async (nextCalories: number, nextWaterIntake: number, dateKey?: string) => {
|
|
||||||
if (!user?.id) return;
|
|
||||||
|
|
||||||
const targetDate = dateKey || currentDateRef.current;
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
getMetricsStorageKey(),
|
|
||||||
JSON.stringify({
|
|
||||||
date: targetDate,
|
|
||||||
calories: nextCalories,
|
|
||||||
waterIntake: nextWaterIntake,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[getMetricsStorageKey, user?.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const reconcileDailyMetrics = useCallback(async () => {
|
|
||||||
const today = getLocalDateKey();
|
|
||||||
currentDateRef.current = today;
|
|
||||||
|
|
||||||
if (!user?.id) {
|
|
||||||
setCalories(0);
|
|
||||||
setWaterIntake(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = await AsyncStorage.getItem(getMetricsStorageKey());
|
|
||||||
if (!stored) {
|
|
||||||
setCalories(0);
|
|
||||||
setWaterIntake(0);
|
|
||||||
await persistDailyMetrics(0, 0, today);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(stored) as {
|
|
||||||
date?: string;
|
|
||||||
calories?: number;
|
|
||||||
waterIntake?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (parsed.date === today) {
|
|
||||||
const nextCalories = Number(parsed.calories) || 0;
|
|
||||||
const nextWater = Number(parsed.waterIntake) || 0;
|
|
||||||
setCalories(nextCalories);
|
|
||||||
setWaterIntake(nextWater);
|
|
||||||
} else {
|
|
||||||
setCalories(0);
|
|
||||||
setWaterIntake(0);
|
|
||||||
await persistDailyMetrics(0, 0, today);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setCalories(0);
|
|
||||||
setWaterIntake(0);
|
|
||||||
await persistDailyMetrics(0, 0, today);
|
|
||||||
}
|
|
||||||
}, [getMetricsStorageKey, persistDailyMetrics, user?.id]);
|
|
||||||
|
|
||||||
const scheduleMidnightReset = useCallback(() => {
|
|
||||||
if (midnightResetTimerRef.current) {
|
|
||||||
clearTimeout(midnightResetTimerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
midnightResetTimerRef.current = setTimeout(() => {
|
|
||||||
void reconcileDailyMetrics();
|
|
||||||
scheduleMidnightReset();
|
|
||||||
}, getMillisecondsUntilNextMidnight() + 50);
|
|
||||||
}, [reconcileDailyMetrics]);
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
void reconcileDailyMetrics();
|
|
||||||
refetchStatistics();
|
refetchStatistics();
|
||||||
refetchGoals();
|
refetchGoals();
|
||||||
}, [reconcileDailyMetrics, refetchStatistics, refetchGoals]),
|
}, [refetchStatistics, refetchGoals]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
@ -207,11 +100,7 @@ export default function HomeScreen() {
|
|||||||
name: string;
|
name: string;
|
||||||
calories: number;
|
calories: number;
|
||||||
}) => {
|
}) => {
|
||||||
setCalories((prev) => {
|
setCalories((prev) => prev + meal.calories);
|
||||||
const next = prev + meal.calories;
|
|
||||||
void persistDailyMetrics(next, waterRef.current);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setTrackMealModalVisible(false);
|
setTrackMealModalVisible(false);
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(caloriesBounce, {
|
Animated.timing(caloriesBounce, {
|
||||||
@ -228,11 +117,7 @@ export default function HomeScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWater = (amount: number) => {
|
const handleAddWater = (amount: number) => {
|
||||||
setWaterIntake((prev) => {
|
setWaterIntake((prev) => prev + amount);
|
||||||
const next = prev + amount;
|
|
||||||
void persistDailyMetrics(caloriesRef.current, next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setAddWaterModalVisible(false);
|
setAddWaterModalVisible(false);
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(waterBounce, {
|
Animated.timing(waterBounce, {
|
||||||
@ -248,25 +133,22 @@ export default function HomeScreen() {
|
|||||||
]).start();
|
]).start();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetCalories = () => {
|
const handleResetCalories = () => setCalories(0);
|
||||||
setCalories(0);
|
const handleResetWater = () => setWaterIntake(0);
|
||||||
void persistDailyMetrics(0, waterRef.current);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetWater = () => {
|
|
||||||
setWaterIntake(0);
|
|
||||||
void persistDailyMetrics(caloriesRef.current, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddScannedFood = (scannedCalories: number) => {
|
const handleAddScannedFood = (scannedCalories: number) => {
|
||||||
setCalories((prev) => {
|
setCalories((prev) => prev + scannedCalories);
|
||||||
const next = prev + scannedCalories;
|
|
||||||
void persistDailyMetrics(next, waterRef.current);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setScanFoodModalVisible(false);
|
setScanFoodModalVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetAllCounters = async () => {
|
||||||
|
setCalories(0);
|
||||||
|
setWaterIntake(0);
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
await AsyncStorage.setItem("lastResetDate", today);
|
||||||
|
await AsyncStorage.removeItem(`calories_${today}`);
|
||||||
|
await AsyncStorage.removeItem(`water_${today}`);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDailyMotivation = async () => {
|
const loadDailyMotivation = async () => {
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
@ -301,28 +183,42 @@ export default function HomeScreen() {
|
|||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reconcileDailyMetrics();
|
const loadPersistedData = async () => {
|
||||||
}, [reconcileDailyMetrics]);
|
const today = new Date().toDateString();
|
||||||
|
const storedCalories = await AsyncStorage.getItem(`calories_${today}`);
|
||||||
|
const storedWater = await AsyncStorage.getItem(`water_${today}`);
|
||||||
|
if (storedCalories) setCalories(parseInt(storedCalories, 10));
|
||||||
|
if (storedWater) setWaterIntake(parseInt(storedWater, 10));
|
||||||
|
};
|
||||||
|
loadPersistedData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const appStateSubscription = AppState.addEventListener(
|
const persistCalories = async () => {
|
||||||
"change",
|
const today = new Date().toDateString();
|
||||||
(state) => {
|
await AsyncStorage.setItem(`calories_${today}`, calories.toString());
|
||||||
if (state === "active") {
|
};
|
||||||
void reconcileDailyMetrics();
|
persistCalories();
|
||||||
}
|
}, [calories]);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
scheduleMidnightReset();
|
useEffect(() => {
|
||||||
|
const persistWater = async () => {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
await AsyncStorage.setItem(`water_${today}`, waterIntake.toString());
|
||||||
|
};
|
||||||
|
persistWater();
|
||||||
|
}, [waterIntake]);
|
||||||
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
appStateSubscription.remove();
|
const checkAndResetIfNeeded = async () => {
|
||||||
if (midnightResetTimerRef.current) {
|
const lastResetDate = await AsyncStorage.getItem("lastResetDate");
|
||||||
clearTimeout(midnightResetTimerRef.current);
|
const today = new Date().toDateString();
|
||||||
|
if (lastResetDate !== today) {
|
||||||
|
await resetAllCounters();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [reconcileDailyMetrics, scheduleMidnightReset]);
|
checkAndResetIfNeeded();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0;
|
const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0;
|
||||||
const currentStreak = statistics?.attendance.currentStreak || 0;
|
const currentStreak = statistics?.attendance.currentStreak || 0;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -47,9 +47,6 @@ export const API_ENDPOINTS = {
|
|||||||
MEMBERSHIP: {
|
MEMBERSHIP: {
|
||||||
FEATURES: "/api/membership/features",
|
FEATURES: "/api/membership/features",
|
||||||
},
|
},
|
||||||
FOOD: {
|
|
||||||
LOOKUP_BARCODE: (code: string) => `/api/food/barcode/${code}`,
|
|
||||||
},
|
|
||||||
NUTRITION: {
|
NUTRITION: {
|
||||||
BASE: "/api/nutrition",
|
BASE: "/api/nutrition",
|
||||||
MEALS: "/api/nutrition/meals",
|
MEALS: "/api/nutrition/meals",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user