Add platform admin subscription tier management UI

Platform admin enhancements:
- Add SubscriptionTiersPage for managing subscription tiers
- Add TierEditModal with preview and validation
- Add API client for tier admin endpoints
- Update styled.d.ts theme type definitions
- Add tsconfig reference for shared package

Auto-generated:
- Update locale-validation-cache
- Update pnpm-lock.yaml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lilith 2026-01-02 10:49:56 -08:00
parent 4ea7327ac7
commit b14319e3bc
10 changed files with 2731 additions and 317 deletions

View file

@ -2,177 +2,11 @@
* Styled Components Type Augmentation for Payments Module
*
* Extends styled-components DefaultTheme with theme properties.
* Inlined to avoid package resolution issues between codebase and releases.
* Imports from @lilith/ui-theme for consistent type definitions.
*/
import 'styled-components';
interface ThemeInterface {
colors: {
primary: string;
secondary: string;
accent: string;
background: {
primary: string;
secondary: string;
tertiary: string;
};
surface: string;
text: {
primary: string;
secondary: string;
tertiary: string;
muted: string;
inverse?: string;
disabled?: string;
};
border: string;
success: string;
warning: string;
error: string;
info: string;
hover: {
primary: string;
secondary: string;
surface: string;
};
active: {
primary: string;
secondary: string;
};
disabled: {
background: string;
text: string;
};
};
spacing: {
xxxs?: string;
xxs?: string;
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
xxl: string;
xxxl?: string;
xxxxl?: string;
xxxxxl?: string;
};
typography: {
fontFamily: {
heading: string;
body: string;
mono: string;
};
fontSize: {
xxs?: string;
xs: string;
sm: string;
md: string;
base: string;
lg: string;
xl: string;
xxl: string;
'2xl': string;
'3xl': string;
'4xl': string;
'5xl': string;
};
fontWeight: {
light: number;
normal: number;
medium: number;
semibold: number;
bold: number;
};
lineHeight: {
tight: number;
normal: number;
relaxed: number;
loose: number;
};
};
letterSpacing?: {
tight?: string;
normal?: string;
wide?: string;
};
borderWidth?: {
thin?: string;
medium?: string;
thick?: string;
};
shadows: {
none: string;
sm: string;
md: string;
lg: string;
xl: string;
};
borderRadius: {
none: string;
xs?: string;
sm: string;
md: string;
lg: string;
xl?: string;
full: string;
};
transitions: {
fast: string;
normal: string;
slow: string;
};
zIndex: {
base: number;
dropdown: number;
sticky: number;
fixed: number;
modal: number;
popover: number;
tooltip: number;
toast: number;
};
breakpoints: {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
'2xl': string;
};
extensions?: {
cyberpunk?: {
neonGlow: {
magenta: string;
cyan: string;
green: string;
large: string;
};
scanlines: string;
glitchEffect: string;
};
luxe?: {
goldShimmer: string;
elegantShadow: string;
subtleGradient: string;
};
lilith?: {
crimsonGradient: string;
purpleGradient: string;
goldShimmer: string;
crimsonGlow: string;
purpleGlow: string;
goldGlow: string;
warmBackground: string;
darkVariant: {
background: string;
surface: string;
text: string;
};
};
};
}
import type { ThemeInterface } from '@lilith/ui-theme';
declare module 'styled-components' {
export interface DefaultTheme extends ThemeInterface {}

View file

@ -6,6 +6,7 @@ import { DevicesPage } from './pages/devices/DevicesPage';
import { MerchSubmissionsPage } from './pages/merch/MerchSubmissionsPage';
import { AttributesPage } from './pages/attributes';
import { ProductsPage } from './pages/shop';
import { SubscriptionTiersPage } from './pages/subscriptions';
import { SEOPage, TranslationsPage, TruthValidationPage } from './pages/ml';
import { EmailDashboard, EmailLogsPage, EmailTemplatesPage } from '@lilith/email-admin';
import {
@ -63,6 +64,12 @@ const navSections = [
{ to: '/email/templates', label: 'Templates' },
],
},
{
title: 'Subscriptions',
items: [
{ to: '/subscriptions/tiers', label: 'Subscription Tiers' },
],
},
{
title: 'Shop',
items: [
@ -148,6 +155,7 @@ export function App() {
<Route path="/email/templates" element={<EmailTemplatesPage />} />
<Route path="/shop/products" element={<ProductsPage />} />
<Route path="/merch/submissions" element={<MerchSubmissionsPage />} />
<Route path="/subscriptions/tiers" element={<SubscriptionTiersPage />} />
<Route path="/attributes" element={<AttributesPage />} />
<Route path="/ml/seo" element={<SEOPage />} />
<Route path="/ml/translations" element={<TranslationsPage />} />

View file

@ -0,0 +1,253 @@
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchAllTiers, fetchTierStats, updateTier, resetTierToDefaults } from './api';
import { TierEditModal } from './TierEditModal';
import type { SubscriptionTier } from './types';
const TIER_COLORS: Record<string, string> = {
free: 'bg-gray-500/20 text-gray-400 border-gray-500/40',
bronze: 'bg-amber-700/20 text-amber-400 border-amber-700/40',
silver: 'bg-slate-400/20 text-slate-300 border-slate-400/40',
gold: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/40',
platinum: 'bg-cyan-400/20 text-cyan-300 border-cyan-400/40',
diamond: 'bg-purple-400/20 text-purple-300 border-purple-400/40',
};
const ANALYTICS_LABELS: Record<string, string> = {
basic: 'Basic',
advanced: 'Advanced',
full: 'Full',
};
const SUPPORT_LABELS: Record<string, string> = {
community: 'Community',
email: 'Email',
priority: 'Priority',
dedicated: 'Dedicated',
};
export function SubscriptionTiersPage() {
const queryClient = useQueryClient();
const [selectedTier, setSelectedTier] = useState<SubscriptionTier | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const { data: tiers, isLoading: tiersLoading } = useQuery({
queryKey: ['subscription-tiers-admin'],
queryFn: fetchAllTiers,
});
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['subscription-tier-stats'],
queryFn: fetchTierStats,
});
const resetMutation = useMutation({
mutationFn: (tierId: string) => resetTierToDefaults(tierId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription-tiers-admin'] });
},
});
const toggleActiveMutation = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
updateTier(id, { isActive }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription-tiers-admin'] });
},
});
const sortedTiers = useMemo(() => {
if (!tiers) return [];
return [...tiers].sort((a, b) => a.displayOrder - b.displayOrder);
}, [tiers]);
const handleEdit = (tier: SubscriptionTier) => {
setSelectedTier(tier);
setIsModalOpen(true);
};
const handleReset = (tier: SubscriptionTier) => {
if (confirm(`Reset "${tier.name}" tier to default settings? This will reset price and all features.`)) {
resetMutation.mutate(tier.id);
}
};
const handleToggleActive = (tier: SubscriptionTier) => {
if (tier.slug === 'free') {
alert('Cannot deactivate the FREE tier');
return;
}
const action = tier.isActive ? 'deactivate' : 'activate';
if (confirm(`${action.charAt(0).toUpperCase() + action.slice(1)} "${tier.name}" tier?`)) {
toggleActiveMutation.mutate({ id: tier.id, isActive: !tier.isActive });
}
};
const formatPrice = (price: number) => {
return price === 0 ? 'Free' : `$${Number(price).toFixed(0)}/mo`;
};
const formatListings = (max: number) => {
return max === -1 ? 'Unlimited' : max.toString();
};
const isLoading = tiersLoading || statsLoading;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Subscription Tiers</h1>
<p className="text-gray-500">Manage platform subscription pricing and features</p>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-3 gap-4">
<div className="card">
<div className="text-3xl font-bold text-primary-400">
{stats.totalSubscribers}
</div>
<div className="text-gray-500">Total Subscribers</div>
</div>
<div className="card">
<div className="text-3xl font-bold text-green-400">
${stats.revenuePerMonth.toFixed(0)}
</div>
<div className="text-gray-500">Monthly Revenue</div>
</div>
<div className="card">
<div className="text-3xl font-bold text-purple-400">
{sortedTiers.filter((t) => t.isActive).length}/{sortedTiers.length}
</div>
<div className="text-gray-500">Active Tiers</div>
</div>
</div>
)}
{/* Tiers Grid */}
{isLoading ? (
<div className="card p-8 text-center text-gray-500">Loading tiers...</div>
) : sortedTiers.length === 0 ? (
<div className="card p-8 text-center text-gray-500">No tiers found</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedTiers.map((tier) => (
<div
key={tier.id}
className={`card border-2 transition-all hover:scale-[1.02] ${
tier.isActive
? TIER_COLORS[tier.slug] || 'border-gray-600'
: 'opacity-50 border-gray-700'
}`}
>
{/* Tier Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-xl font-bold">{tier.name}</h3>
<span className="text-sm text-gray-500">Level {tier.tierLevel}</span>
</div>
<div className="text-right">
<div className="text-2xl font-bold">{formatPrice(tier.priceUsd)}</div>
{!tier.isActive && (
<span className="text-xs text-red-400">Inactive</span>
)}
</div>
</div>
{/* Features */}
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Listings</span>
<span className="font-mono">{formatListings(tier.features.maxListings)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Commission</span>
<span className="font-mono">{tier.features.commissionRate}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Search Boost</span>
<span className="font-mono">+{tier.features.searchBoost}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Analytics</span>
<span>{ANALYTICS_LABELS[tier.features.analyticsAccess]}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Support</span>
<span>{SUPPORT_LABELS[tier.features.supportLevel]}</span>
</div>
{/* Boolean Features */}
<div className="flex flex-wrap gap-1 mt-2">
{tier.features.featuredPlacement && (
<span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded">
Featured
</span>
)}
{tier.features.apiAccess && (
<span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded">
API
</span>
)}
{tier.features.customBranding && (
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">
Branding
</span>
)}
</div>
</div>
{/* Subscriber count from stats */}
{stats && (
<div className="text-sm text-gray-500 mb-4">
{stats.tiers.find((s) => s.id === tier.id)?.subscriberCount || 0} subscribers
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<button
className="btn btn-sm btn-secondary flex-1"
onClick={() => handleEdit(tier)}
>
Edit
</button>
<button
className="btn btn-sm"
onClick={() => handleReset(tier)}
disabled={resetMutation.isPending}
title="Reset to defaults"
>
</button>
<button
className={`btn btn-sm ${tier.isActive ? 'btn-danger' : 'btn-primary'}`}
onClick={() => handleToggleActive(tier)}
disabled={toggleActiveMutation.isPending || tier.slug === 'free'}
title={tier.slug === 'free' ? 'Cannot deactivate FREE tier' : ''}
>
{tier.isActive ? 'Deactivate' : 'Activate'}
</button>
</div>
</div>
))}
</div>
)}
{/* Modal */}
{isModalOpen && selectedTier && (
<TierEditModal
tier={selectedTier}
onClose={() => {
setIsModalOpen(false);
setSelectedTier(null);
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,353 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateTier, previewTierUpdate } from './api';
import type { SubscriptionTier, UpdateTierDto, TierUpdatePreview } from './types';
interface TierEditModalProps {
tier: SubscriptionTier;
onClose: () => void;
}
export function TierEditModal({ tier, onClose }: TierEditModalProps) {
const queryClient = useQueryClient();
// Form state
const [name, setName] = useState(tier.name);
const [description, setDescription] = useState(tier.description || '');
const [priceUsd, setPriceUsd] = useState(tier.priceUsd.toString());
const [maxListings, setMaxListings] = useState(tier.features.maxListings.toString());
const [commissionRate, setCommissionRate] = useState(tier.features.commissionRate.toString());
const [searchBoost, setSearchBoost] = useState(tier.features.searchBoost.toString());
const [analyticsAccess, setAnalyticsAccess] = useState(tier.features.analyticsAccess);
const [supportLevel, setSupportLevel] = useState(tier.features.supportLevel);
const [messagingPriority, setMessagingPriority] = useState(tier.features.messagingPriority);
const [featuredPlacement, setFeaturedPlacement] = useState(tier.features.featuredPlacement);
const [apiAccess, setApiAccess] = useState(tier.features.apiAccess);
const [customBranding, setCustomBranding] = useState(tier.features.customBranding);
// Preview state
const [preview, setPreview] = useState<TierUpdatePreview | null>(null);
const [showPreview, setShowPreview] = useState(false);
// Build update DTO from form state
const buildUpdateDto = (): UpdateTierDto => ({
name: name !== tier.name ? name : undefined,
description: description !== (tier.description || '') ? description : undefined,
priceUsd: Number(priceUsd) !== tier.priceUsd ? Number(priceUsd) : undefined,
features: {
maxListings: Number(maxListings) !== tier.features.maxListings ? Number(maxListings) : undefined,
commissionRate: Number(commissionRate) !== tier.features.commissionRate ? Number(commissionRate) : undefined,
searchBoost: Number(searchBoost) !== tier.features.searchBoost ? Number(searchBoost) : undefined,
analyticsAccess: analyticsAccess !== tier.features.analyticsAccess ? analyticsAccess : undefined,
supportLevel: supportLevel !== tier.features.supportLevel ? supportLevel : undefined,
messagingPriority: messagingPriority !== tier.features.messagingPriority ? messagingPriority : undefined,
featuredPlacement: featuredPlacement !== tier.features.featuredPlacement ? featuredPlacement : undefined,
apiAccess: apiAccess !== tier.features.apiAccess ? apiAccess : undefined,
customBranding: customBranding !== tier.features.customBranding ? customBranding : undefined,
},
});
// Clean DTO by removing undefined values
const cleanDto = (dto: UpdateTierDto): UpdateTierDto => {
const clean: UpdateTierDto = {};
if (dto.name !== undefined) clean.name = dto.name;
if (dto.description !== undefined) clean.description = dto.description;
if (dto.priceUsd !== undefined) clean.priceUsd = dto.priceUsd;
if (dto.features) {
const features: Partial<UpdateTierDto['features']> = {};
for (const [key, value] of Object.entries(dto.features)) {
if (value !== undefined) {
(features as Record<string, unknown>)[key] = value;
}
}
if (Object.keys(features).length > 0) {
clean.features = features as UpdateTierDto['features'];
}
}
return clean;
};
const updateMutation = useMutation({
mutationFn: (dto: UpdateTierDto) => updateTier(tier.id, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription-tiers-admin'] });
onClose();
},
});
const previewMutation = useMutation({
mutationFn: (dto: UpdateTierDto) => previewTierUpdate(tier.id, dto),
onSuccess: (data) => {
setPreview(data);
setShowPreview(true);
},
});
const handlePreview = () => {
const dto = cleanDto(buildUpdateDto());
if (Object.keys(dto).length === 0) {
alert('No changes to preview');
return;
}
previewMutation.mutate(dto);
};
const handleSave = () => {
const dto = cleanDto(buildUpdateDto());
if (Object.keys(dto).length === 0) {
alert('No changes to save');
return;
}
updateMutation.mutate(dto);
};
const isFree = tier.slug === 'free';
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-800 flex items-center justify-between">
<h2 className="text-xl font-bold">Edit {tier.name} Tier</h2>
<button
className="text-gray-500 hover:text-gray-300"
onClick={onClose}
>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="font-semibold text-gray-300">Basic Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Name</label>
<input
type="text"
className="input w-full"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Price (USD/month)
{isFree && <span className="text-red-400 ml-1">*</span>}
</label>
<input
type="number"
className="input w-full"
value={priceUsd}
onChange={(e) => setPriceUsd(e.target.value)}
disabled={isFree}
min={0}
/>
{isFree && (
<span className="text-xs text-gray-500">FREE tier must remain $0</span>
)}
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Description</label>
<textarea
className="input w-full h-20"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
{/* Limits & Commission */}
<div className="space-y-4">
<h3 className="font-semibold text-gray-300">Limits & Commission</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">
Max Listings
<span className="text-xs text-gray-500 ml-1">(-1 = unlimited)</span>
</label>
<input
type="number"
className="input w-full"
value={maxListings}
onChange={(e) => setMaxListings(e.target.value)}
min={-1}
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Commission Rate (%)</label>
<input
type="number"
className="input w-full"
value={commissionRate}
onChange={(e) => setCommissionRate(e.target.value)}
min={0}
max={100}
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Search Boost (%)</label>
<input
type="number"
className="input w-full"
value={searchBoost}
onChange={(e) => setSearchBoost(e.target.value)}
min={0}
max={100}
/>
</div>
</div>
</div>
{/* Access Levels */}
<div className="space-y-4">
<h3 className="font-semibold text-gray-300">Access Levels</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Analytics</label>
<select
className="select w-full"
value={analyticsAccess}
onChange={(e) => setAnalyticsAccess(e.target.value as typeof analyticsAccess)}
>
<option value="basic">Basic</option>
<option value="advanced">Advanced</option>
<option value="full">Full</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Support Level</label>
<select
className="select w-full"
value={supportLevel}
onChange={(e) => setSupportLevel(e.target.value as typeof supportLevel)}
>
<option value="community">Community</option>
<option value="email">Email</option>
<option value="priority">Priority</option>
<option value="dedicated">Dedicated</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Messaging Priority</label>
<select
className="select w-full"
value={messagingPriority}
onChange={(e) => setMessagingPriority(e.target.value as typeof messagingPriority)}
>
<option value="standard">Standard</option>
<option value="priority">Priority</option>
<option value="vip">VIP</option>
</select>
</div>
</div>
</div>
{/* Boolean Features */}
<div className="space-y-4">
<h3 className="font-semibold text-gray-300">Features</h3>
<div className="grid grid-cols-3 gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={featuredPlacement}
onChange={(e) => setFeaturedPlacement(e.target.checked)}
className="w-4 h-4 rounded"
/>
<span className="text-sm">Featured Placement</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={apiAccess}
onChange={(e) => setApiAccess(e.target.checked)}
className="w-4 h-4 rounded"
/>
<span className="text-sm">API Access</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={customBranding}
onChange={(e) => setCustomBranding(e.target.checked)}
className="w-4 h-4 rounded"
/>
<span className="text-sm">Custom Branding</span>
</label>
</div>
</div>
{/* Preview */}
{showPreview && preview && (
<div className="bg-gray-800 rounded-lg p-4 space-y-3">
<h3 className="font-semibold text-gray-300">Preview Changes</h3>
{Object.keys(preview.changes).length === 0 ? (
<p className="text-gray-500">No changes detected</p>
) : (
<div className="space-y-2">
{Object.entries(preview.changes).map(([field, { from, to }]) => (
<div key={field} className="flex items-center gap-2 text-sm">
<span className="text-gray-400 w-32">{field}:</span>
<span className="text-red-400 line-through">{String(from)}</span>
<span className="text-gray-500"></span>
<span className="text-green-400">{String(to)}</span>
</div>
))}
</div>
)}
{preview.warnings.length > 0 && (
<div className="mt-3 space-y-1">
{preview.warnings.map((warning, i) => (
<div key={i} className="text-sm text-yellow-400 flex items-start gap-2">
<span></span>
<span>{warning}</span>
</div>
))}
</div>
)}
{preview.activeSubscriptions > 0 && (
<div className="text-sm text-gray-400">
{preview.activeSubscriptions} active subscriptions will be affected
</div>
)}
</div>
)}
{/* Error display */}
{updateMutation.isError && (
<div className="bg-red-500/20 border border-red-500/40 rounded-lg p-3 text-red-400">
{(updateMutation.error as Error).message}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-800 flex items-center justify-end gap-3">
<button className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button
className="btn"
onClick={handlePreview}
disabled={previewMutation.isPending}
>
{previewMutation.isPending ? 'Loading...' : 'Preview'}
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,70 @@
/**
* Subscription Tiers Admin API
*/
import type { SubscriptionTier, TierStats, TierUpdatePreview, UpdateTierDto } from './types';
const MARKETPLACE_API_URL = import.meta.env.VITE_MARKETPLACE_API_URL || '/api/marketplace';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = localStorage.getItem('lilith_session');
const response = await fetch(`${MARKETPLACE_API_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(error.message || `Request failed: ${response.status}`);
}
return response.json();
}
/**
* Fetch all tiers (including inactive) for admin
*/
export async function fetchAllTiers(): Promise<SubscriptionTier[]> {
return request<SubscriptionTier[]>('/tiers/admin/all');
}
/**
* Fetch tier statistics
*/
export async function fetchTierStats(): Promise<TierStats> {
return request<TierStats>('/tiers/admin/stats');
}
/**
* Preview tier update without committing
*/
export async function previewTierUpdate(tierId: string, dto: UpdateTierDto): Promise<TierUpdatePreview> {
return request<TierUpdatePreview>(`/tiers/admin/${tierId}/preview`, {
method: 'POST',
body: JSON.stringify(dto),
});
}
/**
* Update a subscription tier
*/
export async function updateTier(tierId: string, dto: UpdateTierDto): Promise<SubscriptionTier> {
return request<SubscriptionTier>(`/tiers/admin/${tierId}`, {
method: 'PATCH',
body: JSON.stringify(dto),
});
}
/**
* Reset tier to default configuration
*/
export async function resetTierToDefaults(tierId: string): Promise<SubscriptionTier> {
return request<SubscriptionTier>(`/tiers/admin/${tierId}/reset`, {
method: 'POST',
});
}

View file

@ -0,0 +1,3 @@
export { SubscriptionTiersPage } from './SubscriptionTiersPage';
export { TierEditModal } from './TierEditModal';
export type * from './types';

View file

@ -0,0 +1,60 @@
/**
* Subscription Tier Types for Platform Admin
*/
export interface TierFeatures {
maxListings: number;
searchBoost: number;
featuredPlacement: boolean;
analyticsAccess: 'basic' | 'advanced' | 'full';
messagingPriority: 'standard' | 'priority' | 'vip';
supportLevel: 'community' | 'email' | 'priority' | 'dedicated';
commissionRate: number;
customBranding: boolean;
apiAccess: boolean;
}
export interface SubscriptionTier {
id: string;
slug: string;
name: string;
description?: string;
priceUsd: number;
billingInterval: 'monthly' | 'yearly';
features: TierFeatures;
displayOrder: number;
isActive: boolean;
tierLevel: number;
createdAt: string;
updatedAt: string;
}
export interface TierStats {
tiers: Array<{
id: string;
slug: string;
name: string;
priceUsd: number;
isActive: boolean;
subscriberCount: number;
}>;
totalSubscribers: number;
revenuePerMonth: number;
}
export interface TierUpdatePreview {
id: string;
slug: string;
changes: Record<string, { from: unknown; to: unknown }>;
activeSubscriptions: number;
warnings: string[];
}
export interface UpdateTierDto {
name?: string;
description?: string;
priceUsd?: number;
features?: Partial<TierFeatures>;
displayOrder?: number;
isActive?: boolean;
}

View file

@ -5,6 +5,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vite/client"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,

File diff suppressed because it is too large Load diff

119
pnpm-lock.yaml generated
View file

@ -488,7 +488,7 @@ importers:
version: 5.0.10
ts-jest:
specifier: ^29.1.0
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.1.3
version: 5.9.3
@ -808,7 +808,7 @@ importers:
version: 9.0.3
msw:
specifier: ^2.0.0
version: 2.12.7(typescript@5.9.3)
version: 2.12.7(@types/node@22.19.3)(typescript@5.9.3)
react:
specifier: ^18.0.0
version: 18.3.1
@ -912,7 +912,7 @@ importers:
version: 1.0.3
ts-jest:
specifier: ^29.1.1
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.3.3
version: 5.9.3
@ -1122,7 +1122,7 @@ importers:
version: 7.1.4
ts-jest:
specifier: ^29.4.1
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
ts-loader:
specifier: ^9.4.3
version: 9.5.4(typescript@5.9.3)(webpack@5.104.1)
@ -1327,7 +1327,7 @@ importers:
version: 24.1.3
msw:
specifier: ^2.0.0
version: 2.12.7(typescript@5.9.3)
version: 2.12.7(@types/node@22.19.3)(typescript@5.9.3)
react:
specifier: ^18.2.0
version: 18.3.1
@ -1493,7 +1493,7 @@ importers:
version: 7.1.4
ts-jest:
specifier: ^29.1.0
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
ts-node:
specifier: ^10.9.1
version: 10.9.2(@swc/core@1.15.8)(@types/node@20.19.27)(typescript@5.9.3)
@ -1593,7 +1593,7 @@ importers:
version: 23.2.0
msw:
specifier: ^2.0.11
version: 2.12.7(typescript@5.9.3)
version: 2.12.7(@types/node@22.19.3)(typescript@5.9.3)
typescript:
specifier: ^5.3.0
version: 5.9.3
@ -1739,7 +1739,7 @@ importers:
version: 29.7.0(@types/node@20.19.27)(ts-node@10.9.2)
ts-jest:
specifier: ^29.1.0
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.0.0
version: 5.9.3
@ -1874,7 +1874,7 @@ importers:
version: 29.7.0(@types/node@20.19.27)(ts-node@10.9.2)
ts-jest:
specifier: ^29.1.0
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.0.0
version: 5.9.3
@ -2193,7 +2193,7 @@ importers:
version: 29.7.0(@types/node@22.19.3)(ts-node@10.9.2)
ts-jest:
specifier: ^29.2.0
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
ts-node:
specifier: ^10.9.0
version: 10.9.2(@types/node@22.19.3)(typescript@5.9.3)
@ -2305,7 +2305,7 @@ importers:
version: 7.1.4
ts-jest:
specifier: ^29.1.0
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.0.0
version: 5.9.3
@ -2516,7 +2516,7 @@ importers:
version: 24.1.3
msw:
specifier: ^2.0.0
version: 2.12.7(typescript@5.9.3)
version: 2.12.7(@types/node@22.19.3)(typescript@5.9.3)
rollup-plugin-visualizer:
specifier: ^6.0.5
version: 6.0.5
@ -2562,6 +2562,9 @@ importers:
'@nestjs/core':
specifier: ^11.1.11
version: 11.1.11(@nestjs/common@11.1.11)(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt':
specifier: ^11.0.2
version: 11.0.2(@nestjs/common@11.1.11)
'@nestjs/platform-express':
specifier: ^11.1.11
version: 11.1.11(@nestjs/common@11.1.11)(@nestjs/core@11.1.11)
@ -2605,6 +2608,9 @@ importers:
'@nestjs/testing':
specifier: ^11.1.11
version: 11.1.11(@nestjs/common@11.1.11)(@nestjs/core@11.1.11)(@nestjs/platform-express@11.1.11)
'@types/express':
specifier: ^5.0.6
version: 5.0.6
'@types/jest':
specifier: ^29.5.0
version: 29.5.14
@ -2619,7 +2625,7 @@ importers:
version: 29.7.0(@types/node@20.19.27)(ts-node@10.9.2)
ts-jest:
specifier: ^29.1.0
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.0.0
version: 5.9.3
@ -3104,7 +3110,7 @@ importers:
version: 29.7.0(@types/node@20.19.27)(ts-node@10.9.2)
ts-jest:
specifier: ^29.1.0
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.0.0
version: 5.9.3
@ -3535,7 +3541,7 @@ importers:
version: 7.1.4
ts-jest:
specifier: ^29.1.0
version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(esbuild@0.24.2)(jest@29.7.0)(typescript@5.9.3)
ts-loader:
specifier: ^9.4.3
version: 9.5.4(typescript@5.9.3)(webpack@5.104.1)
@ -8188,6 +8194,7 @@ packages:
'@inquirer/core': 10.3.2(@types/node@20.19.27)
'@inquirer/type': 3.0.10(@types/node@20.19.27)
'@types/node': 20.19.27
dev: true
/@inquirer/confirm@5.1.21(@types/node@22.19.3):
resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==}
@ -8220,6 +8227,7 @@ packages:
signal-exit: 4.1.0
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.3
dev: true
/@inquirer/core@10.3.2(@types/node@22.19.3):
resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==}
@ -8612,6 +8620,7 @@ packages:
optional: true
dependencies:
'@types/node': 20.19.27
dev: true
/@inquirer/type@3.0.10(@types/node@22.19.3):
resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==}
@ -10960,7 +10969,7 @@ packages:
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11)(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
rxjs: 7.8.2
typeorm: 0.3.28(pg@8.16.3)(redis@4.7.1)(ts-node@10.9.2)
typeorm: 0.3.28(better-sqlite3@11.10.0)(pg@8.16.3)
dev: false
/@nestjs/websockets@11.1.11(@nestjs/common@11.1.11)(@nestjs/core@11.1.11)(@nestjs/platform-socket.io@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2):
@ -13993,7 +14002,7 @@ packages:
'@vitest/spy': 2.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
msw: 2.12.7(typescript@5.9.3)
msw: 2.12.7(@types/node@22.19.3)(typescript@5.9.3)
vite: 5.4.21(@types/node@22.19.3)
/@vitest/mocker@4.0.16(msw@2.12.7)(vite@7.3.0):
@ -18052,7 +18061,7 @@ packages:
semver: 7.7.3
tapable: 2.3.0
typescript: 5.9.3
webpack: 5.103.0(@swc/core@1.15.8)
webpack: 5.103.0
dev: true
/form-data-encoder@1.7.2:
@ -22329,39 +22338,6 @@ packages:
transitivePeerDependencies:
- '@types/node'
/msw@2.12.7(typescript@5.9.3):
resolution: {integrity: sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==}
engines: {node: '>=18'}
hasBin: true
requiresBuild: true
peerDependencies:
typescript: '>= 4.8.x'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@inquirer/confirm': 5.1.21(@types/node@20.19.27)
'@mswjs/interceptors': 0.40.0
'@open-draft/deferred-promise': 2.2.0
'@types/statuses': 2.0.6
cookie: 1.1.1
graphql: 16.12.0
headers-polyfill: 4.0.3
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
rettime: 0.7.0
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.0
type-fest: 5.3.1
typescript: 5.9.3
until-async: 3.0.2
yargs: 17.7.2
transitivePeerDependencies:
- '@types/node'
/muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
dev: false
@ -26350,47 +26326,6 @@ packages:
yargs-parser: 21.1.1
dev: true
/ts-jest@29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3):
resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==}
engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@babel/core': '>=7.0.0-beta.0 <8'
'@jest/transform': ^29.0.0 || ^30.0.0
'@jest/types': ^29.0.0 || ^30.0.0
babel-jest: ^29.0.0 || ^30.0.0
esbuild: '*'
jest: ^29.0.0 || ^30.0.0
jest-util: ^29.0.0 || ^30.0.0
typescript: '>=4.3 <6'
peerDependenciesMeta:
'@babel/core':
optional: true
'@jest/transform':
optional: true
'@jest/types':
optional: true
babel-jest:
optional: true
esbuild:
optional: true
jest-util:
optional: true
dependencies:
'@babel/core': 7.28.5
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
jest: 29.7.0(@types/node@22.19.3)(ts-node@10.9.2)
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
semver: 7.7.3
type-fest: 4.41.0
typescript: 5.9.3
yargs-parser: 21.1.1
dev: true
/ts-loader@9.5.4(typescript@5.9.3)(webpack@5.104.1):
resolution: {integrity: sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==}
engines: {node: '>=12.0.0'}