09a5cb69a7
CI / build-and-push (push) Successful in 42s
Replaces the simplified single-subscriber market with a full competitive simulation: shared TAM with softmax market shares across 4 segments, multi-tier consumer subscriptions (Free/Plus/Pro/Team) and API tiers (Free/PAYG/Scale/Enterprise), enterprise sales pipeline (Lead→Qualification→POC→Negotiation→Active→Renewal) with SLA tracking, developer ecosystem flywheel, technology obsolescence pressure, seasonal demand cycles, and two new product lines (Code Assistant, AI Agents Platform). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
110 lines
4.9 KiB
TypeScript
110 lines
4.9 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useGameStore } from '@/store';
|
|
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
|
import type { ConsumerTierId } from '@ai-tycoon/shared';
|
|
import { Users, Check } from 'lucide-react';
|
|
|
|
const TIER_ORDER: ConsumerTierId[] = ['free', 'plus', 'pro', 'team'];
|
|
const TIER_COLORS: Record<ConsumerTierId, string> = {
|
|
free: 'border-surface-500',
|
|
plus: 'border-blue-500',
|
|
pro: 'border-purple-500',
|
|
team: 'border-orange-500',
|
|
};
|
|
|
|
function useAppliedFeedback() {
|
|
const [show, setShow] = useState(false);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
const trigger = useCallback(() => {
|
|
setShow(true);
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = setTimeout(() => setShow(false), 1200);
|
|
}, []);
|
|
useEffect(() => () => clearTimeout(timerRef.current), []);
|
|
return { show, trigger };
|
|
}
|
|
|
|
export function ConsumerTiersPanel() {
|
|
const consumerTiers = useGameStore((s) => s.market.consumerTiers);
|
|
const setConsumerTierPrice = useGameStore((s) => s.setConsumerTierPrice);
|
|
const toggleConsumerTier = useGameStore((s) => s.toggleConsumerTier);
|
|
const feedback = useAppliedFeedback();
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Users size={16} className="text-orange-400" />
|
|
<span className="text-sm font-semibold">Consumer Subscriptions</span>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-xs text-surface-400">
|
|
<span>Total: <span className="font-mono text-surface-200">{formatNumber(consumerTiers.totalUsers)}</span></span>
|
|
<span>Satisfaction: <span className={`font-mono ${consumerTiers.satisfaction > 0.7 ? 'text-success' : consumerTiers.satisfaction > 0.4 ? 'text-warning' : 'text-danger'}`}>{formatPercent(consumerTiers.satisfaction)}</span></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 gap-3">
|
|
{TIER_ORDER.map(tierId => {
|
|
const tier = consumerTiers.tiers[tierId];
|
|
const revenue = tierId === 'free' ? 0 : tier.userCount * tier.config.price / 86400;
|
|
return (
|
|
<div key={tierId} className={`bg-surface-900 border-t-2 ${TIER_COLORS[tierId]} border border-surface-700 rounded-xl p-4 space-y-3`}>
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="font-semibold text-sm">{tier.config.name}</h4>
|
|
{tierId !== 'free' && (
|
|
<button
|
|
onClick={() => { toggleConsumerTier(tierId); feedback.trigger(); }}
|
|
className={`text-[10px] px-2 py-0.5 rounded-full ${tier.config.isActive ? 'bg-success/20 text-success' : 'bg-surface-700 text-surface-400'}`}
|
|
>
|
|
{tier.config.isActive ? 'Active' : 'Inactive'}
|
|
</button>
|
|
)}
|
|
{tierId === 'free' && (
|
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-700 text-surface-400">Always On</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-2xl font-bold font-mono">{formatNumber(tier.userCount)}</div>
|
|
<div className="text-xs text-surface-400">users</div>
|
|
|
|
{tierId !== 'free' && (
|
|
<div>
|
|
<label className="block text-[10px] text-surface-400 mb-1">
|
|
Price ($/mo)
|
|
{feedback.show && <span className="inline-flex items-center gap-0.5 text-success ml-1 animate-pulse"><Check size={8} /></span>}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={tier.config.price}
|
|
onChange={(e) => { setConsumerTierPrice(tierId, Number(e.target.value)); feedback.trigger(); }}
|
|
className="w-full bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
|
|
min={1}
|
|
step={5}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-xs space-y-1 text-surface-400">
|
|
<div className="flex justify-between">
|
|
<span>Tokens/mo</span>
|
|
<span className="font-mono">{formatNumber(tier.config.tokenAllowance)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Churn</span>
|
|
<span className="font-mono">{formatPercent(tier.churnRate)}/t</span>
|
|
</div>
|
|
{tierId !== 'free' && (
|
|
<div className="flex justify-between">
|
|
<span>Revenue/s</span>
|
|
<span className="font-mono text-success">{formatMoney(revenue)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|