Files
WooNooW/customer-spa/src/pages/Account/AffiliateCollections.tsx

423 lines
20 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus, Trash2, Edit2, Link as LinkIcon, Search, Copy, CheckCircle, X, ChevronLeft } from 'lucide-react';
import { toast } from 'sonner';
import { Link } from 'react-router-dom';
import { useDebounce } from '@/hooks/useDebounce';
interface Product {
id: number;
name: string;
slug: string;
image?: string;
price_html?: string;
}
interface Collection {
id: number;
title: string;
slug: string;
description: string;
product_ids: number[];
link: string;
}
export function AffiliateCollections() {
const config = (window as any).woonoowCustomer || {};
const enableCuratedCollections = config.affiliateSettings?.enableCuratedCollections !== false;
const queryClient = useQueryClient();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingCollection, setEditingCollection] = useState<Collection | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [selectedProducts, setSelectedProducts] = useState<Product[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 300);
const [copiedId, setCopiedId] = useState<string | null>(null);
const { data: collections, isLoading: isLoadingCollections } = useQuery<Collection[]> ({
queryKey: ['affiliate-collections'],
queryFn: async () => {
const res: any = await api.get('/account/affiliate/collections');
return Array.isArray(res) ? res : [];
}
});
const { data: searchResults, isLoading: isSearching } = useQuery({
queryKey: ['collection-product-search', debouncedSearch],
queryFn: async () => {
if (!debouncedSearch) return [];
try {
const res: any = await api.get(`/shop/products?search=${encodeURIComponent(debouncedSearch)}&per_page=5`);
return res.products || [];
} catch (err) {
return [];
}
},
enabled: debouncedSearch.length > 2,
placeholderData: keepPreviousData
});
// When editing, fetch details of products so we can show their names/images
const { data: editingProducts } = useQuery({
queryKey: ['collection-editing-products', editingCollection?.id],
queryFn: async () => {
if (!editingCollection || editingCollection.product_ids.length === 0) return [];
const res: any = await api.get(`/shop/products?include=${editingCollection.product_ids.join(',')}&per_page=20`);
return res.products || [];
},
enabled: !!editingCollection
});
// Pre-fill form when editingProducts is loaded
React.useEffect(() => {
if (editingCollection && editingProducts) {
setTitle(editingCollection.title);
setDescription(editingCollection.description);
setSelectedProducts(editingProducts);
}
}, [editingCollection, editingProducts]);
const resetForm = () => {
setIsFormOpen(false);
setEditingCollection(null);
setTitle('');
setDescription('');
setSelectedProducts([]);
setSearchQuery('');
};
const createMutation = useMutation({
mutationFn: async (data: any) => {
return api.post('/account/affiliate/collections', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
toast.success('Collection created successfully!');
resetForm();
},
onError: (err: any) => {
toast.error(err.message || 'Failed to create collection');
}
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: number, data: any }) => {
return api.put(`/account/affiliate/collections/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
toast.success('Collection updated successfully!');
resetForm();
},
onError: (err: any) => {
toast.error(err.message || 'Failed to update collection');
}
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
return api.delete(`/account/affiliate/collections/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
toast.success('Collection deleted!');
}
});
const handleSave = () => {
if (!title) {
toast.error('Title is required');
return;
}
if (selectedProducts.length > 20) {
toast.error('Maximum 20 products allowed per collection');
return;
}
const data = {
title,
description,
product_ids: selectedProducts.map(p => p.id)
};
if (editingCollection) {
updateMutation.mutate({ id: editingCollection.id, data });
} else {
createMutation.mutate(data);
}
};
const toggleProduct = (product: Product) => {
const exists = selectedProducts.find(p => p.id === product.id);
if (exists) {
setSelectedProducts(prev => prev.filter(p => p.id !== product.id));
} else {
if (selectedProducts.length >= 20) {
toast.error('Maximum 20 products allowed');
return;
}
setSelectedProducts(prev => [...prev, product]);
}
};
const copyToClipboard = (link: string, id: string) => {
navigator.clipboard.writeText(link);
setCopiedId(id);
toast.success('Link copied to clipboard!');
setTimeout(() => setCopiedId(null), 2000);
};
if (isLoadingCollections) return <div>Loading collections...</div>;
if (!enableCuratedCollections) {
return (
<div className="space-y-6">
<div className="mb-2">
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
</Link>
</div>
<h2 className="text-xl font-bold tracking-tight">My Curated Collections</h2>
<p className="text-muted-foreground">This feature has been disabled by the administrator.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="mb-2">
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
</Link>
</div>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 className="text-xl font-bold tracking-tight">My Curated Collections</h2>
<p className="text-muted-foreground">Group your favorite products into a single shareable link.</p>
</div>
{!isFormOpen && (
<Button onClick={() => setIsFormOpen(true)} className="gap-2">
<Plus className="w-4 h-4" /> New Collection
</Button>
)}
</div>
{isFormOpen && (
<div className="bg-card border rounded-lg p-6 space-y-6">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-lg">
{editingCollection ? 'Edit Collection' : 'Create New Collection'}
</h3>
<Button variant="ghost" size="icon" onClick={resetForm}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<label className="text-sm font-medium">Collection Title</label>
<Input
placeholder="e.g., My Summer Favorites"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2 sm:col-span-2">
<label className="text-sm font-medium">Description (Optional)</label>
<textarea
placeholder="Tell your audience why you love these products..."
value={description}
onChange={(e) => setDescription(e.target.value)}
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-end">
<label className="text-sm font-medium">Select Products ({selectedProducts.length}/20)</label>
</div>
{/* Selected Products Area */}
{selectedProducts.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{selectedProducts.map(p => (
<div key={p.id} className="flex items-center gap-2 bg-secondary text-secondary-foreground text-xs rounded-full pl-2 pr-1 py-1">
<span className="truncate max-w-[150px]">{p.name}</span>
<button
onClick={() => toggleProduct(p)}
className="hover:bg-background/20 rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search products to add..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Search Results Dropdown */}
{searchQuery && (
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
{isSearching && <div className="p-3 text-sm text-center text-muted-foreground">Searching...</div>}
{!isSearching && searchResults?.length === 0 && (
<div className="p-3 text-sm text-center text-muted-foreground">No products found.</div>
)}
{searchResults?.map((product: Product) => {
const isSelected = selectedProducts.some(p => p.id === product.id);
return (
<div
key={product.id}
className={`p-3 flex items-center justify-between hover:bg-muted/50 cursor-pointer transition-colors ${isSelected ? 'bg-primary/5' : ''}`}
onClick={() => toggleProduct(product)}
>
<div className="flex items-center gap-3 overflow-hidden">
{product.image ? (
<img src={product.image} alt={product.name} className="w-10 h-10 object-cover rounded" />
) : (
<div className="w-10 h-10 bg-muted rounded flex-shrink-0"></div>
)}
<div className="truncate">
<div className="font-medium truncate text-sm">{product.name}</div>
<div className="text-xs text-muted-foreground" dangerouslySetInnerHTML={{ __html: product.price_html || '' }} />
</div>
</div>
{isSelected && <CheckCircle className="w-4 h-4 text-primary flex-shrink-0" />}
</div>
);
})}
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-4">
<Button variant="outline" onClick={resetForm}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={createMutation.isPending || updateMutation.isPending}
>
{(createMutation.isPending || updateMutation.isPending) ? 'Saving...' : 'Save Collection'}
</Button>
</div>
</div>
)}
{!isFormOpen && (!collections || collections.length === 0) ? (
<div className="text-center py-12 bg-muted/30 rounded-lg border border-dashed">
<h3 className="font-semibold mb-2">No collections yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Create a curated list of products to share with your audience.
</p>
<Button onClick={() => setIsFormOpen(true)}>
Create First Collection
</Button>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{collections?.map(collection => (
<div key={collection.id} className="border rounded-lg p-5 bg-card flex flex-col">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-semibold text-lg line-clamp-1">{collection.title}</h3>
<p className="text-sm text-muted-foreground">
{collection.product_ids.length} products
</p>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
setEditingCollection(collection);
setIsFormOpen(true);
}}
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if(window.confirm('Delete this collection?')) {
deleteMutation.mutate(collection.id);
}
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{collection.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">
{collection.description}
</p>
)}
<div className="mt-auto pt-4 space-y-3">
<div className="space-y-1">
<span className="text-xs font-medium text-gray-500">Collection Link (Shows all products)</span>
<div className="flex gap-2">
<Input
readOnly
value={collection.link}
className="bg-muted h-9 text-xs font-mono"
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
variant={copiedId === `col-${collection.id}` ? "default" : "outline"}
onClick={() => copyToClipboard(collection.link, `col-${collection.id}`)}
>
{copiedId === `col-${collection.id}` ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
<div className="space-y-1">
<span className="text-xs font-medium text-gray-500">Smart Link (Redirects to random product)</span>
<div className="flex gap-2">
<Input
readOnly
value={`${window.location.origin}/go/${collection.slug}`}
className="bg-muted h-9 text-xs font-mono border-primary/20"
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
variant={copiedId === `smart-${collection.id}` ? "default" : "outline"}
onClick={() => copyToClipboard(`${window.location.origin}/go/${collection.slug}`, `smart-${collection.id}`)}
>
{copiedId === `smart-${collection.id}` ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}