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:
parent
a4cf099b2c
commit
fed6ba1b7b
3 changed files with 363 additions and 18 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue