diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 5a800b73..ee3d0b3d 100755 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -150,6 +150,7 @@ def create_app(): from app.routes.admin import admin_bp from app.routes.usage import usage_bp from app.routes.billing import billing_bp + from app.routes.marketplace import marketplace_bp app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(personas_bp, url_prefix='/api/personas') @@ -161,7 +162,8 @@ def create_app(): app.register_blueprint(admin_bp, url_prefix='/api/admin') app.register_blueprint(usage_bp, url_prefix='/api/usage') app.register_blueprint(billing_bp, url_prefix='/api/billing') - + app.register_blueprint(marketplace_bp, url_prefix='/api/marketplace') + @app.before_serving async def start_task_sweeper(): import asyncio diff --git a/backend/app/models/persona.py b/backend/app/models/persona.py index fb9c2d25..d6761cc4 100755 --- a/backend/app/models/persona.py +++ b/backend/app/models/persona.py @@ -12,7 +12,7 @@ PERSONA_ALLOWED_FIELDS = { "priceConsciousness", "environmentalConcern", "hasPurchasingPower", "hasChildren", "thinkFeelDo", "description", "imageUrl", "folder_ids", "llm_model", "traits", "background", "goals", - "communication_style", "values", "demographics", + "communication_style", "values", "demographics", "marketplace", } diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index ebdd66df..57f19ca5 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -801,3 +801,118 @@ async def test_ai_config(): # Log full exception (may contain key fragments) only server-side logger.warning('AI config test failed for provider %s: %s', provider_id, exc) return jsonify({'ok': False, 'error': 'Connection failed — check endpoint and API key'}), 200 + + +# ── Marketplace admin routes ──────────────────────────────────────────────── + +@admin_bp.route('/marketplace/personas', methods=['GET']) +@jwt_required() +@admin_required +async def admin_list_marketplace_personas(): + """All original (non-purchased) personas available for marketplace publishing.""" + from app.routes.marketplace import PERSONA_CATEGORIES # avoid circular import at module level + + search = request.args.get('search', '').strip() + published_filter = request.args.get('published', '').lower() + try: + page = max(1, int(request.args.get('page', 1))) + except ValueError: + page = 1 + + PAGE_SIZE = 50 + + # Only originals — exclude copies purchased via marketplace + match: dict = { + "$or": [ + {"marketplace.source_persona_id": {"$exists": False}}, + {"marketplace.source_persona_id": None}, + ] + } + if search: + match["$and"] = [ + match.pop("$or"), + {"$or": [ + {"name": {"$regex": search, "$options": "i"}}, + {"occupation": {"$regex": search, "$options": "i"}}, + ]}, + ] + if published_filter == 'true': + match["marketplace.published"] = True + elif published_filter == 'false': + match["marketplace.published"] = {"$ne": True} + + db = await get_db() + skip = (page - 1) * PAGE_SIZE + total = await db.personas.count_documents(match) + cursor = db.personas.find(match, { + "name": 1, "occupation": 1, "created_by": 1, "created_at": 1, + "imageUrl": 1, "marketplace": 1, + }).sort("created_at", -1).skip(skip).limit(PAGE_SIZE) + docs = await cursor.to_list(length=PAGE_SIZE) + + user_ids = list({d.get("created_by") for d in docs if d.get("created_by")}) + user_map: dict = {} + if user_ids: + users_cursor = db.users.find( + {"_id": {"$in": [ObjectId(uid) for uid in user_ids]}}, + {"username": 1, "email": 1}, + ) + async for u in users_cursor: + user_map[str(u["_id"])] = u.get("username") or u.get("email", "Unknown") + + for d in docs: + d["_id"] = str(d["_id"]) + d["created_by_name"] = user_map.get(d.get("created_by", ""), "Unknown") + if isinstance(d.get("created_at"), datetime): + d["created_at"] = d["created_at"].isoformat() + + return jsonify({ + "personas": docs, + "total": total, + "page": page, + "pages": max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE), + "categories": PERSONA_CATEGORIES, + }), 200 + + +@admin_bp.route('/marketplace/personas/', methods=['PUT']) +@jwt_required() +@admin_required +async def admin_update_marketplace_persona(persona_id: str): + """Set price, category, and published flag on a persona.""" + from app.routes.marketplace import PERSONA_CATEGORIES + + data = await request.get_json() or {} + admin_id = get_jwt_identity() + + 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}) + if not persona: + return jsonify({"message": "Persona not found"}), 404 + + if data.get("published") and persona.get("marketplace", {}).get("source_persona_id"): + return jsonify({"message": "Cannot publish purchased copies"}), 400 + + update: dict = {} + if "price" in data: + update["marketplace.price"] = max(0, int(data["price"])) + if "category" in data: + if data["category"] in PERSONA_CATEGORIES: + update["marketplace.category"] = data["category"] + if "published" in data: + published = bool(data["published"]) + update["marketplace.published"] = published + if published and not persona.get("marketplace", {}).get("published"): + update["marketplace.published_at"] = datetime.now(timezone.utc) + update["marketplace.published_by"] = admin_id + + if not update: + return jsonify({"message": "Nothing to update"}), 400 + + await db.personas.update_one({"_id": oid}, {"$set": update}) + return jsonify({"status": "ok"}), 200 diff --git a/backend/app/routes/marketplace.py b/backend/app/routes/marketplace.py new file mode 100644 index 00000000..1a21bd0c --- /dev/null +++ b/backend/app/routes/marketplace.py @@ -0,0 +1,143 @@ +""" +Marketplace endpoints: + GET /api/marketplace/personas — list published personas + POST /api/marketplace/personas//purchase — buy a persona (jwt required) +""" + +import logging +from datetime import datetime, timezone +from quart import Blueprint, request, jsonify +from bson import ObjectId + +from app.auth.quart_jwt import jwt_required, get_jwt_identity +from app.models.user import User +from app.models.credit_transaction import CreditTransaction +from app.db import get_db + +logger = logging.getLogger(__name__) +marketplace_bp = Blueprint('marketplace', __name__) + +PERSONA_CATEGORIES = [ + 'Retail', 'Finance', 'Technology', 'Healthcare', + 'Food & Beverage', 'Entertainment', 'Automotive', + 'Beauty & Personal Care', 'Travel', 'B2B', 'Other', +] + +_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", + "communication_style", "values", "demographics", +} + + +@marketplace_bp.route('/personas', methods=['GET']) +@jwt_required() +async def list_personas(): + search = request.args.get('search', '').strip() + category = request.args.get('category', '').strip() + try: + page = max(1, int(request.args.get('page', 1))) + except ValueError: + page = 1 + + match: dict = {"marketplace.published": True} + if category and category in PERSONA_CATEGORIES: + match["marketplace.category"] = category + if search: + match["$or"] = [ + {"name": {"$regex": search, "$options": "i"}}, + {"personality": {"$regex": search, "$options": "i"}}, + {"occupation": {"$regex": search, "$options": "i"}}, + ] + + db = await get_db() + skip = (page - 1) * _PAGE_SIZE + total = await db.personas.count_documents(match) + cursor = db.personas.find(match, { + "name": 1, "age": 1, "gender": 1, "occupation": 1, "location": 1, + "description": 1, "imageUrl": 1, "personality": 1, "marketplace": 1, + }).sort("marketplace.published_at", -1).skip(skip).limit(_PAGE_SIZE) + docs = await cursor.to_list(length=_PAGE_SIZE) + for d in docs: + d["_id"] = str(d["_id"]) + + buyer_id = get_jwt_identity() + source_ids = [d["_id"] for d in docs] + owned_sources: set[str] = set() + if source_ids: + owned_cursor = db.personas.find( + {"created_by": buyer_id, "marketplace.source_persona_id": {"$in": source_ids}}, + {"marketplace.source_persona_id": 1}, + ) + async for owned in owned_cursor: + owned_sources.add(str(owned["marketplace"]["source_persona_id"])) + + for d in docs: + d["already_owned"] = d["_id"] in owned_sources + + return jsonify({ + "personas": docs, + "total": total, + "page": page, + "pages": max(1, (total + _PAGE_SIZE - 1) // _PAGE_SIZE), + "categories": PERSONA_CATEGORIES, + }), 200 + + +@marketplace_bp.route('/personas//purchase', methods=['POST']) +@jwt_required() +async def purchase_persona(persona_id: str): + buyer_id = get_jwt_identity() + + 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}) + if not persona or not persona.get("marketplace", {}).get("published"): + return jsonify({"message": "Persona not found"}), 404 + + existing = await db.personas.find_one({ + "created_by": buyer_id, + "marketplace.source_persona_id": persona_id, + }) + if existing: + return jsonify({"message": "Already owned"}), 409 + + price = int(persona["marketplace"].get("price", 0)) + new_balance = await User.deduct_credits(buyer_id, price) + if new_balance is None: + return jsonify({"message": "Insufficient credits"}), 402 + + now = datetime.now(timezone.utc) + copy_data = {k: v for k, v in persona.items() if k in _COPY_FIELDS} + copy_data["created_by"] = buyer_id + copy_data["created_at"] = now + copy_data["folder_ids"] = [] + copy_data["marketplace"] = { + "source_persona_id": persona_id, + "purchased_at": now, + "published": False, + } + + result = await db.personas.insert_one(copy_data) + new_persona_id = str(result.inserted_id) + + await CreditTransaction.record( + user_id=buyer_id, + tx_type="debit", + amount=-price, + balance_after=new_balance, + description=f"Purchased persona '{persona.get('name', '')}' from marketplace", + ref={"source_persona_id": persona_id}, + ) + + logger.info("User %s purchased persona %s for %d credits", buyer_id, persona_id, price) + return jsonify({"persona_id": new_persona_id, "new_balance": new_balance}), 200 diff --git a/src/App.tsx b/src/App.tsx index aac346d5..260dc1f3 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import VerifyRequired from "./pages/VerifyRequired"; import Admin from "./pages/Admin"; import MyUsage from "./pages/MyUsage"; import Billing from "./pages/Billing"; +import PersonaMarketplace from "./pages/PersonaMarketplace"; import ProtectedRoute from "./components/ProtectedRoute"; import AdminRoute from "./components/admin/AdminRoute"; import { AuthProvider } from "./contexts/AuthContext"; @@ -81,6 +82,7 @@ const App = () => ( } /> } /> } /> + } /> {/* Admin */} diff --git a/src/components/admin/MarketplaceTab.tsx b/src/components/admin/MarketplaceTab.tsx new file mode 100644 index 00000000..13cd6e16 --- /dev/null +++ b/src/components/admin/MarketplaceTab.tsx @@ -0,0 +1,231 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { adminApi } from '@/lib/api'; +import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, Search, Globe, GlobeLock } from 'lucide-react'; +import { toast } from '@/lib/toast'; + +const CATEGORIES = [ + 'Retail', 'Finance', 'Technology', 'Healthcare', + 'Food & Beverage', 'Entertainment', 'Automotive', + 'Beauty & Personal Care', 'Travel', 'B2B', 'Other', +]; + +interface MarketplacePersona { + _id: string; + name: string; + occupation?: string; + imageUrl?: string; + created_by_name: string; + created_at: string; + marketplace?: { + published?: boolean; + price?: number; + category?: string; + source_persona_id?: string; + }; +} + +function PersonaRow({ + persona, + onUpdate, +}: { + persona: MarketplacePersona; + onUpdate: (id: string, data: { price?: number; category?: string; published?: boolean }) => Promise; +}) { + const mp = persona.marketplace ?? {}; + const [price, setPrice] = useState(String(mp.price ?? 5)); + const [category, setCategory] = useState(mp.category ?? ''); + const [saving, setSaving] = useState(false); + + const save = async (patch: { price?: number; category?: string; published?: boolean }) => { + setSaving(true); + try { + await onUpdate(persona._id, patch); + } finally { + setSaving(false); + } + }; + + const handlePriceBlur = () => { + const num = parseInt(price, 10); + if (!isNaN(num) && num >= 0) save({ price: num }); + }; + + const handleCategoryChange = (val: string) => { + setCategory(val); + save({ category: val }); + }; + + const handleTogglePublish = () => { + save({ published: !mp.published }); + }; + + return ( + + +
+ {persona.imageUrl && ( + + )} +
+

{persona.name}

+

{persona.occupation}

+
+
+ + {persona.created_by_name} + + + + +
+ setPrice(e.target.value)} + onBlur={handlePriceBlur} + className="h-7 w-20 text-xs" + /> + cr +
+ + + + {mp.published ? 'Published' : 'Draft'} + + + + + + + ); +} + +export default function MarketplaceTab() { + const { t } = useTranslation(); + const [personas, setPersonas] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pages, setPages] = useState(1); + const [search, setSearch] = useState(''); + const [publishedFilter, setPublishedFilter] = useState(''); + const [loading, setLoading] = useState(true); + + const load = useCallback(async (p = page) => { + setLoading(true); + try { + const params: Record = { page: p }; + if (search) params.search = search; + if (publishedFilter) params.published = publishedFilter; + const r = await adminApi.listAllPersonas(params as { search?: string; published?: string; page?: number }); + setPersonas(r.data.personas); + setTotal(r.data.total); + setPages(r.data.pages); + setPage(p); + } catch { + toast.error(t('common.error', 'Error loading personas')); + } finally { + setLoading(false); + } + }, [page, search, publishedFilter, t]); + + useEffect(() => { load(1); }, [search, publishedFilter]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleUpdate = async (id: string, data: { price?: number; category?: string; published?: boolean }) => { + try { + await adminApi.updateMarketplacePersona(id, data); + setPersonas(prev => prev.map(p => { + if (p._id !== id) return p; + return { ...p, marketplace: { ...p.marketplace, ...data } }; + })); + toast.success(t('marketplace.admin.saved', 'Saved')); + } catch { + toast.error(t('common.error', 'Save failed')); + } + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ + {total} personas +
+ + + + {loading ? ( +
+ ) : personas.length === 0 ? ( +

No personas found.

+ ) : ( +
+ + + + + + + + + + + + + {personas.map(p => ( + + ))} + +
PersonaCreatorCategoryPriceStatusAction
+
+ )} +
+
+ + {pages > 1 && ( +
+ + {page} / {pages} + +
+ )} +
+ ); +} diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index e67a67e9..9e75570d 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -6,7 +6,7 @@ import { billingApi } from '@/lib/api'; import Logo from '@/components/brand/Logo'; import UserDropdown from '@/components/brand/UserDropdown'; import LanguageSwitcher from '@/components/LanguageSwitcher'; -import { Users, MessageSquare, LayoutDashboard, CreditCard, ShieldCheck, Zap, Menu, X, LogOut, User } from 'lucide-react'; +import { Users, MessageSquare, LayoutDashboard, CreditCard, ShieldCheck, Zap, Menu, X, LogOut, User, Store } from 'lucide-react'; import { cn } from '@/lib/utils'; const NAV_ITEMS = [ @@ -14,6 +14,7 @@ const NAV_ITEMS = [ { labelKey: 'nav.app_personas', fallback: 'Personas', to: '/synthetic-users', icon: Users }, { labelKey: 'nav.app_focus_groups', fallback: 'Focus Groups', to: '/focus-groups', icon: MessageSquare }, { labelKey: 'nav.app_billing', fallback: 'Billing', to: '/billing', icon: CreditCard }, + { labelKey: 'nav.app_marketplace', fallback: 'Marketplace', to: '/marketplace', icon: Store }, ] as const; export default function AppLayout({ children }: { children?: React.ReactNode }) { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 755f4b8b..0b58e4df 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -13,6 +13,7 @@ "app_focus_groups": "Focus Groups", "app_billing": "Billing", "app_admin": "Admin", + "app_marketplace": "Marketplace", "sign_out": "Sign out" }, "hero": { @@ -264,7 +265,30 @@ "tab_usage": "Usage", "tab_pricing": "Model Pricing", "tab_focus_groups": "Focus Groups", - "tab_ai_config": "AI Config" + "tab_ai_config": "AI Config", + "tab_marketplace": "Marketplace" + }, + "marketplace": { + "title": "Persona Marketplace", + "subtitle": "Browse and purchase AI personas created by the community.", + "search_placeholder": "Search by name, occupation…", + "all_categories": "All", + "personas_found": "personas found", + "no_results": "No personas found.", + "credits": "cr", + "get_free": "Get free", + "already_owned": "In your collection", + "purchase_success": "Added to your personas!", + "insufficient_credits": "Not enough credits", + "confirm_title": "Purchase persona?", + "confirm_desc": "You are about to purchase", + "confirm_for": "for", + "confirm_copy": "A copy will be added to your personas.", + "confirm_buy": "Buy", + "admin": { + "search_placeholder": "Search personas…", + "saved": "Saved" + } }, "usage": { "title": "Usage", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 3defda5f..33328d69 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -13,6 +13,7 @@ "app_focus_groups": "Фокус-группы", "app_billing": "Биллинг", "app_admin": "Админ", + "app_marketplace": "Маркетплейс", "sign_out": "Выйти" }, "hero": { @@ -264,7 +265,30 @@ "tab_usage": "Использование", "tab_pricing": "Цены моделей", "tab_focus_groups": "Фокус-группы", - "tab_ai_config": "AI Config" + "tab_ai_config": "AI Config", + "tab_marketplace": "Маркетплейс" + }, + "marketplace": { + "title": "Маркетплейс персон", + "subtitle": "Просматривайте и покупайте AI-персоны.", + "search_placeholder": "Поиск по имени, профессии…", + "all_categories": "Все", + "personas_found": "персон найдено", + "no_results": "Персоны не найдены.", + "credits": "кр", + "get_free": "Получить бесплатно", + "already_owned": "Уже в коллекции", + "purchase_success": "Добавлено в ваши персоны!", + "insufficient_credits": "Недостаточно кредитов", + "confirm_title": "Купить персону?", + "confirm_desc": "Вы собираетесь купить", + "confirm_for": "за", + "confirm_copy": "Копия будет добавлена в ваши персоны.", + "confirm_buy": "Купить", + "admin": { + "search_placeholder": "Поиск персон…", + "saved": "Сохранено" + } }, "usage": { "title": "Использование", diff --git a/src/i18n/locales/uk/common.json b/src/i18n/locales/uk/common.json index 0ca73308..b0bc0f1f 100644 --- a/src/i18n/locales/uk/common.json +++ b/src/i18n/locales/uk/common.json @@ -13,6 +13,7 @@ "app_focus_groups": "Фокус-групи", "app_billing": "Білінг", "app_admin": "Адмін", + "app_marketplace": "Маркетплейс", "sign_out": "Вийти" }, "hero": { @@ -264,7 +265,30 @@ "tab_usage": "Використання", "tab_pricing": "Ціни моделей", "tab_focus_groups": "Фокус-групи", - "tab_ai_config": "AI Config" + "tab_ai_config": "AI Config", + "tab_marketplace": "Маркетплейс" + }, + "marketplace": { + "title": "Маркетплейс персон", + "subtitle": "Переглядайте та купуйте AI-персони.", + "search_placeholder": "Пошук за ім'ям, професією…", + "all_categories": "Всі", + "personas_found": "персон знайдено", + "no_results": "Персони не знайдені.", + "credits": "кр", + "get_free": "Отримати безкоштовно", + "already_owned": "Вже у колекції", + "purchase_success": "Додано до ваших персон!", + "insufficient_credits": "Недостатньо кредитів", + "confirm_title": "Купити персону?", + "confirm_desc": "Ви збираєтесь купити", + "confirm_for": "за", + "confirm_copy": "Копія буде додана до ваших персон.", + "confirm_buy": "Купити", + "admin": { + "search_placeholder": "Пошук персон…", + "saved": "Збережено" + } }, "usage": { "title": "Використання", diff --git a/src/lib/api.ts b/src/lib/api.ts index 3d2bd9c6..3c21e16f 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -812,6 +812,19 @@ export const adminApi = { // Analytics getAnalytics: (params?: { from?: string; to?: string }) => api.get('/admin/analytics', { params }), + + // Marketplace admin + listAllPersonas: (params?: { search?: string; published?: string; page?: number }) => + api.get('/admin/marketplace/personas', { params }), + updateMarketplacePersona: (id: string, data: { price?: number; category?: string; published?: boolean }) => + api.put(`/admin/marketplace/personas/${id}`, data), +}; + +export const marketplaceApi = { + list: (params?: { search?: string; category?: string; page?: number }) => + api.get('/marketplace/personas', { params }), + purchase: (id: string) => + api.post(`/marketplace/personas/${id}/purchase`, {}), }; export const usageApi = { diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 2e92026c..f459edab 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -10,6 +10,7 @@ import FocusGroupsTab from '@/components/admin/FocusGroupsTab'; import AnalyticsTab from '@/components/admin/AnalyticsTab'; import CreditSettingsTab from '@/components/admin/CreditSettingsTab'; import AIConfigTab from '@/components/admin/AIConfigTab'; +import MarketplaceTab from '@/components/admin/MarketplaceTab'; export default function Admin() { const { t } = useTranslation(); @@ -38,6 +39,7 @@ export default function Admin() { {t('admin.tab_pricing')} {t('admin.tab_focus_groups')} {t('admin.tab_ai_config')} + {t('admin.tab_marketplace', 'Marketplace')} @@ -67,6 +69,10 @@ export default function Admin() { + + + + diff --git a/src/pages/PersonaMarketplace.tsx b/src/pages/PersonaMarketplace.tsx new file mode 100644 index 00000000..ebf71e73 --- /dev/null +++ b/src/pages/PersonaMarketplace.tsx @@ -0,0 +1,275 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { marketplaceApi } from '@/lib/api'; +import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Loader2, Search, Zap, ShoppingCart, CheckCircle2 } from 'lucide-react'; +import { toast } from '@/lib/toast'; +import { getGenderAvatarPath } from '@/utils/avatarUtils'; + +const CATEGORIES = [ + 'Retail', 'Finance', 'Technology', 'Healthcare', + 'Food & Beverage', 'Entertainment', 'Automotive', + 'Beauty & Personal Care', 'Travel', 'B2B', 'Other', +]; + +interface MarketPersona { + _id: string; + name: string; + age?: number; + gender?: string; + occupation?: string; + location?: string; + description?: string; + imageUrl?: string; + personality?: string; + already_owned: boolean; + marketplace: { + price: number; + category?: string; + published_at?: string; + }; +} + +function PersonaCard({ + persona, + onBuy, + buying, +}: { + persona: MarketPersona; + onBuy: (p: MarketPersona) => void; + buying: boolean; +}) { + const { t } = useTranslation(); + const avatarUrl = persona.imageUrl || getGenderAvatarPath(persona.gender ?? ''); + const price = persona.marketplace.price; + + return ( + +
+ {persona.name} +
+ + +

{persona.name}

+ {persona.occupation && ( +

{persona.occupation}

+ )} + {persona.marketplace.category && ( + + {persona.marketplace.category} + + )} + {persona.description && ( +

{persona.description}

+ )} + +
+ {persona.already_owned ? ( + + ) : ( + + )} +
+
+
+ ); +} + +export default function PersonaMarketplace() { + const { t } = useTranslation(); + + const [personas, setPersonas] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pages, setPages] = useState(1); + const [search, setSearch] = useState(''); + const [category, setCategory] = useState(''); + const [loading, setLoading] = useState(true); + const [buying, setBuying] = useState(null); + const [confirm, setConfirm] = useState(null); + + const load = useCallback(async (p = 1) => { + setLoading(true); + try { + const params: Record = { page: p }; + if (search) params.search = search; + if (category) params.category = category; + const r = await marketplaceApi.list(params as { search?: string; category?: string; page?: number }); + setPersonas(r.data.personas); + setTotal(r.data.total); + setPages(r.data.pages); + setPage(p); + } catch { + toast.error(t('common.error', 'Error')); + } finally { + setLoading(false); + } + }, [search, category, t]); + + useEffect(() => { load(1); }, [search, category]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleBuy = async (persona: MarketPersona) => { + setBuying(persona._id); + try { + const r = await marketplaceApi.purchase(persona._id); + setPersonas(prev => prev.map(p => p._id === persona._id ? { ...p, already_owned: true } : p)); + toast.success(t('marketplace.purchase_success', `${persona.name} added to your personas!`)); + } 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(null); + setConfirm(null); + } + }; + + return ( +
+
+

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

+

+ {t('marketplace.subtitle', 'Browse and purchase AI personas created by the community.')} +

+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+
+ + {CATEGORIES.map(c => ( + + ))} +
+
+ +

{total} {t('marketplace.personas_found', 'personas found')}

+ + {loading ? ( +
+ ) : personas.length === 0 ? ( +
+

{t('marketplace.no_results', 'No personas found.')}

+
+ ) : ( +
+ {personas.map(p => ( + setConfirm(p2)} + buying={buying === p._id} + /> + ))} +
+ )} + + {pages > 1 && ( +
+ + {page} / {pages} + +
+ )} + + {/* Purchase confirmation dialog */} + !open && setConfirm(null)}> + + + {t('marketplace.confirm_title', 'Purchase persona?')} + + {confirm && ( + <> + {t('marketplace.confirm_desc', 'You are about to purchase')} {confirm.name}{' '} + {t('marketplace.confirm_for', 'for')}{' '} + + {confirm.marketplace.price} {t('marketplace.credits', 'cr')} + .{' '} + {t('marketplace.confirm_copy', 'A copy will be added to your personas.')} + + )} + + + + {t('common.cancel', 'Cancel')} + confirm && handleBuy(confirm)} + > + {buying ? : null} + {t('marketplace.confirm_buy', 'Buy')} + + + + +
+ ); +}