Enhance Subscriptions, Affiliates, and Software Distribution modules

This commit is contained in:
Dwindi Ramadhana
2026-06-03 21:24:03 +07:00
parent f8c733832e
commit 21ece27b9b
9 changed files with 803 additions and 96 deletions

View File

@@ -30,7 +30,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Search, Package, Plus, History, Download, Loader2 } from 'lucide-react';
import { Search, Package, Plus, History, Download, Loader2, ChevronDown, ChevronRight, X, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
@@ -43,11 +43,21 @@ interface SoftwareProduct {
total_downloads: number;
}
interface ChangelogPoint {
type: string;
text: string;
}
interface ChangelogData {
narrative: string;
points: ChangelogPoint[];
}
interface SoftwareVersion {
id: number;
product_id: number;
version: string;
changelog: string;
changelog: ChangelogData | string;
release_date: string;
is_current: boolean;
download_count: number;
@@ -72,7 +82,13 @@ export default function SoftwareVersions() {
const [search, setSearch] = useState('');
const [selectedProduct, setSelectedProduct] = useState<number | null>(null);
const [isAddVersionOpen, setIsAddVersionOpen] = useState(false);
const [newVersion, setNewVersion] = useState({ version: '', changelog: '' });
const [editingVersionId, setEditingVersionId] = useState<number | null>(null);
const [newVersion, setNewVersion] = useState({
version: '',
changelog: { narrative: '', points: [] as ChangelogPoint[] }
});
const [expandedVersions, setExpandedVersions] = useState<Record<number, boolean>>({});
const queryClient = useQueryClient();
// Fetch software-enabled products
@@ -80,15 +96,14 @@ export default function SoftwareVersions() {
queryKey: ['software-products'],
queryFn: async () => {
const response = await api.get('/products?software_enabled=true&per_page=100');
// Filter products that have software distribution enabled
const products = (response as any).products || [];
const products = (response as any).rows || [];
return {
products: products.filter((p: any) => p.meta?._woonoow_software_enabled === 'yes').map((p: any) => ({
products: products.map((p: any) => ({
id: p.id,
name: p.name,
slug: p.meta?._woonoow_software_slug || '',
current_version: p.meta?._woonoow_software_current_version || '',
wp_enabled: p.meta?._woonoow_software_wp_enabled === 'yes',
slug: p.software_slug || p.meta?._woonoow_software_slug || '',
current_version: p.software_current_version || p.meta?._woonoow_software_current_version || '',
wp_enabled: p.software_wp_enabled || p.meta?._woonoow_software_wp_enabled === 'yes',
total_downloads: 0,
}))
} as ProductsResponse;
@@ -107,21 +122,121 @@ export default function SoftwareVersions() {
// Add new version mutation
const addVersion = useMutation({
mutationFn: async (data: { version: string; changelog: string }) => {
mutationFn: async (data: { version: string; changelog: ChangelogData }) => {
return await api.post(`/software/products/${selectedProduct}/versions`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['software-versions', selectedProduct] });
queryClient.invalidateQueries({ queryKey: ['software-products'] });
toast.success(__('Version added successfully'));
setIsAddVersionOpen(false);
setNewVersion({ version: '', changelog: '' });
closeModal();
},
onError: (error: any) => {
toast.error(error.message || __('Failed to add version'));
},
});
// Edit version mutation
const editVersion = useMutation({
mutationFn: async (data: { version_id: number; version: string; changelog: ChangelogData }) => {
return await api.put(`/software/products/${selectedProduct}/versions/${data.version_id}`, {
version: data.version,
changelog: data.changelog
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['software-versions', selectedProduct] });
queryClient.invalidateQueries({ queryKey: ['software-products'] });
toast.success(__('Version updated successfully'));
closeModal();
},
onError: (error: any) => {
toast.error(error.message || __('Failed to update version'));
},
});
const openAddModal = () => {
setEditingVersionId(null);
setNewVersion({ version: '', changelog: { narrative: '', points: [] } });
setIsAddVersionOpen(true);
};
const openEditModal = (version: SoftwareVersion, e: React.MouseEvent) => {
e.stopPropagation();
const cl = typeof version.changelog === 'object' && version.changelog !== null
? (version.changelog as ChangelogData)
: { narrative: version.changelog as string, points: [] };
setEditingVersionId(version.id);
setNewVersion({
version: version.version,
changelog: { narrative: cl.narrative || '', points: cl.points || [] }
});
setIsAddVersionOpen(true);
};
const closeModal = () => {
setIsAddVersionOpen(false);
setEditingVersionId(null);
setNewVersion({ version: '', changelog: { narrative: '', points: [] } });
};
const handleSaveVersion = () => {
if (editingVersionId) {
editVersion.mutate({
version_id: editingVersionId,
...newVersion
});
} else {
addVersion.mutate(newVersion);
}
};
const toggleVersion = (id: number) => {
setExpandedVersions(prev => ({
...prev,
[id]: !prev[id]
}));
};
const addChangelogPoint = () => {
setNewVersion(prev => ({
...prev,
changelog: {
...prev.changelog,
points: [...prev.changelog.points, { type: 'ADD', text: '' }]
}
}));
};
const updateChangelogPoint = (index: number, field: 'type' | 'text', value: string) => {
setNewVersion(prev => {
const newPoints = [...prev.changelog.points];
newPoints[index] = { ...newPoints[index], [field]: value };
return {
...prev,
changelog: {
...prev.changelog,
points: newPoints
}
};
});
};
const removeChangelogPoint = (index: number) => {
setNewVersion(prev => {
const newPoints = [...prev.changelog.points];
newPoints.splice(index, 1);
return {
...prev,
changelog: {
...prev.changelog,
points: newPoints
}
};
});
};
const filteredProducts = productsData?.products?.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.slug.toLowerCase().includes(search.toLowerCase())
@@ -129,9 +244,20 @@ export default function SoftwareVersions() {
const selectedProductData = productsData?.products?.find(p => p.id === selectedProduct);
const getBadgeColor = (type: string) => {
switch (type) {
case 'ADD': return 'bg-emerald-500 hover:bg-emerald-600';
case 'FIX': return 'bg-orange-500 hover:bg-orange-600';
case 'IMPROVE': return 'bg-blue-500 hover:bg-blue-600';
case 'DROP': return 'bg-rose-500 hover:bg-rose-600';
default: return 'bg-gray-500 hover:bg-gray-600';
}
};
const isSaving = addVersion.isPending || editVersion.isPending;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{__('Software Versions')}</h1>
@@ -142,7 +268,6 @@ export default function SoftwareVersions() {
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Products List */}
<div className="lg:col-span-1 border rounded-lg bg-card">
<div className="p-4 border-b">
<h2 className="font-semibold mb-3">{__('Software Products')}</h2>
@@ -176,8 +301,7 @@ export default function SoftwareVersions() {
<button
key={product.id}
onClick={() => setSelectedProduct(product.id)}
className={`w-full p-4 text-left hover:bg-accent transition-colors ${selectedProduct === product.id ? 'bg-accent' : ''
}`}
className={`w-full p-4 text-left hover:bg-accent transition-colors ${selectedProduct === product.id ? 'bg-accent' : ''}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
@@ -186,7 +310,7 @@ export default function SoftwareVersions() {
{product.slug}
</p>
</div>
<Badge variant="secondary" className="ml-2">
<Badge variant="secondary" className="ml-2 whitespace-nowrap">
v{product.current_version || '—'}
</Badge>
</div>
@@ -202,7 +326,6 @@ export default function SoftwareVersions() {
</div>
</div>
{/* Version Details */}
<div className="lg:col-span-2 border rounded-lg bg-card">
{!selectedProduct ? (
<div className="flex items-center justify-center h-full min-h-[400px] text-muted-foreground">
@@ -217,7 +340,6 @@ export default function SoftwareVersions() {
</div>
) : (
<>
{/* Version Header */}
<div className="p-4 border-b flex items-center justify-between">
<div>
<h2 className="font-semibold">{selectedProductData?.name}</h2>
@@ -225,21 +347,25 @@ export default function SoftwareVersions() {
{__('Current version')}: <span className="font-mono">{versionsData?.config?.current_version || '—'}</span>
</p>
</div>
<Dialog open={isAddVersionOpen} onOpenChange={setIsAddVersionOpen}>
<Dialog open={isAddVersionOpen} onOpenChange={(open) => !open ? closeModal() : setIsAddVersionOpen(true)}>
<DialogTrigger asChild>
<Button>
<Button onClick={openAddModal}>
<Plus className="w-4 h-4 mr-2" />
{__('New Version')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{__('Add New Version')}</DialogTitle>
<DialogTitle>
{editingVersionId ? __('Edit Version') : __('Add New Version')}
</DialogTitle>
<DialogDescription>
{__('Release a new version of')} {selectedProductData?.name}
{editingVersionId
? `${__('Modify release details for')} ${selectedProductData?.name}`
: `${__('Release a new version of')} ${selectedProductData?.name}`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-6 py-4">
<div className="space-y-6 px-6 py-4">
<div className="space-y-2">
<Label htmlFor="version">{__('Version Number')}</Label>
<Input
@@ -248,92 +374,195 @@ export default function SoftwareVersions() {
value={newVersion.version}
onChange={(e) => setNewVersion(prev => ({ ...prev, version: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
{__('Use semantic versioning (e.g., 1.0.0, 1.2.3)')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="changelog">{__('Changelog')}</Label>
<Textarea
id="changelog"
placeholder="## What's New&#10;- Added new feature&#10;- Fixed bug"
value={newVersion.changelog}
onChange={(e) => setNewVersion(prev => ({ ...prev, changelog: e.target.value }))}
rows={8}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{__('Supports Markdown formatting')}
</p>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="narrative">{__('Changelog Narrative (Optional)')}</Label>
<Textarea
id="narrative"
placeholder={__('Provide a general overview of this release...')}
value={newVersion.changelog.narrative}
onChange={(e) => setNewVersion(prev => ({
...prev,
changelog: { ...prev.changelog, narrative: e.target.value }
}))}
className="min-h-[100px]"
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>{__('Changes List')}</Label>
</div>
{newVersion.changelog.points.length === 0 ? (
<div className="text-center p-6 border rounded-md border-dashed text-muted-foreground">
<p className="text-sm">{__('No changes added yet.')}</p>
</div>
) : (
<div className="space-y-3">
{newVersion.changelog.points.map((point, index) => (
<div key={index} className="flex items-start gap-2">
<Select
value={point.type}
onValueChange={(val) => updateChangelogPoint(index, 'type', val)}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADD">{__('ADD')}</SelectItem>
<SelectItem value="FIX">{__('FIX')}</SelectItem>
<SelectItem value="IMPROVE">{__('IMPROVE')}</SelectItem>
<SelectItem value="DROP">{__('DROP')}</SelectItem>
<SelectItem value="OTHER">{__('OTHER')}</SelectItem>
</SelectContent>
</Select>
<Input
value={point.text}
onChange={(e) => updateChangelogPoint(index, 'text', e.target.value)}
placeholder={__('Describe the change...')}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeChangelogPoint(index)}
className="text-muted-foreground hover:text-destructive shrink-0"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
<Button variant="outline" size="sm" onClick={addChangelogPoint} className="w-full">
<Plus className="w-4 h-4 mr-2" />
{__('Add Change Item')}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddVersionOpen(false)}>
<Button variant="outline" onClick={closeModal} disabled={isSaving}>
{__('Cancel')}
</Button>
<Button
onClick={() => addVersion.mutate(newVersion)}
disabled={!newVersion.version || addVersion.isPending}
onClick={handleSaveVersion}
disabled={!newVersion.version || isSaving}
>
{addVersion.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{__('Release Version')}
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{editingVersionId ? __('Save Changes') : __('Release Version')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Version History */}
<div className="p-4">
{versionsData?.versions?.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{__('No versions released yet')}</p>
<p className="text-sm mt-1">
{__('Click "New Version" to release your first version')}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8"></TableHead>
<TableHead>{__('Version')}</TableHead>
<TableHead>{__('Release Date')}</TableHead>
<TableHead>{__('Downloads')}</TableHead>
<TableHead>{__('Changelog')}</TableHead>
<TableHead>{__('Summary')}</TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{versionsData?.versions?.map((version) => (
<TableRow key={version.id}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono font-medium">
v{version.version}
</span>
{version.is_current && (
<Badge variant="default" className="text-xs">
{__('Current')}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{version.release_date ? new Date(version.release_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-muted-foreground">
<Download className="w-3 h-3" />
{version.download_count}
</div>
</TableCell>
<TableCell className="max-w-xs">
<p className="text-sm text-muted-foreground truncate">
{version.changelog?.split('\n')[0] || '—'}
</p>
</TableCell>
</TableRow>
))}
{versionsData?.versions?.map((version) => {
const isExpanded = !!expandedVersions[version.id];
const cl = typeof version.changelog === 'object' && version.changelog !== null
? (version.changelog as ChangelogData)
: { narrative: version.changelog as string, points: [] };
return (
<React.Fragment key={version.id}>
<TableRow className="cursor-pointer hover:bg-muted/50 group" onClick={() => toggleVersion(version.id)}>
<TableCell>
<Button variant="ghost" size="icon" className="w-6 h-6">
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</Button>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono font-medium">
v{version.version}
</span>
{version.is_current && (
<Badge variant="default" className="text-xs">
{__('Current')}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{version.release_date ? new Date(version.release_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-muted-foreground">
<Download className="w-3 h-3" />
{version.download_count}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{cl.points?.length > 0 ? `${cl.points.length} changes` : (cl.narrative ? 'Notes attached' : '—')}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="w-8 h-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => openEditModal(version, e)}
title={__('Edit version')}
>
<Pencil className="w-4 h-4 text-muted-foreground" />
</Button>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="bg-muted/30">
<TableCell colSpan={6} className="p-0">
<div className="p-6 text-sm border-t">
{cl.narrative && (
<div className="mb-4 text-foreground whitespace-pre-wrap">
{cl.narrative}
</div>
)}
{cl.points && cl.points.length > 0 && (
<ul className="space-y-2">
{cl.points.map((pt, idx) => (
<li key={idx} className="flex items-start gap-3">
<Badge className={`${getBadgeColor(pt.type)} text-[10px] uppercase font-bold mt-0.5`}>
{pt.type}
</Badge>
<span className="text-muted-foreground">{pt.text}</span>
</li>
))}
</ul>
)}
{!cl.narrative && (!cl.points || cl.points.length === 0) && (
<p className="text-muted-foreground italic">{__('No changelog details provided.')}</p>
)}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}

View File

@@ -4,13 +4,14 @@ import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
import { Package, DollarSign, Layers, Tag, Download } from 'lucide-react';
import { Package, DollarSign, Layers, Tag, Download, Cloud } from 'lucide-react';
import { toast } from 'sonner';
import { GeneralTab } from './tabs/GeneralTab';
import { InventoryTab } from './tabs/InventoryTab';
import { VariationsTab, ProductVariant } from './tabs/VariationsTab';
import { OrganizationTab } from './tabs/OrganizationTab';
import { DownloadsTab, DownloadableFile } from './tabs/DownloadsTab';
import { SoftwareTab } from './tabs/SoftwareTab';
// Types
export type ProductFormData = {
@@ -50,6 +51,13 @@ export type ProductFormData = {
// Affiliate
affiliate_enabled?: boolean;
affiliate_commission_rate?: string;
// Software
software_enabled?: boolean;
software_slug?: string;
software_wp_enabled?: boolean;
software_requires_wp?: string;
software_tested_wp?: string;
software_requires_php?: string;
};
type Props = {
@@ -109,6 +117,13 @@ export function ProductFormTabbed({
// Affiliate state
const [affiliateEnabled, setAffiliateEnabled] = useState(initial?.affiliate_enabled || false);
const [affiliateCommissionRate, setAffiliateCommissionRate] = useState(initial?.affiliate_commission_rate || '');
// Software state
const [softwareEnabled, setSoftwareEnabled] = useState(initial?.software_enabled || false);
const [softwareSlug, setSoftwareSlug] = useState(initial?.software_slug || '');
const [softwareWpEnabled, setSoftwareWpEnabled] = useState(initial?.software_wp_enabled || false);
const [softwareRequiresWp, setSoftwareRequiresWp] = useState(initial?.software_requires_wp || '');
const [softwareTestedWp, setSoftwareTestedWp] = useState(initial?.software_tested_wp || '');
const [softwareRequiresPhp, setSoftwareRequiresPhp] = useState(initial?.software_requires_php || '');
const [submitting, setSubmitting] = useState(false);
// Update form state when initial data changes (for edit mode)
@@ -149,6 +164,13 @@ export function ProductFormTabbed({
// Affiliate
setAffiliateEnabled(initial.affiliate_enabled || false);
setAffiliateCommissionRate(initial.affiliate_commission_rate || '');
// Software
setSoftwareEnabled(initial.software_enabled || false);
setSoftwareSlug(initial.software_slug || '');
setSoftwareWpEnabled(initial.software_wp_enabled || false);
setSoftwareRequiresWp(initial.software_requires_wp || '');
setSoftwareTestedWp(initial.software_tested_wp || '');
setSoftwareRequiresPhp(initial.software_requires_php || '');
}
}, [initial, mode]);
@@ -221,6 +243,13 @@ export function ProductFormTabbed({
// Affiliate
affiliate_enabled: affiliateEnabled,
affiliate_commission_rate: affiliateEnabled ? affiliateCommissionRate : undefined,
// Software
software_enabled: softwareEnabled,
software_slug: softwareEnabled ? softwareSlug : undefined,
software_wp_enabled: softwareEnabled ? softwareWpEnabled : undefined,
software_requires_wp: (softwareEnabled && softwareWpEnabled) ? softwareRequiresWp : undefined,
software_tested_wp: (softwareEnabled && softwareWpEnabled) ? softwareTestedWp : undefined,
software_requires_php: (softwareEnabled && softwareWpEnabled) ? softwareRequiresPhp : undefined,
};
await onSubmit(payload);
@@ -238,6 +267,7 @@ export function ProductFormTabbed({
...(downloadable ? [{ id: 'downloads', label: __('Downloads'), icon: <Download className="w-4 h-4" /> }] : []),
...(type === 'variable' ? [{ id: 'variations', label: __('Variations'), icon: <Layers className="w-4 h-4" /> }] : []),
{ id: 'organization', label: __('Organization'), icon: <Tag className="w-4 h-4" /> },
{ id: 'software', label: __('Software'), icon: <Cloud className="w-4 h-4" /> },
];
return (
@@ -348,6 +378,24 @@ export function ProductFormTabbed({
/>
</FormSection>
{/* Software Tab */}
<FormSection id="software">
<SoftwareTab
softwareEnabled={softwareEnabled}
setSoftwareEnabled={setSoftwareEnabled}
softwareSlug={softwareSlug}
setSoftwareSlug={setSoftwareSlug}
softwareWpEnabled={softwareWpEnabled}
setSoftwareWpEnabled={setSoftwareWpEnabled}
softwareRequiresWp={softwareRequiresWp}
setSoftwareRequiresWp={setSoftwareRequiresWp}
softwareTestedWp={softwareTestedWp}
setSoftwareTestedWp={setSoftwareTestedWp}
softwareRequiresPhp={softwareRequiresPhp}
setSoftwareRequiresPhp={setSoftwareRequiresPhp}
/>
</FormSection>
{/* Submit Button */}
{!hideSubmitButton && (
<div className="mt-6 flex gap-3">

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { __ } from '@/lib/i18n';
type Props = {
softwareEnabled: boolean;
setSoftwareEnabled: (val: boolean) => void;
softwareSlug: string;
setSoftwareSlug: (val: string) => void;
softwareWpEnabled: boolean;
setSoftwareWpEnabled: (val: boolean) => void;
softwareRequiresWp: string;
setSoftwareRequiresWp: (val: string) => void;
softwareTestedWp: string;
setSoftwareTestedWp: (val: string) => void;
softwareRequiresPhp: string;
setSoftwareRequiresPhp: (val: string) => void;
};
export function SoftwareTab({
softwareEnabled,
setSoftwareEnabled,
softwareSlug,
setSoftwareSlug,
softwareWpEnabled,
setSoftwareWpEnabled,
softwareRequiresWp,
setSoftwareRequiresWp,
softwareTestedWp,
setSoftwareTestedWp,
softwareRequiresPhp,
setSoftwareRequiresPhp,
}: Props) {
return (
<div className="space-y-6">
<div className="flex flex-col space-y-4">
<h3 className="text-lg font-medium">{__('Software Distribution')}</h3>
<p className="text-sm text-gray-500">
{__('Enable this to distribute software updates, manage versioning, and secure downloads.')}
</p>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border">
<div className="space-y-0.5">
<Label className="text-base">{__('Enable Software Distribution')}</Label>
<p className="text-sm text-gray-500">
{__('Allow this product to serve OTA updates and track versions.')}
</p>
</div>
<Switch checked={softwareEnabled} onCheckedChange={setSoftwareEnabled} />
</div>
</div>
{softwareEnabled && (
<div className="space-y-4 pt-4 border-t">
<div className="space-y-2">
<Label htmlFor="softwareSlug">{__('Software Slug (Unique Identifier)')}</Label>
<Input
id="softwareSlug"
value={softwareSlug}
onChange={(e) => setSoftwareSlug(e.target.value)}
placeholder="e.g. acme-seo-pro"
/>
<p className="text-sm text-gray-500">
{__('The unique slug that the software client will use to check for updates.')}
</p>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border mt-6">
<div className="space-y-0.5">
<Label className="text-base">{__('WordPress Product')}</Label>
<p className="text-sm text-gray-500">
{__('Check if this software is a WordPress Plugin or Theme.')}
</p>
</div>
<Switch checked={softwareWpEnabled} onCheckedChange={setSoftwareWpEnabled} />
</div>
{softwareWpEnabled && (
<div className="grid grid-cols-3 gap-4 pt-4">
<div className="space-y-2">
<Label htmlFor="requiresWp">{__('Requires WP')}</Label>
<Input
id="requiresWp"
value={softwareRequiresWp}
onChange={(e) => setSoftwareRequiresWp(e.target.value)}
placeholder="e.g. 5.8"
/>
</div>
<div className="space-y-2">
<Label htmlFor="testedWp">{__('Tested up to WP')}</Label>
<Input
id="testedWp"
value={softwareTestedWp}
onChange={(e) => setSoftwareTestedWp(e.target.value)}
placeholder="e.g. 6.4"
/>
</div>
<div className="space-y-2">
<Label htmlFor="requiresPhp">{__('Requires PHP')}</Label>
<Input
id="requiresPhp"
value={softwareRequiresPhp}
onChange={(e) => setSoftwareRequiresPhp(e.target.value)}
placeholder="e.g. 7.4"
/>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -25,6 +25,10 @@ export type ProductVariant = {
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
image?: string;
license_duration_days?: string;
subscription_signup_fee?: string;
subscription_trial_days?: string;
subscription_period?: 'day' | 'week' | 'month' | 'year';
subscription_interval?: string;
};
type VariationsTabProps = {
@@ -282,8 +286,83 @@ export function VariationsTab({
</div>
</div>
{/* Subscription Fields */}
<div className="col-span-2 md:col-span-4 space-y-3">
<Label className="text-xs font-semibold">{__('Subscription Settings (Optional)')}</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<Label className="text-xs">{__('Period')}</Label>
<select
value={variation.subscription_period || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].subscription_period = e.target.value as any;
setVariations(updated);
}}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 mt-1"
>
<option value="">{__('Parent Default')}</option>
<option value="day">{__('Day')}</option>
<option value="week">{__('Week')}</option>
<option value="month">{__('Month')}</option>
<option value="year">{__('Year')}</option>
</select>
</div>
<div>
<Label className="text-xs">{__('Interval')}</Label>
<Input
type="number"
min="1"
placeholder={__('Parent')}
value={variation.subscription_interval || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].subscription_interval = e.target.value;
setVariations(updated);
}}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">{__('Trial Days')}</Label>
<Input
type="number"
min="0"
placeholder={__('Parent')}
value={variation.subscription_trial_days || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].subscription_trial_days = e.target.value;
setVariations(updated);
}}
className="mt-1"
/>
</div>
<div className="relative">
<Label className="text-xs">{__('Signup Fee')}</Label>
<div className="relative mt-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground font-medium">
{store.symbol}
</span>
<Input
type="number"
step={store.decimals === 0 ? '1' : '0.01'}
placeholder={__('Parent')}
value={variation.subscription_signup_fee || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].subscription_signup_fee = e.target.value;
setVariations(updated);
}}
className="pl-8 pr-3 text-right"
/>
</div>
</div>
</div>
</div>
{/* License Duration - only show if licensing is enabled on product */}
<div className="col-span-2 md:col-span-4">
<div className="col-span-2 md:col-span-4 mt-2">
<Label className="text-xs">{__('License Duration (Days)')}</Label>
<Input
type="number"