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];