feat: Add customer avatar upload and product downloadable files
Customer Avatar Upload: - Add /account/avatar endpoint for upload/delete - Add /account/avatar-settings endpoint for settings - Update AccountDetails.tsx with avatar upload UI - Support base64 image upload with validation Product Downloadable Files: - Create DownloadsTab component for file management - Add downloads state to ProductFormTabbed - Show Downloads tab when 'downloadable' is checked - Support file name, URL, download limit, and expiry
This commit is contained in:
@@ -4,12 +4,13 @@ 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 } from 'lucide-react';
|
||||
import { Package, DollarSign, Layers, Tag, Download } 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';
|
||||
|
||||
// Types
|
||||
export type ProductFormData = {
|
||||
@@ -32,6 +33,9 @@ export type ProductFormData = {
|
||||
virtual?: boolean;
|
||||
downloadable?: boolean;
|
||||
featured?: boolean;
|
||||
downloads?: DownloadableFile[];
|
||||
download_limit?: string;
|
||||
download_expiry?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -75,6 +79,9 @@ export function ProductFormTabbed({
|
||||
const [virtual, setVirtual] = useState(initial?.virtual || false);
|
||||
const [downloadable, setDownloadable] = useState(initial?.downloadable || false);
|
||||
const [featured, setFeatured] = useState(initial?.featured || false);
|
||||
const [downloads, setDownloads] = useState<DownloadableFile[]>(initial?.downloads || []);
|
||||
const [downloadLimit, setDownloadLimit] = useState(initial?.download_limit || '');
|
||||
const [downloadExpiry, setDownloadExpiry] = useState(initial?.download_expiry || '');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Update form state when initial data changes (for edit mode)
|
||||
@@ -99,6 +106,9 @@ export function ProductFormTabbed({
|
||||
setVirtual(initial.virtual || false);
|
||||
setDownloadable(initial.downloadable || false);
|
||||
setFeatured(initial.featured || false);
|
||||
setDownloads(initial.downloads || []);
|
||||
setDownloadLimit(initial.download_limit || '');
|
||||
setDownloadExpiry(initial.download_expiry || '');
|
||||
}
|
||||
}, [initial, mode]);
|
||||
|
||||
@@ -155,6 +165,9 @@ export function ProductFormTabbed({
|
||||
virtual,
|
||||
downloadable,
|
||||
featured,
|
||||
downloads: downloadable ? downloads : undefined,
|
||||
download_limit: downloadable ? downloadLimit : undefined,
|
||||
download_expiry: downloadable ? downloadExpiry : undefined,
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
@@ -169,6 +182,7 @@ export function ProductFormTabbed({
|
||||
const tabs = [
|
||||
{ id: 'general', label: __('General'), icon: <Package className="w-4 h-4" /> },
|
||||
{ id: 'inventory', label: __('Inventory'), icon: <Layers className="w-4 h-4" /> },
|
||||
...(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" /> },
|
||||
];
|
||||
@@ -218,6 +232,20 @@ export function ProductFormTabbed({
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Downloads Tab (only for downloadable products) */}
|
||||
{downloadable && (
|
||||
<FormSection id="downloads">
|
||||
<DownloadsTab
|
||||
downloads={downloads}
|
||||
setDownloads={setDownloads}
|
||||
downloadLimit={downloadLimit}
|
||||
setDownloadLimit={setDownloadLimit}
|
||||
downloadExpiry={downloadExpiry}
|
||||
setDownloadExpiry={setDownloadExpiry}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
{/* Variations Tab (only for variable products) */}
|
||||
{type === 'variable' && (
|
||||
<FormSection id="variations">
|
||||
@@ -251,8 +279,8 @@ export function ProductFormTabbed({
|
||||
{submitting
|
||||
? __('Saving...')
|
||||
: mode === 'create'
|
||||
? __('Create Product')
|
||||
: __('Update Product')}
|
||||
? __('Create Product')
|
||||
: __('Update Product')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
195
admin-spa/src/routes/Products/partials/tabs/DownloadsTab.tsx
Normal file
195
admin-spa/src/routes/Products/partials/tabs/DownloadsTab.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Upload, Trash2, FileIcon, Plus, GripVertical } from 'lucide-react';
|
||||
import { openWPMediaGallery } from '@/lib/wp-media';
|
||||
|
||||
export interface DownloadableFile {
|
||||
id?: string;
|
||||
name: string;
|
||||
file: string; // URL
|
||||
}
|
||||
|
||||
type DownloadsTabProps = {
|
||||
downloads: DownloadableFile[];
|
||||
setDownloads: (files: DownloadableFile[]) => void;
|
||||
downloadLimit: string;
|
||||
setDownloadLimit: (value: string) => void;
|
||||
downloadExpiry: string;
|
||||
setDownloadExpiry: (value: string) => void;
|
||||
};
|
||||
|
||||
export function DownloadsTab({
|
||||
downloads,
|
||||
setDownloads,
|
||||
downloadLimit,
|
||||
setDownloadLimit,
|
||||
downloadExpiry,
|
||||
setDownloadExpiry,
|
||||
}: DownloadsTabProps) {
|
||||
|
||||
const addFile = () => {
|
||||
openWPMediaGallery((files) => {
|
||||
const newDownloads = files.map(file => ({
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name || file.title || 'Untitled',
|
||||
file: file.url,
|
||||
}));
|
||||
setDownloads([...downloads, ...newDownloads]);
|
||||
});
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newDownloads = downloads.filter((_, i) => i !== index);
|
||||
setDownloads(newDownloads);
|
||||
};
|
||||
|
||||
const updateFileName = (index: number, name: string) => {
|
||||
const newDownloads = [...downloads];
|
||||
newDownloads[index] = { ...newDownloads[index], name };
|
||||
setDownloads(newDownloads);
|
||||
};
|
||||
|
||||
const updateFileUrl = (index: number, file: string) => {
|
||||
const newDownloads = [...downloads];
|
||||
newDownloads[index] = { ...newDownloads[index], file };
|
||||
setDownloads(newDownloads);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Downloadable Files')}</CardTitle>
|
||||
<CardDescription>
|
||||
{__('Add files that customers can download after purchase')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Downloadable Files List */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Files')}</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addFile}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Add File')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{downloads.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{downloads.map((download, index) => (
|
||||
<div
|
||||
key={download.id || index}
|
||||
className="flex items-center gap-3 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground cursor-move" />
|
||||
<FileIcon className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">{__('File Name')}</Label>
|
||||
<Input
|
||||
value={download.name}
|
||||
onChange={(e) => updateFileName(index, e.target.value)}
|
||||
placeholder={__('My Downloadable File')}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">{__('File URL')}</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
value={download.file}
|
||||
onChange={(e) => updateFileUrl(index, e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
openWPMediaGallery((files) => {
|
||||
if (files.length > 0) {
|
||||
updateFileUrl(index, files[0].url);
|
||||
if (!download.name || download.name === 'Untitled') {
|
||||
updateFileName(index, files[0].name || files[0].title || 'Untitled');
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => removeFile(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
|
||||
<FileIcon className="mx-auto h-12 w-12 mb-2 opacity-50" />
|
||||
<p className="text-sm">{__('No downloadable files added yet')}</p>
|
||||
<Button type="button" variant="outline" size="sm" className="mt-3" onClick={addFile}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{__('Choose files from Media Library')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download Settings */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="font-medium mb-4">{__('Download Settings')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="download_limit">{__('Download Limit')}</Label>
|
||||
<Input
|
||||
id="download_limit"
|
||||
type="number"
|
||||
min="0"
|
||||
value={downloadLimit}
|
||||
onChange={(e) => setDownloadLimit(e.target.value)}
|
||||
placeholder={__('Unlimited')}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{__('Leave blank for unlimited downloads.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="download_expiry">{__('Download Expiry (days)')}</Label>
|
||||
<Input
|
||||
id="download_expiry"
|
||||
type="number"
|
||||
min="0"
|
||||
value={downloadExpiry}
|
||||
onChange={(e) => setDownloadExpiry(e.target.value)}
|
||||
placeholder={__('Never expires')}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{__('Leave blank for downloads that never expire.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user