diff --git a/backend/app/routes/marketplace.py b/backend/app/routes/marketplace.py index 1a21bd0c..5e7b9edd 100644 --- a/backend/app/routes/marketplace.py +++ b/backend/app/routes/marketplace.py @@ -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/', 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//purchase', methods=['POST']) @jwt_required() async def purchase_persona(persona_id: str): diff --git a/backend/scripts/enrich_old_personas.py b/backend/scripts/enrich_old_personas.py new file mode 100644 index 00000000..ab29fd3e --- /dev/null +++ b/backend/scripts/enrich_old_personas.py @@ -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()) diff --git a/src/App.tsx b/src/App.tsx index 260dc1f3..da68d51f 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => ( } /> } /> } /> + } /> {/* Admin */} diff --git a/src/lib/api.ts b/src/lib/api.ts index 3c21e16f..510cd2b6 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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`, {}), }; diff --git a/src/pages/MarketplacePersonaView.tsx b/src/pages/MarketplacePersonaView.tsx new file mode 100644 index 00000000..fa026a57 --- /dev/null +++ b/src/pages/MarketplacePersonaView.tsx @@ -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 ; + if (!persona) return null; + + const price = persona.marketplace?.price ?? 0; + + return ( +
+
+ + {/* Header */} +
+
+ +
+

+ {t('marketplace.title', 'Persona Marketplace')} +

+

+ {persona.name} +

+
+
+ +
+ {persona.already_owned ? ( + + ) : ( + + )} +
+
+ + {/* Body */} +
+ + + + + {[ + { 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 }) => ( + + + {label} + + ))} + + + + + + + + + + + + + + + +
+
+ + {/* Purchase confirmation */} + + + + {t('marketplace.confirm_title', 'Purchase persona?')} + + {t('marketplace.confirm_desc', 'You are about to purchase')} {persona.name}{' '} + {t('marketplace.confirm_for', 'for')}{' '} + + {price} {t('marketplace.credits', 'cr')} + .{' '} + {t('marketplace.confirm_copy', 'A copy will be added to your personas.')} + + + + {t('common.cancel', 'Cancel')} + + {buying ? : null} + {t('marketplace.confirm_buy', 'Buy')} + + + + +
+ ); +} diff --git a/src/pages/PersonaMarketplace.tsx b/src/pages/PersonaMarketplace.tsx index f3e926fd..ace6b0ac 100644 --- a/src/pages/PersonaMarketplace.tsx +++ b/src/pages/PersonaMarketplace.tsx @@ -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 ( - + onPreview(persona)} + >
{persona.name}
@@ -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); }} > {t('marketplace.preview', 'Preview')} {persona.already_owned ? ( - @@ -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 ? ( @@ -130,6 +127,7 @@ function PersonaCard({ export default function PersonaMarketplace() { const { t } = useTranslation(); + const navigate = useNavigate(); const [personas, setPersonas] = useState([]); const [total, setTotal] = useState(0); @@ -140,7 +138,6 @@ export default function PersonaMarketplace() { const [loading, setLoading] = useState(true); const [buying, setBuying] = useState(null); const [confirm, setConfirm] = useState(null); - const [preview, setPreview] = useState(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() { )} - {/* Persona preview dialog */} - !open && setPreview(null)}> - - {preview && (() => { - const avatarUrl = preview.imageUrl || getGenderAvatarPath(preview.gender ?? ''); - const price = preview.marketplace.price ?? 0; - return ( - <> - -
- {preview.name} -
- {preview.name} - {preview.occupation &&

{preview.occupation}

} -
- {preview.age && {preview.age}} - {preview.gender && {preview.gender}} - {preview.location && {preview.location}} - {preview.marketplace.category && {preview.marketplace.category}} -
-
-
-
- - {preview.description && ( -
-

{t('marketplace.preview_description', 'Description')}

-

{preview.description}

-
- )} - {preview.personality && ( -
-

{t('marketplace.preview_personality', 'Personality')}

-

{preview.personality}

-
- )} - - - - {preview.already_owned ? ( - - ) : ( - - )} - - - ); - })()} -
-
- {/* Purchase confirmation dialog */} !open && setConfirm(null)}>