feat: OG image, meta tags, dynamic pricing from admin
Epic 1 — OG-image & SEO base: - Replace wrong og-image.png with branded 1200×630 Cohorta design - index.html: full title, og:type/url/image dimensions, twitter:card, canonical Epic 2 — Pricing from admin panel: - Pricing.tsx: remove hardcoded DEFAULT_PACKS; add loading skeleton and error+retry state - Features list and personas/sessions counts computed from API credits/costs - billing.py /packs: also returns persona_cost and run_cost for frontend math - app_settings.py: add popular:True to pro pack default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3ec9aeaf42
commit
d679691cc3
5 changed files with 130 additions and 40 deletions
|
|
@ -20,8 +20,8 @@ DEFAULTS = {
|
|||
"run_cost": 40,
|
||||
"trial_grant": 50,
|
||||
"credit_packs": [
|
||||
{"id": "starter", "name": "Starter", "price_usd": 49, "credits": 50},
|
||||
{"id": "pro", "name": "Pro", "price_usd": 199, "credits": 220},
|
||||
{"id": "starter", "name": "Starter", "price_usd": 49, "credits": 50},
|
||||
{"id": "pro", "name": "Pro", "price_usd": 199, "credits": 220, "popular": True},
|
||||
{"id": "scale", "name": "Scale", "price_usd": 499, "credits": 600},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,13 @@ billing_bp = Blueprint('billing', __name__)
|
|||
|
||||
@billing_bp.route('/packs', methods=['GET'])
|
||||
async def get_credit_packs():
|
||||
"""Public — returns available credit pack definitions for the landing page."""
|
||||
"""Public — returns credit packs plus action costs for the landing page."""
|
||||
settings = await get_settings()
|
||||
return jsonify({'packs': settings.get('credit_packs', [])}), 200
|
||||
return jsonify({
|
||||
'packs': settings.get('credit_packs', []),
|
||||
'persona_cost': settings.get('persona_cost', 2),
|
||||
'run_cost': settings.get('run_cost', 40),
|
||||
}), 200
|
||||
|
||||
|
||||
@billing_bp.route('/balance', methods=['GET'])
|
||||
|
|
|
|||
25
index.html
25
index.html
|
|
@ -4,12 +4,27 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=2" />
|
||||
<title>Cohorta</title>
|
||||
<meta name="description" content="AI-powered synthetic focus groups for product research" />
|
||||
<title>Cohorta — AI-Powered Synthetic Focus Groups</title>
|
||||
<meta name="description" content="Run AI-moderated focus groups with synthetic personas. Get real research insights in minutes, not weeks — no recruits, no scheduling, no bias." />
|
||||
<meta name="author" content="AImpress" />
|
||||
<meta property="og:title" content="Cohorta" />
|
||||
<meta property="og:description" content="AI-powered synthetic focus groups for product research" />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
<link rel="canonical" href="https://cohorta.ai-impress.com" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Cohorta" />
|
||||
<meta property="og:url" content="https://cohorta.ai-impress.com" />
|
||||
<meta property="og:title" content="Cohorta — AI-Powered Synthetic Focus Groups" />
|
||||
<meta property="og:description" content="Run AI-moderated focus groups with synthetic personas. Get real research insights in minutes, not weeks — no recruits, no scheduling, no bias." />
|
||||
<meta property="og:image" content="https://cohorta.ai-impress.com/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Cohorta — Synthetic focus groups. Real insights, no recruits." />
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Cohorta — AI-Powered Synthetic Focus Groups" />
|
||||
<meta name="twitter:description" content="Run AI-moderated focus groups with synthetic personas. Get real research insights in minutes, not weeks." />
|
||||
<meta name="twitter:image" content="https://cohorta.ai-impress.com/og-image.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 152 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CheckCircle2, Info } from 'lucide-react';
|
||||
import { CheckCircle2, Info, RefreshCw } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { fadeUp, staggerChildren, viewportOnce } from '@/lib/motion';
|
||||
import { billingApi } from '@/lib/api';
|
||||
|
|
@ -14,25 +14,30 @@ interface CreditPack {
|
|||
popular?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PACKS: CreditPack[] = [
|
||||
{ id: 'starter', name: 'Starter', price_usd: 49, credits: 50 },
|
||||
{ id: 'pro', name: 'Pro', price_usd: 199, credits: 220, popular: true },
|
||||
{ id: 'scale', name: 'Scale', price_usd: 499, credits: 600 },
|
||||
];
|
||||
interface PacksResponse {
|
||||
packs: CreditPack[];
|
||||
persona_cost: number;
|
||||
run_cost: number;
|
||||
}
|
||||
|
||||
const PACK_FEATURES: Record<string, string[]> = {
|
||||
starter: ['50 credits', '~25 AI personas', '1 focus group run', 'Export transcripts', 'Email support'],
|
||||
pro: ['220 credits', '~110 AI personas', '5 focus group runs', 'Bulk export', 'Priority support', 'Advanced analytics'],
|
||||
scale: ['600 credits', '~300 AI personas', '15 focus group runs', 'Unlimited exports', 'Dedicated support', 'Custom prompts', 'API access'],
|
||||
const STATIC_FEATURES: Record<string, string[]> = {
|
||||
starter: ['Export transcripts', 'Email support'],
|
||||
pro: ['Bulk export', 'Priority support', 'Advanced analytics'],
|
||||
scale: ['Unlimited exports', 'Dedicated support', 'Custom prompts', 'API access'],
|
||||
};
|
||||
|
||||
const COST_MATH = {
|
||||
starter: { sessions: 1, personas: 25 },
|
||||
pro: { sessions: 5, personas: 110 },
|
||||
scale: { sessions: 15, personas: 300 },
|
||||
};
|
||||
function buildFeatures(pack: CreditPack, personaCost: number, runCost: number): string[] {
|
||||
const personas = Math.floor(pack.credits / personaCost);
|
||||
const runs = Math.floor(pack.credits / runCost);
|
||||
return [
|
||||
`${pack.credits} credits`,
|
||||
`~${personas} AI personas`,
|
||||
`${runs} focus group run${runs !== 1 ? 's' : ''}`,
|
||||
...(STATIC_FEATURES[pack.id] ?? STATIC_FEATURES.starter),
|
||||
];
|
||||
}
|
||||
|
||||
function CreditTooltip() {
|
||||
function CreditTooltip({ personaCost, runCost }: { personaCost: number; runCost: number }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
|
@ -44,8 +49,8 @@ function CreditTooltip() {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs p-3">
|
||||
<p className="text-xs leading-relaxed">
|
||||
<strong>1 persona</strong> = 2 credits<br />
|
||||
<strong>1 focus group run</strong> = 40 credits<br />
|
||||
<strong>1 persona</strong> = {personaCost} credits<br />
|
||||
<strong>1 focus group run</strong> = {runCost} credits<br />
|
||||
<strong>Credits never expire.</strong> Trial: 50 free on signup.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
|
|
@ -54,15 +59,58 @@ function CreditTooltip() {
|
|||
);
|
||||
}
|
||||
|
||||
function SkeletonCard({ popular }: { popular?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded-2xl p-8 flex flex-col animate-pulse ${
|
||||
popular
|
||||
? 'bg-card border-2 border-primary/30 scale-[1.02]'
|
||||
: 'bg-card border border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="h-6 w-20 bg-muted rounded mb-3" />
|
||||
<div className="h-10 w-28 bg-muted rounded mb-2" />
|
||||
<div className="h-4 w-32 bg-muted/60 rounded mb-6" />
|
||||
<div className="h-1.5 bg-muted/40 rounded-full mb-8" />
|
||||
<div className="space-y-3 flex-1">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="flex items-center gap-2.5">
|
||||
<div className="h-4 w-4 rounded-full bg-muted flex-shrink-0" />
|
||||
<div className="h-3 bg-muted/60 rounded" style={{ width: `${55 + i * 10}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-11 w-full bg-muted rounded-xl mt-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Pricing() {
|
||||
const navigate = useNavigate();
|
||||
const [packs, setPacks] = useState<CreditPack[]>(DEFAULT_PACKS);
|
||||
const [data, setData] = useState<PacksResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
billingApi.getPacks()
|
||||
.then(r => { if (r.data?.packs?.length) setPacks(r.data.packs); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
.then(r => {
|
||||
const { packs, persona_cost, run_cost } = r.data as PacksResponse;
|
||||
if (packs?.length) {
|
||||
setData({ packs, persona_cost: persona_cost ?? 2, run_cost: run_cost ?? 40 });
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const personaCost = data?.persona_cost ?? 2;
|
||||
const runCost = data?.run_cost ?? 40;
|
||||
|
||||
return (
|
||||
<section className="py-24 px-6" id="pricing">
|
||||
|
|
@ -83,16 +131,40 @@ export default function Pricing() {
|
|||
</h2>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Credits never expire. Start with 50 free — no card required.{' '}
|
||||
<CreditTooltip />
|
||||
{!loading && !error && (
|
||||
<CreditTooltip personaCost={personaCost} runCost={runCost} />
|
||||
)}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Cards */}
|
||||
<motion.div variants={staggerChildren} className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{packs.map((pack, i) => {
|
||||
const features = PACK_FEATURES[pack.id] || PACK_FEATURES.pro;
|
||||
const math = COST_MATH[pack.id as keyof typeof COST_MATH];
|
||||
const costPerSession = (pack.price_usd / math.sessions).toFixed(0);
|
||||
{loading && (
|
||||
<>
|
||||
<SkeletonCard />
|
||||
<SkeletonCard popular />
|
||||
<SkeletonCard />
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="col-span-3 flex flex-col items-center justify-center py-16 gap-4">
|
||||
<p className="text-muted-foreground text-sm">Could not load pricing.</p>
|
||||
<button
|
||||
onClick={load}
|
||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && data?.packs.map((pack, i) => {
|
||||
const features = buildFeatures(pack, personaCost, runCost);
|
||||
const runs = Math.floor(pack.credits / runCost);
|
||||
const costPerSession = runs > 0 ? (pack.price_usd / runs).toFixed(0) : '—';
|
||||
const personas = Math.floor(pack.credits / personaCost);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -122,16 +194,15 @@ export default function Pricing() {
|
|||
|
||||
<p className="text-xs text-primary font-semibold mb-1">{pack.credits} credits included</p>
|
||||
|
||||
{/* Cost-per-session visualisation */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between text-[11px] mb-1">
|
||||
<span className="text-muted-foreground">~{math.personas} personas</span>
|
||||
<span className="text-muted-foreground">~{personas} personas</span>
|
||||
<span className="text-muted-foreground">~${costPerSession}/session</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-primary/15">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${(pack.credits / 600) * 100}%` }}
|
||||
style={{ width: `${Math.min((pack.credits / 600) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue