diff --git a/AFFILIATE_PROGRAM_ENRICHMENT.md b/AFFILIATE_PROGRAM_ENRICHMENT.md
new file mode 100644
index 0000000..6180f45
--- /dev/null
+++ b/AFFILIATE_PROGRAM_ENRICHMENT.md
@@ -0,0 +1,83 @@
+# Affiliate Link Enrichment Brief
+
+## Overview
+Affiliate Link Enrichment is a feature set that helps affiliates choose the most suitable destination for each campaign instead of relying only on a homepage link or a single-product link. The goal is to improve click quality, reduce mismatch between content and landing page, and increase the chance that visitors find a relevant product path quickly.[cite:36][cite:46][cite:53]
+
+This feature is designed for e-commerce platforms, affiliate plugins, and marketplace-adjacent tools that want to support better traffic routing without requiring marketplace-scale data. It sits between simple deep linking and advanced smart-link systems by giving affiliates more structured, intent-aware link options.[cite:36][cite:50][cite:58]
+
+## Problem
+Traditional affiliate links usually fall into two extremes: a generic homepage or catalog link, or a deep link to one specific product. Homepage links create distraction because visitors must browse again from the top, while single-product links can fail when the visitor's intent is adjacent to, but not exactly aligned with, the promoted item.[cite:53][cite:46]
+
+This problem becomes more obvious in social content, where one post often attracts mixed intent. A viewer may like the category, style, or use case presented in the content, but not the exact product chosen by the affiliate, which creates unnecessary bounce risk.[cite:46][cite:48]
+
+## Product Idea
+Affiliate Link Enrichment should let affiliates generate links based on **intent shape**, not only based on URL type. Instead of asking, "Do you want a homepage link or a product link?", the product should ask, "What kind of buying intent does your content create?" and then recommend the right destination model.[cite:36][cite:46]
+
+The core idea is to support several enriched destination types:
+- Single Product Link, for highly specific review or promo content.[cite:36]
+- Category Link, for broader topical discovery within one product family.[cite:53]
+- Curated Collection Link, for themed sets of relevant products selected by the affiliate.[cite:21][cite:46]
+- Controlled Smart Rotation, for rotating a small pool of relevant products inside the same intent group, not across unrelated products.[cite:50][cite:58]
+
+## Positioning
+The tone of this feature should be practical, conversion-oriented, and creator-friendly. It is not framed as "AI magic" or "full automation," but as a smarter way to match content intent with the right shopping destination.[cite:46][cite:53]
+
+Affiliate Link Enrichment should be positioned as a conversion support layer for affiliates who need more flexibility than a standard deep link, but who do not have enough volume or data to run a true network-grade smart-link engine.[cite:36][cite:50][cite:55]
+
+## User Segments
+Primary users include social-media affiliates, content creators, bloggers, and store partners who promote products through short-form video, reviews, listicles, and themed recommendations. These users often need one shareable link that still preserves relevance across varied audience preferences.[cite:21][cite:31][cite:46]
+
+Secondary users include merchants and WordPress store owners who run their own affiliate programs and want to help affiliates convert better without sending traffic into an unstructured storefront. For these users, enrichment is also a program-design tool, not only a link-generation tool.[cite:65][cite:67][cite:76]
+
+## Use Cases
+| Content scenario | Visitor intent | Recommended link type | Why it fits |
+|---|---|---|---|
+| Single product review | Very specific | Single Product Link | Sends visitors directly to the exact item discussed.[cite:36] |
+| "Best X for Y" content | Comparative but focused | Curated Collection Link | Preserves relevance while giving the visitor choice.[cite:21][cite:46] |
+| Broad topical content | Exploratory | Category Link | Supports browsing within the same intent family.[cite:53] |
+| Repeat campaigns with similar products | Semi-predictable | Controlled Smart Rotation | Tests alternatives without breaking relevance.[cite:50][cite:58] |
+| Link in bio or creator storefront | Mixed but branded | Collection Link / Bio Link | Gives one stable destination for multiple relevant offers.[cite:31][cite:46] |
+
+## Feature Principles
+The feature should follow five principles:
+
+1. **Intent first**: recommend links based on the campaign's promise, not just on object type.[cite:46][cite:53]
+2. **Relevance guardrails**: any rotation must stay inside a tightly defined product pool.[cite:50][cite:58]
+3. **Choice without overload**: collection pages should offer enough alternatives to reduce bounce, but not so many that users feel dropped into a marketplace homepage again.[cite:46][cite:48]
+4. **Trackability**: every enriched link should still support product-level or group-level attribution through sub IDs, click IDs, or similar campaign parameters.[cite:36][cite:56]
+5. **Progressive sophistication**: stores should be able to start with curated collections and only later add controlled rotation or rule-based routing.[cite:50][cite:55]
+
+## Functional Scope
+A minimum viable version should include:
+- Link type recommendation based on campaign goal.
+- Manual creation of curated collection pages.
+- Optional product grouping by theme, use case, audience, or price band.
+- Link-level tracking fields such as source, campaign, content format, and creator tag.[cite:36][cite:56]
+
+A more advanced version can include:
+- Rule-based routing by device, location, traffic source, or stock status.[cite:32][cite:58]
+- Controlled rotation inside a relevant product cluster.[cite:50]
+- Best-pick pinning, where one featured product stays fixed while alternatives rotate below it.[cite:46]
+- Collection-page templates optimized for mobile affiliate traffic.[cite:46][cite:48]
+
+## UX Guidance
+The user experience should help affiliates decide quickly which link model to use. A simple decision flow works best: "Are you promoting one exact product, one topic, or a curated set?" That framing is easier to understand than exposing technical terms such as deep link, category path, or routing logic at the start.[cite:36][cite:46]
+
+Collection pages should behave more like decision pages than full storefronts. They need a clear headline, one featured recommendation, a limited set of relevant alternatives, lightweight filters, and strong mobile-first call-to-action placement.[cite:46][cite:48][cite:49]
+
+## Success Metrics
+Success should be measured by both conversion performance and creator adoption. Core metrics include click-through rate to merchant pages, product-page engagement, bounce reduction versus homepage links, conversion rate by link type, and earnings per click for each enriched-link model.[cite:36][cite:50][cite:56]
+
+Product-level learning is also important. The system should reveal which campaign types perform best with single-product links, which benefit from curated collections, and when controlled rotation adds value instead of introducing noise.[cite:36][cite:50]
+
+## Messaging Examples
+Suggested product-language examples:
+
+- "Match each campaign to the right destination."
+- "Give visitors a better path than a generic storefront."
+- "Turn one affiliate post into a relevant shopping journey."
+- "Offer choice without losing intent."
+- "Enrich every affiliate link with context, structure, and better conversion potential."
+
+## Strategic Summary
+Affiliate Link Enrichment is best presented as a practical bridge between basic affiliate linking and advanced traffic-routing systems. It helps smaller e-commerce programs support better affiliate outcomes by turning raw links into intent-aware destinations that are easier to click, easier to shop, and easier to optimize over time.[cite:36][cite:46][cite:50]
diff --git a/AFFILIATE_PROGRAM_ENRICHMENT_IMPLEMENTATION_PLAN.md b/AFFILIATE_PROGRAM_ENRICHMENT_IMPLEMENTATION_PLAN.md
new file mode 100644
index 0000000..35dc6e0
--- /dev/null
+++ b/AFFILIATE_PROGRAM_ENRICHMENT_IMPLEMENTATION_PLAN.md
@@ -0,0 +1,52 @@
+# Affiliate Link Enrichment - Implementation Plan
+
+This document outlines the phased implementation plan for the Affiliate Link Enrichment feature, based on the `AFFILIATE_PROGRAM_ENRICHMENT.md` brief and the current state of the WooNooW project.
+
+## 1. Current State of the Project
+
+- **Frontend (`customer-spa/src/pages/Account/AffiliateDashboard.tsx`)**: The affiliate dashboard currently only provides a **single, generic storefront link**: `site.com/shop?ref=YOUR_CODE`. This creates the "generic storefront" problem described in the brief.
+- **Backend (`includes/Modules/Affiliate/AffiliateTracker.php`)**: The backend is highly prepared. The tracking logic natively intercepts **any** page visit that has the `?ref=` parameter. Furthermore, it already captures UTM parameters (`utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `referrer_url`) and stores them in the `woonoow_ref_utm` cookie, which are eventually saved to the `woonoow_referrals` table when an order is completed.
+
+---
+
+## Phase 1: Immediate Opportunities (Low Effort, High Impact)
+
+Because the backend already tracks referrals across the entire site and captures campaign parameters, Phase 1 can be implemented strictly via **Frontend (SPA) changes**, requiring no backend database migrations.
+
+### 1.1 Implement a "Link Generator" UI (Customer SPA)
+Add a new "Link Builder" section in the Affiliate Dashboard (`AffiliateDashboard.tsx`) where affiliates can:
+- **Single Product Link**: Select a specific product (via a dropdown/search) to generate a link like `/product/slug?ref=CODE`.
+- **Category Link**: Select a category to generate a link like `/shop?category=slug&ref=CODE`.
+- **Campaign Tracking**: Add custom Campaign tags (`utm_campaign`, `utm_source`, etc.) to the generated links.
+
+### 1.2 Campaign Analytics UI
+Since the `woonoow_referrals` table already stores UTM data:
+- **Affiliate Dashboard**: Update the UI to show earnings/clicks grouped by `utm_campaign` or `utm_source`.
+- **Admin SPA**: Add reports in the admin area to view referral performance by campaign, fulfilling the trackability requirement.
+
+---
+
+## Phase 2: Medium-Term Opportunities (Curated Collections)
+
+To fulfill the "Curated Collection Link" requirement (where an affiliate groups a themed set of relevant products), we need to build a new feature:
+
+### 2.1 Backend Development
+- Create a new custom database table or Custom Post Type (e.g., `woonoow_affiliate_collections`) that maps an Affiliate ID to a list of Product IDs, along with a title and description.
+- Create REST API endpoints for affiliates to CRUD their collections.
+
+### 2.2 Frontend Development
+- **Affiliate Dashboard**: Add a "My Collections" manager where affiliates can pick products, set a title, and generate a specific collection link.
+- **Storefront**: Add a new dynamic route to the customer-spa (e.g., `/shop/collection/:collection_id?ref=CODE`) that fetches and displays only the specific products in that collection.
+
+---
+
+## Phase 3: Advanced Opportunities (Smart Rotation)
+
+To support "Controlled Smart Rotation" (rotating a small pool of products):
+
+### 3.1 Smart Router API Endpoint
+- Create a lightweight REST API endpoint (e.g., `/wp-json/woonoow/v1/go/:rotation_id`).
+- When a visitor clicks this link, the backend briefly evaluates the rotation rules, applies the `?ref=` and UTM cookies server-side, and does a 302 redirect to the chosen product page.
+
+### 3.2 UI/UX
+- Add configuration settings in the Affiliate Dashboard to set up these rule-based or random rotation links.
diff --git a/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx b/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
index 1786f52..e5bf87c 100644
--- a/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
+++ b/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
@@ -33,6 +33,8 @@ interface Referral {
currency: string;
created_at: string;
approved_at?: string;
+ utm_campaign?: string;
+ utm_source?: string;
}
export default function AffiliatesReferrals() {
@@ -88,7 +90,7 @@ export default function AffiliatesReferrals() {
// Export to CSV
const exportToCSV = () => {
- const headers = ['ID', 'Affiliate', 'Order ID', 'Status', 'Commission', 'Currency', 'Created At'];
+ const headers = ['ID', 'Affiliate', 'Order ID', 'Status', 'Commission', 'Currency', 'Campaign', 'Created At'];
const rows = filteredReferrals.map(ref => [
ref.id,
ref.affiliate_name || `Affiliate #${ref.affiliate_id}`,
@@ -96,6 +98,7 @@ export default function AffiliatesReferrals() {
ref.status,
ref.commission_amount,
ref.currency,
+ [ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / '),
new Date(ref.created_at).toISOString()
]);
@@ -311,6 +314,7 @@ export default function AffiliatesReferrals() {
{__('Affiliate')}{__('Order ID')}{__('Status')}
+ {__('Campaign')}{__('Date')}{__('Commission')}
@@ -336,6 +340,9 @@ export default function AffiliatesReferrals() {
{ref.status}
+
+ {[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / ') || '—'}
+
{new Date(ref.created_at).toLocaleDateString('id-ID', {
year: 'numeric',
diff --git a/customer-spa/src/App.tsx b/customer-spa/src/App.tsx
index 8e4cdc1..1ba5757 100644
--- a/customer-spa/src/App.tsx
+++ b/customer-spa/src/App.tsx
@@ -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 */}
} />
} />
+ } />
{/* Cart & Checkout */}
} />
diff --git a/customer-spa/src/lib/api/client.ts b/customer-spa/src/lib/api/client.ts
index 25db43f..f947726 100644
--- a/customer-spa/src/lib/api/client.ts
+++ b/customer-spa/src/lib/api/client.ts
@@ -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',
},
};
diff --git a/customer-spa/src/pages/Account/AffiliateCollections.tsx b/customer-spa/src/pages/Account/AffiliateCollections.tsx
new file mode 100644
index 0000000..36a4265
--- /dev/null
+++ b/customer-spa/src/pages/Account/AffiliateCollections.tsx
@@ -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(null);
+
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [selectedProducts, setSelectedProducts] = useState([]);
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const debouncedSearch = useDebounce(searchQuery, 300);
+ const [copiedId, setCopiedId] = useState(null);
+
+ const { data: collections, isLoading: isLoadingCollections } = useQuery ({
+ 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
Loading collections...
;
+
+ if (!enableCuratedCollections) {
+ return (
+
+
+
+ Back to Affiliate Dashboard
+
+
+
My Curated Collections
+
This feature has been disabled by the administrator.
+
+ );
+ }
+
+ return (
+
+
+
+ Back to Affiliate Dashboard
+
+
+
+
+
My Curated Collections
+
Group your favorite products into a single shareable link.
+
+ {!isFormOpen && (
+
+ )}
+
+
+ {isFormOpen && (
+
+
+
+ {editingCollection ? 'Edit Collection' : 'Create New Collection'}
+