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 { __ } 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">
|
||||||
@@ -251,8 +279,8 @@ export function ProductFormTabbed({
|
|||||||
{submitting
|
{submitting
|
||||||
? __('Saving...')
|
? __('Saving...')
|
||||||
: mode === 'create'
|
: mode === 'create'
|
||||||
? __('Create Product')
|
? __('Create Product')
|
||||||
: __('Update Product')}
|
: __('Update Product')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,17 +48,93 @@ 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);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/account/profile', {
|
await api.post('/account/profile', {
|
||||||
first_name: formData.firstName,
|
first_name: formData.firstName,
|
||||||
last_name: formData.lastName,
|
last_name: formData.lastName,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success('Profile updated successfully');
|
toast.success('Profile updated successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save profile error:', error);
|
console.error('Save profile error:', error);
|
||||||
@@ -58,25 +146,25 @@ export default function AccountDetails() {
|
|||||||
|
|
||||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||||
toast.error('New passwords do not match');
|
toast.error('New passwords do not match');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordData.newPassword.length < 8) {
|
if (passwordData.newPassword.length < 8) {
|
||||||
toast.error('Password must be at least 8 characters');
|
toast.error('Password must be at least 8 characters');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/account/password', {
|
await api.post('/account/password', {
|
||||||
current_password: passwordData.currentPassword,
|
current_password: passwordData.currentPassword,
|
||||||
new_password: passwordData.newPassword,
|
new_password: passwordData.newPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success('Password updated successfully');
|
toast.success('Password updated successfully');
|
||||||
setPasswordData({
|
setPasswordData({
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
@@ -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">
|
||||||
@@ -102,7 +192,58 @@ export default function AccountDetails() {
|
|||||||
return (
|
return (
|
||||||
<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>
|
||||||
@@ -116,7 +257,7 @@ export default function AccountDetails() {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="lastName" className="block text-sm font-medium mb-2">Last Name</label>
|
<label htmlFor="lastName" className="block text-sm font-medium mb-2">Last Name</label>
|
||||||
<input
|
<input
|
||||||
@@ -142,8 +283,8 @@ export default function AccountDetails() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="font-[inherit] px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="font-[inherit] px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
@@ -154,13 +295,13 @@ export default function AccountDetails() {
|
|||||||
{/* Password Change Form - Separate */}
|
{/* Password Change Form - Separate */}
|
||||||
<form onSubmit={handlePasswordChange} className="space-y-6 mt-8 pt-8 border-t">
|
<form onSubmit={handlePasswordChange} className="space-y-6 mt-8 pt-8 border-t">
|
||||||
<h2 className="text-xl font-semibold">Password Change</h2>
|
<h2 className="text-xl font-semibold">Password Change</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="currentPassword" className="block text-sm font-medium mb-2">Current Password</label>
|
<label htmlFor="currentPassword" className="block text-sm font-medium mb-2">Current Password</label>
|
||||||
<input
|
<input
|
||||||
id="currentPassword"
|
id="currentPassword"
|
||||||
type="password"
|
type="password"
|
||||||
value={passwordData.currentPassword}
|
value={passwordData.currentPassword}
|
||||||
onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })}
|
onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
@@ -169,9 +310,9 @@ export default function AccountDetails() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="newPassword" className="block text-sm font-medium mb-2">New Password</label>
|
<label htmlFor="newPassword" className="block text-sm font-medium mb-2">New Password</label>
|
||||||
<input
|
<input
|
||||||
id="newPassword"
|
id="newPassword"
|
||||||
type="password"
|
type="password"
|
||||||
value={passwordData.newPassword}
|
value={passwordData.newPassword}
|
||||||
onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })}
|
onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
@@ -181,9 +322,9 @@ export default function AccountDetails() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-2">Confirm New Password</label>
|
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-2">Confirm New Password</label>
|
||||||
<input
|
<input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
value={passwordData.confirmPassword}
|
value={passwordData.confirmPassword}
|
||||||
onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })}
|
onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
@@ -192,8 +333,8 @@ export default function AccountDetails() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="font-[inherit] px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="font-[inherit] px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user