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
This commit is contained in:
sirpiglr 2025-12-12 23:18:03 +00:00
parent a4cf099b2c
commit fed6ba1b7b
3 changed files with 363 additions and 18 deletions

View file

@ -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<AIChatProps> = ({
const [isLoading, setIsLoading] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [userBadges, setUserBadges] = useState<UserBadgeInfo[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(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<AIChatProps> = ({
onSelectPersona={handlePersonaChange}
userTier={userTier}
currentRealm={currentRealm}
userBadges={userBadges}
/>
<div className="flex items-center gap-2">
{messages.length > 1 && !isLoading && (

View file

@ -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<PersonaSelectorProps> = ({
currentPersona,
onSelectPersona,
userTier,
userBadges = [],
currentRealm
}) => {
const CurrentIcon = getPersonaIcon(currentPersona.icon);
@ -33,9 +41,21 @@ export const PersonaSelector: React.FC<PersonaSelectorProps> = ({
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 = (
<span className={`text-[10px] px-1.5 py-0.5 rounded flex items-center gap-1 ${
persona.requiredTier === 'Council'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{accessInfo.reason === 'badge' && <Award className="w-2.5 h-2.5" />}
{persona.requiredTier}
</span>
);
return (
<DropdownMenuItem
key={persona.id}
@ -51,19 +71,37 @@ export const PersonaSelector: React.FC<PersonaSelectorProps> = ({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{persona.name}</span>
{!hasAccess && <Lock className="w-3 h-3 text-muted-foreground" />}
{!hasAccess && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Lock className="w-3 h-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
Requires {persona.requiredTier} tier
{persona.unlockBadgeSlug && ` or "${persona.unlockBadgeSlug.replace(/_/g, ' ')}" badge`}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{accessInfo.reason === 'badge' && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Award className="w-3 h-3 text-amber-400" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Unlocked with {accessInfo.badgeName} badge</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className="text-xs text-muted-foreground truncate">{persona.description}</p>
</div>
{persona.requiredTier !== 'Free' && (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
persona.requiredTier === 'Council'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{persona.requiredTier}
</span>
)}
{persona.requiredTier !== 'Free' && tierBadgeContent}
</DropdownMenuItem>
);
};

View file

@ -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<AethexBadge[]> {
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<AethexUserBadge[]> {
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<string[]> {
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<boolean> {
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<boolean> {
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<AethexBadge | null> {
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<boolean> {
if (!userId) return false;
try {
ensureSupabase();
const updates: Record<string, any> = { 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 };
}
},
};