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:
Dwindi Ramadhana
2026-01-05 00:05:18 +07:00
parent 6c8cbb93e6
commit 51c759a4f5
4 changed files with 549 additions and 27 deletions

View File

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

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