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:
Dwindi Ramadhana
2026-02-27 23:15:10 +07:00
parent 687a2318b0
commit a62037d993
22 changed files with 2711 additions and 294 deletions

View File

@@ -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 />} />

View File

@@ -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(

View File

@@ -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>

View File

@@ -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;

View 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&#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>
</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>
);
}