feat(marketplace): persona marketplace — admin publish + user purchase

- New /api/marketplace/personas GET (list published) + POST /<id>/purchase (deduct credits, copy persona)
- Admin routes: GET /api/admin/marketplace/personas (all originals) + PUT /<id> (price/category/publish)
- Duplicate protection: can't buy same persona twice (409), can't publish purchased copies
- MarketplaceTab.tsx — admin table with inline price/category edit and publish toggle
- PersonaMarketplace.tsx — user-facing grid with search, category filter, buy dialog
- /marketplace route added to App.tsx + nav link in AppLayout
- Translations: en/ru/uk for all marketplace strings
- marketplace field added to PERSONA_ALLOWED_FIELDS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-25 17:49:43 +01:00
parent bbbca5bdf8
commit e8d2483a84
13 changed files with 866 additions and 6 deletions

View file

@ -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

View file

@ -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",
}

View file

@ -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/<persona_id>', 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

View file

@ -0,0 +1,143 @@
"""
Marketplace endpoints:
GET /api/marketplace/personas list published personas
POST /api/marketplace/personas/<id>/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/<persona_id>/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

View file

@ -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 = () => (
<Route path="/focus-groups/:id" element={<FocusGroupSession />} />
<Route path="/billing" element={<Billing />} />
<Route path="/usage" element={<MyUsage />} />
<Route path="/marketplace" element={<PersonaMarketplace />} />
</Route>
{/* Admin */}

View file

@ -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<void>;
}) {
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 (
<tr className="border-b border-border/40 hover:bg-secondary/20 transition-colors">
<td className="py-2 px-3">
<div className="flex items-center gap-2">
{persona.imageUrl && (
<img src={persona.imageUrl} alt="" className="h-8 w-8 rounded-full object-cover flex-shrink-0" />
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate max-w-[160px]">{persona.name}</p>
<p className="text-xs text-muted-foreground truncate max-w-[160px]">{persona.occupation}</p>
</div>
</div>
</td>
<td className="py-2 px-3 text-xs text-muted-foreground">{persona.created_by_name}</td>
<td className="py-2 px-3">
<select
value={category}
onChange={e => handleCategoryChange(e.target.value)}
className="text-xs bg-secondary border border-border rounded px-1.5 py-1 w-40"
>
<option value=""> category </option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</td>
<td className="py-2 px-3">
<div className="flex items-center gap-1">
<Input
type="number"
min={0}
value={price}
onChange={e => setPrice(e.target.value)}
onBlur={handlePriceBlur}
className="h-7 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">cr</span>
</div>
</td>
<td className="py-2 px-3">
<Badge variant={mp.published ? 'default' : 'outline'} className="text-xs">
{mp.published ? 'Published' : 'Draft'}
</Badge>
</td>
<td className="py-2 px-3">
<Button
size="sm"
variant={mp.published ? 'outline' : 'default'}
className="h-7 text-xs gap-1"
disabled={saving}
onClick={handleTogglePublish}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : mp.published ? <GlobeLock className="h-3 w-3" /> : <Globe className="h-3 w-3" />}
{mp.published ? 'Unpublish' : 'Publish'}
</Button>
</td>
</tr>
);
}
export default function MarketplaceTab() {
const { t } = useTranslation();
const [personas, setPersonas] = useState<MarketplacePersona[]>([]);
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<string, string | number> = { 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 (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={t('marketplace.admin.search_placeholder', 'Search personas…')}
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<select
value={publishedFilter}
onChange={e => setPublishedFilter(e.target.value)}
className="text-sm bg-secondary border border-border rounded px-2 py-1.5 h-9"
>
<option value="">All statuses</option>
<option value="true">Published</option>
<option value="false">Draft</option>
</select>
<span className="text-sm text-muted-foreground">{total} personas</span>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : personas.length === 0 ? (
<p className="text-center text-muted-foreground py-12 text-sm">No personas found.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/60 text-left">
<th className="py-2 px-3 font-medium text-muted-foreground">Persona</th>
<th className="py-2 px-3 font-medium text-muted-foreground">Creator</th>
<th className="py-2 px-3 font-medium text-muted-foreground">Category</th>
<th className="py-2 px-3 font-medium text-muted-foreground">Price</th>
<th className="py-2 px-3 font-medium text-muted-foreground">Status</th>
<th className="py-2 px-3 font-medium text-muted-foreground">Action</th>
</tr>
</thead>
<tbody>
{personas.map(p => (
<PersonaRow key={p._id} persona={p} onUpdate={handleUpdate} />
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{pages > 1 && (
<div className="flex justify-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => load(page - 1)}>Previous</Button>
<span className="text-sm text-muted-foreground self-center">{page} / {pages}</span>
<Button variant="outline" size="sm" disabled={page >= pages} onClick={() => load(page + 1)}>Next</Button>
</div>
)}
</div>
);
}

View file

@ -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 }) {

View file

@ -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",

View file

@ -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": "Использование",

View file

@ -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": "Використання",

View file

@ -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 = {

View file

@ -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() {
<TabsTrigger value="pricing">{t('admin.tab_pricing')}</TabsTrigger>
<TabsTrigger value="focus-groups">{t('admin.tab_focus_groups')}</TabsTrigger>
<TabsTrigger value="ai-config">{t('admin.tab_ai_config')}</TabsTrigger>
<TabsTrigger value="marketplace">{t('admin.tab_marketplace', 'Marketplace')}</TabsTrigger>
</TabsList>
<TabsContent value="users">
@ -67,6 +69,10 @@ export default function Admin() {
<TabsContent value="ai-config">
<AIConfigTab />
</TabsContent>
<TabsContent value="marketplace">
<MarketplaceTab />
</TabsContent>
</Tabs>
</div>
</div>

View file

@ -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 (
<Card className="overflow-hidden hover:border-primary/40 transition-colors flex flex-col">
<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"
/>
</div>
<CardContent className="pt-12 pb-4 px-4 flex flex-col flex-1 text-center">
<h3 className="font-semibold text-sm leading-tight">{persona.name}</h3>
{persona.occupation && (
<p className="text-xs text-muted-foreground mt-0.5">{persona.occupation}</p>
)}
{persona.marketplace.category && (
<Badge variant="outline" className="mt-2 mx-auto text-xs">
{persona.marketplace.category}
</Badge>
)}
{persona.description && (
<p className="text-xs text-muted-foreground mt-2 line-clamp-3 text-left">{persona.description}</p>
)}
<div className="mt-auto pt-4">
{persona.already_owned ? (
<Button size="sm" variant="outline" disabled className="w-full gap-1.5 text-xs">
<CheckCircle2 className="h-3.5 w-3.5 text-primary" />
{t('marketplace.already_owned', 'In your collection')}
</Button>
) : (
<Button
size="sm"
className="w-full gap-1.5 text-xs"
disabled={buying}
onClick={() => onBuy(persona)}
>
{buying ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ShoppingCart className="h-3.5 w-3.5" />
)}
{price === 0
? t('marketplace.get_free', 'Get free')
: <><Zap className="h-3 w-3" />{price} {t('marketplace.credits', 'cr')}</>
}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
export default function PersonaMarketplace() {
const { t } = useTranslation();
const [personas, setPersonas] = useState<MarketPersona[]>([]);
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<string | null>(null);
const [confirm, setConfirm] = useState<MarketPersona | null>(null);
const load = useCallback(async (p = 1) => {
setLoading(true);
try {
const params: Record<string, string | number> = { 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 (
<div className="max-w-7xl mx-auto px-4 py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold">{t('marketplace.title', 'Persona Marketplace')}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('marketplace.subtitle', 'Browse and purchase AI personas created by the community.')}
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-56">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={t('marketplace.search_placeholder', 'Search by name, occupation…')}
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex flex-wrap gap-1.5">
<Button
size="sm"
variant={!category ? 'default' : 'outline'}
className="text-xs"
onClick={() => setCategory('')}
>
{t('marketplace.all_categories', 'All')}
</Button>
{CATEGORIES.map(c => (
<Button
key={c}
size="sm"
variant={category === c ? 'default' : 'outline'}
className="text-xs"
onClick={() => setCategory(category === c ? '' : c)}
>
{c}
</Button>
))}
</div>
</div>
<p className="text-xs text-muted-foreground">{total} {t('marketplace.personas_found', 'personas found')}</p>
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
) : personas.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<p className="text-sm">{t('marketplace.no_results', 'No personas found.')}</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{personas.map(p => (
<PersonaCard
key={p._id}
persona={p}
onBuy={p2 => setConfirm(p2)}
buying={buying === p._id}
/>
))}
</div>
)}
{pages > 1 && (
<div className="flex justify-center gap-2 pt-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => load(page - 1)}>
{t('common.previous', 'Previous')}
</Button>
<span className="text-sm text-muted-foreground self-center">{page} / {pages}</span>
<Button variant="outline" size="sm" disabled={page >= pages} onClick={() => load(page + 1)}>
{t('common.next', 'Next')}
</Button>
</div>
)}
{/* Purchase confirmation dialog */}
<AlertDialog open={!!confirm} onOpenChange={open => !open && setConfirm(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('marketplace.confirm_title', 'Purchase persona?')}</AlertDialogTitle>
<AlertDialogDescription>
{confirm && (
<>
{t('marketplace.confirm_desc', 'You are about to purchase')} <strong>{confirm.name}</strong>{' '}
{t('marketplace.confirm_for', 'for')}{' '}
<span className="font-semibold text-primary">
<Zap className="inline h-3 w-3 mr-0.5" />{confirm.marketplace.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={() => confirm && handleBuy(confirm)}
>
{buying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{t('marketplace.confirm_buy', 'Buy')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}