diff --git a/apps/admin/src/app/api/hydration/route.ts b/apps/admin/src/app/api/hydration/route.ts index f22ba44..807ea16 100644 --- a/apps/admin/src/app/api/hydration/route.ts +++ b/apps/admin/src/app/api/hydration/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getDatabase } from "@/lib/database"; import { ensureUserSynced } from "@/lib/sync-user"; import log from "@/lib/logger"; +import { getUserMembershipContext } from "@/lib/membership/access"; export async function POST(req: NextRequest) { try { @@ -11,6 +12,18 @@ export async function POST(req: NextRequest) { const db = await getDatabase(); await ensureUserSynced(userId, db); + const { features, membershipType } = await getUserMembershipContext(userId); + + if (!features.hydrationTracking) { + return NextResponse.json( + { + error: + "Hydration tracking is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } const body = await req.json(); const { date, entries, totalWater, waterGoal } = body; @@ -58,6 +71,18 @@ export async function GET(req: NextRequest) { const db = await getDatabase(); await ensureUserSynced(userId, db); + const { features, membershipType } = await getUserMembershipContext(userId); + + if (!features.hydrationTracking) { + return NextResponse.json( + { + error: + "Hydration tracking is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } const url = new URL(req.url); const date = url.searchParams.get("date"); @@ -100,6 +125,18 @@ export async function DELETE(req: NextRequest) { const db = await getDatabase(); await ensureUserSynced(userId, db); + const { features, membershipType } = await getUserMembershipContext(userId); + + if (!features.hydrationTracking) { + return NextResponse.json( + { + error: + "Hydration tracking is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } const url = new URL(req.url); const id = url.searchParams.get("id"); diff --git a/apps/admin/src/app/api/nutrition/meals/route.ts b/apps/admin/src/app/api/nutrition/meals/route.ts index dd1f8b2..8fc0d69 100644 --- a/apps/admin/src/app/api/nutrition/meals/route.ts +++ b/apps/admin/src/app/api/nutrition/meals/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getDatabase } from "@/lib/database"; import { ensureUserSynced } from "@/lib/sync-user"; import log from "@/lib/logger"; +import { getUserMembershipContext } from "@/lib/membership/access"; export async function POST(req: NextRequest) { try { @@ -11,6 +12,18 @@ export async function POST(req: NextRequest) { const db = await getDatabase(); await ensureUserSynced(userId, db); + const { features, membershipType } = await getUserMembershipContext(userId); + + if (!features.nutritionTracking) { + return NextResponse.json( + { + error: + "Nutrition tracking is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } const body = await req.json(); const { @@ -59,6 +72,18 @@ export async function GET(req: NextRequest) { const db = await getDatabase(); await ensureUserSynced(userId, db); + const { features, membershipType } = await getUserMembershipContext(userId); + + if (!features.nutritionTracking) { + return NextResponse.json( + { + error: + "Nutrition tracking is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } const url = new URL(req.url); const date = url.searchParams.get("date"); @@ -88,6 +113,18 @@ export async function DELETE(req: NextRequest) { const db = await getDatabase(); await ensureUserSynced(userId, db); + const { features, membershipType } = await getUserMembershipContext(userId); + + if (!features.nutritionTracking) { + return NextResponse.json( + { + error: + "Nutrition tracking is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } const url = new URL(req.url); const id = url.searchParams.get("id"); diff --git a/apps/admin/src/app/api/nutrition/route.ts b/apps/admin/src/app/api/nutrition/route.ts index 88206f8..b85b47a 100644 --- a/apps/admin/src/app/api/nutrition/route.ts +++ b/apps/admin/src/app/api/nutrition/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getDatabase } from "@/lib/database"; import { ensureUserSynced } from "@/lib/sync-user"; import log from "@/lib/logger"; +import { getUserMembershipContext } from "@/lib/membership/access"; export async function POST(req: NextRequest) { try { @@ -11,6 +12,18 @@ export async function POST(req: NextRequest) { const db = await getDatabase(); await ensureUserSynced(userId, db); + const { features, membershipType } = await getUserMembershipContext(userId); + + if (!features.nutritionTracking) { + return NextResponse.json( + { + error: + "Nutrition tracking is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } const body = await req.json(); const { date, meals, totalCalories, calorieGoal } = body; @@ -58,6 +71,18 @@ export async function GET(req: NextRequest) { const db = await getDatabase(); await ensureUserSynced(userId, db); + const { features, membershipType } = await getUserMembershipContext(userId); + + if (!features.nutritionTracking) { + return NextResponse.json( + { + error: + "Nutrition tracking is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } const url = new URL(req.url); const date = url.searchParams.get("date"); @@ -100,6 +125,18 @@ export async function DELETE(req: NextRequest) { const db = await getDatabase(); await ensureUserSynced(userId, db); + const { features, membershipType } = await getUserMembershipContext(userId); + + if (!features.nutritionTracking) { + return NextResponse.json( + { + error: + "Nutrition tracking is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } const url = new URL(req.url); const id = url.searchParams.get("id"); diff --git a/apps/admin/src/app/api/recommendations/generate/route.ts b/apps/admin/src/app/api/recommendations/generate/route.ts index a3d5d80..c86d166 100644 --- a/apps/admin/src/app/api/recommendations/generate/route.ts +++ b/apps/admin/src/app/api/recommendations/generate/route.ts @@ -5,6 +5,7 @@ import { buildAIContext } from "@/lib/ai/ai-context"; import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder"; import log from "@/lib/logger"; import { ensureUserSynced } from "@/lib/sync-user"; +import { getUserMembershipContext } from "@/lib/membership/access"; export async function POST(req: Request) { try { @@ -49,6 +50,41 @@ export async function POST(req: Request) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } + const { membershipType, features } = await getUserMembershipContext(userId); + + if (features.recommendationsPerMonth === 1) { + const currentMonth = new Date(); + const monthStart = new Date( + currentMonth.getFullYear(), + currentMonth.getMonth(), + 1, + ); + const monthEnd = new Date( + currentMonth.getFullYear(), + currentMonth.getMonth() + 1, + 1, + ); + + const existingRecommendations = + await db.getRecommendationsByUserId(userId); + const recommendationsThisMonth = existingRecommendations.filter( + (recommendation) => + recommendation.generatedAt >= monthStart && + recommendation.generatedAt < monthEnd, + ).length; + + if (recommendationsThisMonth >= 1) { + return NextResponse.json( + { + error: + "Basic membership includes 1 recommendation per month. Upgrade to Premium or VIP for unlimited recommendations.", + membershipType, + }, + { status: 403 }, + ); + } + } + if (currentUser.role !== "superAdmin") { if (!currentUser.gymId || targetUser.gymId !== currentUser.gymId) { return NextResponse.json( diff --git a/apps/admin/src/app/settings/page.tsx b/apps/admin/src/app/settings/page.tsx index 4e286f5..16d5e8c 100644 --- a/apps/admin/src/app/settings/page.tsx +++ b/apps/admin/src/app/settings/page.tsx @@ -17,6 +17,7 @@ import { import { Button } from "@/components/ui/button"; import { useUser } from "@clerk/nextjs"; import log from "@/lib/logger"; +import { MEMBERSHIP_FEATURES } from "@/lib/membership/features"; interface Backup { name: string; @@ -558,6 +559,109 @@ export default function SettingsPage() { )} + + {/* Membership Feature Access */} +
+
+ Membership Feature Access +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Feature + + Basic + + Premium + + VIP +
+ Recommendations per month + + {MEMBERSHIP_FEATURES.basic.recommendationsPerMonth} + + Unlimited + + Unlimited +
+ Nutrition tracking + + {MEMBERSHIP_FEATURES.basic.nutritionTracking + ? "Yes" + : "No"} + + {MEMBERSHIP_FEATURES.premium.nutritionTracking + ? "Yes" + : "No"} + + {MEMBERSHIP_FEATURES.vip.nutritionTracking + ? "Yes" + : "No"} +
+ Hydration tracking + + {MEMBERSHIP_FEATURES.basic.hydrationTracking + ? "Yes" + : "No"} + + {MEMBERSHIP_FEATURES.premium.hydrationTracking + ? "Yes" + : "No"} + + {MEMBERSHIP_FEATURES.vip.hydrationTracking + ? "Yes" + : "No"} +
+ Advanced statistics + + {MEMBERSHIP_FEATURES.basic.advancedStatistics + ? "Yes" + : "No"} + + {MEMBERSHIP_FEATURES.premium.advancedStatistics + ? "Yes" + : "No"} + + {MEMBERSHIP_FEATURES.vip.advancedStatistics + ? "Yes" + : "No"} +
+
+
) : (
diff --git a/apps/admin/src/lib/membership/access.ts b/apps/admin/src/lib/membership/access.ts new file mode 100644 index 0000000..a6aae53 --- /dev/null +++ b/apps/admin/src/lib/membership/access.ts @@ -0,0 +1,26 @@ +import { getDatabase } from "@/lib/database"; +import { getMembershipFeatures } from "./features"; + +export async function getUserMembershipContext(userId: string): Promise<{ + membershipType: "basic" | "premium" | "vip"; + features: ReturnType; +}> { + const db = await getDatabase(); + const user = await db.getUserById(userId); + + if (!user || user.role !== "client") { + const membershipType = "vip" as const; + return { + membershipType, + features: getMembershipFeatures(membershipType), + }; + } + + const client = await db.getClientByUserId(userId); + const membershipType = client?.membershipType ?? "basic"; + + return { + membershipType, + features: getMembershipFeatures(membershipType), + }; +} diff --git a/apps/admin/src/lib/membership/features.ts b/apps/admin/src/lib/membership/features.ts new file mode 100644 index 0000000..b9bb65c --- /dev/null +++ b/apps/admin/src/lib/membership/features.ts @@ -0,0 +1,35 @@ +import type { MembershipType } from "@/lib/validation/schemas"; + +export interface MembershipFeatures { + recommendationsPerMonth: number; + hydrationTracking: boolean; + nutritionTracking: boolean; + advancedStatistics: boolean; +} + +export const MEMBERSHIP_FEATURES: Record = { + basic: { + recommendationsPerMonth: 1, + hydrationTracking: false, + nutritionTracking: false, + advancedStatistics: false, + }, + premium: { + recommendationsPerMonth: -1, + hydrationTracking: true, + nutritionTracking: true, + advancedStatistics: true, + }, + vip: { + recommendationsPerMonth: -1, + hydrationTracking: true, + nutritionTracking: true, + advancedStatistics: true, + }, +}; + +export function getMembershipFeatures( + membershipType: MembershipType, +): MembershipFeatures { + return MEMBERSHIP_FEATURES[membershipType]; +} diff --git a/apps/mobile/src/api/index.ts b/apps/mobile/src/api/index.ts index 1776eda..0acc3c8 100644 --- a/apps/mobile/src/api/index.ts +++ b/apps/mobile/src/api/index.ts @@ -14,4 +14,5 @@ export * from "./nutrition"; export * from "./hydration"; export * from "./client"; export * from "./helpers"; +export * from "./membership"; export * from "./gyms"; diff --git a/apps/mobile/src/api/membership.ts b/apps/mobile/src/api/membership.ts new file mode 100644 index 0000000..802ff14 --- /dev/null +++ b/apps/mobile/src/api/membership.ts @@ -0,0 +1,87 @@ +import { apiClient, withAuth } from "./client"; +import { API_ENDPOINTS } from "../config/api"; + +export type MembershipType = "basic" | "premium" | "vip"; + +export interface MembershipFeatures { + recommendationsPerMonth: number; + hydrationTracking: boolean; + nutritionTracking: boolean; + advancedStatistics: boolean; +} + +const MEMBERSHIP_FEATURES: Record = { + basic: { + recommendationsPerMonth: 1, + hydrationTracking: false, + nutritionTracking: false, + advancedStatistics: false, + }, + premium: { + recommendationsPerMonth: -1, + hydrationTracking: true, + nutritionTracking: true, + advancedStatistics: true, + }, + vip: { + recommendationsPerMonth: -1, + hydrationTracking: true, + nutritionTracking: true, + advancedStatistics: true, + }, +}; + +interface UsersListResponse { + success?: boolean; + data?: { + users?: Array<{ + id: string; + role: string; + client?: { + membershipType?: MembershipType; + } | null; + }>; + }; + users?: Array<{ + id: string; + role: string; + client?: { + membershipType?: MembershipType; + } | null; + }>; +} + +function isMembershipType(value: unknown): value is MembershipType { + return value === "basic" || value === "premium" || value === "vip"; +} + +export async function getCurrentMembershipType( + userId: string, + token: string | null, +): Promise { + if (!token || !userId) { + return "basic"; + } + + const response = await apiClient.get( + API_ENDPOINTS.USERS.LIST, + withAuth(token), + ); + + const payload = response.data; + const users = payload.data?.users ?? payload.users ?? []; + const currentUser = users.find((user) => user.id === userId); + + if (!currentUser || currentUser.role !== "client") { + return "vip"; + } + + const membershipType = currentUser.client?.membershipType; + return isMembershipType(membershipType) ? membershipType : "basic"; +} + +export function getMembershipFeatures( + membershipType: MembershipType, +): MembershipFeatures { + return MEMBERSHIP_FEATURES[membershipType]; +} diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index ecdeb1f..0933f02 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -7,6 +7,7 @@ import { Image, Animated, TouchableOpacity, + Alert, } from "react-native"; import { useUser } from "@clerk/clerk-expo"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; @@ -24,6 +25,7 @@ import { TrackMealModal } from "../../components/TrackMealModal"; import { AddWaterModal } from "../../components/AddWaterModal"; import { ScanFoodModal } from "../../components/ScanFoodModal"; import { ActivityRing } from "../../components/ActivityRing"; +import { useMembership } from "../../hooks/useMembership"; import { checkInsToActivities, completedGoalsToActivities, @@ -44,6 +46,7 @@ const WORKOUT_GOAL = 3; export default function HomeScreen() { const { user } = useUser(); const { colors, typography } = useTheme(); + const { features, membershipType } = useMembership(); const { refetchStatistics, forceRefresh, statistics, loading } = useStatistics(); const { goals, refetchGoals } = useFitnessGoals(); @@ -386,7 +389,16 @@ export default function HomeScreen() { setTrackMealModalVisible(true)} + onPress={() => { + if (!features.nutritionTracking) { + Alert.alert( + "Premium Feature", + "Meal tracking is available on Premium and VIP plans.", + ); + return; + } + setTrackMealModalVisible(true); + }} activeOpacity={0.85} style={[ styles.quickActionCard, @@ -417,7 +429,16 @@ export default function HomeScreen() { setAddWaterModalVisible(true)} + onPress={() => { + if (!features.hydrationTracking) { + Alert.alert( + "Premium Feature", + "Hydration tracking is available on Premium and VIP plans.", + ); + return; + } + setAddWaterModalVisible(true); + }} activeOpacity={0.85} style={[styles.quickActionCard, { backgroundColor: colors.info }]} > @@ -479,6 +500,23 @@ export default function HomeScreen() { {/* Today's Progress */} + {!features.nutritionTracking || !features.hydrationTracking ? ( + + + {membershipType === "basic" + ? "Upgrade to Premium or VIP to unlock nutrition and hydration tracking." + : "Some advanced tracking features are unavailable on your plan."} + + + ) : null} ([]); const [gymsLoading, setGymsLoading] = useState(false); @@ -203,8 +205,8 @@ export default function ProfileScreen() { {user?.primaryEmailAddress?.emailAddress} diff --git a/apps/mobile/src/app/(tabs)/recommendations.tsx b/apps/mobile/src/app/(tabs)/recommendations.tsx index 801af20..ac047bb 100644 --- a/apps/mobile/src/app/(tabs)/recommendations.tsx +++ b/apps/mobile/src/app/(tabs)/recommendations.tsx @@ -21,6 +21,7 @@ import { IconContainer } from "../../components/IconContainer"; import { useRecommendations } from "../../contexts/RecommendationsContext"; import { useNotifications } from "../../contexts/NotificationsContext"; import { NotificationsModal } from "../../components/NotificationsModal"; +import { useMembership } from "../../hooks/useMembership"; import type { Recommendation } from "../../api/recommendations"; import log from "../../utils/logger"; @@ -33,6 +34,7 @@ export default function RecommendationsScreen() { refetchRecommendations, generateNewRecommendation, } = useRecommendations(); + const { membershipType, features } = useMembership(); const { unreadCount, refetchNotifications } = useNotifications(); const [generating, setGenerating] = useState(false); const [refreshing, setRefreshing] = useState(false); @@ -69,6 +71,17 @@ export default function RecommendationsScreen() { const handleGenerateRecommendation = async () => { if (!user?.id) return; + if ( + features.recommendationsPerMonth === 1 && + allRecommendations.length >= 1 + ) { + Alert.alert( + "Basic Plan Limit", + "Basic plan includes 1 recommendation per month. Upgrade to Premium or VIP for unlimited recommendations.", + ); + return; + } + Alert.alert( "Generate AI Recommendation", "Generate a personalized fitness and nutrition plan based on your profile and goals?", @@ -180,7 +193,11 @@ export default function RecommendationsScreen() { {/* Generate Button */} + + {membershipType === "basic" + ? `Basic plan: ${Math.max(0, 1 - allRecommendations.length)} recommendation left this month` + : `${membershipType.toUpperCase()} plan: unlimited recommendations`} + {/* Recommendations List */} diff --git a/apps/mobile/src/hooks/useMembership.ts b/apps/mobile/src/hooks/useMembership.ts new file mode 100644 index 0000000..35fb342 --- /dev/null +++ b/apps/mobile/src/hooks/useMembership.ts @@ -0,0 +1,68 @@ +import { useAuth, useUser } from "@clerk/clerk-expo"; +import { useEffect, useState } from "react"; +import { + getCurrentMembershipType, + getMembershipFeatures, + type MembershipFeatures, + type MembershipType, +} from "../api/membership"; +import log from "../utils/logger"; + +const BASIC_FEATURES = getMembershipFeatures("basic"); + +interface UseMembershipResult { + membershipType: MembershipType; + features: MembershipFeatures; + loading: boolean; +} + +export function useMembership(): UseMembershipResult { + const { user } = useUser(); + const { getToken, isSignedIn } = useAuth(); + const [membershipType, setMembershipType] = useState("basic"); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + const loadMembership = async () => { + if (!isSignedIn || !user?.id) { + if (isMounted) { + setMembershipType("basic"); + setLoading(false); + } + return; + } + + try { + setLoading(true); + const token = await getToken(); + const type = await getCurrentMembershipType(user.id, token); + if (isMounted) { + setMembershipType(type); + } + } catch (error) { + log.error("Failed to load membership", error, { userId: user.id }); + if (isMounted) { + setMembershipType("basic"); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + loadMembership(); + + return () => { + isMounted = false; + }; + }, [isSignedIn, user?.id, getToken]); + + return { + membershipType, + features: getMembershipFeatures(membershipType) || BASIC_FEATURES, + loading, + }; +} diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 1441f22..ff3ce46 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -18,6 +18,35 @@ export type GymStatus = (typeof GYM_STATUSES)[number]; export const MEMBERSHIP_TYPES = ["basic", "premium", "vip"] as const; export type MembershipType = (typeof MEMBERSHIP_TYPES)[number]; +export const MEMBERSHIP_FEATURES = { + basic: { + recommendationsPerMonth: 1, + hydrationTracking: false, + nutritionTracking: false, + advancedStatistics: false, + }, + premium: { + recommendationsPerMonth: -1, + hydrationTracking: true, + nutritionTracking: true, + advancedStatistics: true, + }, + vip: { + recommendationsPerMonth: -1, + hydrationTracking: true, + nutritionTracking: true, + advancedStatistics: true, + }, +} as const; + +export type MembershipFeatures = (typeof MEMBERSHIP_FEATURES)[MembershipType]; + +export function getMembershipFeatures( + membershipType: MembershipType, +): MembershipFeatures { + return MEMBERSHIP_FEATURES[membershipType]; +} + // Membership Statuses export const MEMBERSHIP_STATUSES = ["active", "inactive", "suspended"] as const; export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number];