1. replaceAll (Find & Replace) — une seule transaction ProseMirror au lieu d'un forEach cassé. Tous les matchs sont maintenant remplacés. 2. Link Preview unwrap — deleteNode() au lieu de clearer les attrs qui laissaient un nœud fantôme invisible dans le document. 3. Conversion Markdown → richtext — breaks: true dans marked.parse() Les simple newlines sont maintenant convertis en <br>. + préserve les blocs custom (toggle, callout, math, columns, outline, link-preview) en commentaires HTML lors de l'export MD. 4. emitNoteChange exercices — shape corrigée (type:'created' attend un objet Note, pas noteId/notebookId séparés). 5. Raccourcis clavier sans conflit : Cmd+Shift+C → Cmd+Alt+C (callout, avant: copier) Cmd+Shift+O → Cmd+Alt+O (outline, avant: historique/signets) Cmd+Shift+L → Cmd+Alt+L (colonnes, avant: lock screen macOS)
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { updateBillingConfig, updatePlanEntitlement } from '@/app/actions/admin-billing'
|
|
import { toast } from 'sonner'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { CreditCard, Gauge, Settings2 } from 'lucide-react'
|
|
|
|
type EntitlementRow = {
|
|
tier: string
|
|
feature: string
|
|
limitValue: number | null
|
|
mode: 'limited' | 'unlimited' | 'unavailable'
|
|
}
|
|
|
|
type BillingAdminData = {
|
|
entitlements: EntitlementRow[]
|
|
billingConfig: Record<string, string>
|
|
usageOverview: {
|
|
period: string
|
|
lastSyncedAt: string | null
|
|
byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }>
|
|
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
|
}
|
|
features: string[]
|
|
tiers: string[]
|
|
}
|
|
|
|
function getEntitlement(
|
|
entitlements: EntitlementRow[],
|
|
tier: string,
|
|
feature: string,
|
|
): EntitlementRow | undefined {
|
|
return entitlements.find((e) => e.tier === tier && e.feature === feature)
|
|
}
|
|
|
|
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
|
|
const { t } = useLanguage()
|
|
const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC')
|
|
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
|
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
|
const [savingCell, setSavingCell] = useState<string | null>(null)
|
|
|
|
const handleSaveBilling = async (formData: FormData) => {
|
|
setIsSavingBilling(true)
|
|
try {
|
|
const data: Record<string, string> = {
|
|
BILLING_ENABLED: billingEnabled ? 'true' : 'false',
|
|
STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''),
|
|
STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''),
|
|
STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''),
|
|
STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''),
|
|
}
|
|
await updateBillingConfig(data)
|
|
toast.success(t('admin.billing.configSaved'))
|
|
} catch (e: unknown) {
|
|
toast.error(e instanceof Error ? e.message : t('admin.billing.configFailed'))
|
|
} finally {
|
|
setIsSavingBilling(false)
|
|
}
|
|
}
|
|
|
|
const handleEntitlementChange = async (
|
|
feature: string,
|
|
mode: 'unavailable' | 'unlimited' | 'limited',
|
|
limitValue?: number,
|
|
) => {
|
|
const key = `${activeTier}:${feature}`
|
|
setSavingCell(key)
|
|
try {
|
|
await updatePlanEntitlement(activeTier, feature, mode, limitValue)
|
|
toast.success(t('admin.billing.limitSaved'))
|
|
} catch (e: unknown) {
|
|
toast.error(e instanceof Error ? e.message : t('admin.billing.limitFailed'))
|
|
} finally {
|
|
setSavingCell(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div>
|
|
<h1 className="font-memento-serif text-2xl font-semibold">{t('admin.billing.title')}</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
|
|
</div>
|
|
|
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
|
<CreditCard className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<h2 className="font-semibold">{t('admin.billing.stripeConfigTitle')}</h2>
|
|
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
|
|
</div>
|
|
</div>
|
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="BILLING_ENABLED"
|
|
checked={billingEnabled}
|
|
onCheckedChange={(c) => setBillingEnabled(!!c)}
|
|
/>
|
|
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => (
|
|
<div key={key} className="space-y-2">
|
|
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
|
<Input
|
|
id={key}
|
|
name={key}
|
|
defaultValue={initialData.billingConfig[key] ?? ''}
|
|
placeholder="price_..."
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
|
|
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
|
<Settings2 className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<h2 className="font-semibold">{t('admin.billing.limitsTitle')}</h2>
|
|
<p className="text-sm text-muted-foreground">{t('admin.billing.limitsDescription')}</p>
|
|
</div>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
{initialData.tiers.map((tier) => (
|
|
<button
|
|
key={tier}
|
|
type="button"
|
|
onClick={() => setActiveTier(tier)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
|
activeTier === tier
|
|
? 'border-primary bg-primary/10 text-primary'
|
|
: 'border-border text-muted-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
{tier}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<th className="py-2 pr-4">{t('admin.billing.feature')}</th>
|
|
<th className="py-2 pr-4">{t('admin.billing.mode')}</th>
|
|
<th className="py-2 pr-4">{t('admin.billing.monthlyLimit')}</th>
|
|
<th className="py-2">{t('admin.billing.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{initialData.features.map((feature) => {
|
|
const row = getEntitlement(initialData.entitlements, activeTier, feature)
|
|
const mode = row?.mode ?? 'unavailable'
|
|
const cellKey = `${activeTier}:${feature}`
|
|
return (
|
|
<EntitlementRowEditor
|
|
key={`${activeTier}:${feature}`}
|
|
feature={feature}
|
|
mode={mode}
|
|
limitValue={row?.limitValue ?? null}
|
|
isSaving={savingCell === cellKey}
|
|
onSave={handleEntitlementChange}
|
|
t={t}
|
|
/>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
|
<Gauge className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<h2 className="font-semibold">{t('admin.billing.usageTitle')}</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
|
{initialData.usageOverview.lastSyncedAt
|
|
? ` · ${t('admin.billing.lastSync', { date: new Date(initialData.usageOverview.lastSyncedAt).toLocaleString() })}`
|
|
: ` · ${t('admin.billing.notSynced')}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="p-6 grid gap-6 lg:grid-cols-2">
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-3">{t('admin.billing.byFeature')}</h3>
|
|
{initialData.usageOverview.byFeature.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{t('admin.billing.noUsageData')}</p>
|
|
) : (
|
|
<ul className="space-y-2 text-sm">
|
|
{initialData.usageOverview.byFeature.map((row) => (
|
|
<li key={row.feature} className="flex justify-between gap-4 border-b border-border/50 pb-2">
|
|
<span className="font-mono text-xs">{row.feature}</span>
|
|
<span className="text-muted-foreground">{row.requests} req · {row.tokens} tok</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-3">{t('admin.billing.topUsers')}</h3>
|
|
{initialData.usageOverview.topUsers.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{t('admin.billing.noUsageData')}</p>
|
|
) : (
|
|
<ul className="space-y-2 text-sm">
|
|
{initialData.usageOverview.topUsers.map((row) => (
|
|
<li key={row.userId} className="flex justify-between gap-4 border-b border-border/50 pb-2">
|
|
<span className="truncate">{row.email}</span>
|
|
<span className="text-muted-foreground shrink-0">{row.requests} req</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EntitlementRowEditor({
|
|
feature,
|
|
mode: initialMode,
|
|
limitValue: initialLimit,
|
|
isSaving,
|
|
onSave,
|
|
t,
|
|
}: {
|
|
feature: string
|
|
mode: 'unavailable' | 'unlimited' | 'limited'
|
|
limitValue: number | null
|
|
isSaving: boolean
|
|
onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise<void>
|
|
t: (key: string, params?: Record<string, string | number>) => string
|
|
}) {
|
|
const [mode, setMode] = useState(initialMode)
|
|
const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50')
|
|
|
|
useEffect(() => {
|
|
setMode(initialMode)
|
|
setLimit(initialLimit != null ? String(initialLimit) : '50')
|
|
}, [initialMode, initialLimit])
|
|
|
|
return (
|
|
<tr className="border-b border-border/30">
|
|
<td className="py-3 pr-4 font-mono text-xs">{feature}</td>
|
|
<td className="py-3 pr-4">
|
|
<select
|
|
value={mode}
|
|
onChange={(e) => setMode(e.target.value as typeof mode)}
|
|
className="h-9 rounded-md border border-input bg-background px-2 text-xs"
|
|
>
|
|
<option value="unavailable">{t('admin.billing.modeUnavailable')}</option>
|
|
<option value="limited">{t('admin.billing.modeLimited')}</option>
|
|
<option value="unlimited">{t('admin.billing.modeUnlimited')}</option>
|
|
</select>
|
|
</td>
|
|
<td className="py-3 pr-4">
|
|
{mode === 'limited' ? (
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={limit}
|
|
onChange={(e) => setLimit(e.target.value)}
|
|
className="h-9 w-24 text-xs"
|
|
/>
|
|
) : (
|
|
<span className="text-muted-foreground text-xs">—</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={isSaving}
|
|
onClick={() => onSave(feature, mode, mode === 'limited' ? parseInt(limit, 10) : undefined)}
|
|
>
|
|
{isSaving ? '…' : t('admin.billing.saveLimit')}
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|