feat: Affiliate program enrichment (Link Builder, Curated Collections, Smart Links)
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
60
customer-spa/src/pages/Account/AffiliateLinks.tsx
Normal file
60
customer-spa/src/pages/Account/AffiliateLinks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
74
customer-spa/src/pages/Shop/CollectionPage.tsx
Normal file
74
customer-spa/src/pages/Shop/CollectionPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user