feat: Affiliate program enrichment (Link Builder, Curated Collections, Smart Links)
This commit is contained in:
83
AFFILIATE_PROGRAM_ENRICHMENT.md
Normal file
83
AFFILIATE_PROGRAM_ENRICHMENT.md
Normal file
@@ -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]
|
||||||
52
AFFILIATE_PROGRAM_ENRICHMENT_IMPLEMENTATION_PLAN.md
Normal file
52
AFFILIATE_PROGRAM_ENRICHMENT_IMPLEMENTATION_PLAN.md
Normal file
@@ -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.
|
||||||
@@ -33,6 +33,8 @@ interface Referral {
|
|||||||
currency: string;
|
currency: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
approved_at?: string;
|
approved_at?: string;
|
||||||
|
utm_campaign?: string;
|
||||||
|
utm_source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AffiliatesReferrals() {
|
export default function AffiliatesReferrals() {
|
||||||
@@ -88,7 +90,7 @@ export default function AffiliatesReferrals() {
|
|||||||
|
|
||||||
// Export to CSV
|
// Export to CSV
|
||||||
const exportToCSV = () => {
|
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 => [
|
const rows = filteredReferrals.map(ref => [
|
||||||
ref.id,
|
ref.id,
|
||||||
ref.affiliate_name || `Affiliate #${ref.affiliate_id}`,
|
ref.affiliate_name || `Affiliate #${ref.affiliate_id}`,
|
||||||
@@ -96,6 +98,7 @@ export default function AffiliatesReferrals() {
|
|||||||
ref.status,
|
ref.status,
|
||||||
ref.commission_amount,
|
ref.commission_amount,
|
||||||
ref.currency,
|
ref.currency,
|
||||||
|
[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / '),
|
||||||
new Date(ref.created_at).toISOString()
|
new Date(ref.created_at).toISOString()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -311,6 +314,7 @@ export default function AffiliatesReferrals() {
|
|||||||
<TableHead>{__('Affiliate')}</TableHead>
|
<TableHead>{__('Affiliate')}</TableHead>
|
||||||
<TableHead>{__('Order ID')}</TableHead>
|
<TableHead>{__('Order ID')}</TableHead>
|
||||||
<TableHead>{__('Status')}</TableHead>
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
<TableHead>{__('Campaign')}</TableHead>
|
||||||
<TableHead>{__('Date')}</TableHead>
|
<TableHead>{__('Date')}</TableHead>
|
||||||
<TableHead className="text-right">{__('Commission')}</TableHead>
|
<TableHead className="text-right">{__('Commission')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -336,6 +340,9 @@ export default function AffiliatesReferrals() {
|
|||||||
{ref.status}
|
{ref.status}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / ') || '—'}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
{new Date(ref.created_at).toLocaleDateString('id-ID', {
|
{new Date(ref.created_at).toLocaleDateString('id-ID', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { BaseLayout } from './layouts/BaseLayout';
|
|||||||
// Pages
|
// Pages
|
||||||
import Shop from './pages/Shop';
|
import Shop from './pages/Shop';
|
||||||
import Product from './pages/Product';
|
import Product from './pages/Product';
|
||||||
|
import CollectionPage from './pages/Shop/CollectionPage';
|
||||||
import Cart from './pages/Cart';
|
import Cart from './pages/Cart';
|
||||||
import Checkout from './pages/Checkout';
|
import Checkout from './pages/Checkout';
|
||||||
import ThankYou from './pages/ThankYou';
|
import ThankYou from './pages/ThankYou';
|
||||||
@@ -106,6 +107,7 @@ function AppRoutes() {
|
|||||||
{/* Shop Routes */}
|
{/* Shop Routes */}
|
||||||
<Route path="/shop" element={<Shop />} />
|
<Route path="/shop" element={<Shop />} />
|
||||||
<Route path="/product/:slug" element={<Product />} />
|
<Route path="/product/:slug" element={<Product />} />
|
||||||
|
<Route path="/collection/:slug" element={<CollectionPage />} />
|
||||||
|
|
||||||
{/* Cart & Checkout */}
|
{/* Cart & Checkout */}
|
||||||
<Route path="/cart" element={<Cart />} />
|
<Route path="/cart" element={<Cart />} />
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const endpoints = {
|
|||||||
product: (id: number) => `/shop/products/${id}`,
|
product: (id: number) => `/shop/products/${id}`,
|
||||||
categories: '/shop/categories',
|
categories: '/shop/categories',
|
||||||
search: '/shop/search',
|
search: '/shop/search',
|
||||||
|
collection: (slug: string) => `/shop/collections/${slug}`,
|
||||||
},
|
},
|
||||||
cart: {
|
cart: {
|
||||||
get: '/cart',
|
get: '/cart',
|
||||||
@@ -115,6 +116,7 @@ const endpoints = {
|
|||||||
profile: '/account/profile',
|
profile: '/account/profile',
|
||||||
password: '/account/password',
|
password: '/account/password',
|
||||||
addresses: '/account/addresses',
|
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 { api } from '@/lib/api/client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { toast } from 'sonner';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { formatPrice, getCurrencySettings } from '@/lib/currency';
|
import { formatPrice, getCurrencySettings } from '@/lib/currency';
|
||||||
@@ -17,6 +17,7 @@ interface AffiliateProfile {
|
|||||||
global_commission_rate: number;
|
global_commission_rate: number;
|
||||||
total_earnings: number;
|
total_earnings: number;
|
||||||
pending_earnings: number;
|
pending_earnings: number;
|
||||||
|
collections_enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginatedReferrals {
|
interface PaginatedReferrals {
|
||||||
@@ -430,7 +431,25 @@ export default function AffiliateDashboard() {
|
|||||||
|
|
||||||
{/* Referral Link */}
|
{/* Referral Link */}
|
||||||
<div className="bg-white p-6 rounded-lg border">
|
<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">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={referralLink}
|
value={referralLink}
|
||||||
@@ -509,6 +528,12 @@ export default function AffiliateDashboard() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<div className="text-right">
|
<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 SubscriptionDetail from './SubscriptionDetail';
|
||||||
import AffiliateDashboard from './AffiliateDashboard';
|
import AffiliateDashboard from './AffiliateDashboard';
|
||||||
import AffiliateReferrals from './AffiliateReferrals';
|
import AffiliateReferrals from './AffiliateReferrals';
|
||||||
|
import AffiliateLinks from './AffiliateLinks';
|
||||||
|
import { AffiliateCollections } from './AffiliateCollections';
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
@@ -46,6 +48,8 @@ export default function Account() {
|
|||||||
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
||||||
<Route path="affiliate" element={<AffiliateDashboard />} />
|
<Route path="affiliate" element={<AffiliateDashboard />} />
|
||||||
<Route path="affiliate/referrals" element={<AffiliateReferrals />} />
|
<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="account-details" element={<AccountDetails />} />
|
||||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||||
</Routes>
|
</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 React, { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { Search, Filter, X } from 'lucide-react';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useCartStore } from '@/lib/cart/store';
|
import { useCartStore } from '@/lib/cart/store';
|
||||||
@@ -31,10 +31,11 @@ function useBreakpoint() {
|
|||||||
|
|
||||||
export default function Shop() {
|
export default function Shop() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { layout: shopLayout, elements } = useShopSettings();
|
const { layout: shopLayout, elements } = useShopSettings();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState(searchParams.get('category') || '');
|
||||||
const [minPriceInput, setMinPriceInput] = useState('');
|
const [minPriceInput, setMinPriceInput] = useState('');
|
||||||
const [maxPriceInput, setMaxPriceInput] = useState('');
|
const [maxPriceInput, setMaxPriceInput] = useState('');
|
||||||
const minPrice = useDebounce(minPriceInput, 500);
|
const minPrice = useDebounce(minPriceInput, 500);
|
||||||
|
|||||||
@@ -48,6 +48,33 @@ class AffiliateCustomerController
|
|||||||
'callback' => [$this, 'update_payment_details'],
|
'callback' => [$this, 'update_payment_details'],
|
||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Affiliate Collections
|
||||||
|
register_rest_route($this->namespace, '/account/affiliate/collections', [
|
||||||
|
[
|
||||||
|
'methods' => WP_REST_Server::READABLE,
|
||||||
|
'callback' => [$this, 'get_collections'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => [$this, 'create_collection'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route($this->namespace, '/account/affiliate/collections/(?P<id>\d+)', [
|
||||||
|
[
|
||||||
|
'methods' => WP_REST_Server::EDITABLE,
|
||||||
|
'callback' => [$this, 'update_collection'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'methods' => WP_REST_Server::DELETABLE,
|
||||||
|
'callback' => [$this, 'delete_collection'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
]
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function check_permission()
|
public function check_permission()
|
||||||
@@ -90,6 +117,12 @@ class AffiliateCustomerController
|
|||||||
$affiliate['total_earnings'] = $earnings->total_earnings ?: 0;
|
$affiliate['total_earnings'] = $earnings->total_earnings ?: 0;
|
||||||
$affiliate['pending_earnings'] = $earnings->pending_earnings ?: 0;
|
$affiliate['pending_earnings'] = $earnings->pending_earnings ?: 0;
|
||||||
|
|
||||||
|
if (class_exists('\WooNooW\Modules\Affiliate\AffiliateSettings')) {
|
||||||
|
$affiliate['collections_enabled'] = \WooNooW\Modules\Affiliate\AffiliateSettings::get_setting('woonoow_affiliate_enable_curated_collections', true);
|
||||||
|
} else {
|
||||||
|
$affiliate['collections_enabled'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
return rest_ensure_response($affiliate);
|
return rest_ensure_response($affiliate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,4 +351,125 @@ class AffiliateCustomerController
|
|||||||
|
|
||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Collections ---
|
||||||
|
|
||||||
|
public function get_collections(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
if (class_exists('\WooNooW\Modules\Affiliate\AffiliateSettings') && !\WooNooW\Modules\Affiliate\AffiliateSettings::get_setting('woonoow_affiliate_enable_curated_collections', true)) {
|
||||||
|
return new \WP_Error('rest_forbidden', 'Curated collections are disabled.', ['status' => 403]);
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$affiliate_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT id, referral_code FROM $affiliate_table WHERE user_id = %d", $user_id));
|
||||||
|
if (!$affiliate) {
|
||||||
|
return rest_ensure_response([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$collections = $wpdb->get_results($wpdb->prepare("SELECT * FROM $collections_table WHERE affiliate_id = %d ORDER BY created_at DESC", $affiliate->id), ARRAY_A);
|
||||||
|
|
||||||
|
foreach ($collections as &$collection) {
|
||||||
|
$collection['product_ids'] = $collection['product_ids'] ? json_decode($collection['product_ids'], true) : [];
|
||||||
|
$collection['link'] = site_url("/collection/{$collection['slug']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response($collections);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create_collection(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$affiliate_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT id FROM $affiliate_table WHERE user_id = %d", $user_id));
|
||||||
|
if (!$affiliate) {
|
||||||
|
return new \WP_Error('not_found', 'Affiliate profile not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = sanitize_text_field($request->get_param('title'));
|
||||||
|
$description = sanitize_textarea_field($request->get_param('description'));
|
||||||
|
$product_ids = $request->get_param('product_ids');
|
||||||
|
if (!is_array($product_ids)) $product_ids = [];
|
||||||
|
|
||||||
|
$product_ids = array_map('intval', $product_ids);
|
||||||
|
if (count($product_ids) > 20) {
|
||||||
|
return new \WP_Error('too_many_products', 'A collection can have a maximum of 20 products.', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = sanitize_title($title);
|
||||||
|
// Check unique slug for this affiliate
|
||||||
|
$existing = $wpdb->get_var($wpdb->prepare("SELECT id FROM $collections_table WHERE affiliate_id = %d AND slug = %s", $affiliate->id, $slug));
|
||||||
|
if ($existing) {
|
||||||
|
$slug .= '-' . wp_generate_password(4, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'affiliate_id' => $affiliate->id,
|
||||||
|
'title' => $title,
|
||||||
|
'slug' => $slug,
|
||||||
|
'description' => $description,
|
||||||
|
'product_ids' => json_encode($product_ids)
|
||||||
|
];
|
||||||
|
|
||||||
|
$wpdb->insert($collections_table, $data);
|
||||||
|
$data['id'] = $wpdb->insert_id;
|
||||||
|
$data['product_ids'] = $product_ids;
|
||||||
|
|
||||||
|
return rest_ensure_response($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update_collection(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$id = (int) $request->get_param('id');
|
||||||
|
$affiliate_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT id FROM $affiliate_table WHERE user_id = %d", $user_id));
|
||||||
|
if (!$affiliate) return new \WP_Error('unauthorized', 'Unauthorized', ['status' => 401]);
|
||||||
|
|
||||||
|
$collection = $wpdb->get_row($wpdb->prepare("SELECT id FROM $collections_table WHERE id = %d AND affiliate_id = %d", $id, $affiliate->id));
|
||||||
|
if (!$collection) return new \WP_Error('not_found', 'Collection not found', ['status' => 404]);
|
||||||
|
|
||||||
|
$title = sanitize_text_field($request->get_param('title'));
|
||||||
|
$description = sanitize_textarea_field($request->get_param('description'));
|
||||||
|
$product_ids = $request->get_param('product_ids');
|
||||||
|
if (!is_array($product_ids)) $product_ids = [];
|
||||||
|
|
||||||
|
$product_ids = array_map('intval', $product_ids);
|
||||||
|
if (count($product_ids) > 20) {
|
||||||
|
return new \WP_Error('too_many_products', 'A collection can have a maximum of 20 products.', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$wpdb->update($collections_table, [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'product_ids' => json_encode($product_ids)
|
||||||
|
], ['id' => $id]);
|
||||||
|
|
||||||
|
return rest_ensure_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete_collection(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$id = (int) $request->get_param('id');
|
||||||
|
$affiliate_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT id FROM $affiliate_table WHERE user_id = %d", $user_id));
|
||||||
|
if (!$affiliate) return new \WP_Error('unauthorized', 'Unauthorized', ['status' => 401]);
|
||||||
|
|
||||||
|
$wpdb->delete($collections_table, ['id' => $id, 'affiliate_id' => $affiliate->id]);
|
||||||
|
|
||||||
|
return rest_ensure_response(['success' => true]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use WooNooW\Branding;
|
|||||||
use WooNooW\Frontend\Assets as FrontendAssets;
|
use WooNooW\Frontend\Assets as FrontendAssets;
|
||||||
use WooNooW\Frontend\Shortcodes;
|
use WooNooW\Frontend\Shortcodes;
|
||||||
use WooNooW\Frontend\TemplateOverride;
|
use WooNooW\Frontend\TemplateOverride;
|
||||||
|
use WooNooW\Frontend\SmartRotator;
|
||||||
use WooNooW\Frontend\PageAppearance;
|
use WooNooW\Frontend\PageAppearance;
|
||||||
|
|
||||||
class Bootstrap {
|
class Bootstrap {
|
||||||
@@ -47,6 +48,7 @@ class Bootstrap {
|
|||||||
FrontendAssets::init();
|
FrontendAssets::init();
|
||||||
// Note: Shortcodes removed - WC pages now redirect to SPA routes via TemplateOverride
|
// Note: Shortcodes removed - WC pages now redirect to SPA routes via TemplateOverride
|
||||||
TemplateOverride::init();
|
TemplateOverride::init();
|
||||||
|
SmartRotator::init();
|
||||||
new PageAppearance();
|
new PageAppearance();
|
||||||
|
|
||||||
// Activity Log
|
// Activity Log
|
||||||
@@ -76,6 +78,13 @@ class Bootstrap {
|
|||||||
add_action('woocommerce_product_variation_object_read', [self::class, 'load_variation_attributes']);
|
add_action('woocommerce_product_variation_object_read', [self::class, 'load_variation_attributes']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function init_frontend() {
|
||||||
|
Shortcodes::init();
|
||||||
|
TemplateOverride::init();
|
||||||
|
SmartRotator::init();
|
||||||
|
PageAppearance::init();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Properly initialize WooCommerce cart for REST API requests
|
* Properly initialize WooCommerce cart for REST API requests
|
||||||
* This is the recommended approach per WooCommerce core team
|
* This is the recommended approach per WooCommerce core team
|
||||||
|
|||||||
@@ -243,6 +243,19 @@ class Assets
|
|||||||
$base_path = '';
|
$base_path = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also force empty base path for /collection/ routes since they are global
|
||||||
|
$spa_path_var = get_query_var('woonoow_spa_path');
|
||||||
|
if (!empty($spa_path_var) && strpos($spa_path_var, 'collection/') === 0) {
|
||||||
|
$base_path = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle serve_spa_for_frontpage_routes which bypasses WP queries
|
||||||
|
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$path = parse_url($request_uri, PHP_URL_PATH);
|
||||||
|
if (strpos($path, '/collection/') === 0) {
|
||||||
|
$base_path = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Check if BrowserRouter is enabled (default: true for SEO)
|
// Check if BrowserRouter is enabled (default: true for SEO)
|
||||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||||
|
|
||||||
@@ -263,6 +276,9 @@ class Assets
|
|||||||
'useBrowserRouter' => $use_browser_router,
|
'useBrowserRouter' => $use_browser_router,
|
||||||
'frontPageSlug' => $front_page_slug,
|
'frontPageSlug' => $front_page_slug,
|
||||||
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
|
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
|
||||||
|
'affiliateSettings' => [
|
||||||
|
'enableCuratedCollections' => class_exists('\WooNooW\Modules\Affiliate\AffiliateSettings') ? \WooNooW\Modules\Affiliate\AffiliateSettings::get_setting('woonoow_affiliate_enable_curated_collections', true) : false,
|
||||||
|
],
|
||||||
'security' => \WooNooW\Compat\SecuritySettingsProvider::get_public_settings(),
|
'security' => \WooNooW\Compat\SecuritySettingsProvider::get_public_settings(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -460,7 +476,7 @@ class Assets
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check path prefixes
|
// Check path prefixes
|
||||||
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
|
$prefix_routes = ['/shop/', '/my-account/', '/product/', '/collection/'];
|
||||||
foreach ($prefix_routes as $prefix) {
|
foreach ($prefix_routes as $prefix) {
|
||||||
if (strpos($path, $prefix) === 0) {
|
if (strpos($path, $prefix) === 0) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ class ShopController
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Get affiliate collection (public)
|
||||||
|
register_rest_route($namespace, '/shop/collections/(?P<slug>[a-zA-Z0-9-]+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_collection'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,6 +297,57 @@ class ShopController
|
|||||||
return new WP_REST_Response($products, 200);
|
return new WP_REST_Response($products, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get affiliate collection by slug
|
||||||
|
*/
|
||||||
|
public static function get_collection(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$slug = sanitize_title($request->get_param('slug'));
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
|
$collection = $wpdb->get_row($wpdb->prepare("SELECT * FROM $collections_table WHERE slug = %s", $slug));
|
||||||
|
|
||||||
|
if (!$collection) {
|
||||||
|
return new WP_Error('not_found', 'Collection not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$product_ids = $collection->product_ids ? json_decode($collection->product_ids, true) : [];
|
||||||
|
if (!is_array($product_ids)) $product_ids = [];
|
||||||
|
|
||||||
|
$products = [];
|
||||||
|
if (!empty($product_ids)) {
|
||||||
|
$args = [
|
||||||
|
'post_type' => 'product',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => -1,
|
||||||
|
'post__in' => $product_ids,
|
||||||
|
'orderby' => 'post__in',
|
||||||
|
];
|
||||||
|
|
||||||
|
$query = new \WP_Query($args);
|
||||||
|
if ($query->have_posts()) {
|
||||||
|
while ($query->have_posts()) {
|
||||||
|
$query->the_post();
|
||||||
|
$product = wc_get_product(get_the_ID());
|
||||||
|
if ($product) {
|
||||||
|
$products[] = self::format_product($product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wp_reset_postdata();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $collection->id,
|
||||||
|
'title' => $collection->title,
|
||||||
|
'description' => $collection->description,
|
||||||
|
'products' => $products
|
||||||
|
];
|
||||||
|
|
||||||
|
return new WP_REST_Response($data, 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format product data for API response
|
* Format product data for API response
|
||||||
*/
|
*/
|
||||||
|
|||||||
83
includes/Frontend/SmartRotator.php
Normal file
83
includes/Frontend/SmartRotator.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WooNooW\Frontend;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
class SmartRotator
|
||||||
|
{
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
|
add_action('init', [__CLASS__, 'register_rewrite_rules']);
|
||||||
|
add_filter('query_vars', [__CLASS__, 'register_query_vars']);
|
||||||
|
add_action('template_redirect', [__CLASS__, 'handle_redirect']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_rewrite_rules()
|
||||||
|
{
|
||||||
|
// Match /go/collection-slug
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^go/([^/]+)/?$',
|
||||||
|
'index.php?woonoow_go_slug=$matches[1]',
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_query_vars($vars)
|
||||||
|
{
|
||||||
|
$vars[] = 'woonoow_go_slug';
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function handle_redirect()
|
||||||
|
{
|
||||||
|
$slug = get_query_var('woonoow_go_slug');
|
||||||
|
if (empty($slug)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
// Lookup collection and affiliate in one query
|
||||||
|
$query = $wpdb->prepare(
|
||||||
|
"SELECT c.product_ids, a.referral_code
|
||||||
|
FROM {$collections_table} c
|
||||||
|
JOIN {$affiliates_table} a ON c.affiliate_id = a.id
|
||||||
|
WHERE c.slug = %s LIMIT 1",
|
||||||
|
$slug
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $wpdb->get_row($query);
|
||||||
|
|
||||||
|
if (!$result || empty($result->product_ids)) {
|
||||||
|
// Fallback: 404 or redirect to shop
|
||||||
|
wp_safe_redirect(site_url('/shop/'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product_ids = json_decode($result->product_ids, true);
|
||||||
|
if (!is_array($product_ids) || empty($product_ids)) {
|
||||||
|
wp_safe_redirect(site_url('/shop/'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Randomly pick a product
|
||||||
|
$random_product_id = $product_ids[array_rand($product_ids)];
|
||||||
|
|
||||||
|
// Get the permalink for the product
|
||||||
|
$target_url = get_permalink($random_product_id);
|
||||||
|
if (!$target_url) {
|
||||||
|
wp_safe_redirect(site_url('/shop/'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the affiliate referral code
|
||||||
|
$target_url = add_query_arg('ref', $result->referral_code, $target_url);
|
||||||
|
|
||||||
|
// Redirect to the product page
|
||||||
|
wp_redirect($target_url, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -211,6 +211,13 @@ class TemplateOverride
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /collection/* → SPA page (global, independent of frontpage setting)
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^collection/(.*)$',
|
||||||
|
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=collection/$matches[1]',
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
|
||||||
// Register query var for the SPA path
|
// Register query var for the SPA path
|
||||||
add_filter('query_vars', function ($vars) {
|
add_filter('query_vars', function ($vars) {
|
||||||
$vars[] = 'woonoow_spa_path';
|
$vars[] = 'woonoow_spa_path';
|
||||||
@@ -430,7 +437,7 @@ class TemplateOverride
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check path prefixes (for sub-routes)
|
// Check path prefixes (for sub-routes)
|
||||||
$prefix_routes = ['/shop/', '/my-account/', '/product/', '/checkout/'];
|
$prefix_routes = ['/shop/', '/my-account/', '/product/', '/checkout/', '/collection/'];
|
||||||
foreach ($prefix_routes as $prefix) {
|
foreach ($prefix_routes as $prefix) {
|
||||||
if (strpos($path, $prefix) === 0) {
|
if (strpos($path, $prefix) === 0) {
|
||||||
$should_serve_spa = true;
|
$should_serve_spa = true;
|
||||||
@@ -500,7 +507,7 @@ class TemplateOverride
|
|||||||
|
|
||||||
// Check if this is a SPA route
|
// Check if this is a SPA route
|
||||||
// We include /product/ and standard endpoints
|
// We include /product/ and standard endpoints
|
||||||
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account'];
|
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account', '/go/'];
|
||||||
|
|
||||||
foreach ($spa_routes as $route) {
|
foreach ($spa_routes as $route) {
|
||||||
if (strpos($requested_url, $route) !== false) {
|
if (strpos($requested_url, $route) !== false) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class AffiliateManager
|
|||||||
private static $affiliates_table = 'woonoow_affiliates';
|
private static $affiliates_table = 'woonoow_affiliates';
|
||||||
private static $referrals_table = 'woonoow_referrals';
|
private static $referrals_table = 'woonoow_referrals';
|
||||||
private static $payouts_table = 'woonoow_affiliate_payouts';
|
private static $payouts_table = 'woonoow_affiliate_payouts';
|
||||||
|
private static $collections_table = 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize
|
* Initialize
|
||||||
@@ -37,6 +38,7 @@ class AffiliateManager
|
|||||||
$affiliates_table = $wpdb->prefix . self::$affiliates_table;
|
$affiliates_table = $wpdb->prefix . self::$affiliates_table;
|
||||||
$referrals_table = $wpdb->prefix . self::$referrals_table;
|
$referrals_table = $wpdb->prefix . self::$referrals_table;
|
||||||
$payouts_table = $wpdb->prefix . self::$payouts_table;
|
$payouts_table = $wpdb->prefix . self::$payouts_table;
|
||||||
|
$collections_table = $wpdb->prefix . self::$collections_table;
|
||||||
|
|
||||||
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
||||||
|
|
||||||
@@ -135,6 +137,23 @@ class AffiliateManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collections Table
|
||||||
|
$sql_collections = "CREATE TABLE $collections_table (
|
||||||
|
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
affiliate_id bigint(20) UNSIGNED NOT NULL,
|
||||||
|
title varchar(255) NOT NULL,
|
||||||
|
slug varchar(255) NOT NULL,
|
||||||
|
description text DEFAULT NULL,
|
||||||
|
product_ids longtext DEFAULT NULL,
|
||||||
|
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY affiliate_slug (affiliate_id, slug),
|
||||||
|
KEY affiliate_id (affiliate_id)
|
||||||
|
) $charset_collate;";
|
||||||
|
|
||||||
|
dbDelta($sql_collections);
|
||||||
|
|
||||||
// Payouts Table
|
// Payouts Table
|
||||||
$sql_payouts = "CREATE TABLE $payouts_table (
|
$sql_payouts = "CREATE TABLE $payouts_table (
|
||||||
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ class AffiliateModule
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create collections table if it doesn't exist
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
if ($wpdb->get_var("SHOW TABLES LIKE '$collections_table'") !== $collections_table) {
|
||||||
|
AffiliateManager::create_tables();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ class AffiliateSettings {
|
|||||||
'description' => __('Automatically approve new affiliate applications.', 'woonoow'),
|
'description' => __('Automatically approve new affiliate applications.', 'woonoow'),
|
||||||
'default' => false,
|
'default' => false,
|
||||||
],
|
],
|
||||||
|
'woonoow_affiliate_enable_curated_collections' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Enable Curated Collections', 'woonoow'),
|
||||||
|
'description' => __('Allow affiliates to create and share custom curated product collections.', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
'woonoow_affiliate_allow_self_referral' => [
|
'woonoow_affiliate_allow_self_referral' => [
|
||||||
'type' => 'toggle',
|
'type' => 'toggle',
|
||||||
'label' => __('Allow Self-Referrals', 'woonoow'),
|
'label' => __('Allow Self-Referrals', 'woonoow'),
|
||||||
|
|||||||
@@ -102,9 +102,37 @@ class AffiliateTracker
|
|||||||
'samesite' => 'Lax'
|
'samesite' => 'Lax'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Capture referral code
|
$referral_code = '';
|
||||||
|
|
||||||
|
// 1. Capture from ?ref= parameter
|
||||||
if (isset($_GET['ref']) && !empty($_GET['ref'])) {
|
if (isset($_GET['ref']) && !empty($_GET['ref'])) {
|
||||||
$referral_code = sanitize_text_field($_GET['ref']);
|
$referral_code = sanitize_text_field($_GET['ref']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Or capture from collection slug in URL (e.g., /collection/my-slug)
|
||||||
|
if (empty($referral_code)) {
|
||||||
|
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$path = parse_url($request_uri, PHP_URL_PATH);
|
||||||
|
|
||||||
|
// Extract collection slug, accounting for possible subdirectories (e.g. /store/collection/slug)
|
||||||
|
if (preg_match('#/collection/([^/]+)#', $path, $matches)) {
|
||||||
|
$collection_slug = sanitize_text_field($matches[1]);
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
$referral_code = $wpdb->get_var($wpdb->prepare("
|
||||||
|
SELECT a.referral_code
|
||||||
|
FROM $collections_table c
|
||||||
|
JOIN $affiliates_table a ON c.affiliate_id = a.id
|
||||||
|
WHERE c.slug = %s
|
||||||
|
", $collection_slug));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the cookie if we found a referral code
|
||||||
|
if (!empty($referral_code)) {
|
||||||
$result = setcookie(self::COOKIE_NAME, $referral_code, $options);
|
$result = setcookie(self::COOKIE_NAME, $referral_code, $options);
|
||||||
$_COOKIE[self::COOKIE_NAME] = $referral_code;
|
$_COOKIE[self::COOKIE_NAME] = $referral_code;
|
||||||
error_log('[AffiliateTracker] Set woonoow_ref cookie: ' . $referral_code . ', result=' . ($result ? 'true' : 'false'));
|
error_log('[AffiliateTracker] Set woonoow_ref cookie: ' . $referral_code . ', result=' . ($result ? 'true' : 'false'));
|
||||||
|
|||||||
Reference in New Issue
Block a user