423 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|