feat: Affiliate program enrichment (Link Builder, Curated Collections, Smart Links)

This commit is contained in:
Dwindi Ramadhana
2026-06-03 14:04:17 +07:00
parent fd8eb38512
commit f8c733832e
22 changed files with 1348 additions and 10 deletions

View File

@@ -11,6 +11,7 @@ import { BaseLayout } from './layouts/BaseLayout';
// Pages
import Shop from './pages/Shop';
import Product from './pages/Product';
import CollectionPage from './pages/Shop/CollectionPage';
import Cart from './pages/Cart';
import Checkout from './pages/Checkout';
import ThankYou from './pages/ThankYou';
@@ -106,6 +107,7 @@ function AppRoutes() {
{/* Shop Routes */}
<Route path="/shop" element={<Shop />} />
<Route path="/product/:slug" element={<Product />} />
<Route path="/collection/:slug" element={<CollectionPage />} />
{/* Cart & Checkout */}
<Route path="/cart" element={<Cart />} />

View File

@@ -93,6 +93,7 @@ const endpoints = {
product: (id: number) => `/shop/products/${id}`,
categories: '/shop/categories',
search: '/shop/search',
collection: (slug: string) => `/shop/collections/${slug}`,
},
cart: {
get: '/cart',
@@ -115,6 +116,7 @@ const endpoints = {
profile: '/account/profile',
password: '/account/password',
addresses: '/account/addresses',
affiliateCollections: '/account/affiliate/collections',
},
};

View 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>
);
}

View File

@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard } from 'lucide-react';
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard, Tag } from 'lucide-react';
import { toast } from 'sonner';
import { Link } from 'react-router-dom';
import { formatPrice, getCurrencySettings } from '@/lib/currency';
@@ -17,6 +17,7 @@ interface AffiliateProfile {
global_commission_rate: number;
total_earnings: number;
pending_earnings: number;
collections_enabled?: boolean;
}
interface PaginatedReferrals {
@@ -430,7 +431,25 @@ export default function AffiliateDashboard() {
{/* Referral Link */}
<div className="bg-white p-6 rounded-lg border">
<h3 className="text-lg font-semibold mb-4">Your Referral Link</h3>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Your Referral Link</h3>
<div className="flex items-center gap-4">
{profile?.collections_enabled !== false && (
<Link
to="/my-account/affiliate/collections"
className="text-sm font-medium text-primary hover:opacity-80 flex items-center"
>
My Collections & Smart Links <ChevronRight className="w-4 h-4 ml-1" />
</Link>
)}
<Link
to="/my-account/affiliate/links"
className="text-sm font-medium text-primary hover:opacity-80 flex items-center"
>
Build Links <ChevronRight className="w-4 h-4 ml-1" />
</Link>
</div>
</div>
<div className="flex gap-2">
<Input
value={referralLink}
@@ -509,6 +528,12 @@ export default function AffiliateDashboard() {
})}
</span>
)}
{(ref.utm_campaign || ref.utm_source) && (
<span className="flex items-center gap-1 text-purple-600">
<Tag className="w-3 h-3" />
{[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / ')}
</span>
)}
</div>
</div>
<div className="text-right">

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { AffiliateLinkBuilder } from './components/AffiliateLinkBuilder';
interface AffiliateProfile {
status: string;
referral_code: string;
}
export default function AffiliateLinks() {
const { data: profile, isLoading } = useQuery<AffiliateProfile | null>({
queryKey: ['affiliate-profile'],
queryFn: async () => {
try {
return await api.get('/account/affiliate');
} catch (err: any) {
if (err.response?.status === 404) return null;
throw err;
}
},
});
if (isLoading) {
return <div className="animate-pulse h-64 bg-gray-100 rounded-lg"></div>;
}
if (!profile || profile.status !== 'active') {
return (
<div className="space-y-6">
<div className="mb-6">
<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-2xl font-bold">Link Builder</h2>
<p className="text-gray-500">You do not have an active affiliate account.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="mb-6">
<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>
<h2 className="text-2xl font-bold">Affiliate Link Builder</h2>
<p className="text-muted-foreground mt-1">Create custom links to products and track your campaigns.</p>
</div>
<AffiliateLinkBuilder referralCode={profile.referral_code} />
</div>
);
}

View File

@@ -0,0 +1,220 @@
import React, { useState } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Copy, CheckCircle, Link as LinkIcon, Search } from 'lucide-react';
import { toast } from 'sonner';
import { useDebounce } from '@/hooks/useDebounce';
interface AffiliateLinkBuilderProps {
referralCode: string;
}
export function AffiliateLinkBuilder({ referralCode }: AffiliateLinkBuilderProps) {
const [linkType, setLinkType] = useState<'store' | 'product' | 'category'>('store');
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 300);
const [selectedItem, setSelectedItem] = useState<{ id: number; name: string; slug: string } | null>(null);
// UTM parameters
const [utmSource, setUtmSource] = useState('');
const [utmMedium, setUtmMedium] = useState('');
const [utmCampaign, setUtmCampaign] = useState('');
const [copied, setCopied] = useState(false);
// Fetch products or categories based on search
const { data: searchResults, isLoading: isSearching } = useQuery({
queryKey: ['affiliate-search', linkType, debouncedSearch],
queryFn: async () => {
if (!debouncedSearch || linkType === 'store') return [];
const endpoint = linkType === 'product' ? '/shop/products' : '/shop/categories';
try {
// Assuming standard WP/WC REST API format for search
const res: any = await api.get(`${endpoint}?search=${encodeURIComponent(debouncedSearch)}&per_page=5`);
if (linkType === 'product') {
return res.products || [];
}
return Array.isArray(res) ? res : (res.data || []);
} catch (err) {
console.error("Failed to search", err);
return [];
}
},
enabled: debouncedSearch.length > 2 && linkType !== 'store',
placeholderData: keepPreviousData
});
// Reset selected item when link type changes
const handleLinkTypeChange = (type: 'store' | 'product' | 'category') => {
setLinkType(type);
setSelectedItem(null);
setSearchQuery('');
};
// Build the final link
const buildLink = () => {
const config = (window as any).woonoowCustomer || {};
const basePath = config.basePath || '';
let path = `${basePath}/shop`;
if (linkType === 'product' && selectedItem) {
path = `${basePath}/product/${selectedItem.slug}`;
} else if (linkType === 'category' && selectedItem) {
path = `${basePath}/shop?category=${selectedItem.slug}`; // using query parameter for category as typical for shop
}
const url = new URL(`${window.location.origin}${path}`);
url.searchParams.set('ref', referralCode);
if (utmSource) url.searchParams.set('utm_source', utmSource);
if (utmMedium) url.searchParams.set('utm_medium', utmMedium);
if (utmCampaign) url.searchParams.set('utm_campaign', utmCampaign);
return url.toString();
};
const finalLink = buildLink();
const handleCopy = () => {
navigator.clipboard.writeText(finalLink);
setCopied(true);
toast.success('Enriched link copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="bg-white p-6 rounded-lg border mt-6">
<div className="flex items-center gap-2 mb-4">
<LinkIcon className="w-5 h-5 text-primary" />
<h3 className="text-lg font-semibold">Affiliate Link Builder</h3>
</div>
<p className="text-sm text-gray-500 mb-6">
Create specific links to products or categories, and add campaign tags to track your performance.
</p>
<div className="space-y-6">
{/* Link Type Selection */}
<div>
<label className="text-sm font-medium mb-2 block">Link Destination</label>
<div className="flex flex-wrap gap-2">
{['store', 'product', 'category'].map((type) => (
<button
key={type}
onClick={() => handleLinkTypeChange(type as any)}
className={`px-4 py-2 rounded-md text-sm border capitalize transition-colors ${
linkType === type
? 'bg-primary text-primary-foreground border-primary'
: 'bg-white hover:bg-gray-50'
}`}
>
{type === 'store' ? 'General Store' : `Specific ${type}`}
</button>
))}
</div>
</div>
{/* Search / Selection */}
{linkType !== 'store' && (
<div className="relative">
<label className="text-sm font-medium mb-2 block">
Select {linkType === 'product' ? 'Product' : 'Category'}
</label>
{!selectedItem ? (
<div>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-3 text-gray-400" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={`Search for a ${linkType}...`}
className="pl-9"
/>
</div>
{isSearching && <div className="text-sm text-gray-500 mt-2">Searching...</div>}
{searchResults && searchResults.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-60 overflow-y-auto">
{searchResults.map((item: any) => (
<button
key={item.id}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50 border-b last:border-0"
onClick={() => setSelectedItem({ id: item.id, name: item.name, slug: item.slug })}
>
{item.name}
</button>
))}
</div>
)}
</div>
) : (
<div className="flex items-center justify-between p-3 border rounded-md bg-gray-50">
<span className="text-sm font-medium">{selectedItem.name}</span>
<button
onClick={() => setSelectedItem(null)}
className="text-xs text-red-500 hover:underline"
>
Change
</button>
</div>
)}
</div>
)}
{/* Campaign Tracking (UTMs) */}
<div>
<label className="text-sm font-medium mb-3 block">Campaign Tracking (Optional)</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Input
placeholder="Source (e.g. instagram)"
value={utmSource}
onChange={(e) => setUtmSource(e.target.value)}
className="text-sm"
/>
</div>
<div>
<Input
placeholder="Medium (e.g. story, bio)"
value={utmMedium}
onChange={(e) => setUtmMedium(e.target.value)}
className="text-sm"
/>
</div>
<div>
<Input
placeholder="Campaign (e.g. summer_sale)"
value={utmCampaign}
onChange={(e) => setUtmCampaign(e.target.value)}
className="text-sm"
/>
</div>
</div>
</div>
{/* Final Link Output */}
<div className="pt-4 border-t">
<label className="text-sm font-medium mb-2 block">Your Generated Link</label>
<div className="flex gap-2">
<Input
value={finalLink}
readOnly
className="bg-gray-50 font-mono text-sm"
/>
<Button onClick={handleCopy} className="shrink-0 w-32">
{copied ? (
<><CheckCircle className="w-4 h-4 mr-2" /> Copied</>
) : (
<><Copy className="w-4 h-4 mr-2" /> Copy Link</>
)}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -15,6 +15,8 @@ import Subscriptions from './Subscriptions';
import SubscriptionDetail from './SubscriptionDetail';
import AffiliateDashboard from './AffiliateDashboard';
import AffiliateReferrals from './AffiliateReferrals';
import AffiliateLinks from './AffiliateLinks';
import { AffiliateCollections } from './AffiliateCollections';
export default function Account() {
const user = (window as any).woonoowCustomer?.user;
@@ -46,6 +48,8 @@ export default function Account() {
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
<Route path="affiliate" element={<AffiliateDashboard />} />
<Route path="affiliate/referrals" element={<AffiliateReferrals />} />
<Route path="affiliate/links" element={<AffiliateLinks />} />
<Route path="affiliate/collections" element={<AffiliateCollections />} />
<Route path="account-details" element={<AccountDetails />} />
<Route path="*" element={<Navigate to="/my-account" replace />} />
</Routes>

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { ProductCard } from '@/components/ProductCard';
interface CollectionData {
id: number;
title: string;
description: string;
products: any[];
}
export default function CollectionPage() {
const { slug } = useParams<{ slug: string }>();
const { data: collection, isLoading, error } = useQuery<CollectionData>({
queryKey: ['affiliate-collection', slug],
queryFn: async () => {
return api.get(`/shop/collections/${slug || ''}`);
},
enabled: !!slug
});
if (isLoading) {
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="animate-pulse space-y-8">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded"></div>
))}
</div>
</div>
</div>
);
}
if (error || !collection) {
return (
<div className="max-w-6xl mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold mb-2">Collection Not Found</h1>
<p className="text-muted-foreground">The collection you are looking for does not exist or has been removed.</p>
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="mb-10 text-center">
<h1 className="text-3xl font-bold tracking-tight mb-3">{collection.title}</h1>
{collection.description && (
<p className="text-muted-foreground max-w-2xl mx-auto">
{collection.description}
</p>
)}
</div>
{collection.products.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{collection.products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="text-center py-12 bg-muted/20 rounded-lg">
<p className="text-muted-foreground">There are no products in this collection.</p>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Search, Filter, X } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { useCartStore } from '@/lib/cart/store';
@@ -31,10 +31,11 @@ function useBreakpoint() {
export default function Shop() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { layout: shopLayout, elements } = useShopSettings();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [search, setSearch] = useState(searchParams.get('search') || '');
const [category, setCategory] = useState(searchParams.get('category') || '');
const [minPriceInput, setMinPriceInput] = useState('');
const [maxPriceInput, setMaxPriceInput] = useState('');
const minPrice = useDebounce(minPriceInput, 500);