From 51c759a4f5cbc69926e264ae6c3c24be7d8c39c1 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Mon, 5 Jan 2026 00:05:18 +0700 Subject: [PATCH] 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 --- .../Products/partials/ProductFormTabbed.tsx | 34 ++- .../Products/partials/tabs/DownloadsTab.tsx | 195 ++++++++++++++++++ .../src/pages/Account/AccountDetails.tsx | 189 ++++++++++++++--- includes/Frontend/AccountController.php | 158 ++++++++++++++ 4 files changed, 549 insertions(+), 27 deletions(-) create mode 100644 admin-spa/src/routes/Products/partials/tabs/DownloadsTab.tsx diff --git a/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx b/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx index 7e0de1e..23af01e 100644 --- a/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx +++ b/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx @@ -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(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: }, { id: 'inventory', label: __('Inventory'), icon: }, + ...(downloadable ? [{ id: 'downloads', label: __('Downloads'), icon: }] : []), ...(type === 'variable' ? [{ id: 'variations', label: __('Variations'), icon: }] : []), { id: 'organization', label: __('Organization'), icon: }, ]; @@ -218,6 +232,20 @@ export function ProductFormTabbed({ /> + {/* Downloads Tab (only for downloadable products) */} + {downloadable && ( + + + + )} + {/* Variations Tab (only for variable products) */} {type === 'variable' && ( @@ -251,8 +279,8 @@ export function ProductFormTabbed({ {submitting ? __('Saving...') : mode === 'create' - ? __('Create Product') - : __('Update Product')} + ? __('Create Product') + : __('Update Product')} )} diff --git a/admin-spa/src/routes/Products/partials/tabs/DownloadsTab.tsx b/admin-spa/src/routes/Products/partials/tabs/DownloadsTab.tsx new file mode 100644 index 0000000..4dd683b --- /dev/null +++ b/admin-spa/src/routes/Products/partials/tabs/DownloadsTab.tsx @@ -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 ( + + + {__('Downloadable Files')} + + {__('Add files that customers can download after purchase')} + + + + {/* Downloadable Files List */} +
+
+ + +
+ + {downloads.length > 0 ? ( +
+ {downloads.map((download, index) => ( +
+ + + +
+
+ + updateFileName(index, e.target.value)} + placeholder={__('My Downloadable File')} + className="mt-1" + /> +
+
+ +
+ updateFileUrl(index, e.target.value)} + placeholder="https://..." + className="flex-1" + /> + +
+
+
+ + +
+ ))} +
+ ) : ( +
+ +

{__('No downloadable files added yet')}

+ +
+ )} +
+ + {/* Download Settings */} +
+

{__('Download Settings')}

+
+
+ + setDownloadLimit(e.target.value)} + placeholder={__('Unlimited')} + className="mt-1.5" + /> +

+ {__('Leave blank for unlimited downloads.')} +

+
+ +
+ + setDownloadExpiry(e.target.value)} + placeholder={__('Never expires')} + className="mt-1.5" + /> +

+ {__('Leave blank for downloads that never expire.')} +

+
+
+
+
+
+ ); +} diff --git a/customer-spa/src/pages/Account/AccountDetails.tsx b/customer-spa/src/pages/Account/AccountDetails.tsx index a4abae1..de84c02 100644 --- a/customer-spa/src/pages/Account/AccountDetails.tsx +++ b/customer-spa/src/pages/Account/AccountDetails.tsx @@ -1,7 +1,13 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { toast } from 'sonner'; import { api } from '@/lib/api/client'; +interface AvatarSettings { + allow_custom_avatar: boolean; + current_avatar: string | null; + gravatar_url: string; +} + export default function AccountDetails() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -16,8 +22,14 @@ export default function AccountDetails() { confirmPassword: '', }); + // Avatar state + const [avatarSettings, setAvatarSettings] = useState(null); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const fileInputRef = useRef(null); + useEffect(() => { loadProfile(); + loadAvatarSettings(); }, []); const loadProfile = async () => { @@ -36,17 +48,93 @@ export default function AccountDetails() { } }; + const loadAvatarSettings = async () => { + try { + const data = await api.get('/account/avatar-settings'); + setAvatarSettings(data); + } catch (error) { + console.error('Load avatar settings error:', error); + } + }; + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + 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) => { e.preventDefault(); setSaving(true); - + try { await api.post('/account/profile', { first_name: formData.firstName, last_name: formData.lastName, email: formData.email, }); - + toast.success('Profile updated successfully'); } catch (error) { console.error('Save profile error:', error); @@ -58,25 +146,25 @@ export default function AccountDetails() { const handlePasswordChange = async (e: React.FormEvent) => { e.preventDefault(); - + if (passwordData.newPassword !== passwordData.confirmPassword) { toast.error('New passwords do not match'); return; } - + if (passwordData.newPassword.length < 8) { toast.error('Password must be at least 8 characters'); return; } - + setSaving(true); - + try { await api.post('/account/password', { current_password: passwordData.currentPassword, new_password: passwordData.newPassword, }); - + toast.success('Password updated successfully'); setPasswordData({ currentPassword: '', @@ -91,6 +179,8 @@ export default function AccountDetails() { } }; + const currentAvatarUrl = avatarSettings?.current_avatar || avatarSettings?.gravatar_url; + if (loading) { return (
@@ -102,7 +192,58 @@ export default function AccountDetails() { return (

Account Details

- + + {/* Avatar Section */} + {avatarSettings?.allow_custom_avatar && ( +
+

Profile Photo

+
+
+ Profile + {uploadingAvatar && ( +
+
+
+ )} +
+
+ + + {avatarSettings?.current_avatar && ( + + )} +

+ JPG, PNG, GIF or WebP. Max 2MB. +

+
+
+
+ )} +
@@ -116,7 +257,7 @@ export default function AccountDetails() { disabled={saving} />
- +
-