From fed6ba1b7bcf044fa4127451237023073b478400 Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Fri, 12 Dec 2025 23:18:03 +0000 Subject: [PATCH] Enhance AI chat with user badge support and tier upgrades Update AIChat.tsx to fetch and display user badges, enabling persona access based on badge ownership. Modify getUserTier to recognize 'Pro' users and update PersonaSelector to show badge-based access. Add aethexBadgeService to aethex-database-adapter.ts. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: f38d8505-dec9-46ac-8d03-1ec1b211b9ac Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/LFvmEVc Replit-Helium-Checkpoint-Created: true --- client/components/ai/AIChat.tsx | 36 ++- client/components/ai/PersonaSelector.tsx | 66 ++++-- client/lib/aethex-database-adapter.ts | 279 +++++++++++++++++++++++ 3 files changed, 363 insertions(+), 18 deletions(-) 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 }; + } + }, +};