Backend: - Add TenantCustomTier model for per-tenant feature overrides - Update EntitlementService to check custom tier before plan features - Add custom_tier action on TenantViewSet (GET/PUT/DELETE) - Add Celery task for grace period management (30-day expiry) Frontend: - Add DynamicFeaturesEditor component for dynamic feature management - Fix BusinessEditModal to load features from plan defaults when no custom tier - Update limits (max_users, max_resources, etc.) to use featureValues - Remove outdated canonical feature check from FeaturePicker (removes warning icons) - Add useBillingPlans hook for accessing billing system data - Add custom tier API functions to platform.ts Features now follow consistent rules: - Load from plan defaults when no custom tier exists - Load from custom tier when one exists - Reset to plan defaults when plan changes - Save to custom tier on edit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
767 lines
29 KiB
TypeScript
767 lines
29 KiB
TypeScript
/**
|
|
* PlanDetailPanel Component
|
|
*
|
|
* Detail view for a selected plan or add-on, shown in the main panel
|
|
* of the master-detail layout.
|
|
*/
|
|
|
|
import React, { useState } from 'react';
|
|
import {
|
|
Package,
|
|
Pencil,
|
|
Copy,
|
|
Trash2,
|
|
DollarSign,
|
|
Users,
|
|
Check,
|
|
AlertTriangle,
|
|
ExternalLink,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Plus,
|
|
Archive,
|
|
} from 'lucide-react';
|
|
import { Badge, Alert, Modal, ModalFooter } from '../../components/ui';
|
|
import {
|
|
usePlanVersionSubscribers,
|
|
useDeletePlan,
|
|
useDeletePlanVersion,
|
|
useMarkVersionLegacy,
|
|
useForceUpdatePlanVersion,
|
|
formatCentsToDollars,
|
|
type PlanWithVersions,
|
|
type PlanVersion,
|
|
type AddOnProduct,
|
|
} from '../../hooks/useBillingAdmin';
|
|
import { useCurrentUser } from '../../hooks/useAuth';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
interface PlanDetailPanelProps {
|
|
plan: PlanWithVersions | null;
|
|
addon: AddOnProduct | null;
|
|
onEdit: () => void;
|
|
onDuplicate: () => void;
|
|
onCreateVersion: () => void;
|
|
onEditVersion: (version: PlanVersion) => void;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Component
|
|
// =============================================================================
|
|
|
|
export const PlanDetailPanel: React.FC<PlanDetailPanelProps> = ({
|
|
plan,
|
|
addon,
|
|
onEdit,
|
|
onDuplicate,
|
|
onCreateVersion,
|
|
onEditVersion,
|
|
}) => {
|
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
|
new Set(['overview', 'pricing', 'features'])
|
|
);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
|
const [showForcePushModal, setShowForcePushModal] = useState(false);
|
|
const [forcePushConfirmText, setForcePushConfirmText] = useState('');
|
|
const [forcePushError, setForcePushError] = useState<string | null>(null);
|
|
const [forcePushSuccess, setForcePushSuccess] = useState<string | null>(null);
|
|
|
|
const { data: currentUser } = useCurrentUser();
|
|
const isSuperuser = currentUser?.is_superuser ?? false;
|
|
|
|
const deletePlanMutation = useDeletePlan();
|
|
const deleteVersionMutation = useDeletePlanVersion();
|
|
const markLegacyMutation = useMarkVersionLegacy();
|
|
const forceUpdateMutation = useForceUpdatePlanVersion();
|
|
|
|
if (!plan && !addon) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
|
<div className="text-center">
|
|
<Package className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
<p>Select a plan or add-on from the catalog</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const toggleSection = (section: string) => {
|
|
setExpandedSections((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(section)) {
|
|
next.delete(section);
|
|
} else {
|
|
next.add(section);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const activeVersion = plan?.active_version;
|
|
|
|
const handleDelete = async () => {
|
|
if (!plan) return;
|
|
|
|
const expectedText = `DELETE ${plan.code}`;
|
|
if (deleteConfirmText !== expectedText) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await deletePlanMutation.mutateAsync(plan.id);
|
|
setShowDeleteConfirm(false);
|
|
setDeleteConfirmText('');
|
|
} catch (error) {
|
|
console.error('Failed to delete plan:', error);
|
|
}
|
|
};
|
|
|
|
const handleForcePush = async () => {
|
|
if (!plan || !activeVersion) return;
|
|
|
|
const expectedText = `FORCE PUSH ${plan.code}`;
|
|
if (forcePushConfirmText !== expectedText) {
|
|
setForcePushError('Please type the confirmation text exactly.');
|
|
return;
|
|
}
|
|
|
|
setForcePushError(null);
|
|
|
|
try {
|
|
const result = await forceUpdateMutation.mutateAsync({
|
|
id: activeVersion.id,
|
|
confirm: true,
|
|
// Pass current version data to ensure it's updated in place
|
|
name: activeVersion.name,
|
|
});
|
|
|
|
if ('version' in result) {
|
|
setForcePushSuccess(
|
|
`Successfully pushed changes to ${result.affected_count} subscriber(s).`
|
|
);
|
|
setTimeout(() => {
|
|
setShowForcePushModal(false);
|
|
setForcePushConfirmText('');
|
|
setForcePushSuccess(null);
|
|
}, 2000);
|
|
}
|
|
} catch (error: any) {
|
|
const errorMessage = error.response?.data?.detail || error.message || 'Failed to force push';
|
|
setForcePushError(errorMessage);
|
|
}
|
|
};
|
|
|
|
// Render Plan Detail
|
|
if (plan) {
|
|
return (
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-6 z-10">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{plan.name}
|
|
</h2>
|
|
<span className="px-2 py-1 text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
|
{plan.code}
|
|
</span>
|
|
{!plan.is_active && <Badge variant="warning">Inactive</Badge>}
|
|
</div>
|
|
{plan.description && (
|
|
<p className="text-gray-600 dark:text-gray-400">{plan.description}</p>
|
|
)}
|
|
{activeVersion && (
|
|
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400">
|
|
<span className="flex items-center gap-1">
|
|
<DollarSign className="w-4 h-4" />
|
|
{activeVersion.price_monthly_cents === 0
|
|
? 'Free'
|
|
: `$${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo`}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Users className="w-4 h-4" />
|
|
{plan.total_subscribers} subscriber{plan.total_subscribers !== 1 ? 's' : ''}
|
|
</span>
|
|
<span>Version {activeVersion.version}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onEdit}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={onDuplicate}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
Duplicate
|
|
</button>
|
|
<button
|
|
onClick={onCreateVersion}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
New Version
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-6">
|
|
{/* Overview Section */}
|
|
<CollapsibleSection
|
|
title="Overview"
|
|
isExpanded={expandedSections.has('overview')}
|
|
onToggle={() => toggleSection('overview')}
|
|
>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">Plan Code</label>
|
|
<p className="font-medium text-gray-900 dark:text-white">{plan.code}</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">Display Order</label>
|
|
<p className="font-medium text-gray-900 dark:text-white">{plan.display_order}</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">Status</label>
|
|
<p className="font-medium text-gray-900 dark:text-white">
|
|
{plan.is_active ? 'Active' : 'Inactive'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">Total Subscribers</label>
|
|
<p className="font-medium text-gray-900 dark:text-white">{plan.total_subscribers}</p>
|
|
</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* Pricing Section */}
|
|
{activeVersion && (
|
|
<CollapsibleSection
|
|
title="Pricing"
|
|
isExpanded={expandedSections.has('pricing')}
|
|
onToggle={() => toggleSection('pricing')}
|
|
>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly</label>
|
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
|
${formatCentsToDollars(activeVersion.price_monthly_cents)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">Yearly</label>
|
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
|
${formatCentsToDollars(activeVersion.price_yearly_cents)}
|
|
</p>
|
|
{activeVersion.price_yearly_cents > 0 && (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
${(activeVersion.price_yearly_cents / 12 / 100).toFixed(2)}/mo
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">Trial</label>
|
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
|
{activeVersion.trial_days} days
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Transaction Fees Section */}
|
|
{activeVersion && (
|
|
<CollapsibleSection
|
|
title="Transaction Fees"
|
|
isExpanded={expandedSections.has('fees')}
|
|
onToggle={() => toggleSection('fees')}
|
|
>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">Percentage</label>
|
|
<p className="font-medium text-gray-900 dark:text-white">
|
|
{activeVersion.transaction_fee_percent}%
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">Fixed Fee</label>
|
|
<p className="font-medium text-gray-900 dark:text-white">
|
|
${(activeVersion.transaction_fee_fixed_cents / 100).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Features Section */}
|
|
{activeVersion && activeVersion.features.length > 0 && (
|
|
<CollapsibleSection
|
|
title={`Features (${activeVersion.features.length})`}
|
|
isExpanded={expandedSections.has('features')}
|
|
onToggle={() => toggleSection('features')}
|
|
>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{activeVersion.features.map((f) => (
|
|
<div
|
|
key={f.id}
|
|
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded"
|
|
>
|
|
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
|
<span className="text-sm text-gray-900 dark:text-white">
|
|
{f.feature.name}
|
|
</span>
|
|
{f.int_value !== null && (
|
|
<span className="ml-auto text-sm text-gray-500 dark:text-gray-400">
|
|
{f.int_value === 0 ? 'Unlimited' : f.int_value}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Stripe Section */}
|
|
{activeVersion &&
|
|
(activeVersion.stripe_product_id ||
|
|
activeVersion.stripe_price_id_monthly ||
|
|
activeVersion.stripe_price_id_yearly) && (
|
|
<CollapsibleSection
|
|
title="Stripe Integration"
|
|
isExpanded={expandedSections.has('stripe')}
|
|
onToggle={() => toggleSection('stripe')}
|
|
>
|
|
<div className="space-y-2">
|
|
{activeVersion.stripe_product_id && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">Product:</span>
|
|
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
|
{activeVersion.stripe_product_id}
|
|
</code>
|
|
</div>
|
|
)}
|
|
{activeVersion.stripe_price_id_monthly && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
Monthly Price:
|
|
</span>
|
|
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
|
{activeVersion.stripe_price_id_monthly}
|
|
</code>
|
|
</div>
|
|
)}
|
|
{activeVersion.stripe_price_id_yearly && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
Yearly Price:
|
|
</span>
|
|
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
|
{activeVersion.stripe_price_id_yearly}
|
|
</code>
|
|
</div>
|
|
)}
|
|
{!activeVersion.stripe_product_id && (
|
|
<Alert
|
|
variant="warning"
|
|
message="No Stripe Product ID configured. This plan cannot be purchased until Stripe is set up."
|
|
/>
|
|
)}
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Versions Section */}
|
|
<CollapsibleSection
|
|
title={`Versions (${plan.versions.length})`}
|
|
isExpanded={expandedSections.has('versions')}
|
|
onToggle={() => toggleSection('versions')}
|
|
>
|
|
<div className="space-y-2">
|
|
{plan.versions.map((version) => (
|
|
<VersionRow
|
|
key={version.id}
|
|
version={version}
|
|
isActive={!version.is_legacy}
|
|
onEdit={() => onEditVersion(version)}
|
|
onMarkLegacy={() => markLegacyMutation.mutate(version.id)}
|
|
onDelete={() => deleteVersionMutation.mutate(version.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* Danger Zone */}
|
|
<CollapsibleSection
|
|
title="Danger Zone"
|
|
isExpanded={expandedSections.has('danger')}
|
|
onToggle={() => toggleSection('danger')}
|
|
variant="danger"
|
|
>
|
|
<div className="space-y-4">
|
|
{/* Force Push to Subscribers - Superuser Only */}
|
|
{isSuperuser && activeVersion && plan.total_subscribers > 0 && (
|
|
<div className="p-4 border border-orange-200 dark:border-orange-800 rounded-lg bg-orange-50 dark:bg-orange-900/20">
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle className="w-5 h-5 text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
|
|
<div className="flex-1">
|
|
<h4 className="font-medium text-orange-800 dark:text-orange-200 mb-1">
|
|
Force Push Changes to All Subscribers
|
|
</h4>
|
|
<p className="text-sm text-orange-700 dark:text-orange-300 mb-3">
|
|
This will modify the current plan version in place, immediately affecting
|
|
all {plan.total_subscribers} active subscriber(s). This bypasses grandfathering
|
|
and cannot be undone. Changes to pricing, features, and limits will take
|
|
effect immediately.
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
setShowForcePushModal(true);
|
|
setForcePushError(null);
|
|
setForcePushSuccess(null);
|
|
setForcePushConfirmText('');
|
|
}}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700"
|
|
>
|
|
<AlertTriangle className="w-4 h-4" />
|
|
Force Push to Subscribers
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete Plan */}
|
|
<div className="p-4 border border-red-200 dark:border-red-800 rounded-lg">
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
Deleting a plan is permanent and cannot be undone. Plans with active subscribers
|
|
cannot be deleted.
|
|
</p>
|
|
{plan.total_subscribers > 0 ? (
|
|
<Alert
|
|
variant="warning"
|
|
message={`This plan has ${plan.total_subscribers} active subscriber(s) and cannot be deleted.`}
|
|
/>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Delete Plan
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
</div>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{showDeleteConfirm && (
|
|
<Modal
|
|
isOpen
|
|
onClose={() => {
|
|
setShowDeleteConfirm(false);
|
|
setDeleteConfirmText('');
|
|
}}
|
|
title="Delete Plan"
|
|
size="sm"
|
|
>
|
|
<div className="space-y-4">
|
|
<Alert
|
|
variant="error"
|
|
message="This action cannot be undone. This will permanently delete the plan and all its versions."
|
|
/>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
To confirm, type <strong>DELETE {plan.code}</strong> below:
|
|
</p>
|
|
<input
|
|
type="text"
|
|
value={deleteConfirmText}
|
|
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
placeholder={`DELETE ${plan.code}`}
|
|
/>
|
|
</div>
|
|
<ModalFooter
|
|
onCancel={() => {
|
|
setShowDeleteConfirm(false);
|
|
setDeleteConfirmText('');
|
|
}}
|
|
submitText="Delete Plan"
|
|
submitVariant="danger"
|
|
isDisabled={deleteConfirmText !== `DELETE ${plan.code}`}
|
|
isLoading={deletePlanMutation.isPending}
|
|
onSubmit={handleDelete}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Force Push Confirmation Modal */}
|
|
{showForcePushModal && activeVersion && (
|
|
<Modal
|
|
isOpen
|
|
onClose={() => {
|
|
setShowForcePushModal(false);
|
|
setForcePushConfirmText('');
|
|
setForcePushError(null);
|
|
setForcePushSuccess(null);
|
|
}}
|
|
title="Force Push to All Subscribers"
|
|
size="md"
|
|
>
|
|
<div className="space-y-4">
|
|
{forcePushSuccess ? (
|
|
<Alert variant="success" message={forcePushSuccess} />
|
|
) : (
|
|
<>
|
|
<Alert
|
|
variant="error"
|
|
message={
|
|
<div>
|
|
<strong>DANGER: This action affects paying customers!</strong>
|
|
<ul className="mt-2 ml-4 list-disc text-sm">
|
|
<li>All {plan.total_subscribers} subscriber(s) will be affected immediately</li>
|
|
<li>Changes to pricing will apply to future billing cycles</li>
|
|
<li>Feature and limit changes take effect immediately</li>
|
|
<li>This bypasses grandfathering protection</li>
|
|
<li>This action cannot be undone</li>
|
|
</ul>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
<strong>Current version:</strong> v{activeVersion.version} - {activeVersion.name}
|
|
</p>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
<strong>Price:</strong> ${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo
|
|
</p>
|
|
</div>
|
|
|
|
{forcePushError && (
|
|
<Alert variant="error" message={forcePushError} />
|
|
)}
|
|
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
To confirm this dangerous action, type <strong>FORCE PUSH {plan.code}</strong> below:
|
|
</p>
|
|
<input
|
|
type="text"
|
|
value={forcePushConfirmText}
|
|
onChange={(e) => setForcePushConfirmText(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
placeholder={`FORCE PUSH ${plan.code}`}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
{!forcePushSuccess && (
|
|
<ModalFooter
|
|
onCancel={() => {
|
|
setShowForcePushModal(false);
|
|
setForcePushConfirmText('');
|
|
setForcePushError(null);
|
|
}}
|
|
submitText="Force Push Changes"
|
|
submitVariant="danger"
|
|
isDisabled={forcePushConfirmText !== `FORCE PUSH ${plan.code}`}
|
|
isLoading={forceUpdateMutation.isPending}
|
|
onSubmit={handleForcePush}
|
|
/>
|
|
)}
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Render Add-on Detail
|
|
if (addon) {
|
|
return (
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="p-6">
|
|
<div className="flex items-start justify-between mb-6">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{addon.name}
|
|
</h2>
|
|
<span className="px-2 py-1 text-sm font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded">
|
|
{addon.code}
|
|
</span>
|
|
{!addon.is_active && <Badge variant="warning">Inactive</Badge>}
|
|
</div>
|
|
{addon.description && (
|
|
<p className="text-gray-600 dark:text-gray-400">{addon.description}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={onEdit}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
Edit
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly Price</label>
|
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
|
${formatCentsToDollars(addon.price_monthly_cents)}
|
|
</p>
|
|
</div>
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<label className="text-sm text-gray-500 dark:text-gray-400">One-time Price</label>
|
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
|
${formatCentsToDollars(addon.price_one_time_cents)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// =============================================================================
|
|
// Collapsible Section
|
|
// =============================================================================
|
|
|
|
interface CollapsibleSectionProps {
|
|
title: string;
|
|
isExpanded: boolean;
|
|
onToggle: () => void;
|
|
children: React.ReactNode;
|
|
variant?: 'default' | 'danger';
|
|
}
|
|
|
|
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
|
title,
|
|
isExpanded,
|
|
onToggle,
|
|
children,
|
|
variant = 'default',
|
|
}) => {
|
|
return (
|
|
<div
|
|
className={`border rounded-lg ${
|
|
variant === 'danger'
|
|
? 'border-red-200 dark:border-red-800'
|
|
: 'border-gray-200 dark:border-gray-700'
|
|
}`}
|
|
>
|
|
<button
|
|
onClick={onToggle}
|
|
className={`w-full flex items-center justify-between p-4 text-left ${
|
|
variant === 'danger'
|
|
? 'text-red-700 dark:text-red-400'
|
|
: 'text-gray-900 dark:text-white'
|
|
}`}
|
|
>
|
|
<span className="font-medium">{title}</span>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-5 h-5" />
|
|
) : (
|
|
<ChevronRight className="w-5 h-5" />
|
|
)}
|
|
</button>
|
|
{isExpanded && (
|
|
<div className="px-4 pb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// Version Row
|
|
// =============================================================================
|
|
|
|
interface VersionRowProps {
|
|
version: PlanVersion;
|
|
isActive: boolean;
|
|
onEdit: () => void;
|
|
onMarkLegacy: () => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
const VersionRow: React.FC<VersionRowProps> = ({
|
|
version,
|
|
isActive,
|
|
onEdit,
|
|
onMarkLegacy,
|
|
onDelete,
|
|
}) => {
|
|
return (
|
|
<div
|
|
className={`flex items-center justify-between p-3 rounded-lg ${
|
|
version.is_legacy
|
|
? 'bg-gray-50 dark:bg-gray-700/50'
|
|
: 'bg-blue-50 dark:bg-blue-900/20'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-medium text-gray-900 dark:text-white">v{version.version}</span>
|
|
<span className="text-gray-500 dark:text-gray-400">{version.name}</span>
|
|
{version.is_legacy && (
|
|
<span className="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded">
|
|
Legacy
|
|
</span>
|
|
)}
|
|
{!version.is_public && !version.is_legacy && (
|
|
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
|
|
Hidden
|
|
</span>
|
|
)}
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
{version.subscriber_count} subscriber{version.subscriber_count !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onEdit}
|
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
title="Edit version"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
{!version.is_legacy && version.subscriber_count === 0 && (
|
|
<button
|
|
onClick={onMarkLegacy}
|
|
className="p-1 text-gray-400 hover:text-amber-600 dark:hover:text-amber-400"
|
|
title="Mark as legacy"
|
|
>
|
|
<Archive className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
{version.subscriber_count === 0 && (
|
|
<button
|
|
onClick={onDelete}
|
|
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
|
title="Delete version"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|