Compare commits

5 Commits

75 changed files with 3877 additions and 1259 deletions

View 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]

View 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.

View File

@@ -1 +1,38 @@
export function ProductCard({ product }: any) { return <div className='p-4 border rounded shadow-sm'>{product?.title || 'Product'}</div>; }
import React from 'react';
import { ShoppingCart } from 'lucide-react';
export function ProductCard({ product }: any) {
const name = product?.name || product?.title || 'Sample Product';
const price = product?.price || '$49.99';
const image = product?.image || product?.image_url || '';
return (
<div className="group h-full flex flex-col border border-border rounded-lg overflow-hidden hover:shadow-md transition-shadow bg-card">
<div className="relative w-full overflow-hidden bg-muted aspect-square">
{image ? (
<img src={image} alt={name} className="absolute inset-0 w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm font-medium">
No Image
</div>
)}
</div>
<div className="p-4 flex-1 flex flex-col text-left">
<h3 className="text-sm font-medium text-foreground mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
{name}
</h3>
<div className="flex items-center gap-2 mb-3">
<span className="text-base font-bold text-foreground">
{price}
</span>
</div>
<button
className="w-full mt-auto inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
>
<ShoppingCart className="w-4 h-4 mr-2" />
Add to Cart
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { SectionStyleResult } from '@/lib/sectionStyles';
interface SectionBackgroundRendererProps {
bg: SectionStyleResult;
}
export function SectionBackgroundRenderer({ bg }: SectionBackgroundRendererProps) {
if (!bg.backgroundImage) return null;
return (
<div className="absolute inset-0 pointer-events-none z-0">
<img
src={bg.backgroundImage}
alt=""
role="presentation"
className="w-full h-full object-cover"
/>
{bg.hasOverlay && (
<div
className="absolute inset-0 bg-black"
style={{ opacity: bg.overlayOpacity }}
/>
)}
</div>
);
}

View File

@@ -78,161 +78,79 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
return (
<div className={containerClasses}>
{containerWidth === 'boxed' ? (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8'
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
</div>
) : (
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8'
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
)}
</div>
</div>
);
};

View File

@@ -5,7 +5,6 @@ import Placeholder from '@tiptap/extension-placeholder';
import Link from '@tiptap/extension-link';
import TextAlign from '@tiptap/extension-text-align';
import Image from '@tiptap/extension-image';
import { ButtonExtension } from './tiptap-button-extension';
import { openWPMediaImage } from '@/lib/wp-media';
import {
Bold,
@@ -17,7 +16,6 @@ import {
AlignCenter,
AlignRight,
ImageIcon,
MousePointer,
Undo,
Redo,
} from 'lucide-react';
@@ -50,8 +48,6 @@ export function RichTextEditor({
Placeholder.configure({
placeholder,
}),
// ButtonExtension MUST come before Link to ensure buttons are parsed first
ButtonExtension,
Link.configure({
openOnClick: false,
HTMLAttributes: {
@@ -109,13 +105,6 @@ export function RichTextEditor({
}
};
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
const [buttonText, setButtonText] = useState('Click Here');
const [buttonHref, setButtonHref] = useState('{order_url}');
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline' | 'link'>('solid');
const [isEditingButton, setIsEditingButton] = useState(false);
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
const addImage = () => {
openWPMediaImage((file) => {
editor.chain().focus().setImage({
@@ -126,87 +115,6 @@ export function RichTextEditor({
});
};
const openButtonDialog = () => {
setButtonText('Click Here');
setButtonHref('{order_url}');
setButtonStyle('solid');
setIsEditingButton(false);
setEditingButtonPos(null);
setButtonDialogOpen(true);
};
// Handle clicking on buttons in the editor to edit them
const handleEditorClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const buttonEl = target.closest('a[data-button]') as HTMLElement | null;
if (buttonEl && editor) {
e.preventDefault();
e.stopPropagation();
// Get button attributes
const text = buttonEl.getAttribute('data-text') || buttonEl.textContent?.replace('🔘 ', '') || 'Click Here';
const href = buttonEl.getAttribute('data-href') || '#';
const style = (buttonEl.getAttribute('data-style') as 'solid' | 'outline') || 'solid';
// Find the position of this button node
const { state } = editor.view;
let foundPos: number | null = null;
state.doc.descendants((node, pos) => {
if (node.type.name === 'button' &&
node.attrs.text === text &&
node.attrs.href === href) {
foundPos = pos;
return false; // Stop iteration
}
return true;
});
// Open dialog in edit mode
setButtonText(text);
setButtonHref(href);
setButtonStyle(style);
setIsEditingButton(true);
setEditingButtonPos(foundPos);
setButtonDialogOpen(true);
}
};
const insertButton = () => {
if (isEditingButton && editingButtonPos !== null && editor) {
// Delete old button and insert new one at same position
editor
.chain()
.focus()
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
.insertContentAt(editingButtonPos, {
type: 'button',
attrs: { text: buttonText, href: buttonHref, style: buttonStyle },
})
.run();
} else {
// Insert new button
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
}
setButtonDialogOpen(false);
setIsEditingButton(false);
setEditingButtonPos(null);
};
const deleteButton = () => {
if (editingButtonPos !== null && editor) {
editor
.chain()
.focus()
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
.run();
setButtonDialogOpen(false);
setIsEditingButton(false);
setEditingButtonPos(null);
}
};
const getActiveHeading = () => {
if (editor.isActive('heading', { level: 1 })) return 'h1';
if (editor.isActive('heading', { level: 2 })) return 'h2';
@@ -326,14 +234,6 @@ export function RichTextEditor({
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={openButtonDialog}
>
<MousePointer className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
@@ -356,7 +256,7 @@ export function RichTextEditor({
</div>
{/* Editor */}
<div onClick={handleEditorClick}>
<div>
<EditorContent editor={editor} />
</div>
@@ -444,91 +344,6 @@ export function RichTextEditor({
</div>
</details>
)}
{/* Button Dialog */}
<Dialog open={buttonDialogOpen} onOpenChange={(open) => {
setButtonDialogOpen(open);
if (!open) {
setIsEditingButton(false);
setEditingButtonPos(null);
}
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEditingButton ? __('Edit Button') : __('Insert Button')}</DialogTitle>
<DialogDescription>
{isEditingButton
? __('Edit the button properties below. Click on the button to save.')
: __('Add a styled button to your content. Use variables for dynamic links.')}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-4 !p-4">
<div className="space-y-2">
<Label htmlFor="btn-text">{__('Button Text')}</Label>
<Input
id="btn-text"
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder={__('e.g., View Order')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="btn-href">{__('Button Link')}</Label>
<Input
id="btn-href"
value={buttonHref}
onChange={(e) => setButtonHref(e.target.value)}
placeholder="{order_url}"
/>
{variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
<code
key={variable}
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
>
{`{${variable}}`}
</code>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="btn-style">{__('Button Style')}</Label>
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline' | 'link') => setButtonStyle(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
<SelectItem value="link">{__('Plain Link')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</DialogBody>
<DialogFooter className="flex-col sm:flex-row gap-2">
{isEditingButton && (
<Button variant="destructive" onClick={deleteButton} className="sm:mr-auto">
{__('Delete')}
</Button>
)}
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
{__('Cancel')}
</Button>
<Button onClick={insertButton}>
{isEditingButton ? __('Update Button') : __('Insert Button')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,2 +1,3 @@
import { api } from '../api';
export const apiClient = api;
export { api };

View File

@@ -107,6 +107,8 @@ function withSectionWrapper(Component: any) {
colorScheme={section.colorScheme}
elementStyles={section.elementStyles}
styles={section.styles}
isEditor={true}
section={section}
{...flatProps}
/>
);
@@ -207,10 +209,10 @@ export function CanvasRenderer({
>
<div
className={cn(
'bg-white transition-all duration-300 min-h-[500px]',
'bg-white transition-all duration-300 min-h-[500px] wn-page',
deviceMode === 'mobile'
? 'max-w-sm mx-auto shadow-2xl rounded-[2.5rem] border-[12px] border-gray-800 my-8 overflow-hidden'
: 'w-full h-full'
: cn('min-h-full', containerWidth === 'boxed' ? 'container mx-auto max-w-6xl shadow-sm border-x' : 'w-full')
)}
>
{sections.length === 0 ? (

View File

@@ -82,8 +82,15 @@ export function CanvasSection({
{/* Section content with Styles */}
<div
className={cn(
"relative overflow-hidden rounded-lg",
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50"
"relative overflow-hidden",
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50",
{
'default': 'py-16 md:py-24',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[600px] flex items-center',
}[section.styles?.heightPreset || 'default'] || 'py-16 md:py-24'
)}
style={{
...(section.styles?.backgroundType === 'gradient'
@@ -153,14 +160,23 @@ export function CanvasSection({
{/* Content Wrapper */}
{section.styles?.contentWidth === 'boxed' ? (
<div className="relative z-10 container mx-auto px-4 max-w-5xl">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div
className="rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
style={{
backgroundColor: section.styles?.cardBackgroundColor || '#ffffff',
paddingTop: section.styles?.cardPaddingTop || undefined,
paddingRight: section.styles?.cardPaddingRight || undefined,
paddingBottom: section.styles?.cardPaddingBottom || undefined,
paddingLeft: section.styles?.cardPaddingLeft || undefined,
}}
>
{children}
</div>
</div>
) : (
<div className={cn(
"relative z-10",
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'
"relative z-10 w-full",
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : ''
)}>
{children}
</div>

View File

@@ -127,7 +127,7 @@ export function InspectorField({
placeholder={fieldType === 'url' ? 'https://' : `Enter ${fieldLabel.toLowerCase()}`}
className="flex-1"
/>
{(fieldType === 'url' || fieldType === 'image') && (
{(fieldType === 'image') && (
<MediaUploader
onSelect={(url) => handleValueChange(url)}
type="image"

View File

@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
@@ -413,6 +414,7 @@ export function InspectorPanel({
{ name: 'label', label: 'Label', type: 'text' },
{ name: 'image', label: 'Image', type: 'image' },
{ name: 'url', label: 'Link URL', type: 'text' },
{ name: 'backgroundColor', label: 'Background Color', type: 'color' },
{ name: 'size', label: 'Size (small/medium/large/tall)', type: 'text' },
]}
itemLabelKey="label"
@@ -436,7 +438,7 @@ export function InspectorPanel({
// Allow advanced override/editing of asset/data if needed
{ name: 'product_name', label: 'Product Name', type: 'text' },
{ name: 'product_price', label: 'Price', type: 'text' },
{ name: 'product_image', label: 'Product Image URL', type: 'text' },
{ name: 'product_image', label: 'Product Image URL', type: 'image' },
{ name: 'x', label: 'X Position (%)', type: 'text' },
{ name: 'y', label: 'Y Position (%)', type: 'text' },
]}
@@ -448,6 +450,36 @@ export function InspectorPanel({
</div>
);
})()}
{/* Contact Form Fields Repeater */}
{selectedSection.type === 'contact-form' && (() => {
const fieldsProp = selectedSection.props.fields;
const fields = Array.isArray(fieldsProp?.value) ? fieldsProp.value : [];
return (
<div className="pt-4 border-t">
<InspectorRepeater
label={__('Form Fields')}
items={fields}
onChange={(newItems) => onSectionPropChange('fields', { type: 'static', value: newItems })}
fields={[
{ name: 'name', label: 'Field Name (Key)', type: 'text' },
{ name: 'label', label: 'Label / Placeholder', type: 'text' },
{ name: 'type', label: 'Input Type', type: 'select', options: [
{ label: 'Text', value: 'text' },
{ label: 'Email', value: 'email' },
{ label: 'Telephone', value: 'tel' },
{ label: 'Textarea (Multiline)', value: 'textarea' },
]},
{ name: 'required', label: 'Is Required?', type: 'checkbox' }
]}
itemLabelKey="label"
/>
<p className="text-xs text-muted-foreground mt-2">
The <strong>Field Name (Key)</strong> will be the key used when sending data to your webhook.
</p>
</div>
);
})()}
</TabsContent>
{/* Design Tab */}
@@ -491,10 +523,10 @@ export function InspectorPanel({
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
/>
</div>
<input
<Input
type="text"
placeholder="#FFFFFF"
className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
className="flex-1 h-8 px-3 py-1 text-sm"
value={selectedSection.styles?.backgroundColor || ''}
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
/>
@@ -525,9 +557,9 @@ export function InspectorPanel({
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
/>
</div>
<input
<Input
type="text"
className="flex-1 h-8 rounded-md border border-input bg-background px-2 py-1 text-xs"
className="flex-1 h-8 px-2 py-1 text-xs"
value={selectedSection.styles?.gradientFrom || '#9333ea'}
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
/>
@@ -545,9 +577,9 @@ export function InspectorPanel({
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
/>
</div>
<input
<Input
type="text"
className="flex-1 h-8 rounded-md border border-input bg-background px-2 py-1 text-xs"
className="flex-1 h-8 px-2 py-1 text-xs"
value={selectedSection.styles?.gradientTo || '#3b82f6'}
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
/>
@@ -660,20 +692,20 @@ export function InspectorPanel({
<div className="grid grid-cols-2 gap-2 pt-2 border-t mt-4">
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Padding Top')}</Label>
<input
<Input
type="text"
placeholder="e.g. 40px, 4rem"
className="w-full h-8 text-xs rounded border px-2"
className="h-8 text-xs px-2"
value={selectedSection.styles?.paddingTop || ''}
onChange={(e) => onSectionStylesChange({ paddingTop: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Padding Bottom')}</Label>
<input
<Input
type="text"
placeholder="e.g. 40px, 4rem"
className="w-full h-8 text-xs rounded border px-2"
className="h-8 text-xs px-2"
value={selectedSection.styles?.paddingBottom || ''}
onChange={(e) => onSectionStylesChange({ paddingBottom: e.target.value })}
/>
@@ -702,6 +734,49 @@ export function InspectorPanel({
</RadioGroup>
</div>
{selectedSection.styles?.contentWidth === 'boxed' && (
<>
<div className="space-y-2 pt-2 mt-4">
<Label className="text-xs text-gray-500">{__('Card Background Color')}</Label>
<div className="flex items-center gap-2 mt-1">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.cardBackgroundColor || '#ffffff' }} />
<Input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={selectedSection.styles?.cardBackgroundColor || '#ffffff'}
onChange={(e) => onSectionStylesChange({ cardBackgroundColor: e.target.value })}
/>
</div>
<Input
type="text"
placeholder="#ffffff"
className="flex-1 h-8 text-xs px-2"
value={selectedSection.styles?.cardBackgroundColor || ''}
onChange={(e) => onSectionStylesChange({ cardBackgroundColor: e.target.value })}
/>
</div>
</div>
<div className="space-y-2 pt-2 mt-4 border-t">
<Label className="text-xs text-gray-500">{__('Card Padding')}</Label>
<div className="grid grid-cols-4 gap-2">
<div className="space-y-1 text-center">
<Input type="text" placeholder="Top" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingTop || ''} onChange={(e) => onSectionStylesChange({ cardPaddingTop: e.target.value })} />
</div>
<div className="space-y-1 text-center">
<Input type="text" placeholder="Right" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingRight || ''} onChange={(e) => onSectionStylesChange({ cardPaddingRight: e.target.value })} />
</div>
<div className="space-y-1 text-center">
<Input type="text" placeholder="Bottom" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingBottom || ''} onChange={(e) => onSectionStylesChange({ cardPaddingBottom: e.target.value })} />
</div>
<div className="space-y-1 text-center">
<Input type="text" placeholder="Left" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingLeft || ''} onChange={(e) => onSectionStylesChange({ cardPaddingLeft: e.target.value })} />
</div>
</div>
</div>
</>
)}
<div className="space-y-2 pt-2 border-t mt-4">
<Label className="text-xs">{__('Section Height')}</Label>
<Select
@@ -739,29 +814,31 @@ export function InspectorPanel({
<AccordionTrigger className="text-xs hover:no-underline py-2">{field.label}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-2">
{/* Common: Background Wrapper */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
<div className="flex items-center gap-2">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.backgroundColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.backgroundColor || '#ffffff'}
{!field.disableBackground && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
<div className="flex items-center gap-2">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.backgroundColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.backgroundColor || '#ffffff'}
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
/>
</div>
<Input
type="text"
placeholder="Color (#fff)"
className="flex-1 h-8 text-xs px-2"
value={styles.backgroundColor || ''}
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
/>
</div>
<input
type="text"
placeholder="Color (#fff)"
className="flex-1 h-7 text-xs rounded border px-2"
value={styles.backgroundColor || ''}
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
/>
</div>
</div>
)}
{!isImage ? (
{(!isImage && field.type !== 'container') && (
<>
{/* Text Color */}
<div className="space-y-2">
@@ -776,10 +853,10 @@ export function InspectorPanel({
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
/>
</div>
<input
<Input
type="text"
placeholder="Color (#000)"
className="flex-1 h-7 text-xs rounded border px-2"
className="flex-1 h-8 text-xs px-2"
value={styles.color || ''}
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
/>
@@ -830,15 +907,17 @@ export function InspectorPanel({
</Select>
</div>
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Align</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
{!field.disableAlignment && (
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Align</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
)}
</div>
{/* Link Specific Styles */}
@@ -865,10 +944,10 @@ export function InspectorPanel({
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
/>
</div>
<input
<Input
type="text"
placeholder="Hover Color"
className="flex-1 h-7 text-xs rounded border px-2"
className="flex-1 h-8 text-xs px-2"
value={styles.hoverColor || ''}
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
/>
@@ -876,45 +955,12 @@ export function InspectorPanel({
</div>
</div>
)}
{/* Button/Box Specific Styles */}
{field.name === 'button' && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Box Styles')}</Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Color')}</Label>
<div className="flex items-center gap-2 h-7">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.borderColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.borderColor || '#000000'}
onChange={(e) => onElementStylesChange(field.name, { borderColor: e.target.value })}
/>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Width')}</Label>
<input type="text" placeholder="e.g. 1px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderWidth || ''} onChange={(e) => onElementStylesChange(field.name, { borderWidth: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Radius')}</Label>
<input type="text" placeholder="e.g. 4px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Padding')}</Label>
<input type="text" placeholder="e.g. 8px 16px" className="w-full h-7 text-xs rounded border px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
</div>
</div>
</div>
)}
</>
) : (
)}
{/* Image Settings */}
{isImage && (
<>
{/* Image Settings */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Image Fit')}</Label>
<Select value={styles.objectFit || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectFit: val === 'default' ? undefined : val as any })}>
@@ -927,19 +973,80 @@ export function InspectorPanel({
</SelectContent>
</Select>
</div>
<div className="space-y-2 pt-2">
<Label className="text-xs text-gray-500">{__('Image Focal Point')}</Label>
<Select value={styles.objectPosition || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectPosition: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Position (e.g. center, top)" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="top">Top</SelectItem>
<SelectItem value="bottom">Bottom</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 pt-2 pb-2">
<Label className="text-xs text-gray-500">{__('Wrapper Alignment')}</Label>
<Select value={styles.alignment || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { alignment: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Width')}</Label>
<input type="text" placeholder="e.g. 100%" className="w-full h-7 text-xs rounded border px-2" value={styles.width || ''} onChange={(e) => onElementStylesChange(field.name, { width: e.target.value })} />
<Input type="text" placeholder="e.g. 100%" className="h-8 text-xs px-2" value={styles.width || ''} onChange={(e) => onElementStylesChange(field.name, { width: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Height')}</Label>
<input type="text" placeholder="e.g. auto" className="w-full h-7 text-xs rounded border px-2" value={styles.height || ''} onChange={(e) => onElementStylesChange(field.name, { height: e.target.value })} />
<Input type="text" placeholder="e.g. auto" className="h-8 text-xs px-2" value={styles.height || ''} onChange={(e) => onElementStylesChange(field.name, { height: e.target.value })} />
</div>
</div>
</>
)}
{/* Button/Box Specific Styles */}
{(field.name === 'button' || field.type === 'container') && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Box Styles')}</Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Color')}</Label>
<div className="flex items-center gap-2 h-7">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.borderColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.borderColor || '#000000'}
onChange={(e) => onElementStylesChange(field.name, { borderColor: e.target.value })}
/>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Width')}</Label>
<Input type="text" placeholder="e.g. 1px" className="h-8 text-xs px-2" value={styles.borderWidth || ''} onChange={(e) => onElementStylesChange(field.name, { borderWidth: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Radius')}</Label>
<Input type="text" placeholder="e.g. 4px" className="h-8 text-xs px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Padding')}</Label>
<Input type="text" placeholder="e.g. 8px 16px" className="h-8 text-xs px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
</div>
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
);

View File

@@ -16,7 +16,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, GripVertical } from 'lucide-react';
import { Plus, Trash2, GripVertical, Image as ImageIcon } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
@@ -41,8 +42,9 @@ import RepeaterProductField from './RepeaterProductField';
interface RepeaterFieldDef {
name: string;
label: string;
type: 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'product';
type: 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'product' | 'select' | 'checkbox' | 'color';
placeholder?: string;
options?: { label: string; value: string }[];
}
interface InspectorRepeaterProps {
@@ -91,8 +93,8 @@ function SortableItem({
'Wifi', 'Wrench',
].sort();
const handleFieldChange = (fieldName: string, value: any) => {
onChange(index, fieldName, value);
const handleFieldChange = (fieldNameOrUpdates: string | Record<string, any>, value?: any) => {
onChange(index, fieldNameOrUpdates, value);
};
return (
@@ -151,7 +153,7 @@ function RepeaterFieldRenderer({
field: RepeaterFieldDef;
item: any;
index: number;
onChange: (fieldName: string, value: any) => void;
onChange: (fieldNameOrUpdates: string | Record<string, any>, value?: any) => void;
ICON_OPTIONS: string[];
}) {
const value = item[field.name] || '';
@@ -195,44 +197,53 @@ function RepeaterFieldRenderer({
);
}
if (field.type === 'color') {
return (
<div className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
<div className="flex items-center gap-2">
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: value || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={value || '#ffffff'}
onChange={(e) => onChange(field.name, e.target.value)}
/>
</div>
<Input
type="text"
placeholder="#ffffff"
className="flex-1 h-8 text-xs px-2"
value={value || ''}
onChange={(e) => onChange(field.name, e.target.value)}
/>
</div>
</div>
);
}
if (field.type === 'image') {
return (
<div className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
<div className="space-y-2">
{value ? (
<MediaUploader
onSelect={(url) => onChange(field.name, url)}
type="image"
>
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50 flex items-center justify-center">
<img src={value} alt={field.label} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-white text-xs font-medium">Change</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onChange(field.name, '');
}}
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
type="button"
aria-label="Remove image"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</MediaUploader>
) : (
<MediaUploader
onSelect={(url) => onChange(field.name, url)}
type="image"
>
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal justify-start">
Select Image
</Button>
</MediaUploader>
)}
<div className="flex gap-2">
<Input
type="text"
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
placeholder="https://..."
className="flex-1 text-xs h-8"
/>
<MediaUploader
onSelect={(url) => onChange(field.name, url)}
type="image"
className="shrink-0"
>
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Select Image" type="button">
<ImageIcon className="w-4 h-4" />
</Button>
</MediaUploader>
</div>
</div>
);
@@ -251,6 +262,41 @@ function RepeaterFieldRenderer({
);
}
if (field.type === 'select') {
return (
<div className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
<Select
value={value}
onValueChange={(val) => onChange(field.name, val)}
>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder={field.placeholder || "Select an option"} />
</SelectTrigger>
<SelectContent>
{field.options?.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (field.type === 'checkbox') {
return (
<div className="flex items-center justify-between space-x-2 py-1">
<Label className="text-xs text-gray-500">{field.label}</Label>
<Switch
checked={!!value}
onCheckedChange={(checked) => onChange(field.name, checked)}
/>
</div>
);
}
// default: text/url inputs
const inputType = field.type === 'url' ? 'url' : 'text';
@@ -296,9 +342,13 @@ export function InspectorRepeater({
}
};
const handleItemChange = (index: number, fieldName: string, value: string) => {
const handleItemChange = (index: number, fieldNameOrUpdates: string | Record<string, any>, value?: any) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [fieldName]: value };
if (typeof fieldNameOrUpdates === 'string') {
newItems[index] = { ...newItems[index], [fieldNameOrUpdates]: value };
} else {
newItems[index] = { ...newItems[index], ...fieldNameOrUpdates };
}
onChange(newItems);
};
@@ -341,7 +391,7 @@ export function InspectorRepeater({
item={item}
fields={fields}
itemLabelKey={itemLabelKey}
onChange={(idx: number, fieldName: string, value: string) => handleItemChange(idx, fieldName, value)}
onChange={(idx: number, fieldNameOrUpdates: string | Record<string, any>, value?: any) => handleItemChange(idx, fieldNameOrUpdates, value)}
onDelete={handleDeleteItem}
/>
))}

View File

@@ -10,7 +10,7 @@ export default function RepeaterProductField({
}: {
label: string;
value: string;
onChange: (fieldName: string, nextValue: any) => void;
onChange: (fieldNameOrUpdates: string | Record<string, any>, nextValue?: any) => void;
}) {
const [search, setSearch] = React.useState('');
const [options, setOptions] = React.useState<any[]>([]);
@@ -77,11 +77,13 @@ export default function RepeaterProductField({
const selected = options.find((o) => o.value === v)?.product;
if (!selected) return;
onChange('product_slug', selected.product_slug || '');
onChange('product_name', selected.name || '');
onChange('product_price', selected.sale_price ?? selected.price ?? '');
onChange('product_image', selected.image_url ?? '');
onChange('product_id', selected.id ? Number(selected.id) : 0);
onChange({
product_slug: selected.product_slug || '',
product_name: selected.name || '',
product_price: selected.sale_price ?? selected.price ?? '',
product_image: selected.image_url ?? '',
product_id: selected.id ? Number(selected.id) : 0,
});
}}
options={options.map((o) => ({
value: String(o.value ?? ''),

View File

@@ -32,16 +32,6 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
const buttonText = section.props?.button_text?.value || 'Get Started';
const buttonUrl = section.props?.button_url?.value || '#';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
};
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
@@ -69,7 +59,7 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
return (
<div className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && scheme.bg, scheme.text, className)}>
<div className={cn('px-4 md:px-8', !hasCustomBackground && scheme.bg, scheme.text, className)}>
<div className="max-w-4xl mx-auto text-center space-y-6">
<h2
className={cn(
@@ -88,7 +78,7 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
)}
style={textStyle.style}
>
{text}
{text || "Description text missing"}
</p>
<button className={cn(
'inline-flex items-center gap-2 px-8 py-4 rounded-lg font-semibold transition hover:opacity-90',

View File

@@ -69,21 +69,11 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
return undefined;
};
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
};
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
return (
<div
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={hasCustomBackground ? {} : getBackgroundStyle()}
>
<div className="max-w-xl mx-auto">
@@ -98,59 +88,57 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
</h2>
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
{/* Name field */}
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Your Name"
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
{/* Render fields from config, fallback to default if missing */}
{(() => {
const defaultFields = [
{ name: 'name', label: 'Your Name', type: 'text', required: true },
{ name: 'email', label: 'Your Email', type: 'email', required: true },
{ name: 'message', label: 'Your Message', type: 'textarea', required: true }
];
const fieldsProp = section.props?.fields?.value;
const fields = Array.isArray(fieldsProp) && fieldsProp.length > 0 ? fieldsProp : defaultFields;
{/* Email field */}
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="email"
placeholder="Your Email"
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
{/* Message field */}
<div className="relative">
<MessageSquare className="absolute left-4 top-4 w-5 h-5 text-gray-400" />
<textarea
placeholder="Your Message"
rows={4}
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
return fields.map((field: any, idx: number) => {
const Icon = field.type === 'email' ? Mail : field.type === 'textarea' ? MessageSquare : User;
return (
<div key={field.name || idx} className="relative">
<Icon className={cn(
"absolute left-4 text-gray-400 w-5 h-5",
field.type === 'textarea' ? "top-4" : "top-1/2 -translate-y-1/2"
)} />
{field.type === 'textarea' ? (
<textarea
placeholder={field.label + (field.required ? ' *' : '')}
rows={4}
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
) : (
<input
type={field.type || 'text'}
placeholder={field.label + (field.required ? ' *' : '')}
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
)}
</div>
);
});
})()}
{/* Submit button */}
<button

View File

@@ -155,18 +155,6 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
const layout = section.layoutVariant || 'default';
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
const heightPreset = section.styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-32',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
const content = section.props?.content?.value || 'Your content goes here. Edit this in the inspector panel.';
const isDynamic = section.props?.content?.type === 'dynamic';
@@ -218,7 +206,6 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
className={cn(
'relative w-full overflow-hidden',
'px-4 md:px-8',
heightClasses,
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
scheme.text,
className

View File

@@ -119,21 +119,11 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
return undefined;
};
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
};
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
return (
<div
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={hasCustomBackground ? {} : getBackgroundStyle()}
>
<div className="max-w-6xl mx-auto">

View File

@@ -27,16 +27,6 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
const layout = section.layoutVariant || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
};
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const title = section.props?.title?.value || 'Hero Title';
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
const image = section.props?.image?.value;
@@ -81,7 +71,7 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
if (layout === 'hero-left-image' || layout === 'hero-right-image') {
return (
<div
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
>
<div className={cn(
'max-w-6xl mx-auto flex items-center gap-12',
@@ -156,7 +146,7 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
// Default centered layout
return (
<div
className={cn(heightClasses, 'px-4 md:px-8 text-center', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
className={cn('px-4 md:px-8 text-center', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
>
<div className="max-w-4xl mx-auto space-y-6">
<h1

View File

@@ -72,21 +72,11 @@ export function ImageTextRenderer({ section, className }: ImageTextRendererProps
return undefined;
};
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
};
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
return (
<div
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={hasCustomBackground ? {} : getBackgroundStyle()}
>
<div className={cn(

View File

@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
export function MarqueeBannerRenderer({ section, className }: { section: any; className?: string }) {
const { text, separator } = section.props;
const styles = section.styles || {};
const elementStyles = section.elementStyles || {};
const displayText = text?.value || 'Marquee Banner Text Here';
const displaySeparator = separator?.value || '✦';
@@ -19,7 +20,14 @@ export function MarqueeBannerRenderer({ section, className }: { section: any; cl
<div className="flex whitespace-nowrap opacity-70">
<div className="flex items-center gap-8 pr-8">
{[1, 2, 3].map((idx) => (
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
<span
key={idx}
className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase"
style={{
color: elementStyles?.text?.color,
fontSize: elementStyles?.text?.fontSize?.replace('text-', '') ? undefined : 'inherit' // Basic mock
}}
>
{displayText}
<span className="opacity-50 text-xs">{displaySeparator}</span>
</span>

View File

@@ -37,17 +37,21 @@ export function ShoppableImageRenderer({ section, className }: { section: any; c
<div className="relative rounded-xl overflow-hidden bg-gray-100 aspect-[16/9] border-2 border-dashed border-gray-300 flex items-center justify-center">
{displayImage ? (
<>
<img src={displayImage} alt="Shoppable Preview" className="w-full h-full object-cover opacity-50" />
{displayHotspots.map((hotspot: any, idx: number) => (
<div
key={idx}
className="absolute w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center border-2 border-white shadow-lg text-xs font-bold"
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
>
{idx + 1}
</div>
))}
{displayHotspots.map((hotspot: any, idx: number) => {
const xVal = parseFloat(String(hotspot.x ?? 0).replace('%', '')) || 0;
const yVal = parseFloat(String(hotspot.y ?? 0).replace('%', '')) || 0;
return (
<div
key={idx}
className="absolute w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center border-2 border-white shadow-lg text-xs font-bold"
style={{ left: `${xVal}%`, top: `${yVal}%`, transform: 'translate(-50%, -50%)' }}
>
{idx + 1}
</div>
);
})}
</>
) : (
<div className="text-center text-gray-400">

View File

@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
import { Plus, Layout, Undo2, Redo2, Save, Maximize2, Minimize2 } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
@@ -50,6 +50,11 @@ export default function AppearancePages() {
setInspectorCollapsed,
setAvailableSources,
setIsLoading,
undo,
redo,
past,
future,
updateCurrentPage,
addSection,
deleteSection,
duplicateSection,
@@ -61,6 +66,7 @@ export default function AppearancePages() {
updateSectionStyles,
updateElementStyles,
markAsSaved,
markAsChanged,
setAsSpaLanding,
unsetSpaLanding,
} = usePageEditorStore();
@@ -160,7 +166,10 @@ export default function AppearancePages() {
const endpoint = currentPage.type === 'page'
? `/pages/${currentPage.slug}`
: `/templates/${currentPage.cpt}`;
return api.post(endpoint, { sections });
return api.post(endpoint, {
sections,
container_width: currentPage.containerWidth
});
},
onSuccess: () => {
toast.success(__('Page saved successfully'));
@@ -332,17 +341,40 @@ export default function AppearancePages() {
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</Button>
{hasUnsavedChanges && (
<>
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
<Button
variant="ghost"
size="sm"
onClick={handleDiscard}
>
<Undo2 className="w-4 h-4 mr-2" />
{__('Discard')}
</Button>
</>
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
)}
<div className="flex items-center rounded-md border bg-muted/50 p-0.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={undo}
disabled={past.length === 0}
title={__('Undo')}
>
<Undo2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={redo}
disabled={future.length === 0}
title={__('Redo')}
>
<Redo2 className="w-4 h-4" />
</Button>
</div>
{hasUnsavedChanges && (
<Button
variant="ghost"
size="sm"
onClick={handleDiscard}
>
{__('Discard')}
</Button>
)}
<Button
variant="outline"
@@ -455,10 +487,7 @@ export default function AppearancePages() {
onDeletePage={handleDeletePage}
onDeleteTemplate={handleDeleteTemplate}
onContainerWidthChange={(width) => {
if (currentPage) {
setCurrentPage({ ...currentPage, containerWidth: width });
markAsSaved(); // Mark as changed so save button enables
}
updateCurrentPage({ containerWidth: width });
}}
/>
)

View File

@@ -23,7 +23,9 @@ export interface SectionOption {
export interface StylableElementSchema {
name: string;
label: string;
type: 'text' | 'image';
type: 'text' | 'image' | 'container';
disableAlignment?: boolean;
disableBackground?: boolean;
}
export interface SectionSchema {
@@ -53,7 +55,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
fields: [
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
{ name: 'subtitle', label: 'Subtitle', type: 'text', dynamic: true },
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
{ name: 'image', label: 'Image', type: 'image', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
@@ -89,12 +91,12 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
{ value: 'medium', label: 'Medium' },
],
stylableElements: [
{ name: 'heading', label: 'Headings', type: 'text' },
{ name: 'text', label: 'Body Text', type: 'text' },
{ name: 'link', label: 'Links', type: 'text' },
{ name: 'image', label: 'Images', type: 'image' },
{ name: 'content', label: 'Container', type: 'container', disableAlignment: true },
{ name: 'heading', label: 'Headings', type: 'text', disableAlignment: true },
{ name: 'text', label: 'Body Text', type: 'text', disableAlignment: true },
{ name: 'link', label: 'Links', type: 'text', disableAlignment: true },
{ name: 'image', label: 'Images', type: 'image', disableAlignment: true },
{ name: 'button', label: 'Button', type: 'text' },
{ name: 'content', label: 'Container', type: 'text' },
],
},
'image-text': {
@@ -111,7 +113,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
fields: [
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
{ name: 'text', label: 'Text', type: 'textarea', dynamic: true },
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
{ name: 'image', label: 'Image', type: 'image', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
@@ -145,6 +147,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
stylableElements: [
{ name: 'heading', label: 'Heading', type: 'text' },
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
{ name: 'link', label: 'Link (Read more)', type: 'text' },
],
},
'cta-banner': {
@@ -177,6 +180,14 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
title: { type: 'static', value: 'Contact Us' },
webhook_url: { type: 'static', value: '' },
redirect_url: { type: 'static', value: '' },
fields: {
type: 'static',
value: [
{ name: 'name', label: 'Your Name', type: 'text', required: true },
{ name: 'email', label: 'Your Email', type: 'email', required: true },
{ name: 'message', label: 'Your Message', type: 'textarea', required: true },
]
}
},
fields: [
{ name: 'title', label: 'Title', type: 'text' },
@@ -231,8 +242,8 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
{ value: 'featured', label: 'Featured' },
],
stylableElements: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
{ name: 'title', label: 'Title', type: 'text', disableAlignment: true },
{ name: 'subtitle', label: 'Subtitle', type: 'text', disableAlignment: true },
],
},
'shoppable-image': {
@@ -249,11 +260,12 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
fields: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
{ name: 'image', label: 'Image URL', type: 'url' },
{ name: 'image', label: 'Image', type: 'image' },
{ name: 'alt', label: 'Image Alt Text', type: 'text' },
],
stylableElements: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
],
},
'marquee-banner': {
@@ -270,6 +282,9 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
{ name: 'separator', label: 'Separator', type: 'text' },
{ name: 'speed', label: 'Speed (seconds)', type: 'text' },
],
stylableElements: [
{ name: 'text', label: 'Banner Text', type: 'text', disableBackground: true, disableAlignment: true },
],
},
};

View File

@@ -21,6 +21,11 @@ export interface SectionStyles {
paddingTop?: string;
paddingBottom?: string;
contentWidth?: 'full' | 'contained' | 'boxed';
cardBackgroundColor?: string;
cardPaddingTop?: string;
cardPaddingRight?: string;
cardPaddingBottom?: string;
cardPaddingLeft?: string;
heightPreset?: string;
dynamicBackground?: string; // e.g. 'post_featured_image'
}
@@ -34,6 +39,8 @@ export interface ElementStyle {
// Image specific
objectFit?: 'cover' | 'contain' | 'fill';
objectPosition?: string;
alignment?: 'left' | 'center' | 'right';
backgroundColor?: string; // Wrapper BG
width?: string;
height?: string;
@@ -73,6 +80,10 @@ export interface PageItem {
isSpaLanding?: boolean;
containerWidth?: 'boxed' | 'fullwidth' | 'default';
}
interface HistoryState {
sections: Section[];
currentPage: PageItem | null;
}
interface PageEditorState {
// Current page/template being edited
@@ -91,6 +102,10 @@ interface PageEditorState {
hasUnsavedChanges: boolean;
isLoading: boolean;
// History (Undo/Redo)
past: HistoryState[];
future: HistoryState[];
// Available sources for dynamic fields (CPT templates)
availableSources: { value: string; label: string }[];
@@ -104,6 +119,14 @@ interface PageEditorState {
setAvailableSources: (sources: { value: string; label: string }[]) => void;
setIsLoading: (loading: boolean) => void;
// History actions
undo: () => void;
redo: () => void;
pushHistory: () => void;
// Page updates
updateCurrentPage: (updates: Partial<PageItem>) => void;
// Section actions
addSection: (type: string, index?: number) => void;
deleteSection: (id: string) => void;
@@ -137,11 +160,13 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
inspectorCollapsed: false,
hasUnsavedChanges: false,
isLoading: false,
past: [],
future: [],
availableSources: [],
// Setters
setCurrentPage: (currentPage) => set({ currentPage }),
setSections: (sections) => set({ sections, hasUnsavedChanges: true }),
setSections: (sections) => set({ sections, hasUnsavedChanges: true, past: [], future: [] }),
setSelectedSection: (selectedSectionId) => set({ selectedSectionId }),
setHoveredSection: (hoveredSectionId) => set({ hoveredSectionId }),
setDeviceMode: (deviceMode) => set({ deviceMode }),
@@ -149,9 +174,64 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
setAvailableSources: (availableSources) => set({ availableSources }),
setIsLoading: (isLoading) => set({ isLoading }),
// History actions
pushHistory: () => {
const { sections, currentPage } = get();
set((state) => ({
past: [...state.past, {
sections: JSON.parse(JSON.stringify(sections)),
currentPage: JSON.parse(JSON.stringify(currentPage))
}],
future: []
}));
},
undo: () => {
const { past, future, sections, currentPage } = get();
if (past.length === 0) return;
const previous = past[past.length - 1];
const newPast = past.slice(0, past.length - 1);
set({
past: newPast,
future: [{ sections, currentPage }, ...future],
sections: previous.sections,
currentPage: previous.currentPage,
hasUnsavedChanges: true
});
},
redo: () => {
const { past, future, sections, currentPage } = get();
if (future.length === 0) return;
const next = future[0];
const newFuture = future.slice(1);
set({
past: [...past, { sections, currentPage }],
future: newFuture,
sections: next.sections,
currentPage: next.currentPage,
hasUnsavedChanges: true
});
},
// Page updates
updateCurrentPage: (updates) => {
const { currentPage, pushHistory } = get();
if (!currentPage) return;
pushHistory();
set({
currentPage: { ...currentPage, ...updates },
hasUnsavedChanges: true
});
},
// Section actions
addSection: (type, index) => {
const { sections } = get();
const { sections, pushHistory } = get();
const sectionConfig = getSectionSchema(type);
if (!sectionConfig) return;
@@ -163,6 +243,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
styles: cloneDefaultStyles(type) as SectionStyles,
};
pushHistory();
const newSections = [...sections];
if (typeof index === 'number') {
newSections.splice(index, 0, newSection);
@@ -177,7 +259,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
},
deleteSection: (id) => {
const { sections, selectedSectionId } = get();
const { sections, selectedSectionId, pushHistory } = get();
pushHistory();
const newSections = sections.filter(s => s.id !== id);
set({
@@ -188,10 +271,12 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
},
duplicateSection: (id) => {
const { sections } = get();
const { sections, pushHistory } = get();
const index = sections.findIndex(s => s.id === id);
if (index === -1) return;
pushHistory();
const section = sections[index];
const newSection: Section = {
...JSON.parse(JSON.stringify(section)), // Deep clone
@@ -205,27 +290,32 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
},
moveSection: (id, direction) => {
const { sections } = get();
const { sections, pushHistory } = get();
const index = sections.findIndex(s => s.id === id);
if (index === -1) return;
if (direction === 'up' && index > 0) {
pushHistory();
const newSections = [...sections];
[newSections[index], newSections[index - 1]] = [newSections[index - 1], newSections[index]];
set({ sections: newSections, hasUnsavedChanges: true });
} else if (direction === 'down' && index < sections.length - 1) {
pushHistory();
const newSections = [...sections];
[newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]];
set({ sections: newSections, hasUnsavedChanges: true });
}
},
reorderSections: (sections) => {
set({ sections, hasUnsavedChanges: true });
reorderSections: (newSections) => {
const { sections, pushHistory } = get();
pushHistory();
set({ sections: newSections, hasUnsavedChanges: true });
},
updateSectionProp: (sectionId, propName, value) => {
const { sections } = get();
const { sections, pushHistory } = get();
pushHistory();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
@@ -240,7 +330,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
},
updateSectionLayout: (sectionId, layoutVariant) => {
const { sections } = get();
const { sections, pushHistory } = get();
pushHistory();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
@@ -252,7 +343,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
},
updateSectionColorScheme: (sectionId, colorScheme) => {
const { sections } = get();
const { sections, pushHistory } = get();
pushHistory();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
@@ -264,7 +356,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
},
updateSectionStyles: (sectionId, styles) => {
const { sections } = get();
const { sections, pushHistory } = get();
pushHistory();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
@@ -279,7 +372,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
},
updateElementStyles: (sectionId, fieldName, styles) => {
const { sections } = get();
const { sections, pushHistory } = get();
pushHistory();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;

View File

@@ -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() {
<TableHead>{__('Affiliate')}</TableHead>
<TableHead>{__('Order ID')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Campaign')}</TableHead>
<TableHead>{__('Date')}</TableHead>
<TableHead className="text-right">{__('Commission')}</TableHead>
</TableRow>
@@ -336,6 +340,9 @@ export default function AffiliatesReferrals() {
{ref.status}
</span>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / ') || '—'}
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(ref.created_at).toLocaleDateString('id-ID', {
year: 'numeric',

View File

@@ -30,7 +30,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Search, Package, Plus, History, Download, Loader2 } from 'lucide-react';
import { Search, Package, Plus, History, Download, Loader2, ChevronDown, ChevronRight, X, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
@@ -43,11 +43,21 @@ interface SoftwareProduct {
total_downloads: number;
}
interface ChangelogPoint {
type: string;
text: string;
}
interface ChangelogData {
narrative: string;
points: ChangelogPoint[];
}
interface SoftwareVersion {
id: number;
product_id: number;
version: string;
changelog: string;
changelog: ChangelogData | string;
release_date: string;
is_current: boolean;
download_count: number;
@@ -72,7 +82,13 @@ export default function SoftwareVersions() {
const [search, setSearch] = useState('');
const [selectedProduct, setSelectedProduct] = useState<number | null>(null);
const [isAddVersionOpen, setIsAddVersionOpen] = useState(false);
const [newVersion, setNewVersion] = useState({ version: '', changelog: '' });
const [editingVersionId, setEditingVersionId] = useState<number | null>(null);
const [newVersion, setNewVersion] = useState({
version: '',
changelog: { narrative: '', points: [] as ChangelogPoint[] }
});
const [expandedVersions, setExpandedVersions] = useState<Record<number, boolean>>({});
const queryClient = useQueryClient();
// Fetch software-enabled products
@@ -80,15 +96,14 @@ export default function SoftwareVersions() {
queryKey: ['software-products'],
queryFn: async () => {
const response = await api.get('/products?software_enabled=true&per_page=100');
// Filter products that have software distribution enabled
const products = (response as any).products || [];
const products = (response as any).rows || [];
return {
products: products.filter((p: any) => p.meta?._woonoow_software_enabled === 'yes').map((p: any) => ({
products: products.map((p: any) => ({
id: p.id,
name: p.name,
slug: p.meta?._woonoow_software_slug || '',
current_version: p.meta?._woonoow_software_current_version || '',
wp_enabled: p.meta?._woonoow_software_wp_enabled === 'yes',
slug: p.software_slug || p.meta?._woonoow_software_slug || '',
current_version: p.software_current_version || p.meta?._woonoow_software_current_version || '',
wp_enabled: p.software_wp_enabled || p.meta?._woonoow_software_wp_enabled === 'yes',
total_downloads: 0,
}))
} as ProductsResponse;
@@ -107,21 +122,121 @@ export default function SoftwareVersions() {
// Add new version mutation
const addVersion = useMutation({
mutationFn: async (data: { version: string; changelog: string }) => {
mutationFn: async (data: { version: string; changelog: ChangelogData }) => {
return await api.post(`/software/products/${selectedProduct}/versions`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['software-versions', selectedProduct] });
queryClient.invalidateQueries({ queryKey: ['software-products'] });
toast.success(__('Version added successfully'));
setIsAddVersionOpen(false);
setNewVersion({ version: '', changelog: '' });
closeModal();
},
onError: (error: any) => {
toast.error(error.message || __('Failed to add version'));
},
});
// Edit version mutation
const editVersion = useMutation({
mutationFn: async (data: { version_id: number; version: string; changelog: ChangelogData }) => {
return await api.put(`/software/products/${selectedProduct}/versions/${data.version_id}`, {
version: data.version,
changelog: data.changelog
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['software-versions', selectedProduct] });
queryClient.invalidateQueries({ queryKey: ['software-products'] });
toast.success(__('Version updated successfully'));
closeModal();
},
onError: (error: any) => {
toast.error(error.message || __('Failed to update version'));
},
});
const openAddModal = () => {
setEditingVersionId(null);
setNewVersion({ version: '', changelog: { narrative: '', points: [] } });
setIsAddVersionOpen(true);
};
const openEditModal = (version: SoftwareVersion, e: React.MouseEvent) => {
e.stopPropagation();
const cl = typeof version.changelog === 'object' && version.changelog !== null
? (version.changelog as ChangelogData)
: { narrative: version.changelog as string, points: [] };
setEditingVersionId(version.id);
setNewVersion({
version: version.version,
changelog: { narrative: cl.narrative || '', points: cl.points || [] }
});
setIsAddVersionOpen(true);
};
const closeModal = () => {
setIsAddVersionOpen(false);
setEditingVersionId(null);
setNewVersion({ version: '', changelog: { narrative: '', points: [] } });
};
const handleSaveVersion = () => {
if (editingVersionId) {
editVersion.mutate({
version_id: editingVersionId,
...newVersion
});
} else {
addVersion.mutate(newVersion);
}
};
const toggleVersion = (id: number) => {
setExpandedVersions(prev => ({
...prev,
[id]: !prev[id]
}));
};
const addChangelogPoint = () => {
setNewVersion(prev => ({
...prev,
changelog: {
...prev.changelog,
points: [...prev.changelog.points, { type: 'ADD', text: '' }]
}
}));
};
const updateChangelogPoint = (index: number, field: 'type' | 'text', value: string) => {
setNewVersion(prev => {
const newPoints = [...prev.changelog.points];
newPoints[index] = { ...newPoints[index], [field]: value };
return {
...prev,
changelog: {
...prev.changelog,
points: newPoints
}
};
});
};
const removeChangelogPoint = (index: number) => {
setNewVersion(prev => {
const newPoints = [...prev.changelog.points];
newPoints.splice(index, 1);
return {
...prev,
changelog: {
...prev.changelog,
points: newPoints
}
};
});
};
const filteredProducts = productsData?.products?.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.slug.toLowerCase().includes(search.toLowerCase())
@@ -129,9 +244,20 @@ export default function SoftwareVersions() {
const selectedProductData = productsData?.products?.find(p => p.id === selectedProduct);
const getBadgeColor = (type: string) => {
switch (type) {
case 'ADD': return 'bg-emerald-500 hover:bg-emerald-600';
case 'FIX': return 'bg-orange-500 hover:bg-orange-600';
case 'IMPROVE': return 'bg-blue-500 hover:bg-blue-600';
case 'DROP': return 'bg-rose-500 hover:bg-rose-600';
default: return 'bg-gray-500 hover:bg-gray-600';
}
};
const isSaving = addVersion.isPending || editVersion.isPending;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{__('Software Versions')}</h1>
@@ -142,7 +268,6 @@ export default function SoftwareVersions() {
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Products List */}
<div className="lg:col-span-1 border rounded-lg bg-card">
<div className="p-4 border-b">
<h2 className="font-semibold mb-3">{__('Software Products')}</h2>
@@ -176,8 +301,7 @@ export default function SoftwareVersions() {
<button
key={product.id}
onClick={() => setSelectedProduct(product.id)}
className={`w-full p-4 text-left hover:bg-accent transition-colors ${selectedProduct === product.id ? 'bg-accent' : ''
}`}
className={`w-full p-4 text-left hover:bg-accent transition-colors ${selectedProduct === product.id ? 'bg-accent' : ''}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
@@ -186,7 +310,7 @@ export default function SoftwareVersions() {
{product.slug}
</p>
</div>
<Badge variant="secondary" className="ml-2">
<Badge variant="secondary" className="ml-2 whitespace-nowrap">
v{product.current_version || '—'}
</Badge>
</div>
@@ -202,7 +326,6 @@ export default function SoftwareVersions() {
</div>
</div>
{/* Version Details */}
<div className="lg:col-span-2 border rounded-lg bg-card">
{!selectedProduct ? (
<div className="flex items-center justify-center h-full min-h-[400px] text-muted-foreground">
@@ -217,7 +340,6 @@ export default function SoftwareVersions() {
</div>
) : (
<>
{/* Version Header */}
<div className="p-4 border-b flex items-center justify-between">
<div>
<h2 className="font-semibold">{selectedProductData?.name}</h2>
@@ -225,21 +347,25 @@ export default function SoftwareVersions() {
{__('Current version')}: <span className="font-mono">{versionsData?.config?.current_version || '—'}</span>
</p>
</div>
<Dialog open={isAddVersionOpen} onOpenChange={setIsAddVersionOpen}>
<Dialog open={isAddVersionOpen} onOpenChange={(open) => !open ? closeModal() : setIsAddVersionOpen(true)}>
<DialogTrigger asChild>
<Button>
<Button onClick={openAddModal}>
<Plus className="w-4 h-4 mr-2" />
{__('New Version')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{__('Add New Version')}</DialogTitle>
<DialogTitle>
{editingVersionId ? __('Edit Version') : __('Add New Version')}
</DialogTitle>
<DialogDescription>
{__('Release a new version of')} {selectedProductData?.name}
{editingVersionId
? `${__('Modify release details for')} ${selectedProductData?.name}`
: `${__('Release a new version of')} ${selectedProductData?.name}`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-6 py-4">
<div className="space-y-6 px-6 py-4">
<div className="space-y-2">
<Label htmlFor="version">{__('Version Number')}</Label>
<Input
@@ -248,92 +374,195 @@ export default function SoftwareVersions() {
value={newVersion.version}
onChange={(e) => setNewVersion(prev => ({ ...prev, version: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
{__('Use semantic versioning (e.g., 1.0.0, 1.2.3)')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="changelog">{__('Changelog')}</Label>
<Textarea
id="changelog"
placeholder="## What's New&#10;- Added new feature&#10;- Fixed bug"
value={newVersion.changelog}
onChange={(e) => setNewVersion(prev => ({ ...prev, changelog: e.target.value }))}
rows={8}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{__('Supports Markdown formatting')}
</p>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="narrative">{__('Changelog Narrative (Optional)')}</Label>
<Textarea
id="narrative"
placeholder={__('Provide a general overview of this release...')}
value={newVersion.changelog.narrative}
onChange={(e) => setNewVersion(prev => ({
...prev,
changelog: { ...prev.changelog, narrative: e.target.value }
}))}
className="min-h-[100px]"
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>{__('Changes List')}</Label>
</div>
{newVersion.changelog.points.length === 0 ? (
<div className="text-center p-6 border rounded-md border-dashed text-muted-foreground">
<p className="text-sm">{__('No changes added yet.')}</p>
</div>
) : (
<div className="space-y-3">
{newVersion.changelog.points.map((point, index) => (
<div key={index} className="flex items-start gap-2">
<Select
value={point.type}
onValueChange={(val) => updateChangelogPoint(index, 'type', val)}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADD">{__('ADD')}</SelectItem>
<SelectItem value="FIX">{__('FIX')}</SelectItem>
<SelectItem value="IMPROVE">{__('IMPROVE')}</SelectItem>
<SelectItem value="DROP">{__('DROP')}</SelectItem>
<SelectItem value="OTHER">{__('OTHER')}</SelectItem>
</SelectContent>
</Select>
<Input
value={point.text}
onChange={(e) => updateChangelogPoint(index, 'text', e.target.value)}
placeholder={__('Describe the change...')}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeChangelogPoint(index)}
className="text-muted-foreground hover:text-destructive shrink-0"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
<Button variant="outline" size="sm" onClick={addChangelogPoint} className="w-full">
<Plus className="w-4 h-4 mr-2" />
{__('Add Change Item')}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddVersionOpen(false)}>
<Button variant="outline" onClick={closeModal} disabled={isSaving}>
{__('Cancel')}
</Button>
<Button
onClick={() => addVersion.mutate(newVersion)}
disabled={!newVersion.version || addVersion.isPending}
onClick={handleSaveVersion}
disabled={!newVersion.version || isSaving}
>
{addVersion.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{__('Release Version')}
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{editingVersionId ? __('Save Changes') : __('Release Version')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Version History */}
<div className="p-4">
{versionsData?.versions?.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{__('No versions released yet')}</p>
<p className="text-sm mt-1">
{__('Click "New Version" to release your first version')}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8"></TableHead>
<TableHead>{__('Version')}</TableHead>
<TableHead>{__('Release Date')}</TableHead>
<TableHead>{__('Downloads')}</TableHead>
<TableHead>{__('Changelog')}</TableHead>
<TableHead>{__('Summary')}</TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{versionsData?.versions?.map((version) => (
<TableRow key={version.id}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono font-medium">
v{version.version}
</span>
{version.is_current && (
<Badge variant="default" className="text-xs">
{__('Current')}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{version.release_date ? new Date(version.release_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-muted-foreground">
<Download className="w-3 h-3" />
{version.download_count}
</div>
</TableCell>
<TableCell className="max-w-xs">
<p className="text-sm text-muted-foreground truncate">
{version.changelog?.split('\n')[0] || '—'}
</p>
</TableCell>
</TableRow>
))}
{versionsData?.versions?.map((version) => {
const isExpanded = !!expandedVersions[version.id];
const cl = typeof version.changelog === 'object' && version.changelog !== null
? (version.changelog as ChangelogData)
: { narrative: version.changelog as string, points: [] };
return (
<React.Fragment key={version.id}>
<TableRow className="cursor-pointer hover:bg-muted/50 group" onClick={() => toggleVersion(version.id)}>
<TableCell>
<Button variant="ghost" size="icon" className="w-6 h-6">
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</Button>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono font-medium">
v{version.version}
</span>
{version.is_current && (
<Badge variant="default" className="text-xs">
{__('Current')}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{version.release_date ? new Date(version.release_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-muted-foreground">
<Download className="w-3 h-3" />
{version.download_count}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{cl.points?.length > 0 ? `${cl.points.length} changes` : (cl.narrative ? 'Notes attached' : '—')}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="w-8 h-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => openEditModal(version, e)}
title={__('Edit version')}
>
<Pencil className="w-4 h-4 text-muted-foreground" />
</Button>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="bg-muted/30">
<TableCell colSpan={6} className="p-0">
<div className="p-6 text-sm border-t">
{cl.narrative && (
<div className="mb-4 text-foreground whitespace-pre-wrap">
{cl.narrative}
</div>
)}
{cl.points && cl.points.length > 0 && (
<ul className="space-y-2">
{cl.points.map((pt, idx) => (
<li key={idx} className="flex items-start gap-3">
<Badge className={`${getBadgeColor(pt.type)} text-[10px] uppercase font-bold mt-0.5`}>
{pt.type}
</Badge>
<span className="text-muted-foreground">{pt.text}</span>
</li>
))}
</ul>
)}
{!cl.narrative && (!cl.points || cl.points.length === 0) && (
<p className="text-muted-foreground italic">{__('No changelog details provided.')}</p>
)}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}

View File

@@ -4,13 +4,14 @@ import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
import { Package, DollarSign, Layers, Tag, Download } from 'lucide-react';
import { Package, DollarSign, Layers, Tag, Download, Cloud } from 'lucide-react';
import { toast } from 'sonner';
import { GeneralTab } from './tabs/GeneralTab';
import { InventoryTab } from './tabs/InventoryTab';
import { VariationsTab, ProductVariant } from './tabs/VariationsTab';
import { OrganizationTab } from './tabs/OrganizationTab';
import { DownloadsTab, DownloadableFile } from './tabs/DownloadsTab';
import { SoftwareTab } from './tabs/SoftwareTab';
// Types
export type ProductFormData = {
@@ -50,6 +51,13 @@ export type ProductFormData = {
// Affiliate
affiliate_enabled?: boolean;
affiliate_commission_rate?: string;
// Software
software_enabled?: boolean;
software_slug?: string;
software_wp_enabled?: boolean;
software_requires_wp?: string;
software_tested_wp?: string;
software_requires_php?: string;
};
type Props = {
@@ -109,6 +117,13 @@ export function ProductFormTabbed({
// Affiliate state
const [affiliateEnabled, setAffiliateEnabled] = useState(initial?.affiliate_enabled || false);
const [affiliateCommissionRate, setAffiliateCommissionRate] = useState(initial?.affiliate_commission_rate || '');
// Software state
const [softwareEnabled, setSoftwareEnabled] = useState(initial?.software_enabled || false);
const [softwareSlug, setSoftwareSlug] = useState(initial?.software_slug || '');
const [softwareWpEnabled, setSoftwareWpEnabled] = useState(initial?.software_wp_enabled || false);
const [softwareRequiresWp, setSoftwareRequiresWp] = useState(initial?.software_requires_wp || '');
const [softwareTestedWp, setSoftwareTestedWp] = useState(initial?.software_tested_wp || '');
const [softwareRequiresPhp, setSoftwareRequiresPhp] = useState(initial?.software_requires_php || '');
const [submitting, setSubmitting] = useState(false);
// Update form state when initial data changes (for edit mode)
@@ -149,6 +164,13 @@ export function ProductFormTabbed({
// Affiliate
setAffiliateEnabled(initial.affiliate_enabled || false);
setAffiliateCommissionRate(initial.affiliate_commission_rate || '');
// Software
setSoftwareEnabled(initial.software_enabled || false);
setSoftwareSlug(initial.software_slug || '');
setSoftwareWpEnabled(initial.software_wp_enabled || false);
setSoftwareRequiresWp(initial.software_requires_wp || '');
setSoftwareTestedWp(initial.software_tested_wp || '');
setSoftwareRequiresPhp(initial.software_requires_php || '');
}
}, [initial, mode]);
@@ -221,6 +243,13 @@ export function ProductFormTabbed({
// Affiliate
affiliate_enabled: affiliateEnabled,
affiliate_commission_rate: affiliateEnabled ? affiliateCommissionRate : undefined,
// Software
software_enabled: softwareEnabled,
software_slug: softwareEnabled ? softwareSlug : undefined,
software_wp_enabled: softwareEnabled ? softwareWpEnabled : undefined,
software_requires_wp: (softwareEnabled && softwareWpEnabled) ? softwareRequiresWp : undefined,
software_tested_wp: (softwareEnabled && softwareWpEnabled) ? softwareTestedWp : undefined,
software_requires_php: (softwareEnabled && softwareWpEnabled) ? softwareRequiresPhp : undefined,
};
await onSubmit(payload);
@@ -238,6 +267,7 @@ export function ProductFormTabbed({
...(downloadable ? [{ id: 'downloads', label: __('Downloads'), icon: <Download className="w-4 h-4" /> }] : []),
...(type === 'variable' ? [{ id: 'variations', label: __('Variations'), icon: <Layers className="w-4 h-4" /> }] : []),
{ id: 'organization', label: __('Organization'), icon: <Tag className="w-4 h-4" /> },
{ id: 'software', label: __('Software'), icon: <Cloud className="w-4 h-4" /> },
];
return (
@@ -348,6 +378,24 @@ export function ProductFormTabbed({
/>
</FormSection>
{/* Software Tab */}
<FormSection id="software">
<SoftwareTab
softwareEnabled={softwareEnabled}
setSoftwareEnabled={setSoftwareEnabled}
softwareSlug={softwareSlug}
setSoftwareSlug={setSoftwareSlug}
softwareWpEnabled={softwareWpEnabled}
setSoftwareWpEnabled={setSoftwareWpEnabled}
softwareRequiresWp={softwareRequiresWp}
setSoftwareRequiresWp={setSoftwareRequiresWp}
softwareTestedWp={softwareTestedWp}
setSoftwareTestedWp={setSoftwareTestedWp}
softwareRequiresPhp={softwareRequiresPhp}
setSoftwareRequiresPhp={setSoftwareRequiresPhp}
/>
</FormSection>
{/* Submit Button */}
{!hideSubmitButton && (
<div className="mt-6 flex gap-3">

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { __ } from '@/lib/i18n';
type Props = {
softwareEnabled: boolean;
setSoftwareEnabled: (val: boolean) => void;
softwareSlug: string;
setSoftwareSlug: (val: string) => void;
softwareWpEnabled: boolean;
setSoftwareWpEnabled: (val: boolean) => void;
softwareRequiresWp: string;
setSoftwareRequiresWp: (val: string) => void;
softwareTestedWp: string;
setSoftwareTestedWp: (val: string) => void;
softwareRequiresPhp: string;
setSoftwareRequiresPhp: (val: string) => void;
};
export function SoftwareTab({
softwareEnabled,
setSoftwareEnabled,
softwareSlug,
setSoftwareSlug,
softwareWpEnabled,
setSoftwareWpEnabled,
softwareRequiresWp,
setSoftwareRequiresWp,
softwareTestedWp,
setSoftwareTestedWp,
softwareRequiresPhp,
setSoftwareRequiresPhp,
}: Props) {
return (
<div className="space-y-6">
<div className="flex flex-col space-y-4">
<h3 className="text-lg font-medium">{__('Software Distribution')}</h3>
<p className="text-sm text-gray-500">
{__('Enable this to distribute software updates, manage versioning, and secure downloads.')}
</p>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border">
<div className="space-y-0.5">
<Label className="text-base">{__('Enable Software Distribution')}</Label>
<p className="text-sm text-gray-500">
{__('Allow this product to serve OTA updates and track versions.')}
</p>
</div>
<Switch checked={softwareEnabled} onCheckedChange={setSoftwareEnabled} />
</div>
</div>
{softwareEnabled && (
<div className="space-y-4 pt-4 border-t">
<div className="space-y-2">
<Label htmlFor="softwareSlug">{__('Software Slug (Unique Identifier)')}</Label>
<Input
id="softwareSlug"
value={softwareSlug}
onChange={(e) => setSoftwareSlug(e.target.value)}
placeholder="e.g. acme-seo-pro"
/>
<p className="text-sm text-gray-500">
{__('The unique slug that the software client will use to check for updates.')}
</p>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border mt-6">
<div className="space-y-0.5">
<Label className="text-base">{__('WordPress Product')}</Label>
<p className="text-sm text-gray-500">
{__('Check if this software is a WordPress Plugin or Theme.')}
</p>
</div>
<Switch checked={softwareWpEnabled} onCheckedChange={setSoftwareWpEnabled} />
</div>
{softwareWpEnabled && (
<div className="grid grid-cols-3 gap-4 pt-4">
<div className="space-y-2">
<Label htmlFor="requiresWp">{__('Requires WP')}</Label>
<Input
id="requiresWp"
value={softwareRequiresWp}
onChange={(e) => setSoftwareRequiresWp(e.target.value)}
placeholder="e.g. 5.8"
/>
</div>
<div className="space-y-2">
<Label htmlFor="testedWp">{__('Tested up to WP')}</Label>
<Input
id="testedWp"
value={softwareTestedWp}
onChange={(e) => setSoftwareTestedWp(e.target.value)}
placeholder="e.g. 6.4"
/>
</div>
<div className="space-y-2">
<Label htmlFor="requiresPhp">{__('Requires PHP')}</Label>
<Input
id="requiresPhp"
value={softwareRequiresPhp}
onChange={(e) => setSoftwareRequiresPhp(e.target.value)}
placeholder="e.g. 7.4"
/>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -25,6 +25,10 @@ export type ProductVariant = {
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
image?: string;
license_duration_days?: string;
subscription_signup_fee?: string;
subscription_trial_days?: string;
subscription_period?: 'day' | 'week' | 'month' | 'year';
subscription_interval?: string;
};
type VariationsTabProps = {
@@ -282,8 +286,83 @@ export function VariationsTab({
</div>
</div>
{/* Subscription Fields */}
<div className="col-span-2 md:col-span-4 space-y-3">
<Label className="text-xs font-semibold">{__('Subscription Settings (Optional)')}</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<Label className="text-xs">{__('Period')}</Label>
<select
value={variation.subscription_period || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].subscription_period = e.target.value as any;
setVariations(updated);
}}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 mt-1"
>
<option value="">{__('Parent Default')}</option>
<option value="day">{__('Day')}</option>
<option value="week">{__('Week')}</option>
<option value="month">{__('Month')}</option>
<option value="year">{__('Year')}</option>
</select>
</div>
<div>
<Label className="text-xs">{__('Interval')}</Label>
<Input
type="number"
min="1"
placeholder={__('Parent')}
value={variation.subscription_interval || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].subscription_interval = e.target.value;
setVariations(updated);
}}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">{__('Trial Days')}</Label>
<Input
type="number"
min="0"
placeholder={__('Parent')}
value={variation.subscription_trial_days || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].subscription_trial_days = e.target.value;
setVariations(updated);
}}
className="mt-1"
/>
</div>
<div className="relative">
<Label className="text-xs">{__('Signup Fee')}</Label>
<div className="relative mt-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground font-medium">
{store.symbol}
</span>
<Input
type="number"
step={store.decimals === 0 ? '1' : '0.01'}
placeholder={__('Parent')}
value={variation.subscription_signup_fee || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].subscription_signup_fee = e.target.value;
setVariations(updated);
}}
className="pl-8 pr-3 text-right"
/>
</div>
</div>
</div>
</div>
{/* License Duration - only show if licensing is enabled on product */}
<div className="col-span-2 md:col-span-4">
<div className="col-span-2 md:col-span-4 mt-2">
<Label className="text-xs">{__('License Duration (Days)')}</Label>
<Input
type="number"

View File

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

View File

@@ -18,6 +18,7 @@ interface Address {
email?: string;
phone?: string;
is_default: boolean;
formatted_address?: string;
}
interface AddressSelectorProps {
@@ -148,14 +149,22 @@ export function AddressSelector({
)}
{/* Address */}
<p className="text-sm text-gray-600 mt-2">
{address.address_1}
{address.address_2 && `, ${address.address_2}`}
</p>
<p className="text-sm text-gray-600">
{address.city}, {address.state} {address.postcode}
</p>
<p className="text-sm text-gray-600">{address.country}</p>
<div className="text-sm text-gray-600 mt-2">
{address.formatted_address ? (
<p className="whitespace-pre-wrap">{address.formatted_address}</p>
) : (
<>
<p>
{address.address_1}
{address.address_2 && `, ${address.address_2}`}
</p>
<p>
{address.city}, {address.state} {address.postcode}
</p>
<p>{address.country}</p>
</>
)}
</div>
</div>
</div>
))}

View File

@@ -28,6 +28,7 @@ interface CheckoutField {
interface DynamicCheckoutFieldProps {
field: CheckoutField;
value: string;
valueLabel?: string;
onChange: (value: string) => void;
countryOptions?: { value: string; label: string }[];
stateOptions?: { value: string; label: string }[];
@@ -41,6 +42,7 @@ interface SearchOption {
export function DynamicCheckoutField({
field,
value,
valueLabel,
onChange,
countryOptions = [],
stateOptions = [],
@@ -54,9 +56,11 @@ export function DynamicCheckoutField({
return;
}
// If we have a value but no options yet, we might need to load it
// This handles pre-selected values
}, [field.type, field.search_endpoint, value]);
// If we have a value and a label, inject it into searchOptions so it renders properly when mounted
if (value && valueLabel && searchOptions.length === 0) {
setSearchOptions([{ value, label: valueLabel }]);
}
}, [field.type, field.search_endpoint, value, valueLabel]);
// Handle API search for searchable_select
const handleApiSearch = async (searchTerm: string) => {

View File

@@ -0,0 +1,26 @@
import { SectionStyleResult } from '@/lib/sectionStyles';
interface SectionBackgroundRendererProps {
bg: SectionStyleResult;
}
export function SectionBackgroundRenderer({ bg }: SectionBackgroundRendererProps) {
if (!bg.backgroundImage) return null;
return (
<div className="absolute inset-0 pointer-events-none z-0">
<img
src={bg.backgroundImage}
alt=""
role="presentation"
className="w-full h-full object-cover"
/>
{bg.hasOverlay && (
<div
className="absolute inset-0 bg-black"
style={{ opacity: bg.overlayOpacity }}
/>
)}
</div>
);
}

View File

@@ -22,6 +22,7 @@ interface SharedContentProps {
textClassName?: string;
headingStyle?: React.CSSProperties; // For prose headings override
imageStyle?: React.CSSProperties;
cardStyle?: React.CSSProperties; // For boxed layout background
// Pro Features (for future)
buttons?: Array<{ text: string, url: string }>;
@@ -44,6 +45,7 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
buttons,
imageStyle,
cardStyle,
buttonStyle
}) => {
@@ -53,186 +55,129 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
const isImageTop = imagePosition === 'top';
const isImageBottom = imagePosition === 'bottom';
// Wrapper classes — full = edge-to-edge, contained = narrow readable column, boxed = card at max-w-5xl
// Wrapper classes — no width constraints applied here, parent handles it
const containerClasses = cn(
'w-full mx-auto px-4 sm:px-6 lg:px-8',
containerWidth === 'contained' ? 'max-w-4xl'
: containerWidth === 'boxed' ? 'max-w-5xl'
: '' // full = no max-width cap
containerWidth === 'contained' ? 'max-w-4xl' : '',
containerWidth === 'boxed' ? 'max-w-5xl' : ''
);
const gridClasses = cn(
'mx-auto',
'mx-auto w-full',
hasImage && (isImageLeft || isImageRight)
? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center'
: containerWidth === 'full' ? 'w-full' : '' // no extra constraint for contained — outer already limits it
: ''
);
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
const safeTextStyle = { ...textStyle };
delete safeTextStyle.textAlign;
const proseStyle = {
...textStyle,
...safeTextStyle,
'--tw-prose-headings': headingStyle?.color,
'--tw-prose-body': textStyle?.color,
} as React.CSSProperties;
return (
<div className={containerClasses}>
{containerWidth === 'boxed' ? (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
</div>
</div>
) : (
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'flex flex-col',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8',
{
'items-start': (imageStyle as any)?.alignment === 'left',
'items-center': (imageStyle as any)?.alignment === 'center',
'items-end': (imageStyle as any)?.alignment === 'right',
}
)}>
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8'
)} style={imageStyle}>
)} style={{
backgroundColor: imageStyle?.backgroundColor,
width: imageStyle?.width,
height: imageStyle?.height,
maxWidth: '100%'
}}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
style={{
objectFit: imageStyle?.objectFit,
objectPosition: (imageStyle as any)?.objectPosition,
}}
/>
</div>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6 w-full",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none w-full',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className={cn(
"mt-8 flex flex-wrap gap-4",
buttonStyle?.style?.textAlign === 'center' && "justify-center",
buttonStyle?.style?.textAlign === 'right' && "justify-end",
(!buttonStyle?.style?.textAlign || buttonStyle?.style?.textAlign === 'left') && "justify-start"
)}>
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
)}
</div>
</div>
);
};

View File

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

View File

@@ -345,10 +345,18 @@ export default function Addresses() {
<div className="text-sm text-gray-700 space-y-1 mb-4">
<p className="font-medium">{address.first_name} {address.last_name}</p>
{address.company && <p>{address.company}</p>}
<p>{address.address_1}</p>
{address.address_2 && <p>{address.address_2}</p>}
<p>{address.city}, {address.state} {address.postcode}</p>
<p>{address.country}</p>
{address.formatted_address ? (
<p className="whitespace-pre-wrap">{address.formatted_address}</p>
) : (
<>
<p>{address.address_1}</p>
{address.address_2 && <p>{address.address_2}</p>}
{[address.city, address.state, address.postcode].filter(Boolean).length > 0 && (
<p>{[address.city, address.state, address.postcode].filter(Boolean).join(', ')}</p>
)}
<p>{address.country}</p>
</>
)}
{address.phone && <p className="pt-2">Phone: {address.phone}</p>}
{address.email && <p>Email: {address.email}</p>}
</div>
@@ -429,6 +437,7 @@ export default function Addresses() {
key={field.key}
field={field}
value={getFieldValue(field.key)}
valueLabel={getFieldValue(field.key + '_label')}
onChange={(v) => setFieldValue(field.key, v)}
countryOptions={countryOptions}
stateOptions={stateOptions}

View File

@@ -0,0 +1,422 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus, Trash2, Edit2, Link as LinkIcon, Search, Copy, CheckCircle, X, ChevronLeft } from 'lucide-react';
import { toast } from 'sonner';
import { Link } from 'react-router-dom';
import { useDebounce } from '@/hooks/useDebounce';
interface Product {
id: number;
name: string;
slug: string;
image?: string;
price_html?: string;
}
interface Collection {
id: number;
title: string;
slug: string;
description: string;
product_ids: number[];
link: string;
}
export function AffiliateCollections() {
const config = (window as any).woonoowCustomer || {};
const enableCuratedCollections = config.affiliateSettings?.enableCuratedCollections !== false;
const queryClient = useQueryClient();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingCollection, setEditingCollection] = useState<Collection | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [selectedProducts, setSelectedProducts] = useState<Product[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 300);
const [copiedId, setCopiedId] = useState<string | null>(null);
const { data: collections, isLoading: isLoadingCollections } = useQuery<Collection[]> ({
queryKey: ['affiliate-collections'],
queryFn: async () => {
const res: any = await api.get('/account/affiliate/collections');
return Array.isArray(res) ? res : [];
}
});
const { data: searchResults, isLoading: isSearching } = useQuery({
queryKey: ['collection-product-search', debouncedSearch],
queryFn: async () => {
if (!debouncedSearch) return [];
try {
const res: any = await api.get(`/shop/products?search=${encodeURIComponent(debouncedSearch)}&per_page=5`);
return res.products || [];
} catch (err) {
return [];
}
},
enabled: debouncedSearch.length > 2,
placeholderData: keepPreviousData
});
// When editing, fetch details of products so we can show their names/images
const { data: editingProducts } = useQuery({
queryKey: ['collection-editing-products', editingCollection?.id],
queryFn: async () => {
if (!editingCollection || editingCollection.product_ids.length === 0) return [];
const res: any = await api.get(`/shop/products?include=${editingCollection.product_ids.join(',')}&per_page=20`);
return res.products || [];
},
enabled: !!editingCollection
});
// Pre-fill form when editingProducts is loaded
React.useEffect(() => {
if (editingCollection && editingProducts) {
setTitle(editingCollection.title);
setDescription(editingCollection.description);
setSelectedProducts(editingProducts);
}
}, [editingCollection, editingProducts]);
const resetForm = () => {
setIsFormOpen(false);
setEditingCollection(null);
setTitle('');
setDescription('');
setSelectedProducts([]);
setSearchQuery('');
};
const createMutation = useMutation({
mutationFn: async (data: any) => {
return api.post('/account/affiliate/collections', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
toast.success('Collection created successfully!');
resetForm();
},
onError: (err: any) => {
toast.error(err.message || 'Failed to create collection');
}
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: number, data: any }) => {
return api.put(`/account/affiliate/collections/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
toast.success('Collection updated successfully!');
resetForm();
},
onError: (err: any) => {
toast.error(err.message || 'Failed to update collection');
}
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
return api.delete(`/account/affiliate/collections/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
toast.success('Collection deleted!');
}
});
const handleSave = () => {
if (!title) {
toast.error('Title is required');
return;
}
if (selectedProducts.length > 20) {
toast.error('Maximum 20 products allowed per collection');
return;
}
const data = {
title,
description,
product_ids: selectedProducts.map(p => p.id)
};
if (editingCollection) {
updateMutation.mutate({ id: editingCollection.id, data });
} else {
createMutation.mutate(data);
}
};
const toggleProduct = (product: Product) => {
const exists = selectedProducts.find(p => p.id === product.id);
if (exists) {
setSelectedProducts(prev => prev.filter(p => p.id !== product.id));
} else {
if (selectedProducts.length >= 20) {
toast.error('Maximum 20 products allowed');
return;
}
setSelectedProducts(prev => [...prev, product]);
}
};
const copyToClipboard = (link: string, id: string) => {
navigator.clipboard.writeText(link);
setCopiedId(id);
toast.success('Link copied to clipboard!');
setTimeout(() => setCopiedId(null), 2000);
};
if (isLoadingCollections) return <div>Loading collections...</div>;
if (!enableCuratedCollections) {
return (
<div className="space-y-6">
<div className="mb-2">
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
</Link>
</div>
<h2 className="text-xl font-bold tracking-tight">My Curated Collections</h2>
<p className="text-muted-foreground">This feature has been disabled by the administrator.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="mb-2">
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
</Link>
</div>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 className="text-xl font-bold tracking-tight">My Curated Collections</h2>
<p className="text-muted-foreground">Group your favorite products into a single shareable link.</p>
</div>
{!isFormOpen && (
<Button onClick={() => setIsFormOpen(true)} className="gap-2">
<Plus className="w-4 h-4" /> New Collection
</Button>
)}
</div>
{isFormOpen && (
<div className="bg-card border rounded-lg p-6 space-y-6">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-lg">
{editingCollection ? 'Edit Collection' : 'Create New Collection'}
</h3>
<Button variant="ghost" size="icon" onClick={resetForm}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<label className="text-sm font-medium">Collection Title</label>
<Input
placeholder="e.g., My Summer Favorites"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2 sm:col-span-2">
<label className="text-sm font-medium">Description (Optional)</label>
<textarea
placeholder="Tell your audience why you love these products..."
value={description}
onChange={(e) => setDescription(e.target.value)}
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-end">
<label className="text-sm font-medium">Select Products ({selectedProducts.length}/20)</label>
</div>
{/* Selected Products Area */}
{selectedProducts.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{selectedProducts.map(p => (
<div key={p.id} className="flex items-center gap-2 bg-secondary text-secondary-foreground text-xs rounded-full pl-2 pr-1 py-1">
<span className="truncate max-w-[150px]">{p.name}</span>
<button
onClick={() => toggleProduct(p)}
className="hover:bg-background/20 rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search products to add..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Search Results Dropdown */}
{searchQuery && (
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
{isSearching && <div className="p-3 text-sm text-center text-muted-foreground">Searching...</div>}
{!isSearching && searchResults?.length === 0 && (
<div className="p-3 text-sm text-center text-muted-foreground">No products found.</div>
)}
{searchResults?.map((product: Product) => {
const isSelected = selectedProducts.some(p => p.id === product.id);
return (
<div
key={product.id}
className={`p-3 flex items-center justify-between hover:bg-muted/50 cursor-pointer transition-colors ${isSelected ? 'bg-primary/5' : ''}`}
onClick={() => toggleProduct(product)}
>
<div className="flex items-center gap-3 overflow-hidden">
{product.image ? (
<img src={product.image} alt={product.name} className="w-10 h-10 object-cover rounded" />
) : (
<div className="w-10 h-10 bg-muted rounded flex-shrink-0"></div>
)}
<div className="truncate">
<div className="font-medium truncate text-sm">{product.name}</div>
<div className="text-xs text-muted-foreground" dangerouslySetInnerHTML={{ __html: product.price_html || '' }} />
</div>
</div>
{isSelected && <CheckCircle className="w-4 h-4 text-primary flex-shrink-0" />}
</div>
);
})}
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-4">
<Button variant="outline" onClick={resetForm}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={createMutation.isPending || updateMutation.isPending}
>
{(createMutation.isPending || updateMutation.isPending) ? 'Saving...' : 'Save Collection'}
</Button>
</div>
</div>
)}
{!isFormOpen && (!collections || collections.length === 0) ? (
<div className="text-center py-12 bg-muted/30 rounded-lg border border-dashed">
<h3 className="font-semibold mb-2">No collections yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Create a curated list of products to share with your audience.
</p>
<Button onClick={() => setIsFormOpen(true)}>
Create First Collection
</Button>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{collections?.map(collection => (
<div key={collection.id} className="border rounded-lg p-5 bg-card flex flex-col">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-semibold text-lg line-clamp-1">{collection.title}</h3>
<p className="text-sm text-muted-foreground">
{collection.product_ids.length} products
</p>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
setEditingCollection(collection);
setIsFormOpen(true);
}}
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if(window.confirm('Delete this collection?')) {
deleteMutation.mutate(collection.id);
}
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{collection.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">
{collection.description}
</p>
)}
<div className="mt-auto pt-4 space-y-3">
<div className="space-y-1">
<span className="text-xs font-medium text-gray-500">Collection Link (Shows all products)</span>
<div className="flex gap-2">
<Input
readOnly
value={collection.link}
className="bg-muted h-9 text-xs font-mono"
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
variant={copiedId === `col-${collection.id}` ? "default" : "outline"}
onClick={() => copyToClipboard(collection.link, `col-${collection.id}`)}
>
{copiedId === `col-${collection.id}` ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
<div className="space-y-1">
<span className="text-xs font-medium text-gray-500">Smart Link (Redirects to random product)</span>
<div className="flex gap-2">
<Input
readOnly
value={`${window.location.origin}/go/${collection.slug}`}
className="bg-muted h-9 text-xs font-mono border-primary/20"
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
variant={copiedId === `smart-${collection.id}` ? "default" : "outline"}
onClick={() => copyToClipboard(`${window.location.origin}/go/${collection.slug}`, `smart-${collection.id}`)}
>
{copiedId === `smart-${collection.id}` ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ interface SavedAddress {
email?: string;
phone?: string;
is_default: boolean;
formatted_address?: string;
}
export default function Checkout() {
@@ -389,7 +390,13 @@ export default function Checkout() {
state: addressData.state,
city: addressData.city,
postcode: addressData.postcode,
destination_id: undefined,
// Include custom fields for shipping calculation (e.g., RajaOngkir destination_id)
...Object.fromEntries(
(shipToDifferentAddress ? shippingCustomFields : billingCustomFields).map(f => [
f.key.replace(/^(shipping_|billing_)/, ''),
customFieldData[f.key] || ''
])
),
},
items,
});
@@ -795,7 +802,16 @@ export default function Checkout() {
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4 text-sm">
<p className="font-semibold">{sel.label}{sel.is_default && <span className="ml-2 text-xs bg-green-100 text-green-700 px-1.5 rounded">Default</span>}</p>
<p>{sel.first_name} {sel.last_name}</p>
<p className="text-gray-600">{sel.address_1}, {sel.city}, {sel.state} {sel.postcode}</p>
{sel.formatted_address ? (
<p className="text-gray-600 whitespace-pre-wrap">{sel.formatted_address}</p>
) : (
<div className="text-gray-600">
<p>{sel.address_1}</p>
{[sel.city, sel.state, sel.postcode].filter(Boolean).length > 0 && (
<p>{[sel.city, sel.state, sel.postcode].filter(Boolean).join(', ')}</p>
)}
</div>
)}
</div>
) : null;
})()}
@@ -866,7 +882,7 @@ export default function Checkout() {
</div>
)}
{billingCustomFields.map(field => (
<DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={billingStateOptions} />
<DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} valueLabel={customFieldData[`${field.key}_label`]} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={billingStateOptions} />
))}
</>
)}
@@ -908,7 +924,16 @@ export default function Checkout() {
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4 text-sm">
<p className="font-semibold">{sel.label}</p>
<p>{sel.first_name} {sel.last_name}</p>
<p className="text-gray-600">{sel.address_1}, {sel.city} {sel.postcode}</p>
{sel.formatted_address ? (
<p className="text-gray-600 whitespace-pre-wrap">{sel.formatted_address}</p>
) : (
<div className="text-gray-600">
<p>{sel.address_1}</p>
{[sel.city, sel.state, sel.postcode].filter(Boolean).length > 0 && (
<p>{[sel.city, sel.state, sel.postcode].filter(Boolean).join(', ')}</p>
)}
</div>
)}
</div>
) : null;
})()}
@@ -926,7 +951,7 @@ export default function Checkout() {
{getShippingField('shipping_country') && <div><label className="block text-sm font-medium mb-1">Country</label><SearchableSelect options={countryOptions} value={shippingData.country} onChange={v => setShippingData({ ...shippingData, country: v })} placeholder="Select country" disabled={countries.length === 1} /></div>}
{getShippingField('shipping_state') && <div><label className="block text-sm font-medium mb-1">State</label>{shippingStateOptions.length > 0 ? <SearchableSelect options={shippingStateOptions} value={shippingData.state} onChange={v => setShippingData({ ...shippingData, state: v })} placeholder="Select state" /> : <input type="text" value={shippingData.state} onChange={e => setShippingData({ ...shippingData, state: e.target.value })} className="w-full border rounded-lg px-4 py-2" />}</div>}
{getShippingField('shipping_postcode') && <div><label className="block text-sm font-medium mb-1">Postcode</label><input type="text" value={shippingData.postcode} onChange={e => setShippingData({ ...shippingData, postcode: e.target.value })} className="w-full border rounded-lg px-4 py-2" /></div>}
{shippingCustomFields.map(field => <DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={shippingStateOptions} />)}
{shippingCustomFields.map(field => <DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} valueLabel={customFieldData[`${field.key}_label`]} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={shippingStateOptions} />)}
</div>
)}
</>

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { Helmet } from 'react-helmet-async';
import { cn } from '@/lib/utils';
// Section Components
import { HeroSection } from './sections/HeroSection';
@@ -30,7 +31,16 @@ interface SectionStyles {
backgroundOverlay?: number;
paddingTop?: string;
paddingBottom?: string;
contentWidth?: 'full' | 'contained';
contentWidth?: 'full' | 'contained' | 'boxed';
gradientAngle?: number;
gradientFrom?: string;
gradientTo?: string;
cardBackgroundColor?: string;
cardPaddingTop?: string;
cardPaddingRight?: string;
cardPaddingBottom?: string;
cardPaddingLeft?: string;
heightPreset?: string;
}
interface ElementStyle {
@@ -266,15 +276,28 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
return (
<div
key={section.id}
className="relative overflow-hidden"
className={cn(
"relative overflow-hidden",
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50",
{
'default': 'py-16 md:py-24',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-screen flex items-center',
}[(section.styles?.heightPreset as string) || 'default'] || 'py-16 md:py-24'
)}
style={{
// Only explicit custom padding overrides from the padding fields
...(section.styles?.backgroundType === 'gradient'
? { background: `linear-gradient(${section.styles?.gradientAngle ?? 135}deg, ${section.styles?.gradientFrom || '#9333ea'}, ${section.styles?.gradientTo || '#3b82f6'})` }
: { backgroundColor: section.styles?.backgroundColor }
),
paddingTop: section.styles?.paddingTop,
paddingBottom: section.styles?.paddingBottom,
}}
>
{/* Full-bleed background image & overlay */}
{section.styles?.backgroundImage && (section.styles.backgroundType === 'image' || !section.styles.backgroundType) && (
{section.styles?.backgroundType === 'image' && section.styles?.backgroundImage && (
<>
<div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
@@ -286,19 +309,64 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
/>
</>
)}
{/* Legacy: show bg image even without backgroundType set */}
{!section.styles?.backgroundType && section.styles?.backgroundImage && (
<>
<div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
/>
<div
className="absolute inset-0 z-0 bg-black"
style={{ opacity: (section.styles?.backgroundOverlay || 0) / 100 }}
/>
</>
)}
{/* Section component — manages its own background, height, and inner content width */}
<div className="relative z-10 w-full">
<SectionComponent
id={section.id}
section={section}
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
styles={section.styles}
elementStyles={section.elementStyles}
{...flattenSectionProps(section.props || {})}
/>
</div>
{/* Content Wrapper */}
{section.styles?.contentWidth === 'boxed' ? (
<div className="relative z-10 container mx-auto px-4 max-w-5xl">
<div
className="rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
style={{
backgroundColor: section.styles?.cardBackgroundColor || '#ffffff',
paddingTop: section.styles?.cardPaddingTop || undefined,
paddingRight: section.styles?.cardPaddingRight || undefined,
paddingBottom: section.styles?.cardPaddingBottom || undefined,
paddingLeft: section.styles?.cardPaddingLeft || undefined,
}}
>
<SectionComponent
id={section.id}
section={section}
sourceType={isStructuralPage ? 'page' : 'template'}
sourceId={isStructuralPage ? pageData.id : pageData.cpt}
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
styles={section.styles}
elementStyles={section.elementStyles}
{...flattenSectionProps(section.props || {})}
/>
</div>
</div>
) : (
<div className={cn(
"relative z-10 w-full",
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : ''
)}>
<SectionComponent
id={section.id}
section={section}
sourceType={isStructuralPage ? 'page' : 'template'}
sourceId={isStructuralPage ? pageData.id : pageData.cpt}
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
styles={section.styles}
elementStyles={section.elementStyles}
{...flattenSectionProps(section.props || {})}
/>
</div>
)}
</div>
);
})}

View File

@@ -1,11 +1,13 @@
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface BentoItem {
label: string;
image?: string;
url?: string;
backgroundColor?: string;
size?: 'small' | 'medium' | 'large' | 'tall';
}
@@ -57,7 +59,6 @@ export function BentoCategoryGrid({
styles,
elementStyles,
}: BentoCategoryGridProps) {
const sectionBg = getSectionBackground(styles);
// Keep initial demo layout stable: merge configured items over demo items by index.
// This prevents the preview grid from "collapsing" when the first item is added.
const displayItems: BentoItem[] = (() => {
@@ -69,17 +70,49 @@ export function BentoCategoryGrid({
});
})();
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const es = elementStyles?.[elementName] || {};
return {
classNames: cn(
es.fontSize,
es.fontWeight,
{
'font-sans': es.fontFamily === 'secondary',
'font-serif': es.fontFamily === 'primary',
}
),
style: {
color: es.color,
textAlign: es.textAlign,
backgroundColor: es.backgroundColor,
borderColor: es.borderColor,
borderWidth: es.borderWidth,
borderRadius: es.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const sectionBg = getSectionBackground(styles);
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
return (
<section
id={id}
className="wn-section wn-bento-grid py-12 md:py-16"
style={sectionBg.style}
className={cn("wn-section wn-bento-grid relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
>
<div className="container mx-auto px-4 max-w-7xl">
<div className="w-full mx-auto px-4 relative z-10">
{title && (
<h2
className="text-3xl md:text-4xl font-bold mb-8"
style={{ color: elementStyles?.title?.color }}
className={cn(
"mb-8",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
@@ -105,6 +138,13 @@ export function BentoCategoryGrid({
alt={item.label}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : item.backgroundColor ? (
<div
className="absolute inset-0"
style={{
background: `linear-gradient(to bottom right, ${item.backgroundColor}, color-mix(in srgb, ${item.backgroundColor}, black 35%))`
}}
/>
) : (
<div className={cn('absolute inset-0 bg-gradient-to-br', gradientClass)} />
)}

View File

@@ -1,5 +1,6 @@
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface CTABannerSectionProps {
id: string;
@@ -22,7 +23,9 @@ export function CTABannerSection({
button_url,
elementStyles,
styles,
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
isEditor,
}: CTABannerSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
@@ -65,7 +68,7 @@ export function CTABannerSection({
{title && (
<h2
className={cn(
"wn-cta__title mb-6",
"wn-cta__title mb-6 w-full",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
@@ -78,93 +81,66 @@ export function CTABannerSection({
{text && (
<p className={cn(
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
'wn-cta-banner__text mb-8 max-w-2xl mx-auto w-full',
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
styles?.contentWidth !== 'boxed' && {
'text-white/90': colorScheme === 'primary',
'text-gray-600': colorScheme === 'muted',
'text-gray-700': colorScheme === 'default',
},
styles?.contentWidth === 'boxed' && 'text-gray-600',
textStyle.classNames
)}
style={textStyle.style}
>
{text}
{text || "Description text missing"}
</p>
)}
{button_text && button_url && (
<a
href={button_url}
className={cn(
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
!btnStyle.style?.backgroundColor && (styles?.contentWidth === 'boxed'
? 'bg-primary'
: {
'bg-white': colorScheme === 'primary',
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
}),
!btnStyle.style?.color && (styles?.contentWidth === 'boxed'
? 'text-primary-foreground'
: {
'text-primary': colorScheme === 'primary',
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
}),
btnStyle.classNames
)}
style={btnStyle.style}
>
{button_text}
</a>
<div className="w-full mt-4" style={{ textAlign: (btnStyle.style.textAlign as React.CSSProperties['textAlign']) || 'center' }}>
<a
href={button_url}
className={cn(
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
!btnStyle.style?.backgroundColor && (styles?.contentWidth === 'boxed'
? 'bg-primary'
: {
'bg-white': colorScheme === 'primary',
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
}),
!btnStyle.style?.color && (styles?.contentWidth === 'boxed'
? 'text-primary-foreground'
: {
'text-primary': colorScheme === 'primary',
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
}),
btnStyle.classNames
)}
style={{ ...btnStyle.style, textAlign: undefined }}
>
{button_text}
</a>
</div>
)}
</>
);
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
const isBoxed = styles?.contentWidth === 'boxed';
return (
<section
id={id}
className={cn(
'wn-section wn-cta-banner',
'wn-section wn-cta-banner relative w-full flex flex-col items-center justify-center',
`wn-cta-banner--${layout}`,
`wn-scheme--${colorScheme}`,
heightClasses,
{
'bg-primary text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
'bg-secondary text-secondary-foreground': colorScheme === 'secondary' && !hasCustomBackground,
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
}
heightClasses // Might not be needed if handled by outer, but safe to keep
)}
style={getBackgroundStyle()}
>
{styles?.contentWidth === 'boxed' ? (
<div className="container mx-auto px-4 max-w-5xl">
<div className="bg-white text-gray-900 rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10 text-center">
{innerContent}
</div>
</div>
) : (
<div className={cn(
"mx-auto px-4 text-center",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
)}>
{innerContent}
</div>
)}
<div className="mx-auto px-4 text-center relative z-10 w-full max-w-5xl">
{innerContent}
</div>
</section>
);
}

View File

@@ -1,38 +1,43 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
import { api } from '@/lib/api/client';
interface ContactFormField {
name: string;
label: string;
type: string;
required?: boolean;
}
interface ContactFormSectionProps {
id: string;
sourceType?: string;
sourceId?: string;
layout?: string;
colorScheme?: string;
title?: string;
webhook_url?: string;
redirect_url?: string;
fields?: string[];
fields?: ContactFormField[];
elementStyles?: Record<string, any>;
}
export function ContactFormSection({
id,
sourceType,
sourceId,
layout = 'default',
colorScheme = 'default',
title,
webhook_url,
redirect_url,
fields = ['name', 'email', 'message'],
fields,
elementStyles,
styles,
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-screen flex flex-col justify-center',
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
isEditor,
}: ContactFormSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
const [formData, setFormData] = useState<Record<string, string>>({});
// Helper to get text styles (including font family)
@@ -63,23 +68,82 @@ export function ContactFormSection({
const fieldsStyle = getTextStyles('fields');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const defaultFields: ContactFormField[] = [
{ name: 'name', label: 'Your Name', type: 'text', required: true },
{ name: 'email', label: 'Your Email', type: 'email', required: true },
{ name: 'message', label: 'Your Message', type: 'textarea', required: true },
];
const activeFields = Array.isArray(fields) && fields.length > 0 ? fields : defaultFields;
const validateField = (name: string, value: string, field: ContactFormField) => {
if (field.required && !value?.trim()) {
return `${field.label} is required`;
}
if (field.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Please enter a valid email address';
}
return '';
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error when user types
if (fieldErrors[name]) {
setFieldErrors(prev => ({ ...prev, [name]: '' }));
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
const field = activeFields.find(f => f.name === name);
if (field) {
const errorMsg = validateField(name, value, field);
setFieldErrors(prev => ({ ...prev, [name]: errorMsg }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const newErrors: Record<string, string> = {};
let isValid = true;
activeFields.forEach(field => {
const errorMsg = validateField(field.name, formData[field.name] || '', field);
if (errorMsg) {
newErrors[field.name] = errorMsg;
isValid = false;
}
});
if (!isValid) {
setFieldErrors(newErrors);
// Mark all fields with errors as touched
const allTouched = Object.keys(newErrors).reduce((acc, key) => ({...acc, [key]: true}), {});
setTouched(prev => ({ ...prev, ...allTouched }));
return;
}
setSubmitting(true);
setError(null);
try {
// Submit to webhook if provided
if (webhook_url) {
await fetch(webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
// Submit to webhook proxy if configured
if (webhook_url && !isEditor) {
await api.post('/pages/submit-section-form', {
source_type: sourceType,
source_id: sourceId,
section_id: id,
form_data: formData,
});
}
@@ -97,40 +161,19 @@ export function ContactFormSection({
} finally {
setSubmitting(false);
}
}; const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
const sectionBg = getSectionBackground(styles);
return (
<section
id={id}
className={cn(
'wn-section wn-contact-form',
`wn-scheme--${colorScheme}`,
heightClasses,
{
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
}
'wn-section wn-contact-form relative overflow-hidden w-full',
`wn-scheme--${colorScheme}`
)}
style={getBackgroundStyle()}
>
<div className={cn(
"mx-auto px-4",
styles?.contentWidth === 'full' ? 'w-full'
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
: 'container'
)}>
<div className="mx-auto px-4 relative z-10 w-full">
<div className={cn(
'max-w-xl mx-auto',
{
@@ -139,7 +182,7 @@ export function ContactFormSection({
)}>
{title && (
<h2 className={cn(
"wn-contact__title text-center mb-12",
"wn-contact__title text-center mb-12 w-full",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
@@ -150,55 +193,62 @@ export function ContactFormSection({
</h2>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{fields.map((field) => {
const fieldLabel = field.charAt(0).toUpperCase() + field.slice(1).replace('_', ' ');
const isTextarea = field === 'message' || field === 'content';
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
{activeFields.map((field, idx) => {
const isTextarea = field.type === 'textarea';
const fieldError = fieldErrors[field.name];
const isTouched = touched[field.name];
const showError = isTouched && fieldError;
return (
<div key={field} className="wn-contact-form__field">
<div key={field.name || idx} className="wn-contact-form__field">
<label className="block text-sm font-medium text-gray-700 mb-2">
{fieldLabel}
{field.label} {field.required && <span className="text-red-500">*</span>}
</label>
{isTextarea ? (
<textarea
name={field}
value={formData[field] || ''}
name={field.name}
value={formData[field.name] || ''}
onChange={handleChange}
onBlur={handleBlur}
rows={5}
className={cn(
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
"w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary",
showError ? "border-red-500 focus:border-red-500 focus:ring-red-200" : "border-gray-200",
fieldsStyle.classNames
)}
style={{
backgroundColor: fieldsStyle.style?.backgroundColor,
color: fieldsStyle.style?.color,
borderColor: fieldsStyle.style?.borderColor,
borderColor: showError ? undefined : fieldsStyle.style?.borderColor,
borderRadius: fieldsStyle.style?.borderRadius,
}}
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
required
placeholder={`Enter ${field.label.toLowerCase()}`}
/>
) : (
<input
type={field === 'email' ? 'email' : 'text'}
name={field}
value={formData[field] || ''}
type={field.type || 'text'}
name={field.name}
value={formData[field.name] || ''}
onChange={handleChange}
onBlur={handleBlur}
className={cn(
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
"w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary",
showError ? "border-red-500 focus:border-red-500 focus:ring-red-200" : "border-gray-200",
fieldsStyle.classNames
)}
style={{
backgroundColor: fieldsStyle.style?.backgroundColor,
color: fieldsStyle.style?.color,
borderColor: fieldsStyle.style?.borderColor,
borderColor: showError ? undefined : fieldsStyle.style?.borderColor,
borderRadius: fieldsStyle.style?.borderRadius,
}}
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
required
placeholder={`Enter ${field.label.toLowerCase()}`}
/>
)}
{showError && (
<p className="mt-1 text-sm text-red-500">{fieldError}</p>
)}
</div>
);
})}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface ContentSectionProps {
id?: string;
@@ -47,6 +48,14 @@ const fontSizeToCSS = (className?: string) => {
}
};
const fontFamilyToCSS = (fontFamily?: string) => {
switch (fontFamily) {
case 'primary': return "'Playfair Display', Georgia, serif";
case 'secondary': return "'Inter', system-ui, sans-serif";
default: return undefined;
}
};
const fontWeightToCSS = (className?: string) => {
switch (className) {
case 'font-thin': return '100';
@@ -73,10 +82,10 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
const headingRules = [
hs.color && `color: ${hs.color} !important;`,
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
hs.fontFamily && `font-family: ${fontFamilyToCSS(hs.fontFamily)} !important;`,
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px;`,
].filter(Boolean).join(' ');
if (headingRules) {
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
@@ -90,7 +99,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
ts.color && `color: ${ts.color} !important;`,
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
ts.fontFamily && `font-family: ${fontFamilyToCSS(ts.fontFamily)} !important;`,
].filter(Boolean).join(' ');
if (textRules) {
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
@@ -146,6 +155,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
if (is) {
const imgRules = [
is.objectFit && `object-fit: ${is.objectFit} !important;`,
is.objectPosition && `object-position: ${is.objectPosition} !important;`,
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
is.width && `width: ${is.width} !important;`,
is.height && `height: ${is.height} !important;`,
@@ -158,24 +168,15 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
return styles.join('\n');
};
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl }: ContentSectionProps & { outerPadding?: boolean }) {
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl, isEditor }: ContentSectionProps & { outerPadding?: boolean; isEditor?: boolean }) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
// Default to 'default' width if not specified
const _layout = section.layoutVariant || 'default';
const heightPreset = section.styles?.heightPreset || 'default';
const sectionBg = getSectionBackground(section.styles);
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-32',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
const content = propContent || section.props?.content?.value || section.props?.content || '';
const content = propContent !== undefined ? propContent : (section.props?.content?.value ?? '');
// Helper to get text styles
const getTextStyles = (elementName: string) => {
@@ -203,42 +204,25 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
const buttonStyle = getTextStyles('button');
const containerWidth = section.styles?.contentWidth ?? 'contained';
const cta_text = propCtaText || section.props?.cta_text?.value || section.props?.cta_text;
const cta_url = propCtaUrl || section.props?.cta_url?.value || section.props?.cta_url;
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(section.styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
const cta_text = propCtaText !== undefined ? propCtaText : (section.props?.cta_text?.value ?? '');
const cta_url = propCtaUrl !== undefined ? propCtaUrl : (section.props?.cta_url?.value ?? '');
return (
<>
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
<section
<div
id={section.id}
className={cn(
'wn-content',
heightClasses,
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
'wn-content relative w-full',
scheme.text
)}
style={getBackgroundStyle()}
>
<SharedContentLayout
text={content}
textStyle={textStyle.style}
headingStyle={headingStyle.style}
containerWidth={containerWidth as any}
cardStyle={{ backgroundColor: section.styles?.cardBackgroundColor }}
className={contentStyle.classNames}
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
buttonStyle={{
@@ -246,7 +230,7 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
style: buttonStyle.style
}}
/>
</section>
</div>
</>
);
}

View File

@@ -1,6 +1,7 @@
import { cn } from '@/lib/utils';
import * as LucideIcons from 'lucide-react';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface FeatureItem {
title?: string;
@@ -31,16 +32,8 @@ export function FeatureGridSection({
features = [],
elementStyles,
styles,
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-screen flex flex-col justify-center',
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
isEditor,
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any>, isEditor?: boolean }) {
const safeItems = Array.isArray(items) ? items : [];
const safeFeatures = Array.isArray(features) ? features : [];
const listItems = safeItems.length > 0 ? safeItems : safeFeatures;
@@ -78,43 +71,20 @@ export function FeatureGridSection({
const headingStyle = getTextStyles('heading');
const featureItemStyle = getTextStyles('feature_item');
const linkStyle = getTextStyles('link');
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<section
<div
id={id}
className={cn(
'wn-section wn-feature-grid',
'wn-section wn-feature-grid relative w-full',
`wn-feature-grid--${layout}`,
`wn-scheme--${colorScheme}`,
heightClasses,
{
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
'text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
}
`wn-scheme--${colorScheme}`
)}
style={getBackgroundStyle()}
>
<div className={cn(
"mx-auto px-4",
styles?.contentWidth === 'full' ? 'w-full'
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
: 'container'
)}>
<div className="mx-auto px-4 relative z-10 w-full">
{heading && (
<h2
className={cn(
@@ -167,16 +137,22 @@ export function FeatureGridSection({
<p className="text-xs text-gray-400 mb-2 uppercase tracking-wider">{item.date}</p>
)}
{item.title && (
<h3 className="font-semibold text-gray-900 text-base leading-snug mb-2 group-hover:text-primary transition-colors line-clamp-2">
<h3 className={cn("font-semibold text-gray-900 leading-snug mb-2 group-hover:text-primary transition-colors line-clamp-2 w-full",
!elementStyles?.feature_item?.fontSize && "text-base",
featureItemStyle.classNames
)} style={featureItemStyle.style}>
{item.title}
</h3>
)}
{(item.excerpt || item.description) && (
<p className="text-sm text-gray-500 line-clamp-3 mb-4">
<p className={cn("text-gray-500 line-clamp-3 mb-4 w-full",
!elementStyles?.feature_item?.fontSize && "text-sm",
featureItemStyle.classNames
)} style={featureItemStyle.style}>
{item.excerpt || item.description}
</p>
)}
<span className="inline-flex items-center gap-1 text-sm font-medium text-primary">
<span className={cn("inline-flex items-center gap-1 text-sm font-medium text-primary", linkStyle.classNames)} style={linkStyle.style}>
Read more
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
@@ -246,6 +222,6 @@ export function FeatureGridSection({
<p className="text-center text-gray-400 text-sm py-8">No related articles found.</p>
)}
</div>
</section>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface HeroSectionProps {
id: string;
@@ -16,6 +17,7 @@ interface HeroSectionProps {
export function HeroSection({
id,
layout = 'default',
colorScheme,
title,
subtitle,
image,
@@ -23,16 +25,8 @@ export function HeroSection({
cta_url,
elementStyles,
styles,
}: HeroSectionProps & { styles?: Record<string, any> }) {
const heightMap: Record<string, string> = {
'default': 'py-16 md:py-28',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-screen flex flex-col justify-center',
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-16 md:py-28');
isEditor,
}: HeroSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
const isCentered = layout === 'centered' || layout === 'default';
@@ -83,22 +77,27 @@ export function HeroSection({
return undefined;
}; */
const colorSchemeClasses = {
primary: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
muted: 'bg-muted text-muted-foreground',
dark: 'bg-slate-900 text-white',
}[colorScheme || ''] || '';
const isBoxed = styles?.contentWidth === 'boxed';
return (
<section
id={id}
className={cn(
'wn-section wn-hero',
`wn-hero--${layout}`,
'relative overflow-hidden',
heightClasses,
'relative overflow-hidden w-full',
!isBoxed && !sectionBg.style?.backgroundColor && !sectionBg.style?.backgroundImage && colorSchemeClasses
)}
style={sectionBg.style}
>
<div className={cn(
'mx-auto px-4 z-10 relative flex w-full',
styles?.contentWidth === 'full' ? 'w-full'
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
: 'container max-w-7xl',
'mx-auto z-10 relative flex w-full',
{
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
'text-center': isCentered,
@@ -106,22 +105,31 @@ export function HeroSection({
)}>
{/* Image - Left */}
{image && isImageLeft && (
<div className="w-full md:w-1/2">
<div className={cn("w-full md:w-1/2 flex flex-col", {
'items-start': imageStyle.alignment === 'left',
'items-center': imageStyle.alignment === 'center',
'items-end': imageStyle.alignment === 'right',
})}>
<div
className="rounded-lg shadow-xl overflow-hidden"
className={cn("shadow-xl overflow-hidden", !imageStyle.borderRadius && "rounded-lg")}
style={{
backgroundColor: imageStyle.backgroundColor,
width: imageStyle.width || 'auto',
maxWidth: '100%'
maxWidth: '100%',
borderRadius: imageStyle.borderRadius,
borderColor: imageStyle.borderColor,
borderWidth: imageStyle.borderWidth,
borderStyle: imageStyle.borderWidth ? 'solid' : undefined,
}}
>
<img
src={image}
alt={title || 'Hero image'}
className="w-full h-auto block"
className="w-full h-full object-cover"
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
objectPosition: imageStyle.objectPosition,
height: imageStyle.height || 'auto',
}}
/>
</div>
@@ -139,7 +147,7 @@ export function HeroSection({
{title && (
<h1
className={cn(
"wn-hero__title mb-6 leading-tight",
"wn-hero__title mb-6 leading-tight w-full",
!elementStyles?.title?.fontSize && "text-4xl md:text-5xl lg:text-6xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
@@ -153,7 +161,7 @@ export function HeroSection({
{subtitle && (
<p
className={cn(
"wn-hero__subtitle text-opacity-80 mb-8",
"wn-hero__subtitle text-opacity-80 mb-8 w-full",
!elementStyles?.subtitle?.fontSize && "text-lg md:text-xl",
subtitleStyle.classNames
)}
@@ -166,10 +174,24 @@ export function HeroSection({
{/* Centered Image */}
<div
className={cn(
"mt-12 mx-auto rounded-lg shadow-xl overflow-hidden",
imageStyle.width ? "" : "max-w-4xl"
"mt-12 mx-auto shadow-xl overflow-hidden flex flex-col",
!imageStyle.borderRadius && "rounded-lg",
imageStyle.width ? "" : "max-w-4xl",
{
'mr-auto mx-0': imageStyle.alignment === 'left',
'ml-auto mx-0': imageStyle.alignment === 'right',
'mx-auto': imageStyle.alignment === 'center' || !imageStyle.alignment,
}
)}
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }}
style={{
backgroundColor: imageStyle.backgroundColor,
width: imageStyle.width || 'auto',
maxWidth: '100%',
borderRadius: imageStyle.borderRadius,
borderColor: imageStyle.borderColor,
borderWidth: imageStyle.borderWidth,
borderStyle: imageStyle.borderWidth ? 'solid' : undefined,
}}
>
{image && isCentered && (
<img
@@ -177,12 +199,13 @@ export function HeroSection({
alt={title || 'Hero image'}
className={cn(
"w-full rounded-[inherit]",
!imageStyle.height && "h-auto",
!imageStyle.objectFit && "object-cover"
!imageStyle.objectFit && "object-cover",
"h-full"
)}
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
objectPosition: imageStyle.objectPosition,
height: imageStyle.height || 'auto',
maxWidth: '100%',
}}
/>
@@ -190,24 +213,33 @@ export function HeroSection({
</div>
{cta_text && cta_url && (
<a
href={cta_url}
className={cn(
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors mt-8",
!ctaStyle.style?.backgroundColor && "bg-primary",
!ctaStyle.style?.color && "text-primary-foreground",
ctaStyle.classNames
)}
style={ctaStyle.style}
>
{cta_text}
</a>
<div className="w-full mt-8" style={{ textAlign: ctaStyle.style?.textAlign || (isCentered ? 'center' : 'left') as React.CSSProperties['textAlign'] }}>
<a
href={cta_url}
className={cn(
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors",
!ctaStyle.style?.backgroundColor && "bg-primary",
!ctaStyle.style?.color && "text-primary-foreground",
ctaStyle.classNames
)}
style={{
...ctaStyle.style,
textAlign: undefined
}}
>
{cta_text}
</a>
</div>
)}
</div>
{/* Image - Right */}
{image && isImageRight && (
<div className="w-full md:w-1/2">
<div className={cn("w-full md:w-1/2 flex flex-col", {
'items-start': imageStyle.alignment === 'left',
'items-center': imageStyle.alignment === 'center',
'items-end': imageStyle.alignment === 'right',
})}>
<div
className="rounded-lg shadow-xl overflow-hidden"
style={{
@@ -219,10 +251,11 @@ export function HeroSection({
<img
src={image}
alt={title || 'Hero image'}
className="w-full h-auto block"
className="w-full h-full object-cover"
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
objectPosition: imageStyle.objectPosition,
height: imageStyle.height || 'auto',
}}
/>
</div>

View File

@@ -1,6 +1,7 @@
import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface ImageTextSectionProps {
id: string;
@@ -23,7 +24,8 @@ export function ImageTextSection({
cta_url,
elementStyles,
styles,
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string }) {
isEditor,
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string, isEditor?: boolean }) {
const isImageRight = layout === 'image-right' || layout === 'right';
// Helper to get text styles (including font family)
@@ -55,44 +57,15 @@ export function ImageTextSection({
const imageStyle = elementStyles?.['image'] || {};
// Height preset support
const heightPreset = styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-24',
'small': 'py-8 md:py-16',
'medium': 'py-16 md:py-32',
'large': 'py-24 md:py-48',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<section
<div
id={id}
className={cn(
'wn-section wn-image-text',
`wn-scheme--${colorScheme}`,
!styles?.paddingTop && !styles?.paddingBottom && heightClasses,
{
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
}
'wn-section wn-image-text relative w-full',
`wn-scheme--${colorScheme}`
)}
style={getBackgroundStyle()}
>
<SharedContentLayout
title={title}
@@ -104,16 +77,14 @@ export function ImageTextSection({
titleClassName={titleStyle.classNames}
textStyle={textStyle.style}
textClassName={textStyle.classNames}
imageStyle={{
backgroundColor: imageStyle.backgroundColor,
objectFit: imageStyle.objectFit,
}}
imageStyle={imageStyle}
cardStyle={{ backgroundColor: styles?.cardBackgroundColor }}
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
buttonStyle={{
classNames: buttonStyle.classNames,
style: buttonStyle.style
}}
/>
</section>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface MarqueeBannerProps {
id: string;
@@ -8,6 +9,7 @@ interface MarqueeBannerProps {
speed?: number; // seconds for one full cycle
separator?: string;
styles?: Record<string, any>;
elementStyles?: Record<string, any>;
}
export function MarqueeBanner({
@@ -16,20 +18,46 @@ export function MarqueeBanner({
speed = 30,
separator = '✦',
styles,
elementStyles,
}: MarqueeBannerProps) {
const sectionBg = getSectionBackground(styles);
const items = text.split(separator).map(t => t.trim()).filter(Boolean);
const getTextStyles = (elementName: string) => {
const es = elementStyles?.[elementName] || {};
return {
classNames: cn(
es.fontSize,
es.fontWeight,
{
'font-sans': es.fontFamily === 'secondary',
'font-serif': es.fontFamily === 'primary',
}
),
style: {
color: es.color,
textAlign: es.textAlign as any,
}
};
};
const textStyle = getTextStyles('text');
// If the parent didn't set a custom background, we fallback to primary for the marquee.
const hasCustomBg = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
return (
<section
id={id}
className="wn-section wn-marquee overflow-hidden py-3"
className={cn("wn-section wn-marquee relative overflow-hidden w-full", hasCustomPadding ? "" : "py-3")}
style={{
backgroundColor: sectionBg.style?.backgroundColor || 'var(--wn-primary, #1a1a1a)',
color: sectionBg.style?.color || '#fff',
backgroundColor: !hasCustomBg ? 'var(--wn-primary, #1a1a1a)' : undefined,
color: !hasCustomBg ? '#fff' : 'inherit',
}}
>
<div className="flex whitespace-nowrap">
<div className="flex whitespace-nowrap relative z-10">
{/* Duplicate twice for seamless infinite scroll */}
{[0, 1].map((i) => (
<div
@@ -39,9 +67,13 @@ export function MarqueeBanner({
aria-hidden={i === 1}
>
{items.map((item, idx) => (
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
<span
key={idx}
className={cn("flex items-center gap-8 text-sm font-medium tracking-wide uppercase", textStyle.classNames)}
style={textStyle.style}
>
{item}
{idx < items.length - 1 && <span className="opacity-50 text-xs"></span>}
{idx < items.length - 1 && <span className="opacity-50 text-xs">{separator}</span>}
</span>
))}
</div>

View File

@@ -6,6 +6,7 @@ import { apiClient } from '@/lib/api/client';
import { ProductCard } from '@/components/ProductCard';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
import type { ProductsResponse } from '@/types/product';
interface ProductCarouselProps {
@@ -39,7 +40,6 @@ export function ProductCarousel({
elementStyles,
}: ProductCarouselProps) {
const trackRef = useRef<HTMLDivElement>(null);
const sectionBg = getSectionBackground(styles);
// Build query params
const queryParams = new URLSearchParams({ per_page: String(limit) });
@@ -68,29 +68,62 @@ export function ProductCarousel({
trackRef.current.scrollBy({ left: direction === 'left' ? -cardWidth * 2 : cardWidth * 2, behavior: 'smooth' });
};
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const elementStyle = elementStyles?.[elementName] || {};
return {
classNames: cn(
elementStyle.fontSize,
elementStyle.fontWeight,
{
'font-sans': elementStyle.fontFamily === 'secondary',
'font-serif': elementStyle.fontFamily === 'primary',
}
),
style: {
color: elementStyle.color,
textAlign: elementStyle.textAlign,
backgroundColor: elementStyle.backgroundColor,
borderColor: elementStyle.borderColor,
borderWidth: elementStyle.borderWidth,
borderRadius: elementStyle.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const subtitleStyle = getTextStyles('subtitle');
const linkStyle = getTextStyles('link');
const sectionBg = getSectionBackground(styles);
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
return (
<section id={id} className="wn-section wn-product-carousel py-12 md:py-16" style={sectionBg.style}>
<div className="container mx-auto px-4 max-w-7xl">
<section
id={id}
className={cn("wn-section wn-product-carousel relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
>
<div className="w-full mx-auto px-4 relative z-10">
{/* Header */}
<div className="flex items-end justify-between mb-8">
<div>
{title && (
<h2
className="text-3xl md:text-4xl font-bold"
style={{ color: elementStyles?.title?.color }}
className={cn("text-3xl font-bold", titleStyle.classNames)}
style={titleStyle.style}
>
{title}
</h2>
)}
{subtitle && (
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
<p className={cn("text-muted-foreground mt-2", subtitleStyle.classNames)} style={subtitleStyle.style}>
{subtitle}
</p>
)}
</div>
<div className="flex items-center gap-3">
{cta_text && cta_url && (
<Link to={cta_url} className="text-sm font-semibold hover:underline mr-4 whitespace-nowrap">
{cta_text && (
<Link to={cta_url || '#'} className={cn("text-sm font-semibold hover:underline mr-4 whitespace-nowrap", linkStyle.classNames)} style={linkStyle.style}>
{cta_text}
</Link>
)}

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { X, ShoppingCart, Eye } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
import { formatPrice } from '@/lib/currency';
import { useCartStore } from '@/lib/cart/store';
import { apiClient } from '@/lib/api/client';
@@ -47,13 +48,38 @@ export function ShoppableImage({
styles,
elementStyles,
}: ShoppableImageProps) {
const sectionBg = getSectionBackground(styles);
const [activeHotspot, setActiveHotspot] = useState<number | null>(null);
const { addItem, openCart } = useCartStore();
const displayHotspots = (hotspots && hotspots.length > 0) ? hotspots : DEMO_HOTSPOTS;
const hasImage = !!image;
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const es = elementStyles?.[elementName] || {};
return {
classNames: cn(
es.fontSize,
es.fontWeight,
{
'font-sans': es.fontFamily === 'secondary',
'font-serif': es.fontFamily === 'primary',
}
),
style: {
color: es.color,
textAlign: es.textAlign as any,
backgroundColor: es.backgroundColor,
borderColor: es.borderColor,
borderWidth: es.borderWidth,
borderRadius: es.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const subtitleStyle = getTextStyles('subtitle');
const handleAddToCart = async (hotspot: Hotspot, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -81,18 +107,38 @@ export function ShoppableImage({
}
};
const sectionBg = getSectionBackground(styles);
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
return (
<section id={id} className="wn-section wn-shoppable-image py-12 md:py-16" style={sectionBg.style}>
<div className="container mx-auto px-4 max-w-7xl">
<section
id={id}
className={cn("wn-section wn-shoppable-image relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
>
<div className="w-full mx-auto px-4 relative z-10">
{(title || subtitle) && (
<div className="mb-8 text-center">
{title && (
<h2 className="text-3xl md:text-4xl font-bold" style={{ color: elementStyles?.title?.color }}>
<h2
className={cn(
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
)}
{subtitle && (
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
<p
className={cn(
"mt-2",
!elementStyles?.subtitle?.fontSize && "text-muted-foreground",
subtitleStyle.classNames
)}
style={subtitleStyle.style}
>
{subtitle}
</p>
)}
@@ -116,12 +162,14 @@ export function ShoppableImage({
{/* Hotspot pins */}
{displayHotspots.map((hotspot, idx) => {
const isActive = activeHotspot === idx;
const xVal = parseFloat(String(hotspot.x ?? 0).replace('%', '')) || 0;
const yVal = parseFloat(String(hotspot.y ?? 0).replace('%', '')) || 0;
return (
<div
key={idx}
className="absolute"
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
style={{ left: `${xVal}%`, top: `${yVal}%`, transform: 'translate(-50%, -50%)' }}
>
{/* Pulsing pin */}
<button
@@ -143,8 +191,8 @@ export function ShoppableImage({
<div
className={cn(
'absolute z-20 w-56 bg-white rounded-xl shadow-2xl border p-3',
hotspot.x > 60 ? 'right-full mr-3' : 'left-full ml-3',
hotspot.y > 60 ? 'bottom-0' : 'top-0',
xVal > 60 ? 'right-full mr-3' : 'left-full ml-3',
yVal > 60 ? 'bottom-0' : 'top-0',
)}
>
{/* Close */}

View File

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

View File

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

View File

@@ -2,6 +2,11 @@
module.exports = {
darkMode: ["class"],
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
safelist: [
// Dynamic typography classes selected via Inspector Panel
'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl', 'text-6xl',
'font-light', 'font-normal', 'font-medium', 'font-semibold', 'font-bold', 'font-extrabold'
],
theme: {
container: { center: true, padding: "1rem" },
extend: {

View File

@@ -538,6 +538,9 @@ class CheckoutController
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
}
// Auto-save checkout addresses to user account
$this->auto_save_checkout_addresses($order, $payload);
// Clear WooCommerce cart after successful order placement
// This ensures the cart page won't re-populate from server session
if (function_exists('WC') && WC()->cart) {
@@ -1141,4 +1144,105 @@ class CheckoutController
}
return $results;
}
/**
* Auto-save checkout addresses to the user's address book if they are new.
*/
private function auto_save_checkout_addresses(\WC_Order $order, array $payload): void
{
$user_id = $order->get_customer_id();
if (!$user_id) {
return;
}
$addresses = get_user_meta($user_id, 'woonoow_addresses', true);
if (!is_array($addresses)) {
$addresses = [];
}
// Helper to check if address matches existing
$is_duplicate = function ($new_addr, $type) use ($addresses) {
foreach ($addresses as $addr) {
if ($addr['type'] !== $type && $addr['type'] !== 'both') {
continue;
}
// Compare essential fields
$match = true;
$check_fields = ['first_name', 'last_name', 'address_1', 'city', 'country'];
foreach ($check_fields as $f) {
$v1 = trim(strtolower((string)($addr[$f] ?? '')));
$v2 = trim(strtolower((string)($new_addr[$f] ?? '')));
if ($v1 !== $v2) {
$match = false;
break;
}
}
if ($match) return true;
}
return false;
};
// Helper to build address array
$build_address = function ($type, $data, $custom_fields, $addresses) {
$new_id = empty($addresses) ? 1 : max(array_column($addresses, 'id')) + 1;
$addr = [
'id' => $new_id,
'label' => ucfirst($type) . ' ' . $new_id,
'type' => $type,
'is_default' => empty($addresses), // default if it's the first one
];
$standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
foreach ($standard_fields as $f) {
if (isset($data[$f])) {
$addr[$f] = sanitize_text_field($data[$f]);
}
}
// Add custom fields matching prefix
if (is_array($custom_fields)) {
foreach ($custom_fields as $k => $v) {
if (strpos($k, $type . '_') === 0) {
$addr[$k] = sanitize_text_field($v);
} elseif (!isset($addr[$type . '_' . $k]) && !isset($addr[$k])) {
// Some custom fields might not have the prefix if they apply to both
// Or they are sent without prefix by frontend in payload[type]
}
}
}
// Also, payload[type] can contain custom fields directly because frontend sends them without prefix!
foreach ($data as $k => $v) {
if (!in_array($k, $standard_fields) && !in_array($k, ['ship_to_different'])) {
$addr[$type . '_' . $k] = sanitize_text_field($v);
}
}
return $addr;
};
$changed = false;
// Check billing
if (!empty($payload['billing'])) {
if (!$is_duplicate($payload['billing'], 'billing')) {
$billing_addr = $build_address('billing', $payload['billing'], $payload['custom_fields'] ?? [], $addresses);
$addresses[] = $billing_addr;
$changed = true;
}
}
// Check shipping
$ship_to_different = !empty($payload['shipping']['ship_to_different']);
if ($ship_to_different && !empty($payload['shipping'])) {
if (!$is_duplicate($payload['shipping'], 'shipping')) {
$shipping_addr = $build_address('shipping', $payload['shipping'], $payload['custom_fields'] ?? [], $addresses);
$addresses[] = $shipping_addr;
$changed = true;
}
}
if ($changed) {
update_user_meta($user_id, 'woonoow_addresses', array_values($addresses));
}
}
}

View File

@@ -48,6 +48,33 @@ class AffiliateCustomerController
'callback' => [$this, 'update_payment_details'],
'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()
@@ -90,6 +117,12 @@ class AffiliateCustomerController
$affiliate['total_earnings'] = $earnings->total_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);
}
@@ -318,4 +351,125 @@ class AffiliateCustomerController
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]);
}
}

View File

@@ -38,6 +38,13 @@ class PagesController
'permission_callback' => '__return_true',
]);
// Proxy section form submission (e.g. Contact Form webhooks) (Must be before generic slug route)
register_rest_route($namespace, '/pages/submit-section-form', [
'methods' => 'POST',
'callback' => [__CLASS__, 'submit_section_form'],
'permission_callback' => '__return_true', // Public endpoint
]);
// Get/Save page structure (structural pages)
register_rest_route($namespace, '/pages/(?P<slug>[a-zA-Z0-9_-]+)', [
[
@@ -446,11 +453,15 @@ class PagesController
$rendered_sections = self::resolve_sections_for_post($template['sections'], $post, $type);
}
// Get SPA settings
$settings = get_option('woonoow_appearance_settings', []);
return new WP_REST_Response([
'type' => 'content',
'cpt' => $type,
'post' => $post_data,
'seo' => $seo,
'effective_container_width' => ($settings['general']['container_width'] ?? 'boxed') ?: 'boxed',
'template' => $template ?: ['sections' => []],
'rendered' => [
'sections' => $rendered_sections,
@@ -662,6 +673,80 @@ class PagesController
], 200);
}
/**
* Proxy form submission for a specific section (e.g. Contact Form to Webhook)
*/
public static function submit_section_form(WP_REST_Request $request)
{
$body = $request->get_json_params();
$source_type = sanitize_text_field($body['source_type'] ?? 'page');
$source_id = sanitize_text_field($body['source_id'] ?? '');
$section_id = sanitize_text_field($body['section_id'] ?? '');
$form_data = $body['form_data'] ?? [];
if (empty($source_id) || empty($section_id)) {
return new WP_Error('invalid_params', 'Missing required parameters', ['status' => 400]);
}
$structure = null;
if ($source_type === 'template') {
$template = get_option("wn_template_{$source_id}", null);
if ($template) {
$structure = $template;
}
} else {
// It's a page
$page = get_post($source_id);
if ($page) {
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
}
}
if (empty($structure) || empty($structure['sections'])) {
return new WP_Error('not_found', 'Structure not found', ['status' => 404]);
}
// Find the section
$section = null;
foreach ($structure['sections'] as $s) {
if ($s['id'] === $section_id) {
$section = $s;
break;
}
}
if (!$section) {
return new WP_Error('not_found', 'Section not found', ['status' => 404]);
}
$webhook_url = $section['props']['webhook_url']['value'] ?? null;
if (empty($webhook_url)) {
return new WP_Error('invalid_config', 'Webhook URL not configured', ['status' => 400]);
}
// Send to webhook
$response = wp_remote_post($webhook_url, [
'body' => wp_json_encode($form_data),
'headers' => [
'Content-Type' => 'application/json',
],
'timeout' => 15,
'data_format' => 'body'
]);
if (is_wp_error($response)) {
return new WP_Error('webhook_failed', 'Failed to contact webhook: ' . $response->get_error_message(), ['status' => 502]);
}
$code = wp_remote_retrieve_response_code($response);
if ($code >= 400) {
return new WP_Error('webhook_error', 'Webhook returned error code: ' . $code, ['status' => 502]);
}
return new WP_REST_Response(['success' => true], 200);
}
// ========================================
// Helper Methods
// ========================================

View File

@@ -220,6 +220,7 @@ class ProductsController
$category = $request->get_param('category');
$type = $request->get_param('type');
$stock_status = $request->get_param('stock_status');
$software_enabled = $request->get_param('software_enabled');
$orderby = $request->get_param('orderby') ?: 'date';
$order = $request->get_param('order') ?: 'DESC';
@@ -266,11 +267,19 @@ class ProductsController
// Stock status filter
if ($stock_status) {
$args['meta_query'] = [
[
'key' => '_stock_status',
'value' => $stock_status,
],
$args['meta_query'] = $args['meta_query'] ?? [];
$args['meta_query'][] = [
'key' => '_stock_status',
'value' => $stock_status,
];
}
// Software enabled filter
if ($software_enabled === 'true' || $software_enabled === '1') {
$args['meta_query'] = $args['meta_query'] ?? [];
$args['meta_query'][] = [
'key' => '_woonoow_software_enabled',
'value' => 'yes',
];
}
@@ -660,6 +669,26 @@ class ProductsController
update_post_meta($product->get_id(), '_woonoow_affiliate_commission_rate', self::sanitize_number($data['affiliate_commission_rate']));
}
// Software meta
if (isset($data['software_enabled'])) {
update_post_meta($product->get_id(), '_woonoow_software_enabled', $data['software_enabled'] ? 'yes' : 'no');
}
if (isset($data['software_slug'])) {
update_post_meta($product->get_id(), '_woonoow_software_slug', sanitize_title($data['software_slug']));
}
if (isset($data['software_wp_enabled'])) {
update_post_meta($product->get_id(), '_woonoow_software_wp_enabled', $data['software_wp_enabled'] ? 'yes' : 'no');
}
if (isset($data['software_requires_wp'])) {
update_post_meta($product->get_id(), '_woonoow_software_requires_wp', sanitize_text_field($data['software_requires_wp']));
}
if (isset($data['software_tested_wp'])) {
update_post_meta($product->get_id(), '_woonoow_software_tested_wp', sanitize_text_field($data['software_tested_wp']));
}
if (isset($data['software_requires_php'])) {
update_post_meta($product->get_id(), '_woonoow_software_requires_php', sanitize_text_field($data['software_requires_php']));
}
// Allow plugins to perform additional updates (Level 1 compatibility)
do_action('woonoow/product_updated', $product, $data, $request);
@@ -819,6 +848,10 @@ class ProductsController
'permalink' => get_permalink($product->get_id()),
'date_created' => $product->get_date_created() ? $product->get_date_created()->date('Y-m-d H:i:s') : '',
'date_modified' => $product->get_date_modified() ? $product->get_date_modified()->date('Y-m-d H:i:s') : '',
'software_enabled' => get_post_meta($product->get_id(), '_woonoow_software_enabled', true) === 'yes',
'software_slug' => get_post_meta($product->get_id(), '_woonoow_software_slug', true),
'software_current_version' => get_post_meta($product->get_id(), '_woonoow_software_current_version', true),
'software_wp_enabled' => get_post_meta($product->get_id(), '_woonoow_software_wp_enabled', true) === 'yes',
];
}
@@ -877,6 +910,14 @@ class ProductsController
$data['affiliate_enabled'] = get_post_meta($product->get_id(), '_woonoow_affiliate_enabled', true) === 'yes';
$data['affiliate_commission_rate'] = get_post_meta($product->get_id(), '_woonoow_affiliate_commission_rate', true) ?: '';
// Software fields
$data['software_enabled'] = get_post_meta($product->get_id(), '_woonoow_software_enabled', true) === 'yes';
$data['software_slug'] = get_post_meta($product->get_id(), '_woonoow_software_slug', true) ?: '';
$data['software_wp_enabled'] = get_post_meta($product->get_id(), '_woonoow_software_wp_enabled', true) === 'yes';
$data['software_requires_wp'] = get_post_meta($product->get_id(), '_woonoow_software_requires_wp', true) ?: '';
$data['software_tested_wp'] = get_post_meta($product->get_id(), '_woonoow_software_tested_wp', true) ?: '';
$data['software_requires_php'] = get_post_meta($product->get_id(), '_woonoow_software_requires_php', true) ?: '';
// Images array (URLs) for frontend - featured + gallery
$images = [];
$featured_image_id = $product->get_image_id();
@@ -1078,6 +1119,10 @@ class ProductsController
'image_url' => $image_url,
'image' => $image_url, // For form compatibility
'license_duration_days' => get_post_meta($variation->get_id(), '_license_duration_days', true) ?: '',
'subscription_signup_fee' => get_post_meta($variation->get_id(), '_woonoow_subscription_signup_fee', true) ?: '',
'subscription_trial_days' => get_post_meta($variation->get_id(), '_woonoow_subscription_trial_days', true) ?: '',
'subscription_period' => get_post_meta($variation->get_id(), '_woonoow_subscription_period', true) ?: '',
'subscription_interval' => get_post_meta($variation->get_id(), '_woonoow_subscription_interval', true) ?: '',
];
}
}
@@ -1214,6 +1259,20 @@ class ProductsController
update_post_meta($saved_id, '_license_duration_days', self::sanitize_number($var_data['license_duration_days']));
}
// Save variation-level subscription fields
if (isset($var_data['subscription_signup_fee'])) {
update_post_meta($saved_id, '_woonoow_subscription_signup_fee', self::sanitize_number($var_data['subscription_signup_fee']));
}
if (isset($var_data['subscription_trial_days'])) {
update_post_meta($saved_id, '_woonoow_subscription_trial_days', absint($var_data['subscription_trial_days']));
}
if (isset($var_data['subscription_period'])) {
update_post_meta($saved_id, '_woonoow_subscription_period', sanitize_key($var_data['subscription_period']));
}
if (isset($var_data['subscription_interval'])) {
update_post_meta($saved_id, '_woonoow_subscription_interval', absint($var_data['subscription_interval']));
}
// Manually save attributes using direct database insert
if (!empty($wc_attributes)) {
global $wpdb;

View File

@@ -90,6 +90,15 @@ class SoftwareController
return current_user_can('manage_woocommerce');
},
]);
// Edit version
register_rest_route($namespace, '/software/products/(?P<product_id>\d+)/versions/(?P<version_id>\d+)', [
'methods' => 'PUT',
'callback' => [__CLASS__, 'edit_version'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
}
/**
@@ -127,7 +136,7 @@ class SoftwareController
// Log the check (optional - for analytics)
do_action('woonoow/software/update_check', $slug, $current_version, $site_url, $license_key);
$result = SoftwareManager::check_update($license_key, $slug, $current_version);
$result = SoftwareManager::check_update($license_key, $slug, $current_version, $site_url);
$status_code = isset($result['success']) && $result['success'] === false ? 400 : 200;
@@ -242,7 +251,7 @@ class SoftwareController
return [
'version' => $v['version'],
'release_date' => $v['release_date'],
'changelog' => $v['changelog'],
'changelog' => is_string($v['changelog']) && strpos(trim($v['changelog']), '{') === 0 ? json_decode($v['changelog'], true) : $v['changelog'],
'download_count' => (int) $v['download_count'],
];
}, $versions),
@@ -283,9 +292,30 @@ class SoftwareController
$params = $request->get_json_params();
$version = sanitize_text_field($params['version'] ?? '');
$changelog = wp_kses_post($params['changelog'] ?? '');
$set_current = (bool) ($params['set_current'] ?? true);
$raw_changelog = $params['changelog'] ?? [];
$changelog_data = [];
if (is_array($raw_changelog)) {
$changelog_data = [
'narrative' => wp_kses_post($raw_changelog['narrative'] ?? ''),
'points' => []
];
if (isset($raw_changelog['points']) && is_array($raw_changelog['points'])) {
foreach ($raw_changelog['points'] as $pt) {
if (isset($pt['type']) && isset($pt['text'])) {
$changelog_data['points'][] = [
'type' => sanitize_text_field($pt['type']),
'text' => sanitize_text_field($pt['text']),
];
}
}
}
} else {
$changelog_data = ['narrative' => sanitize_textarea_field($raw_changelog), 'points' => []];
}
$changelog = wp_json_encode($changelog_data);
if (empty($version)) {
return new WP_REST_Response([
'success' => false,
@@ -318,4 +348,62 @@ class SoftwareController
'message' => __('Version added successfully', 'woonoow'),
], 201);
}
/**
* Edit an existing version
*/
public static function edit_version(WP_REST_Request $request)
{
$product_id = $request->get_param('product_id');
$version_id = $request->get_param('version_id');
$version = sanitize_text_field($request->get_param('version'));
$changelog = $request->get_param('changelog');
$set_current = filter_var($request->get_param('set_current'), FILTER_VALIDATE_BOOLEAN);
// If changelog is an array/object, encode it to JSON
if (is_array($changelog) || is_object($changelog)) {
if (isset($changelog['narrative'])) {
$changelog['narrative'] = sanitize_textarea_field($changelog['narrative']);
}
if (isset($changelog['points']) && is_array($changelog['points'])) {
foreach ($changelog['points'] as &$point) {
$point['type'] = sanitize_text_field($point['type'] ?? '');
$point['text'] = sanitize_text_field($point['text'] ?? '');
}
}
$changelog = wp_json_encode($changelog);
} else {
$changelog = wp_kses_post($changelog);
}
if (empty($version)) {
return new WP_REST_Response([
'success' => false,
'error' => 'missing_version',
'message' => __('Version number is required', 'woonoow'),
], 400);
}
$product = wc_get_product($product_id);
if (!$product) {
return new WP_REST_Response([
'success' => false,
'error' => 'product_not_found',
], 404);
}
$result = SoftwareManager::update_version($version_id, $product_id, $version, $changelog, $set_current);
if (is_wp_error($result)) {
return new WP_REST_Response([
'success' => false,
'error' => $result->get_error_code(),
'message' => $result->get_error_message(),
], 400);
}
return new WP_REST_Response([
'success' => true,
'message' => __('Version updated successfully', 'woonoow'),
], 200);
}
}

View File

@@ -28,6 +28,7 @@ use WooNooW\Branding;
use WooNooW\Frontend\Assets as FrontendAssets;
use WooNooW\Frontend\Shortcodes;
use WooNooW\Frontend\TemplateOverride;
use WooNooW\Frontend\SmartRotator;
use WooNooW\Frontend\PageAppearance;
class Bootstrap {
@@ -47,6 +48,7 @@ class Bootstrap {
FrontendAssets::init();
// Note: Shortcodes removed - WC pages now redirect to SPA routes via TemplateOverride
TemplateOverride::init();
SmartRotator::init();
new PageAppearance();
// Activity Log
@@ -76,6 +78,13 @@ class Bootstrap {
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
* This is the recommended approach per WooCommerce core team

View File

@@ -69,6 +69,10 @@ class AddressController {
$addresses = array_values($addresses);
foreach ($addresses as &$address) {
$address['formatted_address'] = apply_filters('woonoow_format_address', '', $address);
}
return new WP_REST_Response($addresses, 200);
}

View File

@@ -243,6 +243,19 @@ class Assets
$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)
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
@@ -263,6 +276,9 @@ class Assets
'useBrowserRouter' => $use_browser_router,
'frontPageSlug' => $front_page_slug,
'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(),
];
@@ -460,7 +476,7 @@ class Assets
}
// Check path prefixes
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
$prefix_routes = ['/shop/', '/my-account/', '/product/', '/collection/'];
foreach ($prefix_routes as $prefix) {
if (strpos($path, $prefix) === 0) {
return true;

View File

@@ -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);
}
/**
* 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
*/

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

View File

@@ -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
add_filter('query_vars', function ($vars) {
$vars[] = 'woonoow_spa_path';
@@ -430,7 +437,7 @@ class TemplateOverride
}
// 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) {
if (strpos($path, $prefix) === 0) {
$should_serve_spa = true;
@@ -500,7 +507,7 @@ class TemplateOverride
// Check if this is a SPA route
// 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) {
if (strpos($requested_url, $route) !== false) {

View File

@@ -17,6 +17,7 @@ class AffiliateManager
private static $affiliates_table = 'woonoow_affiliates';
private static $referrals_table = 'woonoow_referrals';
private static $payouts_table = 'woonoow_affiliate_payouts';
private static $collections_table = 'woonoow_affiliate_collections';
/**
* Initialize
@@ -37,6 +38,7 @@ class AffiliateManager
$affiliates_table = $wpdb->prefix . self::$affiliates_table;
$referrals_table = $wpdb->prefix . self::$referrals_table;
$payouts_table = $wpdb->prefix . self::$payouts_table;
$collections_table = $wpdb->prefix . self::$collections_table;
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
$sql_payouts = "CREATE TABLE $payouts_table (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,

View File

@@ -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();
}
}
/**

View File

@@ -58,6 +58,12 @@ class AffiliateSettings {
'description' => __('Automatically approve new affiliate applications.', 'woonoow'),
'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' => [
'type' => 'toggle',
'label' => __('Allow Self-Referrals', 'woonoow'),

View File

@@ -102,9 +102,37 @@ class AffiliateTracker
'samesite' => 'Lax'
];
// Capture referral code
$referral_code = '';
// 1. Capture from ?ref= parameter
if (isset($_GET['ref']) && !empty($_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);
$_COOKIE[self::COOKIE_NAME] = $referral_code;
error_log('[AffiliateTracker] Set woonoow_ref cookie: ' . $referral_code . ', result=' . ($result ? 'true' : 'false'));

View File

@@ -608,7 +608,7 @@ class LicenseManager
/**
* Validate license (check if valid without activating)
*/
public static function validate($license_key)
public static function validate($license_key, $domain = null)
{
$license = self::get_license_by_key($license_key);
@@ -626,8 +626,31 @@ class LicenseManager
$subscription_status = self::get_order_subscription_status($license['order_id']);
$is_subscription_valid = $subscription_status === null || in_array($subscription_status, ['active', 'pending-cancel']);
// Check domain activation if domain is provided
$is_domain_active = true;
if ($domain) {
global $wpdb;
$table = $wpdb->prefix . self::$activations_table;
$activation = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $table WHERE license_id = %d AND domain = %s AND status = 'active' LIMIT 1",
$license['id'],
$domain
));
if (!$activation) {
$is_domain_active = false;
}
}
if ($domain && !$is_domain_active) {
return [
'valid' => false,
'error' => 'domain_not_activated',
'message' => __('License is not activated for this domain', 'woonoow'),
];
}
return [
'valid' => $license['status'] === 'active' && !$is_expired && $is_subscription_valid,
'valid' => $license['status'] === 'active' && !$is_expired && $is_subscription_valid && $is_domain_active,
'license_key' => $license['license_key'],
'status' => $license['status'],
'activation_limit' => (int) $license['activation_limit'],
@@ -639,6 +662,7 @@ class LicenseManager
'is_expired' => $is_expired,
'subscription_status' => $subscription_status,
'subscription_active' => $is_subscription_valid,
'domain_active' => $is_domain_active,
];
}

View File

@@ -127,10 +127,10 @@ class SoftwareManager
/**
* Check for updates
*/
public static function check_update($license_key, $slug, $current_version)
public static function check_update($license_key, $slug, $current_version, $site_url = null)
{
// Validate license
$license_validation = LicenseManager::validate($license_key);
$license_validation = LicenseManager::validate($license_key, $site_url);
if (!$license_validation['valid']) {
return [
@@ -255,10 +255,21 @@ class SoftwareManager
global $wpdb;
$table = $wpdb->prefix . self::$versions_table;
return $wpdb->get_results($wpdb->prepare(
$results = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table WHERE product_id = %d ORDER BY release_date DESC",
$product_id
), ARRAY_A);
if ($results) {
foreach ($results as &$row) {
$decoded_changelog = json_decode($row['changelog'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$row['changelog'] = $decoded_changelog;
}
}
}
return $results;
}
/**
@@ -308,6 +319,39 @@ class SoftwareManager
return $wpdb->insert_id;
}
/**
* Update a version
*/
public static function update_version($version_id, $product_id, $version, $changelog = '', $set_current = false)
{
global $wpdb;
$table = $wpdb->prefix . self::$versions_table;
// Reset other current versions if this one is set to current
if ($set_current) {
$wpdb->update($table, ['is_current' => 0], ['product_id' => $product_id]);
}
// Update version
$wpdb->update($table, [
'version' => $version,
'changelog' => $changelog,
'is_current' => $set_current ? 1 : 0,
], [
'id' => $version_id,
'product_id' => $product_id
]);
// Update product meta
if ($set_current) {
update_post_meta($product_id, '_woonoow_software_current_version', $version);
}
do_action('woonoow/software/version_updated', $version_id, $product_id, $version);
return true;
}
/**
* Generate secure download token
*/

View File

@@ -48,6 +48,9 @@ class SubscriptionModule
add_action('deleted_post', [__CLASS__, 'on_post_deleted']);
add_action('delete_user', [__CLASS__, 'on_user_deleted']);
// Prevent guest checkout for subscriptions
add_filter('woocommerce_add_to_cart_validation', [__CLASS__, 'validate_subscription_add_to_cart'], 10, 3);
// Modify add to cart button text for subscription products
add_filter('woocommerce_product_single_add_to_cart_text', [__CLASS__, 'subscription_add_to_cart_text'], 10, 2);
add_filter('woocommerce_product_add_to_cart_text', [__CLASS__, 'subscription_add_to_cart_text'], 10, 2);
@@ -271,6 +274,24 @@ class SubscriptionModule
$order->save();
}
/**
* Prevent guest checkout for subscriptions
*/
public static function validate_subscription_add_to_cart($passed, $product_id, $quantity) {
if (!ModuleRegistry::is_enabled('subscription')) {
return $passed;
}
if (get_post_meta($product_id, '_woonoow_subscription_enabled', true) === 'yes') {
if (!is_user_logged_in()) {
wc_add_notice(__('You must be logged in to purchase a subscription.', 'woonoow'), 'error');
return false;
}
}
return $passed;
}
/**
* Modify add to cart button text for subscription products
*/

View File

@@ -91,6 +91,11 @@ add_filter('woocommerce_checkout_fields', function ($fields) {
$fields['billing']['billing_last_name']['type'] = 'hidden';
$fields['billing']['billing_last_name']['default'] = 'ID';
$fields['billing']['billing_last_name']['required'] = false;
// Make first_name take full width since last_name is hidden
if (isset($fields['billing']['billing_first_name'])) {
$fields['billing']['billing_first_name']['class'] = ['form-row-wide'];
}
}
if (isset($fields['billing']['billing_country'])) {
$fields['billing']['billing_country']['type'] = 'hidden';
@@ -115,6 +120,11 @@ add_filter('woocommerce_checkout_fields', function ($fields) {
$fields['shipping']['shipping_last_name']['type'] = 'hidden';
$fields['shipping']['shipping_last_name']['default'] = 'ID';
$fields['shipping']['shipping_last_name']['required'] = false;
// Make first_name take full width since last_name is hidden
if (isset($fields['shipping']['shipping_first_name'])) {
$fields['shipping']['shipping_first_name']['class'] = ['form-row-wide'];
}
}
if (isset($fields['shipping']['shipping_country'])) {
$fields['shipping']['shipping_country']['type'] = 'hidden';
@@ -137,7 +147,8 @@ add_filter('woocommerce_checkout_fields', function ($fields) {
// Check if cart needs shipping
$needs_shipping = true;
if (function_exists('WC') && WC()->cart) {
// If cart is empty, we assume it's for Address Book in My Account where we want fields visible
if (function_exists('WC') && WC()->cart && !WC()->cart->is_empty()) {
$needs_shipping = WC()->cart->needs_shipping();
}
@@ -225,3 +236,79 @@ add_action('woonoow/shipping/before_calculate', function ($shipping, $items) {
// Clear shipping cache to force recalculation
WC()->session->set('shipping_for_package_0', false);
}, 10, 2);
// ============================================================
// 4. Save destination_id to Order Meta on SPA Checkout
// ============================================================
add_action('woocommerce_checkout_order_processed', function ($order_id, $payload) {
// Extract and save destination_id from shipping payload
if (!empty($payload['shipping']['destination_id'])) {
update_post_meta($order_id, '_shipping_destination_id', sanitize_text_field($payload['shipping']['destination_id']));
}
// Extract and save destination_id from billing payload
if (!empty($payload['billing']['destination_id'])) {
update_post_meta($order_id, '_billing_destination_id', sanitize_text_field($payload['billing']['destination_id']));
}
// Fallback to custom_fields array if present
if (!empty($payload['custom_fields']['shipping_destination_id'])) {
update_post_meta($order_id, '_shipping_destination_id', sanitize_text_field($payload['custom_fields']['shipping_destination_id']));
}
if (!empty($payload['custom_fields']['billing_destination_id'])) {
update_post_meta($order_id, '_billing_destination_id', sanitize_text_field($payload['custom_fields']['billing_destination_id']));
}
// Save labels too if they exist, useful for backend viewing
if (!empty($payload['custom_fields']['shipping_destination_id_label'])) {
update_post_meta($order_id, '_shipping_destination_id_label', sanitize_text_field($payload['custom_fields']['shipping_destination_id_label']));
}
if (!empty($payload['custom_fields']['billing_destination_id_label'])) {
update_post_meta($order_id, '_billing_destination_id_label', sanitize_text_field($payload['custom_fields']['billing_destination_id_label']));
}
}, 10, 2);
// ============================================================
// 5. Format address display for SPA saved addresses
// ============================================================
add_filter('woonoow_format_address', function ($formatted, $address) {
// If a snippet has already formatted it, skip
if (!empty($formatted)) {
return $formatted;
}
$type = $address['type'] ?? 'billing';
$is_billing = $type === 'billing' || $type === 'both';
// Look for destination_id_label
$label = '';
if (!empty($address['destination_id_label'])) {
$label = $address['destination_id_label'];
} elseif ($is_billing && !empty($address['billing_destination_id_label'])) {
$label = $address['billing_destination_id_label'];
} elseif (!$is_billing && !empty($address['shipping_destination_id_label'])) {
$label = $address['shipping_destination_id_label'];
}
// If we have a Rajaongkir label, construct a clean Indonesian address
if ($label) {
$parts = [];
if (!empty($address['address_1'])) {
$parts[] = $address['address_1'];
}
if (!empty($address['address_2'])) {
$parts[] = $address['address_2'];
}
// Append the Rajaongkir province/city/subdistrict string
$parts[] = $label;
if (!empty($address['postcode'])) {
$parts[] = $address['postcode'];
}
// The SPA uses whitespace-pre-wrap so newline \n works for visual separation
return implode("\n", $parts);
}
return $formatted;
}, 10, 2);