diff --git a/client/components/ai/AIChat.tsx b/client/components/ai/AIChat.tsx index bcc2563e..b80f9c10 100644 --- a/client/components/ai/AIChat.tsx +++ b/client/components/ai/AIChat.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import type { ChatMessage as ChatMessageType, Persona, ChatSession, UserTier } from '@/lib/ai/types'; +import type { ChatMessage as ChatMessageType, Persona, ChatSession, UserTier, UserBadgeInfo } from '@/lib/ai/types'; import { canAccessPersona } from '@/lib/ai/types'; import { PERSONAS, getDefaultPersona } from '@/lib/ai/personas'; import { runChat, generateTitle } from '@/lib/ai/gemini-service'; @@ -11,6 +11,7 @@ import { getPersonaIcon, CloseIcon, TrashIcon, SparklesIcon, ChatIcon } from './ import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useAuth } from '@/contexts/AuthContext'; +import { aethexBadgeService } from '@/lib/aethex-database-adapter'; interface AIChatProps { isOpen: boolean; @@ -25,8 +26,8 @@ const getUserTier = (roles: string[]): UserTier => { if (roles.includes('council') || roles.includes('admin') || roles.includes('owner')) { return 'Council'; } - if (roles.includes('architect') || roles.includes('staff') || roles.includes('premium')) { - return 'Architect'; + if (roles.includes('architect') || roles.includes('staff') || roles.includes('premium') || roles.includes('pro')) { + return 'Pro'; } return 'Free'; }; @@ -53,9 +54,35 @@ export const AIChat: React.FC = ({ const [isLoading, setIsLoading] = useState(false); const [sessions, setSessions] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(null); + const [userBadges, setUserBadges] = useState([]); const messagesEndRef = useRef(null); - const hasAccess = canAccessPersona(userTier, currentPersona.requiredTier); + const hasAccess = canAccessPersona(userTier, currentPersona.requiredTier, userBadges, currentPersona.unlockBadgeSlug); + + // Fetch user badges + useEffect(() => { + const fetchBadges = async () => { + if (!user?.id) { + setUserBadges([]); + return; + } + try { + const badges = await aethexBadgeService.getUserBadges(user.id); + const badgeInfos: UserBadgeInfo[] = badges + .filter(ub => ub.badge?.slug) + .map(ub => ({ + slug: ub.badge!.slug, + name: ub.badge!.name, + earnedAt: ub.earned_at, + })); + setUserBadges(badgeInfos); + } catch (err) { + console.warn('[AIChat] Failed to fetch user badges:', err); + setUserBadges([]); + } + }; + fetchBadges(); + }, [user?.id]); useEffect(() => { const stored = localStorage.getItem(STORAGE_KEY); @@ -190,6 +217,7 @@ export const AIChat: React.FC = ({ onSelectPersona={handlePersonaChange} userTier={userTier} currentRealm={currentRealm} + userBadges={userBadges} />
{messages.length > 1 && !isLoading && ( diff --git a/client/components/ai/PersonaSelector.tsx b/client/components/ai/PersonaSelector.tsx index 9a950269..6a8908c1 100644 --- a/client/components/ai/PersonaSelector.tsx +++ b/client/components/ai/PersonaSelector.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import type { Persona, UserTier } from '@/lib/ai/types'; -import { canAccessPersona } from '@/lib/ai/types'; +import type { Persona, UserTier, UserBadgeInfo } from '@/lib/ai/types'; +import { canAccessPersona, getPersonaAccessReason } from '@/lib/ai/types'; import { PERSONAS, getPersonasByRealm } from '@/lib/ai/personas'; import { getPersonaIcon, ChevronDownIcon } from './Icons'; -import { Lock } from 'lucide-react'; +import { Lock, Award } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -12,11 +12,18 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; interface PersonaSelectorProps { currentPersona: Persona; onSelectPersona: (persona: Persona) => void; userTier: UserTier; + userBadges?: UserBadgeInfo[]; currentRealm?: string; } @@ -24,6 +31,7 @@ export const PersonaSelector: React.FC = ({ currentPersona, onSelectPersona, userTier, + userBadges = [], currentRealm }) => { const CurrentIcon = getPersonaIcon(currentPersona.icon); @@ -33,9 +41,21 @@ export const PersonaSelector: React.FC = ({ const renderPersonaItem = (persona: Persona) => { const Icon = getPersonaIcon(persona.icon); - const hasAccess = canAccessPersona(userTier, persona.requiredTier); + const accessInfo = getPersonaAccessReason(userTier, persona.requiredTier, userBadges, persona.unlockBadgeSlug); + const hasAccess = accessInfo.hasAccess; const isSelected = persona.id === currentPersona.id; + const tierBadgeContent = ( + + {accessInfo.reason === 'badge' && } + {persona.requiredTier} + + ); + return ( = ({
{persona.name} - {!hasAccess && } + {!hasAccess && ( + + + + + + +

+ Requires {persona.requiredTier} tier + {persona.unlockBadgeSlug && ` or "${persona.unlockBadgeSlug.replace(/_/g, ' ')}" badge`} +

+
+
+
+ )} + {accessInfo.reason === 'badge' && ( + + + + + + +

Unlocked with {accessInfo.badgeName} badge

+
+
+
+ )}

{persona.description}

- {persona.requiredTier !== 'Free' && ( - - {persona.requiredTier} - - )} + {persona.requiredTier !== 'Free' && tierBadgeContent}
); }; diff --git a/client/lib/aethex-database-adapter.ts b/client/lib/aethex-database-adapter.ts index 2ff617a4..c243511f 100644 --- a/client/lib/aethex-database-adapter.ts +++ b/client/lib/aethex-database-adapter.ts @@ -1382,3 +1382,282 @@ export const aethexRoleService = { } }, }; + +// Badge Types +export interface AethexBadge { + id: string; + name: string; + slug: string; + description: string | null; + icon: string | null; + unlock_criteria: string | null; + unlocks_persona: string | null; + created_at: string; +} + +export interface AethexUserBadge { + id: string; + user_id: string; + badge_id: string; + earned_at: string; + badge?: AethexBadge; +} + +// Badge Services +export const aethexBadgeService = { + async getAllBadges(): Promise { + try { + ensureSupabase(); + const { data, error } = await supabase + .from("badges") + .select("*") + .order("name", { ascending: true }); + + if (error) { + if (isTableMissing(error)) return []; + throw error; + } + return (data as AethexBadge[]) || []; + } catch (err) { + console.warn("Failed to fetch badges:", err); + return []; + } + }, + + async getUserBadges(userId: string): Promise { + if (!userId) return []; + try { + ensureSupabase(); + const { data, error } = await supabase + .from("user_badges") + .select(` + *, + badge:badges(*) + `) + .eq("user_id", userId) + .order("earned_at", { ascending: false }); + + if (error) { + if (isTableMissing(error)) return []; + // Try fallback without join + const { data: fallbackData, error: fallbackError } = await supabase + .from("user_badges") + .select("*") + .eq("user_id", userId); + if (fallbackError) throw fallbackError; + return (fallbackData as AethexUserBadge[]) || []; + } + + // Transform the joined data + return ((data as any[]) || []).map((row) => ({ + id: row.id, + user_id: row.user_id, + badge_id: row.badge_id, + earned_at: row.earned_at, + badge: row.badge || undefined, + })); + } catch (err) { + console.warn("Failed to fetch user badges:", err); + return []; + } + }, + + async getUserBadgeSlugs(userId: string): Promise { + const userBadges = await this.getUserBadges(userId); + return userBadges + .filter((ub) => ub.badge?.slug) + .map((ub) => ub.badge!.slug); + }, + + async awardBadge(userId: string, badgeSlug: string): Promise { + if (!userId || !badgeSlug) return false; + try { + ensureSupabase(); + + // Find badge by slug + const { data: badge, error: badgeError } = await supabase + .from("badges") + .select("id, name") + .eq("slug", badgeSlug) + .single(); + + if (badgeError || !badge) { + console.warn(`Badge not found: ${badgeSlug}`); + return false; + } + + // Check if already awarded + const { data: existing } = await supabase + .from("user_badges") + .select("id") + .eq("user_id", userId) + .eq("badge_id", badge.id) + .maybeSingle(); + + if (existing) { + return true; // Already has badge + } + + // Award the badge + const { error: insertError } = await supabase + .from("user_badges") + .insert({ + user_id: userId, + badge_id: badge.id, + earned_at: new Date().toISOString(), + }); + + if (insertError) { + console.warn("Failed to award badge:", insertError); + return false; + } + + // Create notification + try { + await aethexNotificationService.createNotification( + userId, + "success", + `🏆 Badge Earned: ${badge.name}`, + `Congratulations! You've earned the "${badge.name}" badge.`, + ); + } catch (notifErr) { + console.warn("Failed to create badge notification:", notifErr); + } + + return true; + } catch (err) { + console.warn("Failed to award badge:", err); + return false; + } + }, + + async revokeBadge(userId: string, badgeSlug: string): Promise { + if (!userId || !badgeSlug) return false; + try { + ensureSupabase(); + + // Find badge by slug + const { data: badge } = await supabase + .from("badges") + .select("id") + .eq("slug", badgeSlug) + .single(); + + if (!badge) return false; + + const { error } = await supabase + .from("user_badges") + .delete() + .eq("user_id", userId) + .eq("badge_id", badge.id); + + if (error) { + console.warn("Failed to revoke badge:", error); + return false; + } + return true; + } catch (err) { + console.warn("Failed to revoke badge:", err); + return false; + } + }, + + async getBadgeBySlug(slug: string): Promise { + if (!slug) return null; + try { + ensureSupabase(); + const { data, error } = await supabase + .from("badges") + .select("*") + .eq("slug", slug) + .single(); + + if (error) return null; + return data as AethexBadge; + } catch { + return null; + } + }, +}; + +// Tier Service +export const aethexTierService = { + async getUserTier(userId: string): Promise<"free" | "pro" | "council"> { + if (!userId) return "free"; + try { + ensureSupabase(); + const { data, error } = await supabase + .from("user_profiles") + .select("tier") + .eq("id", userId) + .single(); + + if (error || !data) return "free"; + return (data.tier as "free" | "pro" | "council") || "free"; + } catch { + return "free"; + } + }, + + async setUserTier( + userId: string, + tier: "free" | "pro" | "council", + stripeCustomerId?: string, + stripeSubscriptionId?: string, + ): Promise { + if (!userId) return false; + try { + ensureSupabase(); + const updates: Record = { tier }; + if (stripeCustomerId !== undefined) updates.stripe_customer_id = stripeCustomerId; + if (stripeSubscriptionId !== undefined) updates.stripe_subscription_id = stripeSubscriptionId; + + const { error } = await supabase + .from("user_profiles") + .update(updates) + .eq("id", userId); + + if (error) { + console.warn("Failed to update tier:", error); + return false; + } + + // Notify on upgrade + if (tier !== "free") { + try { + await aethexNotificationService.createNotification( + userId, + "success", + `⭐ Welcome to ${tier === "council" ? "Council" : "Pro"}!`, + `Your subscription is now active. Enjoy access to premium AI personas!`, + ); + } catch {} + } + + return true; + } catch (err) { + console.warn("Failed to set tier:", err); + return false; + } + }, + + async getStripeInfo(userId: string): Promise<{ customerId: string | null; subscriptionId: string | null }> { + if (!userId) return { customerId: null, subscriptionId: null }; + try { + ensureSupabase(); + const { data, error } = await supabase + .from("user_profiles") + .select("stripe_customer_id, stripe_subscription_id") + .eq("id", userId) + .single(); + + if (error || !data) return { customerId: null, subscriptionId: null }; + return { + customerId: data.stripe_customer_id || null, + subscriptionId: data.stripe_subscription_id || null, + }; + } catch { + return { customerId: null, subscriptionId: null }; + } + }, +};