feat: Affiliate program enrichment (Link Builder, Curated Collections, Smart Links)
This commit is contained in:
422
customer-spa/src/pages/Account/AffiliateCollections.tsx
Normal file
422
customer-spa/src/pages/Account/AffiliateCollections.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user