feat/fix: checkout email tracing, UI tweaks for add-to-cart, cart page overflow fix, implement hide admin bar setting
This commit is contained in:
@@ -23,6 +23,7 @@ import ProductTags from '@/routes/Products/Tags';
|
||||
import ProductAttributes from '@/routes/Products/Attributes';
|
||||
import Licenses from '@/routes/Products/Licenses';
|
||||
import LicenseDetail from '@/routes/Products/Licenses/Detail';
|
||||
import SoftwareVersions from '@/routes/Products/SoftwareVersions';
|
||||
import SubscriptionsIndex from '@/routes/Subscriptions';
|
||||
import SubscriptionDetail from '@/routes/Subscriptions/Detail';
|
||||
import CouponsIndex from '@/routes/Marketing/Coupons';
|
||||
@@ -590,6 +591,7 @@ function AppRoutes() {
|
||||
<Route path="/products/attributes" element={<ProductAttributes />} />
|
||||
<Route path="/products/licenses" element={<Licenses />} />
|
||||
<Route path="/products/licenses/:id" element={<LicenseDetail />} />
|
||||
<Route path="/products/software" element={<SoftwareVersions />} />
|
||||
|
||||
{/* Orders */}
|
||||
<Route path="/orders" element={<OrdersIndex />} />
|
||||
|
||||
@@ -81,15 +81,18 @@ export function CanvasSection({
|
||||
>
|
||||
{/* Section content with Styles */}
|
||||
<div
|
||||
className={cn("relative overflow-hidden rounded-lg", !section.styles?.backgroundColor && "bg-white/50")}
|
||||
className={cn("relative overflow-hidden rounded-lg", !section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50")}
|
||||
style={{
|
||||
backgroundColor: section.styles?.backgroundColor,
|
||||
...(section.styles?.backgroundType === 'gradient'
|
||||
? { background: `linear-gradient(${section.styles?.gradientAngle ?? 135}deg, ${section.styles?.gradientFrom || '#9333ea'}, ${section.styles?.gradientTo || '#3b82f6'})` }
|
||||
: { backgroundColor: section.styles?.backgroundColor }
|
||||
),
|
||||
paddingTop: section.styles?.paddingTop,
|
||||
paddingBottom: section.styles?.paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Background Image & Overlay */}
|
||||
{section.styles?.backgroundImage && (
|
||||
{section.styles?.backgroundType === 'image' && section.styles?.backgroundImage && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
||||
@@ -101,6 +104,19 @@ export function CanvasSection({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Legacy: show bg image even without backgroundType set */}
|
||||
{!section.styles?.backgroundType && section.styles?.backgroundImage && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-black"
|
||||
style={{ opacity: (section.styles?.backgroundOverlay || 0) / 100 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content Wrapper */}
|
||||
<div className={cn(
|
||||
|
||||
@@ -455,90 +455,170 @@ export function InspectorPanel({
|
||||
{/* Background */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Background')}</h4>
|
||||
|
||||
{/* Background Type Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.backgroundColor || 'transparent' }} />
|
||||
<Label className="text-xs">{__('Type')}</Label>
|
||||
<div className="flex gap-1">
|
||||
{(['solid', 'gradient', 'image'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => onSectionStylesChange({ backgroundType: t })}
|
||||
className={cn(
|
||||
'flex-1 text-xs py-1.5 px-2 rounded-md border transition-colors capitalize',
|
||||
(selectedSection.styles?.backgroundType || 'solid') === t
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700 font-medium'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Solid Color */}
|
||||
{(!selectedSection.styles?.backgroundType || selectedSection.styles?.backgroundType === 'solid') && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.backgroundColor || 'transparent' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={selectedSection.styles?.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={selectedSection.styles?.backgroundColor || '#ffffff'}
|
||||
type="text"
|
||||
placeholder="#FFFFFF"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
value={selectedSection.styles?.backgroundColor || ''}
|
||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="#FFFFFF"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
value={selectedSection.styles?.backgroundColor || ''}
|
||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gradient Controls */}
|
||||
{selectedSection.styles?.backgroundType === 'gradient' && (
|
||||
<div className="space-y-3">
|
||||
{/* Live Preview Swatch */}
|
||||
<div
|
||||
className="w-full h-12 rounded-lg border shadow-inner"
|
||||
style={{
|
||||
background: `linear-gradient(${selectedSection.styles?.gradientAngle ?? 135}deg, ${selectedSection.styles?.gradientFrom || '#9333ea'}, ${selectedSection.styles?.gradientTo || '#3b82f6'})`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Image')}</Label>
|
||||
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
|
||||
{selectedSection.styles?.backgroundImage ? (
|
||||
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
|
||||
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-white text-xs font-medium">{__('Change')}</span>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{__('From')}</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.gradientFrom || '#9333ea' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={selectedSection.styles?.gradientFrom || '#9333ea'}
|
||||
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-2 py-1 text-xs"
|
||||
value={selectedSection.styles?.gradientFrom || '#9333ea'}
|
||||
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
|
||||
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal">
|
||||
<Palette className="w-6 h-6" />
|
||||
{__('Select Image')}
|
||||
</Button>
|
||||
)}
|
||||
</MediaUploader>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{__('Overlay Opacity')}</Label>
|
||||
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{__('To')}</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.gradientTo || '#3b82f6' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={selectedSection.styles?.gradientTo || '#3b82f6'}
|
||||
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-2 py-1 text-xs"
|
||||
value={selectedSection.styles?.gradientTo || '#3b82f6'}
|
||||
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{__('Angle')}</Label>
|
||||
<span className="text-xs text-gray-500">{selectedSection.styles?.gradientAngle ?? 135}°</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[selectedSection.styles?.gradientAngle ?? 135]}
|
||||
min={0}
|
||||
max={360}
|
||||
step={15}
|
||||
onValueChange={(vals) => onSectionStylesChange({ gradientAngle: vals[0] })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Background */}
|
||||
{selectedSection.styles?.backgroundType === 'image' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Image')}</Label>
|
||||
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
|
||||
{selectedSection.styles?.backgroundImage ? (
|
||||
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
|
||||
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-white text-xs font-medium">{__('Change')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
|
||||
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal">
|
||||
<Palette className="w-6 h-6" />
|
||||
{__('Select Image')}
|
||||
</Button>
|
||||
)}
|
||||
</MediaUploader>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{__('Overlay Opacity')}</Label>
|
||||
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-xs">{__('Section Height')}</Label>
|
||||
<Select
|
||||
value={selectedSection.styles?.heightPreset || 'default'}
|
||||
onValueChange={(val) => {
|
||||
// Map presets to padding values
|
||||
const paddingMap: Record<string, string> = {
|
||||
'default': '0',
|
||||
'small': '0',
|
||||
'medium': '0',
|
||||
'large': '0',
|
||||
'screen': '0',
|
||||
};
|
||||
const padding = paddingMap[val] || '4rem';
|
||||
|
||||
// If screen, we might need a specific flag, but for now lets reuse paddingTop/Bottom or add a new prop.
|
||||
// To avoid breaking schema, let's use paddingTop as the "preset carrier" or add a new styles prop if possible.
|
||||
// Since styles key is SectionStyles, let's stick to modifying paddingTop/Bottom for now as a simple preset.
|
||||
|
||||
onSectionStylesChange({
|
||||
paddingTop: padding,
|
||||
paddingBottom: padding,
|
||||
heightPreset: val // We'll add this to interface
|
||||
} as any);
|
||||
onSectionStylesChange({ heightPreset: val });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Height" /></SelectTrigger>
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface SectionProp {
|
||||
|
||||
export interface SectionStyles {
|
||||
backgroundColor?: string;
|
||||
backgroundType?: 'solid' | 'gradient' | 'image';
|
||||
gradientFrom?: string;
|
||||
gradientTo?: string;
|
||||
gradientAngle?: number; // 0-360
|
||||
backgroundImage?: string;
|
||||
backgroundOverlay?: number; // 0-100 opacity
|
||||
paddingTop?: string;
|
||||
|
||||
347
admin-spa/src/routes/Products/SoftwareVersions/index.tsx
Normal file
347
admin-spa/src/routes/Products/SoftwareVersions/index.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Search, Package, Plus, History, Download, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface SoftwareProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
current_version: string;
|
||||
wp_enabled: boolean;
|
||||
total_downloads: number;
|
||||
}
|
||||
|
||||
interface SoftwareVersion {
|
||||
id: number;
|
||||
product_id: number;
|
||||
version: string;
|
||||
changelog: string;
|
||||
release_date: string;
|
||||
is_current: boolean;
|
||||
download_count: number;
|
||||
}
|
||||
|
||||
interface ProductsResponse {
|
||||
products: SoftwareProduct[];
|
||||
}
|
||||
|
||||
interface VersionsResponse {
|
||||
product_id: number;
|
||||
config: {
|
||||
enabled: boolean;
|
||||
slug: string;
|
||||
current_version: string;
|
||||
wp_enabled: boolean;
|
||||
};
|
||||
versions: SoftwareVersion[];
|
||||
}
|
||||
|
||||
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 queryClient = useQueryClient();
|
||||
|
||||
// Fetch software-enabled products
|
||||
const { data: productsData, isLoading: productsLoading } = useQuery({
|
||||
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 || [];
|
||||
return {
|
||||
products: products.filter((p: any) => p.meta?._woonoow_software_enabled === 'yes').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',
|
||||
total_downloads: 0,
|
||||
}))
|
||||
} as ProductsResponse;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch versions for selected product
|
||||
const { data: versionsData, isLoading: versionsLoading } = useQuery({
|
||||
queryKey: ['software-versions', selectedProduct],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/software/products/${selectedProduct}/versions`);
|
||||
return response as VersionsResponse;
|
||||
},
|
||||
enabled: !!selectedProduct,
|
||||
});
|
||||
|
||||
// Add new version mutation
|
||||
const addVersion = useMutation({
|
||||
mutationFn: async (data: { version: string; changelog: string }) => {
|
||||
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: '' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || __('Failed to add version'));
|
||||
},
|
||||
});
|
||||
|
||||
const filteredProducts = productsData?.products?.filter(p =>
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.slug.toLowerCase().includes(search.toLowerCase())
|
||||
) || [];
|
||||
|
||||
const selectedProductData = productsData?.products?.find(p => p.id === selectedProduct);
|
||||
|
||||
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>
|
||||
<p className="text-muted-foreground">
|
||||
{__('Manage software releases, changelogs, and version history')}
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search products...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
{productsLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Package className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>{__('No software products found')}</p>
|
||||
<p className="text-sm mt-1">
|
||||
{__('Enable software distribution on a downloadable product')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredProducts.map((product) => (
|
||||
<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' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{product.name}</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{product.slug}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
v{product.current_version || '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
{product.wp_enabled && (
|
||||
<Badge variant="outline" className="mt-2 text-xs">
|
||||
WordPress
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<div className="text-center">
|
||||
<History className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>{__('Select a product to view versions')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : versionsLoading ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[400px]">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Version Header */}
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">{selectedProductData?.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Current version')}: <span className="font-mono">{versionsData?.config?.current_version || '—'}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isAddVersionOpen} onOpenChange={setIsAddVersionOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('New Version')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Add New Version')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Release a new version of')} {selectedProductData?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="version">{__('Version Number')}</Label>
|
||||
<Input
|
||||
id="version"
|
||||
placeholder="1.2.3"
|
||||
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 - Added new feature - 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>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddVersionOpen(false)}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addVersion.mutate(newVersion)}
|
||||
disabled={!newVersion.version || addVersion.isPending}
|
||||
>
|
||||
{addVersion.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{__('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>{__('Version')}</TableHead>
|
||||
<TableHead>{__('Release Date')}</TableHead>
|
||||
<TableHead>{__('Downloads')}</TableHead>
|
||||
<TableHead>{__('Changelog')}</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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user