feat(marketplace): full persona page + categories + enrichment script
- GET /api/marketplace/personas/:id — returns all fields of a published persona - Expand _COPY_FIELDS to include all new schema fields (oceanTraits, scenarios, aiSynthesizedBio, etc.) - New MarketplacePersonaView page (/marketplace/personas/:id) with full sidebar + tabs - Preview button navigates to full page instead of opening a summary dialog - Clicking card body also navigates to persona page - scripts/enrich_old_personas.py — LLM enrichment for 12 pre-schema personas Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a8b0a40e08
commit
604fdb9458
6 changed files with 371 additions and 81 deletions
|
|
@ -27,13 +27,28 @@ _PAGE_SIZE = 24
|
|||
|
||||
_COPY_FIELDS = {
|
||||
"name", "age", "gender", "occupation", "education", "location",
|
||||
"techSavviness", "personality", "interests", "brandLoyalty",
|
||||
"priceConsciousness", "environmentalConcern", "hasPurchasingPower",
|
||||
"hasChildren", "thinkFeelDo", "description", "imageUrl",
|
||||
"llm_model", "traits", "background", "goals",
|
||||
"ethnicity", "socialGrade",
|
||||
"techSavviness", "brandLoyalty", "priceConsciousness",
|
||||
"environmentalConcern", "hasPurchasingPower", "hasChildren",
|
||||
"personality", "oceanTraits",
|
||||
"interests", "description", "imageUrl",
|
||||
"goals", "frustrations", "motivations", "scenarios", "scenarioType",
|
||||
"thinkFeelDo",
|
||||
"householdComposition", "householdIncome", "livingSituation",
|
||||
"mediaConsumption", "deviceUsage", "shoppingHabits",
|
||||
"brandPreferences", "communicationPreferences", "additionalInformation",
|
||||
"coreValues", "lifestyleChoices", "socialActivities",
|
||||
"categoryKnowledge", "paymentMethods", "purchaseBehaviour",
|
||||
"decisionInfluences", "painPoints", "journeyContext",
|
||||
"keyTouchpoints", "selfDeterminationNeeds", "fears", "narrative",
|
||||
"aiSynthesizedBio", "qualitativeAttributes", "topPersonalityTraits",
|
||||
"audience_brief", "research_objective",
|
||||
"llm_model", "traits", "background",
|
||||
"communication_style", "values", "demographics",
|
||||
}
|
||||
|
||||
_PUBLIC_FIELDS = {k: 1 for k in _COPY_FIELDS} | {"marketplace": 1}
|
||||
|
||||
|
||||
@marketplace_bp.route('/personas', methods=['GET'])
|
||||
@jwt_required()
|
||||
|
|
@ -89,6 +104,34 @@ async def list_personas():
|
|||
}), 200
|
||||
|
||||
|
||||
@marketplace_bp.route('/personas/<persona_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
async def get_persona(persona_id: str):
|
||||
db = await get_db()
|
||||
try:
|
||||
oid = ObjectId(persona_id)
|
||||
except Exception:
|
||||
return jsonify({"message": "Invalid persona id"}), 400
|
||||
|
||||
persona = await db.personas.find_one(
|
||||
{"_id": oid, "marketplace.published": True},
|
||||
{k: 1 for k in _COPY_FIELDS} | {"marketplace": 1},
|
||||
)
|
||||
if not persona:
|
||||
return jsonify({"message": "Persona not found"}), 404
|
||||
|
||||
persona["_id"] = str(persona["_id"])
|
||||
|
||||
buyer_id = get_jwt_identity()
|
||||
existing = await db.personas.find_one(
|
||||
{"created_by": buyer_id, "marketplace.source_persona_id": persona_id},
|
||||
{"_id": 1},
|
||||
)
|
||||
persona["already_owned"] = existing is not None
|
||||
|
||||
return jsonify(persona), 200
|
||||
|
||||
|
||||
@marketplace_bp.route('/personas/<persona_id>/purchase', methods=['POST'])
|
||||
@jwt_required()
|
||||
async def purchase_persona(persona_id: str):
|
||||
|
|
|
|||
123
backend/scripts/enrich_old_personas.py
Normal file
123
backend/scripts/enrich_old_personas.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enrich old marketplace personas with new fields (aiSynthesizedBio, oceanTraits,
|
||||
frustrations, motivations, scenarios, etc.) that were added to the schema later.
|
||||
|
||||
Run inside the backend container:
|
||||
docker compose exec backend python scripts/enrich_old_personas.py
|
||||
|
||||
Or locally (if env vars set):
|
||||
cd backend && python scripts/enrich_old_personas.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from bson import ObjectId
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
|
||||
# These are the 12 personas that existed before the schema expansion
|
||||
OLD_PERSONA_IDS = [
|
||||
"6a142d1d80c6d60341c9c158", # Marcus Johnson
|
||||
"6a142d1d80c6d60341c9c15a", # Sophie Rahman
|
||||
"6a142d2180c6d60341c9c15f", # Priya Nair
|
||||
"6a142d2180c6d60341c9c161", # Olivia Tran
|
||||
"6a142d2280c6d60341c9c163", # Daniel O'Connor
|
||||
"6a147f76326d8df15a5ef7c4", # Alex Chen
|
||||
"6a147f76326d8df15a5ef7c5", # Emma Clarke
|
||||
"6a147f76326d8df15a5ef7c6", # Marcus Thorne
|
||||
"6a147f76326d8df15a5ef7c7", # Jordan Blake
|
||||
"6a147f76326d8df15a5ef7c8", # Marcus Webb
|
||||
"6a147f76326d8df15a5ef7c9", # Sofia Park
|
||||
"6a147f76326d8df15a5ef7ca", # Victor Stone
|
||||
]
|
||||
|
||||
# Fields present in old personas that are missing from new schema
|
||||
ENRICHMENT_FIELDS = [
|
||||
"frustrations", "motivations", "scenarios",
|
||||
"oceanTraits", "householdComposition", "householdIncome",
|
||||
"livingSituation", "mediaConsumption", "deviceUsage",
|
||||
"shoppingHabits", "brandPreferences", "communicationPreferences",
|
||||
"aiSynthesizedBio", "qualitativeAttributes", "topPersonalityTraits",
|
||||
]
|
||||
|
||||
|
||||
async def enrich_persona(db, persona: dict) -> bool:
|
||||
from app.services.ai_persona_service import generate_persona, generate_persona_summary
|
||||
|
||||
name = persona.get("name", "Unknown")
|
||||
pid = str(persona["_id"])
|
||||
|
||||
missing = [f for f in ENRICHMENT_FIELDS if f not in persona]
|
||||
if not missing:
|
||||
print(f" ⏭ {name}: already complete, skipping")
|
||||
return True
|
||||
|
||||
print(f"\n→ Enriching: {name} ({pid})")
|
||||
print(f" Missing fields: {missing}")
|
||||
|
||||
update = {}
|
||||
|
||||
# Summary fields (aiSynthesizedBio, qualitativeAttributes, topPersonalityTraits)
|
||||
summary_missing = [f for f in ["aiSynthesizedBio", "qualitativeAttributes", "topPersonalityTraits"] if f not in persona]
|
||||
if summary_missing:
|
||||
try:
|
||||
print(f" 🤖 Generating summary...")
|
||||
summary = await generate_persona_summary(persona, llm_model="gpt-5.4-mini")
|
||||
for f in summary_missing:
|
||||
if f in summary:
|
||||
update[f] = summary[f]
|
||||
print(f" ✓ Summary fields: {list(summary.keys())}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Summary generation failed: {e}")
|
||||
|
||||
# Detailed fields via full generation with old persona as base
|
||||
detail_missing = [f for f in missing if f not in ["aiSynthesizedBio", "qualitativeAttributes", "topPersonalityTraits"]]
|
||||
if detail_missing:
|
||||
try:
|
||||
print(f" 🤖 Generating detailed enrichment...")
|
||||
detailed = await generate_persona(basic_persona=persona, llm_model="gpt-5.4")
|
||||
for f in detail_missing:
|
||||
if f in detailed and f not in persona:
|
||||
update[f] = detailed[f]
|
||||
print(f" ✓ Detailed fields added: {[f for f in detail_missing if f in update]}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Detailed generation failed: {e}")
|
||||
|
||||
if update:
|
||||
await db.personas.update_one({"_id": persona["_id"]}, {"$set": update})
|
||||
print(f" ✅ Saved {len(update)} new fields to DB")
|
||||
else:
|
||||
print(f" ⚠ Nothing to save")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def main():
|
||||
mongo_uri = os.environ.get("MONGO_URI", "mongodb://mongo:27017/cohorta_db")
|
||||
client = AsyncIOMotorClient(mongo_uri)
|
||||
db = client.get_default_database()
|
||||
|
||||
print(f"Connected to: {mongo_uri}")
|
||||
print(f"Processing {len(OLD_PERSONA_IDS)} old personas...\n")
|
||||
|
||||
ok = 0
|
||||
for pid in OLD_PERSONA_IDS:
|
||||
persona = await db.personas.find_one({"_id": ObjectId(pid)})
|
||||
if not persona:
|
||||
print(f"⚠ Not found: {pid}")
|
||||
continue
|
||||
success = await enrich_persona(db, persona)
|
||||
if success:
|
||||
ok += 1
|
||||
|
||||
print(f"\n✅ Done — enriched {ok}/{len(OLD_PERSONA_IDS)} personas")
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -25,6 +25,7 @@ import Admin from "./pages/Admin";
|
|||
import MyUsage from "./pages/MyUsage";
|
||||
import Billing from "./pages/Billing";
|
||||
import PersonaMarketplace from "./pages/PersonaMarketplace";
|
||||
import MarketplacePersonaView from "./pages/MarketplacePersonaView";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import AdminRoute from "./components/admin/AdminRoute";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
|
|
@ -83,6 +84,7 @@ const App = () => (
|
|||
<Route path="/billing" element={<Billing />} />
|
||||
<Route path="/usage" element={<MyUsage />} />
|
||||
<Route path="/marketplace" element={<PersonaMarketplace />} />
|
||||
<Route path="/marketplace/personas/:id" element={<MarketplacePersonaView />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin */}
|
||||
|
|
|
|||
|
|
@ -823,6 +823,8 @@ export const adminApi = {
|
|||
export const marketplaceApi = {
|
||||
list: (params?: { search?: string; category?: string; page?: number }) =>
|
||||
api.get('/marketplace/personas', { params }),
|
||||
getById: (id: string) =>
|
||||
api.get(`/marketplace/personas/${id}`),
|
||||
purchase: (id: string) =>
|
||||
api.post(`/marketplace/personas/${id}/purchase`, {}),
|
||||
};
|
||||
|
|
|
|||
186
src/pages/MarketplacePersonaView.tsx
Normal file
186
src/pages/MarketplacePersonaView.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { marketplaceApi } from '@/lib/api';
|
||||
import { Persona } from '@/types/persona';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
ArrowLeft, ShoppingCart, CheckCircle2, Zap, Loader2,
|
||||
Target, Sparkles, BookOpen, SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { PersonaSidebar } from '@/components/persona/PersonaSidebar';
|
||||
import { PersonaAttitudinalProfile } from '@/components/persona/PersonaAttitudinalProfile';
|
||||
import { PersonaPersonality } from '@/components/persona/PersonaPersonality';
|
||||
import { PersonaScenarios } from '@/components/persona/PersonaScenarios';
|
||||
import { PersonaGenerationPrompts } from '@/components/persona/PersonaGenerationPrompts';
|
||||
import { PersonaProfileSkeleton } from '@/components/persona/PersonaProfileSkeleton';
|
||||
|
||||
export default function MarketplacePersonaView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [persona, setPersona] = useState<(Persona & { already_owned?: boolean; marketplace?: { price?: number; category?: string } }) | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [buying, setBuying] = useState(false);
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
marketplaceApi.getById(id)
|
||||
.then(r => setPersona({ ...r.data, id: r.data._id }))
|
||||
.catch(() => { toast.error(t('common.error', 'Error')); navigate('/marketplace'); })
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!persona) return;
|
||||
setBuying(true);
|
||||
try {
|
||||
await marketplaceApi.purchase(persona._id!);
|
||||
setPersona(p => p ? { ...p, already_owned: true } : p);
|
||||
toast.success(`${persona.name} ${t('marketplace.purchase_success', 'added to your personas!')}`);
|
||||
setConfirm(false);
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
if (msg === 'Insufficient credits') toast.error(t('marketplace.insufficient_credits', 'Not enough credits'));
|
||||
else if (msg === 'Already owned') toast.info(t('marketplace.already_owned', 'Already in your collection'));
|
||||
else toast.error(t('common.error', 'Purchase failed'));
|
||||
} finally {
|
||||
setBuying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <PersonaProfileSkeleton />;
|
||||
if (!persona) return null;
|
||||
|
||||
const price = persona.marketplace?.price ?? 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/marketplace')}
|
||||
className="h-8 w-8 p-0 rounded-lg border border-border/60 hover:border-primary/40"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<p
|
||||
className="text-[10px] font-medium text-muted-foreground uppercase tracking-widest"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
{t('marketplace.title', 'Persona Marketplace')}
|
||||
</p>
|
||||
<h1
|
||||
className="text-2xl font-bold text-foreground leading-tight"
|
||||
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
||||
>
|
||||
{persona.name}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
{persona.already_owned ? (
|
||||
<Button variant="outline" size="sm" disabled className="gap-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
{t('marketplace.already_owned', 'In your collection')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="gap-1.5" onClick={() => setConfirm(true)} disabled={buying}>
|
||||
{buying ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShoppingCart className="h-4 w-4" />}
|
||||
{price === 0
|
||||
? t('marketplace.get_free', 'Get free')
|
||||
: <><Zap className="h-3.5 w-3.5" />{price} {t('marketplace.credits', 'cr')}</>
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-6">
|
||||
<PersonaSidebar persona={persona} />
|
||||
|
||||
<Tabs defaultValue="attitudinal-profile" className="w-full">
|
||||
<TabsList className="flex items-center gap-1 p-1 bg-secondary/40 rounded-xl border border-border mb-5 w-fit h-auto">
|
||||
{[
|
||||
{ value: 'attitudinal-profile', icon: Target, label: 'Profile' },
|
||||
{ value: 'personality', icon: Sparkles, label: 'Personality' },
|
||||
{ value: 'scenarios', icon: BookOpen, label: 'Scenarios' },
|
||||
{ value: 'generation-prompts', icon: SlidersHorizontal, label: 'Inputs' },
|
||||
].map(({ value, icon: Icon, label }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-medium text-muted-foreground transition-all data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:shadow-sm data-[state=active]:border data-[state=active]:border-primary/25 hover:text-foreground"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="uppercase tracking-wider">{label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="attitudinal-profile" className="mt-0">
|
||||
<PersonaAttitudinalProfile persona={persona} />
|
||||
</TabsContent>
|
||||
<TabsContent value="personality" className="mt-0">
|
||||
<PersonaPersonality persona={persona} />
|
||||
</TabsContent>
|
||||
<TabsContent value="scenarios" className="mt-0">
|
||||
<PersonaScenarios persona={persona} />
|
||||
</TabsContent>
|
||||
<TabsContent value="generation-prompts" className="mt-0">
|
||||
<PersonaGenerationPrompts persona={persona} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Purchase confirmation */}
|
||||
<AlertDialog open={confirm} onOpenChange={setConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('marketplace.confirm_title', 'Purchase persona?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('marketplace.confirm_desc', 'You are about to purchase')} <strong>{persona.name}</strong>{' '}
|
||||
{t('marketplace.confirm_for', 'for')}{' '}
|
||||
<span className="font-semibold text-primary">
|
||||
<Zap className="inline h-3 w-3 mr-0.5" />{price} {t('marketplace.credits', 'cr')}
|
||||
</span>.{' '}
|
||||
{t('marketplace.confirm_copy', 'A copy will be added to your personas.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction disabled={buying} onClick={handleBuy}>
|
||||
{buying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
{t('marketplace.confirm_buy', 'Buy')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { marketplaceApi } from '@/lib/api';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -15,13 +16,6 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2, Search, Zap, ShoppingCart, CheckCircle2, Eye } from 'lucide-react';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { getGenderAvatarPath } from '@/utils/avatarUtils';
|
||||
|
|
@ -66,12 +60,15 @@ function PersonaCard({
|
|||
const price = persona.marketplace.price ?? 0;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden hover:border-primary/40 transition-colors flex flex-col">
|
||||
<Card
|
||||
className="overflow-hidden hover:border-primary/40 transition-colors flex flex-col cursor-pointer group"
|
||||
onClick={() => onPreview(persona)}
|
||||
>
|
||||
<div className="h-32 bg-gradient-to-br from-primary/10 to-secondary/30 relative flex-shrink-0">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={persona.name}
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-20 w-20 rounded-full object-cover border-4 border-background shadow"
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-20 w-20 rounded-full object-cover border-4 border-background shadow group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -94,13 +91,13 @@ function PersonaCard({
|
|||
size="sm"
|
||||
variant="ghost"
|
||||
className="w-full gap-1.5 text-xs text-muted-foreground"
|
||||
onClick={() => onPreview(persona)}
|
||||
onClick={e => { e.stopPropagation(); onPreview(persona); }}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{t('marketplace.preview', 'Preview')}
|
||||
</Button>
|
||||
{persona.already_owned ? (
|
||||
<Button size="sm" variant="outline" disabled className="w-full gap-1.5 text-xs">
|
||||
<Button size="sm" variant="outline" disabled className="w-full gap-1.5 text-xs" onClick={e => e.stopPropagation()}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-primary" />
|
||||
{t('marketplace.already_owned', 'In your collection')}
|
||||
</Button>
|
||||
|
|
@ -109,7 +106,7 @@ function PersonaCard({
|
|||
size="sm"
|
||||
className="w-full gap-1.5 text-xs"
|
||||
disabled={buying}
|
||||
onClick={() => onBuy(persona)}
|
||||
onClick={e => { e.stopPropagation(); onBuy(persona); }}
|
||||
>
|
||||
{buying ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
|
@ -130,6 +127,7 @@ function PersonaCard({
|
|||
|
||||
export default function PersonaMarketplace() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [personas, setPersonas] = useState<MarketPersona[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
|
@ -140,7 +138,6 @@ export default function PersonaMarketplace() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [buying, setBuying] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<MarketPersona | null>(null);
|
||||
const [preview, setPreview] = useState<MarketPersona | null>(null);
|
||||
|
||||
const load = useCallback(async (p = 1) => {
|
||||
setLoading(true);
|
||||
|
|
@ -241,7 +238,7 @@ export default function PersonaMarketplace() {
|
|||
key={p._id}
|
||||
persona={p}
|
||||
onBuy={p2 => setConfirm(p2)}
|
||||
onPreview={p2 => setPreview(p2)}
|
||||
onPreview={p2 => navigate(`/marketplace/personas/${p2._id}`)}
|
||||
buying={buying === p._id}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -260,69 +257,6 @@ export default function PersonaMarketplace() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Persona preview dialog */}
|
||||
<Dialog open={!!preview} onOpenChange={open => !open && setPreview(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
{preview && (() => {
|
||||
const avatarUrl = preview.imageUrl || getGenderAvatarPath(preview.gender ?? '');
|
||||
const price = preview.marketplace.price ?? 0;
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={avatarUrl} alt={preview.name} className="h-16 w-16 rounded-full object-cover flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-lg">{preview.name}</DialogTitle>
|
||||
{preview.occupation && <p className="text-sm text-muted-foreground">{preview.occupation}</p>}
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{preview.age && <Badge variant="outline" className="text-xs">{preview.age}</Badge>}
|
||||
{preview.gender && <Badge variant="outline" className="text-xs">{preview.gender}</Badge>}
|
||||
{preview.location && <Badge variant="outline" className="text-xs">{preview.location}</Badge>}
|
||||
{preview.marketplace.category && <Badge className="text-xs">{preview.marketplace.category}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{preview.description && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t('marketplace.preview_description', 'Description')}</p>
|
||||
<p className="text-sm leading-relaxed">{preview.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{preview.personality && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t('marketplace.preview_personality', 'Personality')}</p>
|
||||
<p className="text-sm leading-relaxed">{preview.personality}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setPreview(null)}>{t('common.close', 'Close')}</Button>
|
||||
{preview.already_owned ? (
|
||||
<Button disabled variant="outline" className="gap-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
{t('marketplace.already_owned', 'In your collection')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="gap-1.5"
|
||||
onClick={() => { setPreview(null); setConfirm(preview); }}
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
{price === 0
|
||||
? t('marketplace.get_free', 'Get free')
|
||||
: <><Zap className="h-3.5 w-3.5" />{price} {t('marketplace.credits', 'cr')}</>
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Purchase confirmation dialog */}
|
||||
<AlertDialog open={!!confirm} onOpenChange={open => !open && setConfirm(null)}>
|
||||
<AlertDialogContent>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue