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 { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm'; 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 { toast } from 'sonner';
import { GeneralTab } from './tabs/GeneralTab'; import { GeneralTab } from './tabs/GeneralTab';
import { InventoryTab } from './tabs/InventoryTab'; import { InventoryTab } from './tabs/InventoryTab';
import { VariationsTab, ProductVariant } from './tabs/VariationsTab'; import { VariationsTab, ProductVariant } from './tabs/VariationsTab';
import { OrganizationTab } from './tabs/OrganizationTab'; import { OrganizationTab } from './tabs/OrganizationTab';
import { DownloadsTab, DownloadableFile } from './tabs/DownloadsTab';
// Types // Types
export type ProductFormData = { export type ProductFormData = {
@@ -32,6 +33,9 @@ export type ProductFormData = {
virtual?: boolean; virtual?: boolean;
downloadable?: boolean; downloadable?: boolean;
featured?: boolean; featured?: boolean;
downloads?: DownloadableFile[];
download_limit?: string;
download_expiry?: string;
}; };
type Props = { type Props = {
@@ -75,6 +79,9 @@ export function ProductFormTabbed({
const [virtual, setVirtual] = useState(initial?.virtual || false); const [virtual, setVirtual] = useState(initial?.virtual || false);
const [downloadable, setDownloadable] = useState(initial?.downloadable || false); const [downloadable, setDownloadable] = useState(initial?.downloadable || false);
const [featured, setFeatured] = useState(initial?.featured || 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); const [submitting, setSubmitting] = useState(false);
// Update form state when initial data changes (for edit mode) // Update form state when initial data changes (for edit mode)
@@ -99,6 +106,9 @@ export function ProductFormTabbed({
setVirtual(initial.virtual || false); setVirtual(initial.virtual || false);
setDownloadable(initial.downloadable || false); setDownloadable(initial.downloadable || false);
setFeatured(initial.featured || false); setFeatured(initial.featured || false);
setDownloads(initial.downloads || []);
setDownloadLimit(initial.download_limit || '');
setDownloadExpiry(initial.download_expiry || '');
} }
}, [initial, mode]); }, [initial, mode]);
@@ -155,6 +165,9 @@ export function ProductFormTabbed({
virtual, virtual,
downloadable, downloadable,
featured, featured,
downloads: downloadable ? downloads : undefined,
download_limit: downloadable ? downloadLimit : undefined,
download_expiry: downloadable ? downloadExpiry : undefined,
}; };
await onSubmit(payload); await onSubmit(payload);
@@ -169,6 +182,7 @@ export function ProductFormTabbed({
const tabs = [ const tabs = [
{ id: 'general', label: __('General'), icon: <Package className="w-4 h-4" /> }, { id: 'general', label: __('General'), icon: <Package className="w-4 h-4" /> },
{ id: 'inventory', label: __('Inventory'), icon: <Layers 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" /> }] : []), ...(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: 'organization', label: __('Organization'), icon: <Tag className="w-4 h-4" /> },
]; ];
@@ -218,6 +232,20 @@ export function ProductFormTabbed({
/> />
</FormSection> </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) */} {/* Variations Tab (only for variable products) */}
{type === 'variable' && ( {type === 'variable' && (
<FormSection id="variations"> <FormSection id="variations">

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

View File

@@ -1,7 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/lib/api/client'; import { api } from '@/lib/api/client';
interface AvatarSettings {
allow_custom_avatar: boolean;
current_avatar: string | null;
gravatar_url: string;
}
export default function AccountDetails() { export default function AccountDetails() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -16,8 +22,14 @@ export default function AccountDetails() {
confirmPassword: '', confirmPassword: '',
}); });
// Avatar state
const [avatarSettings, setAvatarSettings] = useState<AvatarSettings | null>(null);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
loadProfile(); loadProfile();
loadAvatarSettings();
}, []); }, []);
const loadProfile = async () => { const loadProfile = async () => {
@@ -36,6 +48,82 @@ export default function AccountDetails() {
} }
}; };
const loadAvatarSettings = async () => {
try {
const data = await api.get<AvatarSettings>('/account/avatar-settings');
setAvatarSettings(data);
} catch (error) {
console.error('Load avatar settings error:', error);
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
toast.error('Please upload a valid image (JPG, PNG, GIF, or WebP)');
return;
}
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('Image size must be less than 2MB');
return;
}
setUploadingAvatar(true);
try {
// Convert to base64
const reader = new FileReader();
reader.onloadend = async () => {
try {
const result = await api.post<{ avatar_url: string }>('/account/avatar', {
avatar: reader.result,
});
setAvatarSettings(prev => prev ? {
...prev,
current_avatar: result.avatar_url,
} : null);
toast.success('Avatar uploaded successfully');
} catch (error: any) {
console.error('Upload avatar error:', error);
toast.error(error.message || 'Failed to upload avatar');
} finally {
setUploadingAvatar(false);
}
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Read file error:', error);
toast.error('Failed to read image file');
setUploadingAvatar(false);
}
};
const handleRemoveAvatar = async () => {
setUploadingAvatar(true);
try {
await api.delete('/account/avatar');
setAvatarSettings(prev => prev ? {
...prev,
current_avatar: null,
} : null);
toast.success('Avatar removed');
} catch (error: any) {
console.error('Remove avatar error:', error);
toast.error(error.message || 'Failed to remove avatar');
} finally {
setUploadingAvatar(false);
}
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
@@ -91,6 +179,8 @@ export default function AccountDetails() {
} }
}; };
const currentAvatarUrl = avatarSettings?.current_avatar || avatarSettings?.gravatar_url;
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -103,6 +193,57 @@ export default function AccountDetails() {
<div> <div>
<h1 className="text-2xl font-bold mb-6">Account Details</h1> <h1 className="text-2xl font-bold mb-6">Account Details</h1>
{/* Avatar Section */}
{avatarSettings?.allow_custom_avatar && (
<div className="mb-8 pb-8 border-b">
<h2 className="text-xl font-semibold mb-4">Profile Photo</h2>
<div className="flex items-center gap-6">
<div className="relative">
<img
src={currentAvatarUrl || '/placeholder-avatar.png'}
alt="Profile"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/>
{uploadingAvatar && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</div>
<div className="flex flex-col gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleAvatarUpload}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{uploadingAvatar ? 'Uploading...' : 'Upload Photo'}
</button>
{avatarSettings?.current_avatar && (
<button
type="button"
onClick={handleRemoveAvatar}
disabled={uploadingAvatar}
className="px-4 py-2 border border-red-500 text-red-500 rounded-lg hover:bg-red-50 transition-colors disabled:opacity-50"
>
Remove Photo
</button>
)}
<p className="text-xs text-gray-500">
JPG, PNG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>

View File

@@ -87,6 +87,27 @@ class AccountController {
'callback' => [__CLASS__, 'get_downloads'], 'callback' => [__CLASS__, 'get_downloads'],
'permission_callback' => [__CLASS__, 'check_customer_permission'], 'permission_callback' => [__CLASS__, 'check_customer_permission'],
]); ]);
// Avatar upload
register_rest_route($namespace, '/account/avatar', [
[
'methods' => 'POST',
'callback' => [__CLASS__, 'upload_avatar'],
'permission_callback' => [__CLASS__, 'check_customer_permission'],
],
[
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete_avatar'],
'permission_callback' => [__CLASS__, 'check_customer_permission'],
],
]);
// Get avatar settings (check if custom avatars are enabled)
register_rest_route($namespace, '/account/avatar-settings', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_avatar_settings'],
'permission_callback' => [__CLASS__, 'check_customer_permission'],
]);
} }
/** /**
@@ -209,6 +230,143 @@ class AccountController {
], 200); ], 200);
} }
/**
* Upload customer avatar
*/
public static function upload_avatar(WP_REST_Request $request) {
// Check if custom avatars are enabled
$settings = get_option('woonoow_customer_settings', []);
$allow_custom_avatar = $settings['allow_custom_avatar'] ?? false;
if (!$allow_custom_avatar) {
return new WP_Error('avatar_disabled', 'Custom avatars are not enabled', ['status' => 403]);
}
$user_id = get_current_user_id();
// Check for file data (base64 or URL)
$avatar_data = $request->get_param('avatar');
$avatar_url = $request->get_param('avatar_url');
if ($avatar_url) {
// Avatar URL provided (from media library)
update_user_meta($user_id, 'woonoow_custom_avatar', esc_url_raw($avatar_url));
return new WP_REST_Response([
'success' => true,
'message' => 'Avatar updated successfully',
'avatar_url' => $avatar_url,
], 200);
}
if (!$avatar_data) {
return new WP_Error('no_avatar', 'No avatar data provided', ['status' => 400]);
}
// Handle base64 image upload
if (strpos($avatar_data, 'data:image') === 0) {
// Extract base64 data
$parts = explode(',', $avatar_data);
if (count($parts) !== 2) {
return new WP_Error('invalid_data', 'Invalid image data format', ['status' => 400]);
}
$image_data = base64_decode($parts[1]);
// Determine file extension from mime type
preg_match('/data:image\/(\w+);/', $parts[0], $matches);
$extension = $matches[1] ?? 'png';
// Validate extension
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!in_array(strtolower($extension), $allowed)) {
return new WP_Error('invalid_type', 'Invalid image type. Allowed: jpg, png, gif, webp', ['status' => 400]);
}
// Create upload directory
$upload_dir = wp_upload_dir();
$avatar_dir = $upload_dir['basedir'] . '/woonoow-avatars';
if (!file_exists($avatar_dir)) {
wp_mkdir_p($avatar_dir);
}
// Generate unique filename
$filename = 'avatar-' . $user_id . '-' . time() . '.' . $extension;
$filepath = $avatar_dir . '/' . $filename;
// Delete old avatar if exists
$old_avatar = get_user_meta($user_id, 'woonoow_custom_avatar', true);
if ($old_avatar) {
$old_path = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $old_avatar);
if (file_exists($old_path)) {
unlink($old_path);
}
}
// Save new avatar
if (file_put_contents($filepath, $image_data) === false) {
return new WP_Error('upload_failed', 'Failed to save avatar', ['status' => 500]);
}
// Get URL
$avatar_url = $upload_dir['baseurl'] . '/woonoow-avatars/' . $filename;
// Save to user meta
update_user_meta($user_id, 'woonoow_custom_avatar', $avatar_url);
return new WP_REST_Response([
'success' => true,
'message' => 'Avatar uploaded successfully',
'avatar_url' => $avatar_url,
], 200);
}
return new WP_Error('invalid_data', 'Invalid avatar data', ['status' => 400]);
}
/**
* Delete customer avatar
*/
public static function delete_avatar(WP_REST_Request $request) {
$user_id = get_current_user_id();
// Get current avatar
$avatar_url = get_user_meta($user_id, 'woonoow_custom_avatar', true);
if ($avatar_url) {
// Try to delete the file
$upload_dir = wp_upload_dir();
$filepath = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $avatar_url);
if (file_exists($filepath)) {
unlink($filepath);
}
// Remove from user meta
delete_user_meta($user_id, 'woonoow_custom_avatar');
}
return new WP_REST_Response([
'success' => true,
'message' => 'Avatar removed successfully',
], 200);
}
/**
* Get avatar settings
*/
public static function get_avatar_settings(WP_REST_Request $request) {
$user_id = get_current_user_id();
$settings = get_option('woonoow_customer_settings', []);
return new WP_REST_Response([
'allow_custom_avatar' => $settings['allow_custom_avatar'] ?? false,
'current_avatar' => get_user_meta($user_id, 'woonoow_custom_avatar', true) ?: null,
'gravatar_url' => get_avatar_url($user_id),
], 200);
}
/** /**
* Update password * Update password
*/ */