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:
parent
bbbca5bdf8
commit
e8d2483a84
13 changed files with 866 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
143
backend/app/routes/marketplace.py
Normal file
143
backend/app/routes/marketplace.py
Normal 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
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
231
src/components/admin/MarketplaceTab.tsx
Normal file
231
src/components/admin/MarketplaceTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Использование",
|
||||
|
|
|
|||
|
|
@ -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": "Використання",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
275
src/pages/PersonaMarketplace.tsx
Normal file
275
src/pages/PersonaMarketplace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue