Compare commits
5 Commits
fd8eb38512
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec2049913f | ||
|
|
54a3a15f68 | ||
|
|
fb1a6c40ef | ||
|
|
21ece27b9b | ||
|
|
f8c733832e |
83
AFFILIATE_PROGRAM_ENRICHMENT.md
Normal file
83
AFFILIATE_PROGRAM_ENRICHMENT.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Affiliate Link Enrichment Brief
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Affiliate Link Enrichment is a feature set that helps affiliates choose the most suitable destination for each campaign instead of relying only on a homepage link or a single-product link. The goal is to improve click quality, reduce mismatch between content and landing page, and increase the chance that visitors find a relevant product path quickly.[cite:36][cite:46][cite:53]
|
||||||
|
|
||||||
|
This feature is designed for e-commerce platforms, affiliate plugins, and marketplace-adjacent tools that want to support better traffic routing without requiring marketplace-scale data. It sits between simple deep linking and advanced smart-link systems by giving affiliates more structured, intent-aware link options.[cite:36][cite:50][cite:58]
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Traditional affiliate links usually fall into two extremes: a generic homepage or catalog link, or a deep link to one specific product. Homepage links create distraction because visitors must browse again from the top, while single-product links can fail when the visitor's intent is adjacent to, but not exactly aligned with, the promoted item.[cite:53][cite:46]
|
||||||
|
|
||||||
|
This problem becomes more obvious in social content, where one post often attracts mixed intent. A viewer may like the category, style, or use case presented in the content, but not the exact product chosen by the affiliate, which creates unnecessary bounce risk.[cite:46][cite:48]
|
||||||
|
|
||||||
|
## Product Idea
|
||||||
|
Affiliate Link Enrichment should let affiliates generate links based on **intent shape**, not only based on URL type. Instead of asking, "Do you want a homepage link or a product link?", the product should ask, "What kind of buying intent does your content create?" and then recommend the right destination model.[cite:36][cite:46]
|
||||||
|
|
||||||
|
The core idea is to support several enriched destination types:
|
||||||
|
- Single Product Link, for highly specific review or promo content.[cite:36]
|
||||||
|
- Category Link, for broader topical discovery within one product family.[cite:53]
|
||||||
|
- Curated Collection Link, for themed sets of relevant products selected by the affiliate.[cite:21][cite:46]
|
||||||
|
- Controlled Smart Rotation, for rotating a small pool of relevant products inside the same intent group, not across unrelated products.[cite:50][cite:58]
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
The tone of this feature should be practical, conversion-oriented, and creator-friendly. It is not framed as "AI magic" or "full automation," but as a smarter way to match content intent with the right shopping destination.[cite:46][cite:53]
|
||||||
|
|
||||||
|
Affiliate Link Enrichment should be positioned as a conversion support layer for affiliates who need more flexibility than a standard deep link, but who do not have enough volume or data to run a true network-grade smart-link engine.[cite:36][cite:50][cite:55]
|
||||||
|
|
||||||
|
## User Segments
|
||||||
|
Primary users include social-media affiliates, content creators, bloggers, and store partners who promote products through short-form video, reviews, listicles, and themed recommendations. These users often need one shareable link that still preserves relevance across varied audience preferences.[cite:21][cite:31][cite:46]
|
||||||
|
|
||||||
|
Secondary users include merchants and WordPress store owners who run their own affiliate programs and want to help affiliates convert better without sending traffic into an unstructured storefront. For these users, enrichment is also a program-design tool, not only a link-generation tool.[cite:65][cite:67][cite:76]
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
| Content scenario | Visitor intent | Recommended link type | Why it fits |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Single product review | Very specific | Single Product Link | Sends visitors directly to the exact item discussed.[cite:36] |
|
||||||
|
| "Best X for Y" content | Comparative but focused | Curated Collection Link | Preserves relevance while giving the visitor choice.[cite:21][cite:46] |
|
||||||
|
| Broad topical content | Exploratory | Category Link | Supports browsing within the same intent family.[cite:53] |
|
||||||
|
| Repeat campaigns with similar products | Semi-predictable | Controlled Smart Rotation | Tests alternatives without breaking relevance.[cite:50][cite:58] |
|
||||||
|
| Link in bio or creator storefront | Mixed but branded | Collection Link / Bio Link | Gives one stable destination for multiple relevant offers.[cite:31][cite:46] |
|
||||||
|
|
||||||
|
## Feature Principles
|
||||||
|
The feature should follow five principles:
|
||||||
|
|
||||||
|
1. **Intent first**: recommend links based on the campaign's promise, not just on object type.[cite:46][cite:53]
|
||||||
|
2. **Relevance guardrails**: any rotation must stay inside a tightly defined product pool.[cite:50][cite:58]
|
||||||
|
3. **Choice without overload**: collection pages should offer enough alternatives to reduce bounce, but not so many that users feel dropped into a marketplace homepage again.[cite:46][cite:48]
|
||||||
|
4. **Trackability**: every enriched link should still support product-level or group-level attribution through sub IDs, click IDs, or similar campaign parameters.[cite:36][cite:56]
|
||||||
|
5. **Progressive sophistication**: stores should be able to start with curated collections and only later add controlled rotation or rule-based routing.[cite:50][cite:55]
|
||||||
|
|
||||||
|
## Functional Scope
|
||||||
|
A minimum viable version should include:
|
||||||
|
- Link type recommendation based on campaign goal.
|
||||||
|
- Manual creation of curated collection pages.
|
||||||
|
- Optional product grouping by theme, use case, audience, or price band.
|
||||||
|
- Link-level tracking fields such as source, campaign, content format, and creator tag.[cite:36][cite:56]
|
||||||
|
|
||||||
|
A more advanced version can include:
|
||||||
|
- Rule-based routing by device, location, traffic source, or stock status.[cite:32][cite:58]
|
||||||
|
- Controlled rotation inside a relevant product cluster.[cite:50]
|
||||||
|
- Best-pick pinning, where one featured product stays fixed while alternatives rotate below it.[cite:46]
|
||||||
|
- Collection-page templates optimized for mobile affiliate traffic.[cite:46][cite:48]
|
||||||
|
|
||||||
|
## UX Guidance
|
||||||
|
The user experience should help affiliates decide quickly which link model to use. A simple decision flow works best: "Are you promoting one exact product, one topic, or a curated set?" That framing is easier to understand than exposing technical terms such as deep link, category path, or routing logic at the start.[cite:36][cite:46]
|
||||||
|
|
||||||
|
Collection pages should behave more like decision pages than full storefronts. They need a clear headline, one featured recommendation, a limited set of relevant alternatives, lightweight filters, and strong mobile-first call-to-action placement.[cite:46][cite:48][cite:49]
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
Success should be measured by both conversion performance and creator adoption. Core metrics include click-through rate to merchant pages, product-page engagement, bounce reduction versus homepage links, conversion rate by link type, and earnings per click for each enriched-link model.[cite:36][cite:50][cite:56]
|
||||||
|
|
||||||
|
Product-level learning is also important. The system should reveal which campaign types perform best with single-product links, which benefit from curated collections, and when controlled rotation adds value instead of introducing noise.[cite:36][cite:50]
|
||||||
|
|
||||||
|
## Messaging Examples
|
||||||
|
Suggested product-language examples:
|
||||||
|
|
||||||
|
- "Match each campaign to the right destination."
|
||||||
|
- "Give visitors a better path than a generic storefront."
|
||||||
|
- "Turn one affiliate post into a relevant shopping journey."
|
||||||
|
- "Offer choice without losing intent."
|
||||||
|
- "Enrich every affiliate link with context, structure, and better conversion potential."
|
||||||
|
|
||||||
|
## Strategic Summary
|
||||||
|
Affiliate Link Enrichment is best presented as a practical bridge between basic affiliate linking and advanced traffic-routing systems. It helps smaller e-commerce programs support better affiliate outcomes by turning raw links into intent-aware destinations that are easier to click, easier to shop, and easier to optimize over time.[cite:36][cite:46][cite:50]
|
||||||
52
AFFILIATE_PROGRAM_ENRICHMENT_IMPLEMENTATION_PLAN.md
Normal file
52
AFFILIATE_PROGRAM_ENRICHMENT_IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Affiliate Link Enrichment - Implementation Plan
|
||||||
|
|
||||||
|
This document outlines the phased implementation plan for the Affiliate Link Enrichment feature, based on the `AFFILIATE_PROGRAM_ENRICHMENT.md` brief and the current state of the WooNooW project.
|
||||||
|
|
||||||
|
## 1. Current State of the Project
|
||||||
|
|
||||||
|
- **Frontend (`customer-spa/src/pages/Account/AffiliateDashboard.tsx`)**: The affiliate dashboard currently only provides a **single, generic storefront link**: `site.com/shop?ref=YOUR_CODE`. This creates the "generic storefront" problem described in the brief.
|
||||||
|
- **Backend (`includes/Modules/Affiliate/AffiliateTracker.php`)**: The backend is highly prepared. The tracking logic natively intercepts **any** page visit that has the `?ref=` parameter. Furthermore, it already captures UTM parameters (`utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `referrer_url`) and stores them in the `woonoow_ref_utm` cookie, which are eventually saved to the `woonoow_referrals` table when an order is completed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Immediate Opportunities (Low Effort, High Impact)
|
||||||
|
|
||||||
|
Because the backend already tracks referrals across the entire site and captures campaign parameters, Phase 1 can be implemented strictly via **Frontend (SPA) changes**, requiring no backend database migrations.
|
||||||
|
|
||||||
|
### 1.1 Implement a "Link Generator" UI (Customer SPA)
|
||||||
|
Add a new "Link Builder" section in the Affiliate Dashboard (`AffiliateDashboard.tsx`) where affiliates can:
|
||||||
|
- **Single Product Link**: Select a specific product (via a dropdown/search) to generate a link like `/product/slug?ref=CODE`.
|
||||||
|
- **Category Link**: Select a category to generate a link like `/shop?category=slug&ref=CODE`.
|
||||||
|
- **Campaign Tracking**: Add custom Campaign tags (`utm_campaign`, `utm_source`, etc.) to the generated links.
|
||||||
|
|
||||||
|
### 1.2 Campaign Analytics UI
|
||||||
|
Since the `woonoow_referrals` table already stores UTM data:
|
||||||
|
- **Affiliate Dashboard**: Update the UI to show earnings/clicks grouped by `utm_campaign` or `utm_source`.
|
||||||
|
- **Admin SPA**: Add reports in the admin area to view referral performance by campaign, fulfilling the trackability requirement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Medium-Term Opportunities (Curated Collections)
|
||||||
|
|
||||||
|
To fulfill the "Curated Collection Link" requirement (where an affiliate groups a themed set of relevant products), we need to build a new feature:
|
||||||
|
|
||||||
|
### 2.1 Backend Development
|
||||||
|
- Create a new custom database table or Custom Post Type (e.g., `woonoow_affiliate_collections`) that maps an Affiliate ID to a list of Product IDs, along with a title and description.
|
||||||
|
- Create REST API endpoints for affiliates to CRUD their collections.
|
||||||
|
|
||||||
|
### 2.2 Frontend Development
|
||||||
|
- **Affiliate Dashboard**: Add a "My Collections" manager where affiliates can pick products, set a title, and generate a specific collection link.
|
||||||
|
- **Storefront**: Add a new dynamic route to the customer-spa (e.g., `/shop/collection/:collection_id?ref=CODE`) that fetches and displays only the specific products in that collection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Advanced Opportunities (Smart Rotation)
|
||||||
|
|
||||||
|
To support "Controlled Smart Rotation" (rotating a small pool of products):
|
||||||
|
|
||||||
|
### 3.1 Smart Router API Endpoint
|
||||||
|
- Create a lightweight REST API endpoint (e.g., `/wp-json/woonoow/v1/go/:rotation_id`).
|
||||||
|
- When a visitor clicks this link, the backend briefly evaluates the rotation rules, applies the `?ref=` and UTM cookies server-side, and does a 302 redirect to the chosen product page.
|
||||||
|
|
||||||
|
### 3.2 UI/UX
|
||||||
|
- Add configuration settings in the Affiliate Dashboard to set up these rule-based or random rotation links.
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
26
admin-spa/src/components/SectionBackgroundRenderer.tsx
Normal file
26
admin-spa/src/components/SectionBackgroundRenderer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,161 +78,79 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClasses}>
|
<div className={containerClasses}>
|
||||||
{containerWidth === 'boxed' ? (
|
<div className={gridClasses}>
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
|
{/* Image Side */}
|
||||||
<div className={gridClasses}>
|
{hasImage && (
|
||||||
{/* Image Side */}
|
<div className={cn(
|
||||||
{hasImage && (
|
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||||
<div className={cn(
|
imageWrapperOrder,
|
||||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
(isImageTop || isImageBottom) && 'mb-8'
|
||||||
imageWrapperOrder,
|
)} style={imageStyle}>
|
||||||
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
|
<img
|
||||||
)} style={imageStyle}>
|
src={image}
|
||||||
<img
|
alt={title || 'Section Image'}
|
||||||
src={image}
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
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>
|
)}
|
||||||
) : (
|
|
||||||
<div className={gridClasses}>
|
{/* Content Side */}
|
||||||
{/* Image Side */}
|
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||||
{hasImage && (
|
{title && (
|
||||||
<div className={cn(
|
<h2
|
||||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
className={cn(
|
||||||
imageWrapperOrder,
|
"tracking-tight text-current mb-6",
|
||||||
(isImageTop || isImageBottom) && 'mb-8'
|
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
||||||
)} style={imageStyle}>
|
titleClassName
|
||||||
<img
|
)}
|
||||||
src={image}
|
style={titleStyle}
|
||||||
alt={title || 'Section Image'}
|
>
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
{title}
|
||||||
/>
|
</h2>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content Side */}
|
{text && (
|
||||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
<div
|
||||||
{title && (
|
className={cn(
|
||||||
<h2
|
'prose prose-lg max-w-none',
|
||||||
className={cn(
|
'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',
|
||||||
"tracking-tight text-current mb-6",
|
'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',
|
||||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
'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',
|
||||||
titleClassName
|
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||||
)}
|
'prose-p:text-[var(--tw-prose-body)]',
|
||||||
style={titleStyle}
|
'text-[var(--tw-prose-body)]',
|
||||||
>
|
className,
|
||||||
{title}
|
textClassName
|
||||||
</h2>
|
)}
|
||||||
)}
|
style={proseStyle}
|
||||||
|
dangerouslySetInnerHTML={{ __html: text }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{text && (
|
{/* Buttons */}
|
||||||
<div
|
{buttons && buttons.length > 0 && (
|
||||||
className={cn(
|
<div className="mt-8 flex flex-wrap gap-4">
|
||||||
'prose prose-lg max-w-none',
|
{buttons.map((btn, idx) => (
|
||||||
'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',
|
btn.text && btn.url && (
|
||||||
'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',
|
<a
|
||||||
'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',
|
key={idx}
|
||||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
href={btn.url}
|
||||||
'prose-p:text-[var(--tw-prose-body)]',
|
className={cn(
|
||||||
'text-[var(--tw-prose-body)]',
|
"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",
|
||||||
className,
|
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||||
textClassName
|
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||||
)}
|
buttonStyle?.classNames
|
||||||
style={proseStyle}
|
)}
|
||||||
dangerouslySetInnerHTML={{ __html: text }}
|
style={buttonStyle?.style}
|
||||||
/>
|
>
|
||||||
)}
|
{btn.text}
|
||||||
|
</a>
|
||||||
{/* Buttons */}
|
)
|
||||||
{buttons && buttons.length > 0 && (
|
))}
|
||||||
<div className="mt-8 flex flex-wrap gap-4">
|
</div>
|
||||||
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Placeholder from '@tiptap/extension-placeholder';
|
|||||||
import Link from '@tiptap/extension-link';
|
import Link from '@tiptap/extension-link';
|
||||||
import TextAlign from '@tiptap/extension-text-align';
|
import TextAlign from '@tiptap/extension-text-align';
|
||||||
import Image from '@tiptap/extension-image';
|
import Image from '@tiptap/extension-image';
|
||||||
import { ButtonExtension } from './tiptap-button-extension';
|
|
||||||
import { openWPMediaImage } from '@/lib/wp-media';
|
import { openWPMediaImage } from '@/lib/wp-media';
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
AlignCenter,
|
AlignCenter,
|
||||||
AlignRight,
|
AlignRight,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
MousePointer,
|
|
||||||
Undo,
|
Undo,
|
||||||
Redo,
|
Redo,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -50,8 +48,6 @@ export function RichTextEditor({
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
// ButtonExtension MUST come before Link to ensure buttons are parsed first
|
|
||||||
ButtonExtension,
|
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
HTMLAttributes: {
|
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 = () => {
|
const addImage = () => {
|
||||||
openWPMediaImage((file) => {
|
openWPMediaImage((file) => {
|
||||||
editor.chain().focus().setImage({
|
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 = () => {
|
const getActiveHeading = () => {
|
||||||
if (editor.isActive('heading', { level: 1 })) return 'h1';
|
if (editor.isActive('heading', { level: 1 })) return 'h1';
|
||||||
if (editor.isActive('heading', { level: 2 })) return 'h2';
|
if (editor.isActive('heading', { level: 2 })) return 'h2';
|
||||||
@@ -326,14 +234,6 @@ export function RichTextEditor({
|
|||||||
>
|
>
|
||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
</Button>
|
</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" />
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -356,7 +256,7 @@ export function RichTextEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
<div onClick={handleEditorClick}>
|
<div>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -444,91 +344,6 @@ export function RichTextEditor({
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
export const apiClient = api;
|
export const apiClient = api;
|
||||||
|
export { api };
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ function withSectionWrapper(Component: any) {
|
|||||||
colorScheme={section.colorScheme}
|
colorScheme={section.colorScheme}
|
||||||
elementStyles={section.elementStyles}
|
elementStyles={section.elementStyles}
|
||||||
styles={section.styles}
|
styles={section.styles}
|
||||||
|
isEditor={true}
|
||||||
|
section={section}
|
||||||
{...flatProps}
|
{...flatProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -207,10 +209,10 @@ export function CanvasRenderer({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-white transition-all duration-300 min-h-[500px]',
|
'bg-white transition-all duration-300 min-h-[500px] wn-page',
|
||||||
deviceMode === 'mobile'
|
deviceMode === 'mobile'
|
||||||
? 'max-w-sm mx-auto shadow-2xl rounded-[2.5rem] border-[12px] border-gray-800 my-8 overflow-hidden'
|
? '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 ? (
|
{sections.length === 0 ? (
|
||||||
|
|||||||
@@ -82,8 +82,15 @@ export function CanvasSection({
|
|||||||
{/* Section content with Styles */}
|
{/* Section content with Styles */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden rounded-lg",
|
"relative overflow-hidden",
|
||||||
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50"
|
!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={{
|
style={{
|
||||||
...(section.styles?.backgroundType === 'gradient'
|
...(section.styles?.backgroundType === 'gradient'
|
||||||
@@ -153,14 +160,23 @@ export function CanvasSection({
|
|||||||
{/* Content Wrapper */}
|
{/* Content Wrapper */}
|
||||||
{section.styles?.contentWidth === 'boxed' ? (
|
{section.styles?.contentWidth === 'boxed' ? (
|
||||||
<div className="relative z-10 container mx-auto px-4 max-w-5xl">
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"relative z-10",
|
"relative z-10 w-full",
|
||||||
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'
|
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : ''
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function InspectorField({
|
|||||||
placeholder={fieldType === 'url' ? 'https://' : `Enter ${fieldLabel.toLowerCase()}`}
|
placeholder={fieldType === 'url' ? 'https://' : `Enter ${fieldLabel.toLowerCase()}`}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
{(fieldType === 'url' || fieldType === 'image') && (
|
{(fieldType === 'image') && (
|
||||||
<MediaUploader
|
<MediaUploader
|
||||||
onSelect={(url) => handleValueChange(url)}
|
onSelect={(url) => handleValueChange(url)}
|
||||||
type="image"
|
type="image"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -413,6 +414,7 @@ export function InspectorPanel({
|
|||||||
{ name: 'label', label: 'Label', type: 'text' },
|
{ name: 'label', label: 'Label', type: 'text' },
|
||||||
{ name: 'image', label: 'Image', type: 'image' },
|
{ name: 'image', label: 'Image', type: 'image' },
|
||||||
{ name: 'url', label: 'Link URL', type: 'text' },
|
{ name: 'url', label: 'Link URL', type: 'text' },
|
||||||
|
{ name: 'backgroundColor', label: 'Background Color', type: 'color' },
|
||||||
{ name: 'size', label: 'Size (small/medium/large/tall)', type: 'text' },
|
{ name: 'size', label: 'Size (small/medium/large/tall)', type: 'text' },
|
||||||
]}
|
]}
|
||||||
itemLabelKey="label"
|
itemLabelKey="label"
|
||||||
@@ -436,7 +438,7 @@ export function InspectorPanel({
|
|||||||
// Allow advanced override/editing of asset/data if needed
|
// Allow advanced override/editing of asset/data if needed
|
||||||
{ name: 'product_name', label: 'Product Name', type: 'text' },
|
{ name: 'product_name', label: 'Product Name', type: 'text' },
|
||||||
{ name: 'product_price', label: 'Price', 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: 'x', label: 'X Position (%)', type: 'text' },
|
||||||
{ name: 'y', label: 'Y Position (%)', type: 'text' },
|
{ name: 'y', label: 'Y Position (%)', type: 'text' },
|
||||||
]}
|
]}
|
||||||
@@ -448,6 +450,36 @@ export function InspectorPanel({
|
|||||||
</div>
|
</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>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Design Tab */}
|
{/* Design Tab */}
|
||||||
@@ -491,10 +523,10 @@ export function InspectorPanel({
|
|||||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="#FFFFFF"
|
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 || ''}
|
value={selectedSection.styles?.backgroundColor || ''}
|
||||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -525,9 +557,9 @@ export function InspectorPanel({
|
|||||||
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
|
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
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'}
|
value={selectedSection.styles?.gradientFrom || '#9333ea'}
|
||||||
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
|
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -545,9 +577,9 @@ export function InspectorPanel({
|
|||||||
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
|
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
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'}
|
value={selectedSection.styles?.gradientTo || '#3b82f6'}
|
||||||
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
|
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="grid grid-cols-2 gap-2 pt-2 border-t mt-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs text-gray-500">{__('Padding Top')}</Label>
|
<Label className="text-xs text-gray-500">{__('Padding Top')}</Label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. 40px, 4rem"
|
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 || ''}
|
value={selectedSection.styles?.paddingTop || ''}
|
||||||
onChange={(e) => onSectionStylesChange({ paddingTop: e.target.value })}
|
onChange={(e) => onSectionStylesChange({ paddingTop: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs text-gray-500">{__('Padding Bottom')}</Label>
|
<Label className="text-xs text-gray-500">{__('Padding Bottom')}</Label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. 40px, 4rem"
|
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 || ''}
|
value={selectedSection.styles?.paddingBottom || ''}
|
||||||
onChange={(e) => onSectionStylesChange({ paddingBottom: e.target.value })}
|
onChange={(e) => onSectionStylesChange({ paddingBottom: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -702,6 +734,49 @@ export function InspectorPanel({
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</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">
|
<div className="space-y-2 pt-2 border-t mt-4">
|
||||||
<Label className="text-xs">{__('Section Height')}</Label>
|
<Label className="text-xs">{__('Section Height')}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -739,29 +814,31 @@ export function InspectorPanel({
|
|||||||
<AccordionTrigger className="text-xs hover:no-underline py-2">{field.label}</AccordionTrigger>
|
<AccordionTrigger className="text-xs hover:no-underline py-2">{field.label}</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-4 pt-2">
|
<AccordionContent className="space-y-4 pt-2">
|
||||||
{/* Common: Background Wrapper */}
|
{/* Common: Background Wrapper */}
|
||||||
<div className="space-y-2">
|
{!field.disableBackground && (
|
||||||
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
|
||||||
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
<div className="flex items-center gap-2">
|
||||||
<div className="absolute inset-0" style={{ backgroundColor: styles.backgroundColor || 'transparent' }} />
|
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||||
<input
|
<div className="absolute inset-0" style={{ backgroundColor: styles.backgroundColor || 'transparent' }} />
|
||||||
type="color"
|
<input
|
||||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
type="color"
|
||||||
value={styles.backgroundColor || '#ffffff'}
|
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 })}
|
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{!isImage ? (
|
{(!isImage && field.type !== 'container') && (
|
||||||
<>
|
<>
|
||||||
{/* Text Color */}
|
{/* Text Color */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -776,10 +853,10 @@ export function InspectorPanel({
|
|||||||
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
|
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Color (#000)"
|
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 || ''}
|
value={styles.color || ''}
|
||||||
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
|
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -830,15 +907,17 @@ export function InspectorPanel({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
|
{!field.disableAlignment && (
|
||||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
|
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
|
||||||
<SelectContent>
|
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
|
||||||
<SelectItem value="default">Default Align</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="left">Left</SelectItem>
|
<SelectItem value="default">Default Align</SelectItem>
|
||||||
<SelectItem value="center">Center</SelectItem>
|
<SelectItem value="left">Left</SelectItem>
|
||||||
<SelectItem value="right">Right</SelectItem>
|
<SelectItem value="center">Center</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="right">Right</SelectItem>
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Link Specific Styles */}
|
{/* Link Specific Styles */}
|
||||||
@@ -865,10 +944,10 @@ export function InspectorPanel({
|
|||||||
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
|
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Hover Color"
|
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 || ''}
|
value={styles.hoverColor || ''}
|
||||||
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
|
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -876,45 +955,12 @@ export function InspectorPanel({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs text-gray-500">{__('Image Fit')}</Label>
|
<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 })}>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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="grid grid-cols-2 gap-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs text-gray-500">{__('Width')}</Label>
|
<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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs text-gray-500">{__('Height')}</Label>
|
<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>
|
||||||
</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>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} 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 { cn } from '@/lib/utils';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
@@ -41,8 +42,9 @@ import RepeaterProductField from './RepeaterProductField';
|
|||||||
interface RepeaterFieldDef {
|
interface RepeaterFieldDef {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'product';
|
type: 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'product' | 'select' | 'checkbox' | 'color';
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InspectorRepeaterProps {
|
interface InspectorRepeaterProps {
|
||||||
@@ -91,8 +93,8 @@ function SortableItem({
|
|||||||
'Wifi', 'Wrench',
|
'Wifi', 'Wrench',
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
const handleFieldChange = (fieldName: string, value: any) => {
|
const handleFieldChange = (fieldNameOrUpdates: string | Record<string, any>, value?: any) => {
|
||||||
onChange(index, fieldName, value);
|
onChange(index, fieldNameOrUpdates, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -151,7 +153,7 @@ function RepeaterFieldRenderer({
|
|||||||
field: RepeaterFieldDef;
|
field: RepeaterFieldDef;
|
||||||
item: any;
|
item: any;
|
||||||
index: number;
|
index: number;
|
||||||
onChange: (fieldName: string, value: any) => void;
|
onChange: (fieldNameOrUpdates: string | Record<string, any>, value?: any) => void;
|
||||||
ICON_OPTIONS: string[];
|
ICON_OPTIONS: string[];
|
||||||
}) {
|
}) {
|
||||||
const value = item[field.name] || '';
|
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') {
|
if (field.type === 'image') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs text-gray-500">{field.label}</Label>
|
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||||
<div className="space-y-2">
|
<div className="flex gap-2">
|
||||||
{value ? (
|
<Input
|
||||||
<MediaUploader
|
type="text"
|
||||||
onSelect={(url) => onChange(field.name, url)}
|
value={value}
|
||||||
type="image"
|
onChange={(e) => onChange(field.name, e.target.value)}
|
||||||
>
|
placeholder="https://..."
|
||||||
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50 flex items-center justify-center">
|
className="flex-1 text-xs h-8"
|
||||||
<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">
|
<MediaUploader
|
||||||
<span className="text-white text-xs font-medium">Change</span>
|
onSelect={(url) => onChange(field.name, url)}
|
||||||
</div>
|
type="image"
|
||||||
<button
|
className="shrink-0"
|
||||||
onClick={(e) => {
|
>
|
||||||
e.stopPropagation();
|
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Select Image" type="button">
|
||||||
onChange(field.name, '');
|
<ImageIcon className="w-4 h-4" />
|
||||||
}}
|
</Button>
|
||||||
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"
|
</MediaUploader>
|
||||||
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>
|
</div>
|
||||||
</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
|
// default: text/url inputs
|
||||||
const inputType = field.type === 'url' ? 'url' : 'text';
|
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];
|
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);
|
onChange(newItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -341,7 +391,7 @@ export function InspectorRepeater({
|
|||||||
item={item}
|
item={item}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
itemLabelKey={itemLabelKey}
|
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}
|
onDelete={handleDeleteItem}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function RepeaterProductField({
|
|||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (fieldName: string, nextValue: any) => void;
|
onChange: (fieldNameOrUpdates: string | Record<string, any>, nextValue?: any) => void;
|
||||||
}) {
|
}) {
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
const [options, setOptions] = React.useState<any[]>([]);
|
const [options, setOptions] = React.useState<any[]>([]);
|
||||||
@@ -77,11 +77,13 @@ export default function RepeaterProductField({
|
|||||||
const selected = options.find((o) => o.value === v)?.product;
|
const selected = options.find((o) => o.value === v)?.product;
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
|
||||||
onChange('product_slug', selected.product_slug || '');
|
onChange({
|
||||||
onChange('product_name', selected.name || '');
|
product_slug: selected.product_slug || '',
|
||||||
onChange('product_price', selected.sale_price ?? selected.price ?? '');
|
product_name: selected.name || '',
|
||||||
onChange('product_image', selected.image_url ?? '');
|
product_price: selected.sale_price ?? selected.price ?? '',
|
||||||
onChange('product_id', selected.id ? Number(selected.id) : 0);
|
product_image: selected.image_url ?? '',
|
||||||
|
product_id: selected.id ? Number(selected.id) : 0,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
options={options.map((o) => ({
|
options={options.map((o) => ({
|
||||||
value: String(o.value ?? ''),
|
value: String(o.value ?? ''),
|
||||||
|
|||||||
@@ -32,16 +32,6 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
|
|||||||
const buttonText = section.props?.button_text?.value || 'Get Started';
|
const buttonText = section.props?.button_text?.value || 'Get Started';
|
||||||
const buttonUrl = section.props?.button_url?.value || '#';
|
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)
|
// Helper to get text styles (including font family)
|
||||||
const getTextStyles = (elementName: string) => {
|
const getTextStyles = (elementName: string) => {
|
||||||
const styles = section.elementStyles?.[elementName] || {};
|
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';
|
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||||
|
|
||||||
return (
|
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">
|
<div className="max-w-4xl mx-auto text-center space-y-6">
|
||||||
<h2
|
<h2
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -88,7 +78,7 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
|
|||||||
)}
|
)}
|
||||||
style={textStyle.style}
|
style={textStyle.style}
|
||||||
>
|
>
|
||||||
{text}
|
{text || "Description text missing"}
|
||||||
</p>
|
</p>
|
||||||
<button className={cn(
|
<button className={cn(
|
||||||
'inline-flex items-center gap-2 px-8 py-4 rounded-lg font-semibold transition hover:opacity-90',
|
'inline-flex items-center gap-2 px-8 py-4 rounded-lg font-semibold transition hover:opacity-90',
|
||||||
|
|||||||
@@ -69,21 +69,11 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
|
|||||||
return undefined;
|
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';
|
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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()}
|
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||||
>
|
>
|
||||||
<div className="max-w-xl mx-auto">
|
<div className="max-w-xl mx-auto">
|
||||||
@@ -98,59 +88,57 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
|
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
|
||||||
{/* Name field */}
|
{/* Render fields from config, fallback to default if missing */}
|
||||||
<div className="relative">
|
{(() => {
|
||||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
const defaultFields = [
|
||||||
<input
|
{ name: 'name', label: 'Your Name', type: 'text', required: true },
|
||||||
type="text"
|
{ name: 'email', label: 'Your Email', type: 'email', required: true },
|
||||||
placeholder="Your Name"
|
{ name: 'message', label: 'Your Message', type: 'textarea', required: true }
|
||||||
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',
|
const fieldsProp = section.props?.fields?.value;
|
||||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
const fields = Array.isArray(fieldsProp) && fieldsProp.length > 0 ? fieldsProp : defaultFields;
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: fieldsStyle.backgroundColor,
|
|
||||||
color: fieldsStyle.color
|
|
||||||
}}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email field */}
|
return fields.map((field: any, idx: number) => {
|
||||||
<div className="relative">
|
const Icon = field.type === 'email' ? Mail : field.type === 'textarea' ? MessageSquare : User;
|
||||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
return (
|
||||||
<input
|
<div key={field.name || idx} className="relative">
|
||||||
type="email"
|
<Icon className={cn(
|
||||||
placeholder="Your Email"
|
"absolute left-4 text-gray-400 w-5 h-5",
|
||||||
className={cn(
|
field.type === 'textarea' ? "top-4" : "top-1/2 -translate-y-1/2"
|
||||||
'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
|
{field.type === 'textarea' ? (
|
||||||
)}
|
<textarea
|
||||||
style={{
|
placeholder={field.label + (field.required ? ' *' : '')}
|
||||||
backgroundColor: fieldsStyle.backgroundColor,
|
rows={4}
|
||||||
color: fieldsStyle.color
|
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',
|
||||||
disabled
|
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||||
/>
|
)}
|
||||||
</div>
|
style={{
|
||||||
|
backgroundColor: fieldsStyle.backgroundColor,
|
||||||
{/* Message field */}
|
color: fieldsStyle.color
|
||||||
<div className="relative">
|
}}
|
||||||
<MessageSquare className="absolute left-4 top-4 w-5 h-5 text-gray-400" />
|
disabled
|
||||||
<textarea
|
/>
|
||||||
placeholder="Your Message"
|
) : (
|
||||||
rows={4}
|
<input
|
||||||
className={cn(
|
type={field.type || 'text'}
|
||||||
'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',
|
placeholder={field.label + (field.required ? ' *' : '')}
|
||||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
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',
|
||||||
style={{
|
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||||
backgroundColor: fieldsStyle.backgroundColor,
|
)}
|
||||||
color: fieldsStyle.color
|
style={{
|
||||||
}}
|
backgroundColor: fieldsStyle.backgroundColor,
|
||||||
disabled
|
color: fieldsStyle.color
|
||||||
/>
|
}}
|
||||||
</div>
|
disabled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Submit button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -155,18 +155,6 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
|
|||||||
const layout = section.layoutVariant || 'default';
|
const layout = section.layoutVariant || 'default';
|
||||||
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.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 content = section.props?.content?.value || 'Your content goes here. Edit this in the inspector panel.';
|
||||||
const isDynamic = section.props?.content?.type === 'dynamic';
|
const isDynamic = section.props?.content?.type === 'dynamic';
|
||||||
|
|
||||||
@@ -218,7 +206,6 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'relative w-full overflow-hidden',
|
'relative w-full overflow-hidden',
|
||||||
'px-4 md:px-8',
|
'px-4 md:px-8',
|
||||||
heightClasses,
|
|
||||||
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
|
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
|
||||||
scheme.text,
|
scheme.text,
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -119,21 +119,11 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
|
|||||||
return undefined;
|
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';
|
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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()}
|
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||||
>
|
>
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
|||||||
@@ -27,16 +27,6 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
|
|||||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||||
const layout = section.layoutVariant || '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 title = section.props?.title?.value || 'Hero Title';
|
||||||
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
|
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
|
||||||
const image = section.props?.image?.value;
|
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') {
|
if (layout === 'hero-left-image' || layout === 'hero-right-image') {
|
||||||
return (
|
return (
|
||||||
<div
|
<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(
|
<div className={cn(
|
||||||
'max-w-6xl mx-auto flex items-center gap-12',
|
'max-w-6xl mx-auto flex items-center gap-12',
|
||||||
@@ -156,7 +146,7 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
|
|||||||
// Default centered layout
|
// Default centered layout
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
<h1
|
<h1
|
||||||
|
|||||||
@@ -72,21 +72,11 @@ export function ImageTextRenderer({ section, className }: ImageTextRendererProps
|
|||||||
return undefined;
|
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';
|
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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()}
|
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
|||||||
export function MarqueeBannerRenderer({ section, className }: { section: any; className?: string }) {
|
export function MarqueeBannerRenderer({ section, className }: { section: any; className?: string }) {
|
||||||
const { text, separator } = section.props;
|
const { text, separator } = section.props;
|
||||||
const styles = section.styles || {};
|
const styles = section.styles || {};
|
||||||
|
const elementStyles = section.elementStyles || {};
|
||||||
|
|
||||||
const displayText = text?.value || 'Marquee Banner Text Here';
|
const displayText = text?.value || 'Marquee Banner Text Here';
|
||||||
const displaySeparator = separator?.value || '✦';
|
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 whitespace-nowrap opacity-70">
|
||||||
<div className="flex items-center gap-8 pr-8">
|
<div className="flex items-center gap-8 pr-8">
|
||||||
{[1, 2, 3].map((idx) => (
|
{[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}
|
{displayText}
|
||||||
<span className="opacity-50 text-xs">{displaySeparator}</span>
|
<span className="opacity-50 text-xs">{displaySeparator}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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">
|
<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 ? (
|
{displayImage ? (
|
||||||
<>
|
|
||||||
<img src={displayImage} alt="Shoppable Preview" className="w-full h-full object-cover opacity-50" />
|
<img src={displayImage} alt="Shoppable Preview" className="w-full h-full object-cover opacity-50" />
|
||||||
{displayHotspots.map((hotspot: any, idx: number) => (
|
{displayHotspots.map((hotspot: any, idx: number) => {
|
||||||
<div
|
const xVal = parseFloat(String(hotspot.x ?? 0).replace('%', '')) || 0;
|
||||||
key={idx}
|
const yVal = parseFloat(String(hotspot.y ?? 0).replace('%', '')) || 0;
|
||||||
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%)' }}
|
return (
|
||||||
>
|
<div
|
||||||
{idx + 1}
|
key={idx}
|
||||||
</div>
|
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">
|
<div className="text-center text-gray-400">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -50,6 +50,11 @@ export default function AppearancePages() {
|
|||||||
setInspectorCollapsed,
|
setInspectorCollapsed,
|
||||||
setAvailableSources,
|
setAvailableSources,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
past,
|
||||||
|
future,
|
||||||
|
updateCurrentPage,
|
||||||
addSection,
|
addSection,
|
||||||
deleteSection,
|
deleteSection,
|
||||||
duplicateSection,
|
duplicateSection,
|
||||||
@@ -61,6 +66,7 @@ export default function AppearancePages() {
|
|||||||
updateSectionStyles,
|
updateSectionStyles,
|
||||||
updateElementStyles,
|
updateElementStyles,
|
||||||
markAsSaved,
|
markAsSaved,
|
||||||
|
markAsChanged,
|
||||||
setAsSpaLanding,
|
setAsSpaLanding,
|
||||||
unsetSpaLanding,
|
unsetSpaLanding,
|
||||||
} = usePageEditorStore();
|
} = usePageEditorStore();
|
||||||
@@ -160,7 +166,10 @@ export default function AppearancePages() {
|
|||||||
const endpoint = currentPage.type === 'page'
|
const endpoint = currentPage.type === 'page'
|
||||||
? `/pages/${currentPage.slug}`
|
? `/pages/${currentPage.slug}`
|
||||||
: `/templates/${currentPage.cpt}`;
|
: `/templates/${currentPage.cpt}`;
|
||||||
return api.post(endpoint, { sections });
|
return api.post(endpoint, {
|
||||||
|
sections,
|
||||||
|
container_width: currentPage.containerWidth
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(__('Page saved successfully'));
|
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" />}
|
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
{hasUnsavedChanges && (
|
{hasUnsavedChanges && (
|
||||||
<>
|
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
||||||
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
)}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
<div className="flex items-center rounded-md border bg-muted/50 p-0.5">
|
||||||
size="sm"
|
<Button
|
||||||
onClick={handleDiscard}
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<Undo2 className="w-4 h-4 mr-2" />
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
{__('Discard')}
|
onClick={undo}
|
||||||
</Button>
|
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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -455,10 +487,7 @@ export default function AppearancePages() {
|
|||||||
onDeletePage={handleDeletePage}
|
onDeletePage={handleDeletePage}
|
||||||
onDeleteTemplate={handleDeleteTemplate}
|
onDeleteTemplate={handleDeleteTemplate}
|
||||||
onContainerWidthChange={(width) => {
|
onContainerWidthChange={(width) => {
|
||||||
if (currentPage) {
|
updateCurrentPage({ containerWidth: width });
|
||||||
setCurrentPage({ ...currentPage, containerWidth: width });
|
|
||||||
markAsSaved(); // Mark as changed so save button enables
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export interface SectionOption {
|
|||||||
export interface StylableElementSchema {
|
export interface StylableElementSchema {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'text' | 'image';
|
type: 'text' | 'image' | 'container';
|
||||||
|
disableAlignment?: boolean;
|
||||||
|
disableBackground?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SectionSchema {
|
export interface SectionSchema {
|
||||||
@@ -53,7 +55,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||||
{ name: 'subtitle', label: 'Subtitle', 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_text', label: 'Button Text', type: 'text' },
|
||||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||||
],
|
],
|
||||||
@@ -89,12 +91,12 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
|||||||
{ value: 'medium', label: 'Medium' },
|
{ value: 'medium', label: 'Medium' },
|
||||||
],
|
],
|
||||||
stylableElements: [
|
stylableElements: [
|
||||||
{ name: 'heading', label: 'Headings', type: 'text' },
|
{ name: 'content', label: 'Container', type: 'container', disableAlignment: true },
|
||||||
{ name: 'text', label: 'Body Text', type: 'text' },
|
{ name: 'heading', label: 'Headings', type: 'text', disableAlignment: true },
|
||||||
{ name: 'link', label: 'Links', type: 'text' },
|
{ name: 'text', label: 'Body Text', type: 'text', disableAlignment: true },
|
||||||
{ name: 'image', label: 'Images', type: 'image' },
|
{ name: 'link', label: 'Links', type: 'text', disableAlignment: true },
|
||||||
|
{ name: 'image', label: 'Images', type: 'image', disableAlignment: true },
|
||||||
{ name: 'button', label: 'Button', type: 'text' },
|
{ name: 'button', label: 'Button', type: 'text' },
|
||||||
{ name: 'content', label: 'Container', type: 'text' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'image-text': {
|
'image-text': {
|
||||||
@@ -111,7 +113,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||||
{ name: 'text', label: 'Text', type: 'textarea', 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_text', label: 'Button Text', type: 'text' },
|
||||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||||
],
|
],
|
||||||
@@ -145,6 +147,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
|||||||
stylableElements: [
|
stylableElements: [
|
||||||
{ name: 'heading', label: 'Heading', type: 'text' },
|
{ name: 'heading', label: 'Heading', type: 'text' },
|
||||||
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
|
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
|
||||||
|
{ name: 'link', label: 'Link (Read more)', type: 'text' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'cta-banner': {
|
'cta-banner': {
|
||||||
@@ -177,6 +180,14 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
|||||||
title: { type: 'static', value: 'Contact Us' },
|
title: { type: 'static', value: 'Contact Us' },
|
||||||
webhook_url: { type: 'static', value: '' },
|
webhook_url: { type: 'static', value: '' },
|
||||||
redirect_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: [
|
fields: [
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
@@ -231,8 +242,8 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
|||||||
{ value: 'featured', label: 'Featured' },
|
{ value: 'featured', label: 'Featured' },
|
||||||
],
|
],
|
||||||
stylableElements: [
|
stylableElements: [
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
{ name: 'title', label: 'Title', type: 'text', disableAlignment: true },
|
||||||
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
{ name: 'subtitle', label: 'Subtitle', type: 'text', disableAlignment: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'shoppable-image': {
|
'shoppable-image': {
|
||||||
@@ -249,11 +260,12 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
{ name: 'subtitle', label: 'Subtitle', 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' },
|
{ name: 'alt', label: 'Image Alt Text', type: 'text' },
|
||||||
],
|
],
|
||||||
stylableElements: [
|
stylableElements: [
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'marquee-banner': {
|
'marquee-banner': {
|
||||||
@@ -270,6 +282,9 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
|||||||
{ name: 'separator', label: 'Separator', type: 'text' },
|
{ name: 'separator', label: 'Separator', type: 'text' },
|
||||||
{ name: 'speed', label: 'Speed (seconds)', type: 'text' },
|
{ name: 'speed', label: 'Speed (seconds)', type: 'text' },
|
||||||
],
|
],
|
||||||
|
stylableElements: [
|
||||||
|
{ name: 'text', label: 'Banner Text', type: 'text', disableBackground: true, disableAlignment: true },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export interface SectionStyles {
|
|||||||
paddingTop?: string;
|
paddingTop?: string;
|
||||||
paddingBottom?: string;
|
paddingBottom?: string;
|
||||||
contentWidth?: 'full' | 'contained' | 'boxed';
|
contentWidth?: 'full' | 'contained' | 'boxed';
|
||||||
|
cardBackgroundColor?: string;
|
||||||
|
cardPaddingTop?: string;
|
||||||
|
cardPaddingRight?: string;
|
||||||
|
cardPaddingBottom?: string;
|
||||||
|
cardPaddingLeft?: string;
|
||||||
heightPreset?: string;
|
heightPreset?: string;
|
||||||
dynamicBackground?: string; // e.g. 'post_featured_image'
|
dynamicBackground?: string; // e.g. 'post_featured_image'
|
||||||
}
|
}
|
||||||
@@ -34,6 +39,8 @@ export interface ElementStyle {
|
|||||||
|
|
||||||
// Image specific
|
// Image specific
|
||||||
objectFit?: 'cover' | 'contain' | 'fill';
|
objectFit?: 'cover' | 'contain' | 'fill';
|
||||||
|
objectPosition?: string;
|
||||||
|
alignment?: 'left' | 'center' | 'right';
|
||||||
backgroundColor?: string; // Wrapper BG
|
backgroundColor?: string; // Wrapper BG
|
||||||
width?: string;
|
width?: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
@@ -73,6 +80,10 @@ export interface PageItem {
|
|||||||
isSpaLanding?: boolean;
|
isSpaLanding?: boolean;
|
||||||
containerWidth?: 'boxed' | 'fullwidth' | 'default';
|
containerWidth?: 'boxed' | 'fullwidth' | 'default';
|
||||||
}
|
}
|
||||||
|
interface HistoryState {
|
||||||
|
sections: Section[];
|
||||||
|
currentPage: PageItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface PageEditorState {
|
interface PageEditorState {
|
||||||
// Current page/template being edited
|
// Current page/template being edited
|
||||||
@@ -91,6 +102,10 @@ interface PageEditorState {
|
|||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
||||||
|
// History (Undo/Redo)
|
||||||
|
past: HistoryState[];
|
||||||
|
future: HistoryState[];
|
||||||
|
|
||||||
// Available sources for dynamic fields (CPT templates)
|
// Available sources for dynamic fields (CPT templates)
|
||||||
availableSources: { value: string; label: string }[];
|
availableSources: { value: string; label: string }[];
|
||||||
|
|
||||||
@@ -104,6 +119,14 @@ interface PageEditorState {
|
|||||||
setAvailableSources: (sources: { value: string; label: string }[]) => void;
|
setAvailableSources: (sources: { value: string; label: string }[]) => void;
|
||||||
setIsLoading: (loading: boolean) => void;
|
setIsLoading: (loading: boolean) => void;
|
||||||
|
|
||||||
|
// History actions
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
pushHistory: () => void;
|
||||||
|
|
||||||
|
// Page updates
|
||||||
|
updateCurrentPage: (updates: Partial<PageItem>) => void;
|
||||||
|
|
||||||
// Section actions
|
// Section actions
|
||||||
addSection: (type: string, index?: number) => void;
|
addSection: (type: string, index?: number) => void;
|
||||||
deleteSection: (id: string) => void;
|
deleteSection: (id: string) => void;
|
||||||
@@ -137,11 +160,13 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
inspectorCollapsed: false,
|
inspectorCollapsed: false,
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
availableSources: [],
|
availableSources: [],
|
||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
setCurrentPage: (currentPage) => set({ currentPage }),
|
setCurrentPage: (currentPage) => set({ currentPage }),
|
||||||
setSections: (sections) => set({ sections, hasUnsavedChanges: true }),
|
setSections: (sections) => set({ sections, hasUnsavedChanges: true, past: [], future: [] }),
|
||||||
setSelectedSection: (selectedSectionId) => set({ selectedSectionId }),
|
setSelectedSection: (selectedSectionId) => set({ selectedSectionId }),
|
||||||
setHoveredSection: (hoveredSectionId) => set({ hoveredSectionId }),
|
setHoveredSection: (hoveredSectionId) => set({ hoveredSectionId }),
|
||||||
setDeviceMode: (deviceMode) => set({ deviceMode }),
|
setDeviceMode: (deviceMode) => set({ deviceMode }),
|
||||||
@@ -149,9 +174,64 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
setAvailableSources: (availableSources) => set({ availableSources }),
|
setAvailableSources: (availableSources) => set({ availableSources }),
|
||||||
setIsLoading: (isLoading) => set({ isLoading }),
|
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
|
// Section actions
|
||||||
addSection: (type, index) => {
|
addSection: (type, index) => {
|
||||||
const { sections } = get();
|
const { sections, pushHistory } = get();
|
||||||
const sectionConfig = getSectionSchema(type);
|
const sectionConfig = getSectionSchema(type);
|
||||||
|
|
||||||
if (!sectionConfig) return;
|
if (!sectionConfig) return;
|
||||||
@@ -163,6 +243,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
styles: cloneDefaultStyles(type) as SectionStyles,
|
styles: cloneDefaultStyles(type) as SectionStyles,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistory();
|
||||||
|
|
||||||
const newSections = [...sections];
|
const newSections = [...sections];
|
||||||
if (typeof index === 'number') {
|
if (typeof index === 'number') {
|
||||||
newSections.splice(index, 0, newSection);
|
newSections.splice(index, 0, newSection);
|
||||||
@@ -177,7 +259,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteSection: (id) => {
|
deleteSection: (id) => {
|
||||||
const { sections, selectedSectionId } = get();
|
const { sections, selectedSectionId, pushHistory } = get();
|
||||||
|
pushHistory();
|
||||||
const newSections = sections.filter(s => s.id !== id);
|
const newSections = sections.filter(s => s.id !== id);
|
||||||
|
|
||||||
set({
|
set({
|
||||||
@@ -188,10 +271,12 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
duplicateSection: (id) => {
|
duplicateSection: (id) => {
|
||||||
const { sections } = get();
|
const { sections, pushHistory } = get();
|
||||||
const index = sections.findIndex(s => s.id === id);
|
const index = sections.findIndex(s => s.id === id);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
|
|
||||||
|
pushHistory();
|
||||||
|
|
||||||
const section = sections[index];
|
const section = sections[index];
|
||||||
const newSection: Section = {
|
const newSection: Section = {
|
||||||
...JSON.parse(JSON.stringify(section)), // Deep clone
|
...JSON.parse(JSON.stringify(section)), // Deep clone
|
||||||
@@ -205,27 +290,32 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
moveSection: (id, direction) => {
|
moveSection: (id, direction) => {
|
||||||
const { sections } = get();
|
const { sections, pushHistory } = get();
|
||||||
const index = sections.findIndex(s => s.id === id);
|
const index = sections.findIndex(s => s.id === id);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
|
|
||||||
if (direction === 'up' && index > 0) {
|
if (direction === 'up' && index > 0) {
|
||||||
|
pushHistory();
|
||||||
const newSections = [...sections];
|
const newSections = [...sections];
|
||||||
[newSections[index], newSections[index - 1]] = [newSections[index - 1], newSections[index]];
|
[newSections[index], newSections[index - 1]] = [newSections[index - 1], newSections[index]];
|
||||||
set({ sections: newSections, hasUnsavedChanges: true });
|
set({ sections: newSections, hasUnsavedChanges: true });
|
||||||
} else if (direction === 'down' && index < sections.length - 1) {
|
} else if (direction === 'down' && index < sections.length - 1) {
|
||||||
|
pushHistory();
|
||||||
const newSections = [...sections];
|
const newSections = [...sections];
|
||||||
[newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]];
|
[newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]];
|
||||||
set({ sections: newSections, hasUnsavedChanges: true });
|
set({ sections: newSections, hasUnsavedChanges: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
reorderSections: (sections) => {
|
reorderSections: (newSections) => {
|
||||||
set({ sections, hasUnsavedChanges: true });
|
const { sections, pushHistory } = get();
|
||||||
|
pushHistory();
|
||||||
|
set({ sections: newSections, hasUnsavedChanges: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSectionProp: (sectionId, propName, value) => {
|
updateSectionProp: (sectionId, propName, value) => {
|
||||||
const { sections } = get();
|
const { sections, pushHistory } = get();
|
||||||
|
pushHistory();
|
||||||
const newSections = sections.map(section => {
|
const newSections = sections.map(section => {
|
||||||
if (section.id !== sectionId) return section;
|
if (section.id !== sectionId) return section;
|
||||||
return {
|
return {
|
||||||
@@ -240,7 +330,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateSectionLayout: (sectionId, layoutVariant) => {
|
updateSectionLayout: (sectionId, layoutVariant) => {
|
||||||
const { sections } = get();
|
const { sections, pushHistory } = get();
|
||||||
|
pushHistory();
|
||||||
const newSections = sections.map(section => {
|
const newSections = sections.map(section => {
|
||||||
if (section.id !== sectionId) return section;
|
if (section.id !== sectionId) return section;
|
||||||
return {
|
return {
|
||||||
@@ -252,7 +343,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateSectionColorScheme: (sectionId, colorScheme) => {
|
updateSectionColorScheme: (sectionId, colorScheme) => {
|
||||||
const { sections } = get();
|
const { sections, pushHistory } = get();
|
||||||
|
pushHistory();
|
||||||
const newSections = sections.map(section => {
|
const newSections = sections.map(section => {
|
||||||
if (section.id !== sectionId) return section;
|
if (section.id !== sectionId) return section;
|
||||||
return {
|
return {
|
||||||
@@ -264,7 +356,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateSectionStyles: (sectionId, styles) => {
|
updateSectionStyles: (sectionId, styles) => {
|
||||||
const { sections } = get();
|
const { sections, pushHistory } = get();
|
||||||
|
pushHistory();
|
||||||
const newSections = sections.map(section => {
|
const newSections = sections.map(section => {
|
||||||
if (section.id !== sectionId) return section;
|
if (section.id !== sectionId) return section;
|
||||||
return {
|
return {
|
||||||
@@ -279,7 +372,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateElementStyles: (sectionId, fieldName, styles) => {
|
updateElementStyles: (sectionId, fieldName, styles) => {
|
||||||
const { sections } = get();
|
const { sections, pushHistory } = get();
|
||||||
|
pushHistory();
|
||||||
const newSections = sections.map(section => {
|
const newSections = sections.map(section => {
|
||||||
if (section.id !== sectionId) return section;
|
if (section.id !== sectionId) return section;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ interface Referral {
|
|||||||
currency: string;
|
currency: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
approved_at?: string;
|
approved_at?: string;
|
||||||
|
utm_campaign?: string;
|
||||||
|
utm_source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AffiliatesReferrals() {
|
export default function AffiliatesReferrals() {
|
||||||
@@ -88,7 +90,7 @@ export default function AffiliatesReferrals() {
|
|||||||
|
|
||||||
// Export to CSV
|
// Export to CSV
|
||||||
const exportToCSV = () => {
|
const exportToCSV = () => {
|
||||||
const headers = ['ID', 'Affiliate', 'Order ID', 'Status', 'Commission', 'Currency', 'Created At'];
|
const headers = ['ID', 'Affiliate', 'Order ID', 'Status', 'Commission', 'Currency', 'Campaign', 'Created At'];
|
||||||
const rows = filteredReferrals.map(ref => [
|
const rows = filteredReferrals.map(ref => [
|
||||||
ref.id,
|
ref.id,
|
||||||
ref.affiliate_name || `Affiliate #${ref.affiliate_id}`,
|
ref.affiliate_name || `Affiliate #${ref.affiliate_id}`,
|
||||||
@@ -96,6 +98,7 @@ export default function AffiliatesReferrals() {
|
|||||||
ref.status,
|
ref.status,
|
||||||
ref.commission_amount,
|
ref.commission_amount,
|
||||||
ref.currency,
|
ref.currency,
|
||||||
|
[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / '),
|
||||||
new Date(ref.created_at).toISOString()
|
new Date(ref.created_at).toISOString()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -311,6 +314,7 @@ export default function AffiliatesReferrals() {
|
|||||||
<TableHead>{__('Affiliate')}</TableHead>
|
<TableHead>{__('Affiliate')}</TableHead>
|
||||||
<TableHead>{__('Order ID')}</TableHead>
|
<TableHead>{__('Order ID')}</TableHead>
|
||||||
<TableHead>{__('Status')}</TableHead>
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
<TableHead>{__('Campaign')}</TableHead>
|
||||||
<TableHead>{__('Date')}</TableHead>
|
<TableHead>{__('Date')}</TableHead>
|
||||||
<TableHead className="text-right">{__('Commission')}</TableHead>
|
<TableHead className="text-right">{__('Commission')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -336,6 +340,9 @@ export default function AffiliatesReferrals() {
|
|||||||
{ref.status}
|
{ref.status}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / ') || '—'}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
{new Date(ref.created_at).toLocaleDateString('id-ID', {
|
{new Date(ref.created_at).toLocaleDateString('id-ID', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { toast } from 'sonner';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -43,11 +43,21 @@ interface SoftwareProduct {
|
|||||||
total_downloads: number;
|
total_downloads: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChangelogPoint {
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangelogData {
|
||||||
|
narrative: string;
|
||||||
|
points: ChangelogPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
interface SoftwareVersion {
|
interface SoftwareVersion {
|
||||||
id: number;
|
id: number;
|
||||||
product_id: number;
|
product_id: number;
|
||||||
version: string;
|
version: string;
|
||||||
changelog: string;
|
changelog: ChangelogData | string;
|
||||||
release_date: string;
|
release_date: string;
|
||||||
is_current: boolean;
|
is_current: boolean;
|
||||||
download_count: number;
|
download_count: number;
|
||||||
@@ -72,7 +82,13 @@ export default function SoftwareVersions() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedProduct, setSelectedProduct] = useState<number | null>(null);
|
const [selectedProduct, setSelectedProduct] = useState<number | null>(null);
|
||||||
const [isAddVersionOpen, setIsAddVersionOpen] = useState(false);
|
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();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Fetch software-enabled products
|
// Fetch software-enabled products
|
||||||
@@ -80,15 +96,14 @@ export default function SoftwareVersions() {
|
|||||||
queryKey: ['software-products'],
|
queryKey: ['software-products'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/products?software_enabled=true&per_page=100');
|
const response = await api.get('/products?software_enabled=true&per_page=100');
|
||||||
// Filter products that have software distribution enabled
|
const products = (response as any).rows || [];
|
||||||
const products = (response as any).products || [];
|
|
||||||
return {
|
return {
|
||||||
products: products.filter((p: any) => p.meta?._woonoow_software_enabled === 'yes').map((p: any) => ({
|
products: products.map((p: any) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
slug: p.meta?._woonoow_software_slug || '',
|
slug: p.software_slug || p.meta?._woonoow_software_slug || '',
|
||||||
current_version: p.meta?._woonoow_software_current_version || '',
|
current_version: p.software_current_version || p.meta?._woonoow_software_current_version || '',
|
||||||
wp_enabled: p.meta?._woonoow_software_wp_enabled === 'yes',
|
wp_enabled: p.software_wp_enabled || p.meta?._woonoow_software_wp_enabled === 'yes',
|
||||||
total_downloads: 0,
|
total_downloads: 0,
|
||||||
}))
|
}))
|
||||||
} as ProductsResponse;
|
} as ProductsResponse;
|
||||||
@@ -107,21 +122,121 @@ export default function SoftwareVersions() {
|
|||||||
|
|
||||||
// Add new version mutation
|
// Add new version mutation
|
||||||
const addVersion = useMutation({
|
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);
|
return await api.post(`/software/products/${selectedProduct}/versions`, data);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['software-versions', selectedProduct] });
|
queryClient.invalidateQueries({ queryKey: ['software-versions', selectedProduct] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['software-products'] });
|
queryClient.invalidateQueries({ queryKey: ['software-products'] });
|
||||||
toast.success(__('Version added successfully'));
|
toast.success(__('Version added successfully'));
|
||||||
setIsAddVersionOpen(false);
|
closeModal();
|
||||||
setNewVersion({ version: '', changelog: '' });
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(error.message || __('Failed to add version'));
|
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 =>
|
const filteredProducts = productsData?.products?.filter(p =>
|
||||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
p.slug.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 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 (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{__('Software Versions')}</h1>
|
<h1 className="text-2xl font-bold">{__('Software Versions')}</h1>
|
||||||
@@ -142,7 +268,6 @@ export default function SoftwareVersions() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<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="lg:col-span-1 border rounded-lg bg-card">
|
||||||
<div className="p-4 border-b">
|
<div className="p-4 border-b">
|
||||||
<h2 className="font-semibold mb-3">{__('Software Products')}</h2>
|
<h2 className="font-semibold mb-3">{__('Software Products')}</h2>
|
||||||
@@ -176,8 +301,7 @@ export default function SoftwareVersions() {
|
|||||||
<button
|
<button
|
||||||
key={product.id}
|
key={product.id}
|
||||||
onClick={() => setSelectedProduct(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 items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -186,7 +310,7 @@ export default function SoftwareVersions() {
|
|||||||
{product.slug}
|
{product.slug}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="ml-2">
|
<Badge variant="secondary" className="ml-2 whitespace-nowrap">
|
||||||
v{product.current_version || '—'}
|
v{product.current_version || '—'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +326,6 @@ export default function SoftwareVersions() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Version Details */}
|
|
||||||
<div className="lg:col-span-2 border rounded-lg bg-card">
|
<div className="lg:col-span-2 border rounded-lg bg-card">
|
||||||
{!selectedProduct ? (
|
{!selectedProduct ? (
|
||||||
<div className="flex items-center justify-center h-full min-h-[400px] text-muted-foreground">
|
<div className="flex items-center justify-center h-full min-h-[400px] text-muted-foreground">
|
||||||
@@ -217,7 +340,6 @@ export default function SoftwareVersions() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Version Header */}
|
|
||||||
<div className="p-4 border-b flex items-center justify-between">
|
<div className="p-4 border-b flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">{selectedProductData?.name}</h2>
|
<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>
|
{__('Current version')}: <span className="font-mono">{versionsData?.config?.current_version || '—'}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isAddVersionOpen} onOpenChange={setIsAddVersionOpen}>
|
<Dialog open={isAddVersionOpen} onOpenChange={(open) => !open ? closeModal() : setIsAddVersionOpen(true)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button onClick={openAddModal}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
{__('New Version')}
|
{__('New Version')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{__('Add New Version')}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{editingVersionId ? __('Edit Version') : __('Add New Version')}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{__('Release a new version of')} {selectedProductData?.name}
|
{editingVersionId
|
||||||
|
? `${__('Modify release details for')} ${selectedProductData?.name}`
|
||||||
|
: `${__('Release a new version of')} ${selectedProductData?.name}`}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 px-6 py-4">
|
<div className="space-y-6 px-6 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="version">{__('Version Number')}</Label>
|
<Label htmlFor="version">{__('Version Number')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -248,92 +374,195 @@ export default function SoftwareVersions() {
|
|||||||
value={newVersion.version}
|
value={newVersion.version}
|
||||||
onChange={(e) => setNewVersion(prev => ({ ...prev, version: e.target.value }))}
|
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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<Label htmlFor="changelog">{__('Changelog')}</Label>
|
<div className="space-y-2">
|
||||||
<Textarea
|
<Label htmlFor="narrative">{__('Changelog Narrative (Optional)')}</Label>
|
||||||
id="changelog"
|
<Textarea
|
||||||
placeholder="## What's New - Added new feature - Fixed bug"
|
id="narrative"
|
||||||
value={newVersion.changelog}
|
placeholder={__('Provide a general overview of this release...')}
|
||||||
onChange={(e) => setNewVersion(prev => ({ ...prev, changelog: e.target.value }))}
|
value={newVersion.changelog.narrative}
|
||||||
rows={8}
|
onChange={(e) => setNewVersion(prev => ({
|
||||||
className="font-mono text-sm"
|
...prev,
|
||||||
/>
|
changelog: { ...prev.changelog, narrative: e.target.value }
|
||||||
<p className="text-xs text-muted-foreground">
|
}))}
|
||||||
{__('Supports Markdown formatting')}
|
className="min-h-[100px]"
|
||||||
</p>
|
/>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsAddVersionOpen(false)}>
|
<Button variant="outline" onClick={closeModal} disabled={isSaving}>
|
||||||
{__('Cancel')}
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => addVersion.mutate(newVersion)}
|
onClick={handleSaveVersion}
|
||||||
disabled={!newVersion.version || addVersion.isPending}
|
disabled={!newVersion.version || isSaving}
|
||||||
>
|
>
|
||||||
{addVersion.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
{__('Release Version')}
|
{editingVersionId ? __('Save Changes') : __('Release Version')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Version History */}
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{versionsData?.versions?.length === 0 ? (
|
{versionsData?.versions?.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
<History className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<History className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
<p>{__('No versions released yet')}</p>
|
<p>{__('No versions released yet')}</p>
|
||||||
<p className="text-sm mt-1">
|
|
||||||
{__('Click "New Version" to release your first version')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="w-8"></TableHead>
|
||||||
<TableHead>{__('Version')}</TableHead>
|
<TableHead>{__('Version')}</TableHead>
|
||||||
<TableHead>{__('Release Date')}</TableHead>
|
<TableHead>{__('Release Date')}</TableHead>
|
||||||
<TableHead>{__('Downloads')}</TableHead>
|
<TableHead>{__('Downloads')}</TableHead>
|
||||||
<TableHead>{__('Changelog')}</TableHead>
|
<TableHead>{__('Summary')}</TableHead>
|
||||||
|
<TableHead className="w-12"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{versionsData?.versions?.map((version) => (
|
{versionsData?.versions?.map((version) => {
|
||||||
<TableRow key={version.id}>
|
const isExpanded = !!expandedVersions[version.id];
|
||||||
<TableCell>
|
const cl = typeof version.changelog === 'object' && version.changelog !== null
|
||||||
<div className="flex items-center gap-2">
|
? (version.changelog as ChangelogData)
|
||||||
<span className="font-mono font-medium">
|
: { narrative: version.changelog as string, points: [] };
|
||||||
v{version.version}
|
|
||||||
</span>
|
return (
|
||||||
{version.is_current && (
|
<React.Fragment key={version.id}>
|
||||||
<Badge variant="default" className="text-xs">
|
<TableRow className="cursor-pointer hover:bg-muted/50 group" onClick={() => toggleVersion(version.id)}>
|
||||||
{__('Current')}
|
<TableCell>
|
||||||
</Badge>
|
<Button variant="ghost" size="icon" className="w-6 h-6">
|
||||||
)}
|
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
</div>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell>
|
||||||
{version.release_date ? new Date(version.release_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
<div className="flex items-center gap-2">
|
||||||
</TableCell>
|
<span className="font-mono font-medium">
|
||||||
<TableCell>
|
v{version.version}
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
</span>
|
||||||
<Download className="w-3 h-3" />
|
{version.is_current && (
|
||||||
{version.download_count}
|
<Badge variant="default" className="text-xs">
|
||||||
</div>
|
{__('Current')}
|
||||||
</TableCell>
|
</Badge>
|
||||||
<TableCell className="max-w-xs">
|
)}
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
</div>
|
||||||
{version.changelog?.split('\n')[0] || '—'}
|
</TableCell>
|
||||||
</p>
|
<TableCell className="text-muted-foreground">
|
||||||
</TableCell>
|
{version.release_date ? new Date(version.release_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
|
||||||
</TableRow>
|
</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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { api } from '@/lib/api';
|
|||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
|
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 { toast } from 'sonner';
|
||||||
import { GeneralTab } from './tabs/GeneralTab';
|
import { GeneralTab } from './tabs/GeneralTab';
|
||||||
import { InventoryTab } from './tabs/InventoryTab';
|
import { InventoryTab } from './tabs/InventoryTab';
|
||||||
import { VariationsTab, ProductVariant } from './tabs/VariationsTab';
|
import { VariationsTab, ProductVariant } from './tabs/VariationsTab';
|
||||||
import { OrganizationTab } from './tabs/OrganizationTab';
|
import { OrganizationTab } from './tabs/OrganizationTab';
|
||||||
import { DownloadsTab, DownloadableFile } from './tabs/DownloadsTab';
|
import { DownloadsTab, DownloadableFile } from './tabs/DownloadsTab';
|
||||||
|
import { SoftwareTab } from './tabs/SoftwareTab';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type ProductFormData = {
|
export type ProductFormData = {
|
||||||
@@ -50,6 +51,13 @@ export type ProductFormData = {
|
|||||||
// Affiliate
|
// Affiliate
|
||||||
affiliate_enabled?: boolean;
|
affiliate_enabled?: boolean;
|
||||||
affiliate_commission_rate?: string;
|
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 = {
|
type Props = {
|
||||||
@@ -109,6 +117,13 @@ export function ProductFormTabbed({
|
|||||||
// Affiliate state
|
// Affiliate state
|
||||||
const [affiliateEnabled, setAffiliateEnabled] = useState(initial?.affiliate_enabled || false);
|
const [affiliateEnabled, setAffiliateEnabled] = useState(initial?.affiliate_enabled || false);
|
||||||
const [affiliateCommissionRate, setAffiliateCommissionRate] = useState(initial?.affiliate_commission_rate || '');
|
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);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Update form state when initial data changes (for edit mode)
|
// Update form state when initial data changes (for edit mode)
|
||||||
@@ -149,6 +164,13 @@ export function ProductFormTabbed({
|
|||||||
// Affiliate
|
// Affiliate
|
||||||
setAffiliateEnabled(initial.affiliate_enabled || false);
|
setAffiliateEnabled(initial.affiliate_enabled || false);
|
||||||
setAffiliateCommissionRate(initial.affiliate_commission_rate || '');
|
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]);
|
}, [initial, mode]);
|
||||||
|
|
||||||
@@ -221,6 +243,13 @@ export function ProductFormTabbed({
|
|||||||
// Affiliate
|
// Affiliate
|
||||||
affiliate_enabled: affiliateEnabled,
|
affiliate_enabled: affiliateEnabled,
|
||||||
affiliate_commission_rate: affiliateEnabled ? affiliateCommissionRate : undefined,
|
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);
|
await onSubmit(payload);
|
||||||
@@ -238,6 +267,7 @@ export function ProductFormTabbed({
|
|||||||
...(downloadable ? [{ id: 'downloads', label: __('Downloads'), icon: <Download className="w-4 h-4" /> }] : []),
|
...(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" /> }] : []),
|
...(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: 'organization', label: __('Organization'), icon: <Tag className="w-4 h-4" /> },
|
||||||
|
{ id: 'software', label: __('Software'), icon: <Cloud className="w-4 h-4" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -348,6 +378,24 @@ export function ProductFormTabbed({
|
|||||||
/>
|
/>
|
||||||
</FormSection>
|
</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 */}
|
{/* Submit Button */}
|
||||||
{!hideSubmitButton && (
|
{!hideSubmitButton && (
|
||||||
<div className="mt-6 flex gap-3">
|
<div className="mt-6 flex gap-3">
|
||||||
|
|||||||
115
admin-spa/src/routes/Products/partials/tabs/SoftwareTab.tsx
Normal file
115
admin-spa/src/routes/Products/partials/tabs/SoftwareTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@ export type ProductVariant = {
|
|||||||
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
|
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
|
||||||
image?: string;
|
image?: string;
|
||||||
license_duration_days?: string;
|
license_duration_days?: string;
|
||||||
|
subscription_signup_fee?: string;
|
||||||
|
subscription_trial_days?: string;
|
||||||
|
subscription_period?: 'day' | 'week' | 'month' | 'year';
|
||||||
|
subscription_interval?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VariationsTabProps = {
|
type VariationsTabProps = {
|
||||||
@@ -282,8 +286,83 @@ export function VariationsTab({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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>
|
<Label className="text-xs">{__('License Duration (Days)')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { BaseLayout } from './layouts/BaseLayout';
|
|||||||
// Pages
|
// Pages
|
||||||
import Shop from './pages/Shop';
|
import Shop from './pages/Shop';
|
||||||
import Product from './pages/Product';
|
import Product from './pages/Product';
|
||||||
|
import CollectionPage from './pages/Shop/CollectionPage';
|
||||||
import Cart from './pages/Cart';
|
import Cart from './pages/Cart';
|
||||||
import Checkout from './pages/Checkout';
|
import Checkout from './pages/Checkout';
|
||||||
import ThankYou from './pages/ThankYou';
|
import ThankYou from './pages/ThankYou';
|
||||||
@@ -106,6 +107,7 @@ function AppRoutes() {
|
|||||||
{/* Shop Routes */}
|
{/* Shop Routes */}
|
||||||
<Route path="/shop" element={<Shop />} />
|
<Route path="/shop" element={<Shop />} />
|
||||||
<Route path="/product/:slug" element={<Product />} />
|
<Route path="/product/:slug" element={<Product />} />
|
||||||
|
<Route path="/collection/:slug" element={<CollectionPage />} />
|
||||||
|
|
||||||
{/* Cart & Checkout */}
|
{/* Cart & Checkout */}
|
||||||
<Route path="/cart" element={<Cart />} />
|
<Route path="/cart" element={<Cart />} />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface Address {
|
|||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
|
formatted_address?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddressSelectorProps {
|
interface AddressSelectorProps {
|
||||||
@@ -148,14 +149,22 @@ export function AddressSelector({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Address */}
|
{/* Address */}
|
||||||
<p className="text-sm text-gray-600 mt-2">
|
<div className="text-sm text-gray-600 mt-2">
|
||||||
{address.address_1}
|
{address.formatted_address ? (
|
||||||
{address.address_2 && `, ${address.address_2}`}
|
<p className="whitespace-pre-wrap">{address.formatted_address}</p>
|
||||||
</p>
|
) : (
|
||||||
<p className="text-sm text-gray-600">
|
<>
|
||||||
{address.city}, {address.state} {address.postcode}
|
<p>
|
||||||
</p>
|
{address.address_1}
|
||||||
<p className="text-sm text-gray-600">{address.country}</p>
|
{address.address_2 && `, ${address.address_2}`}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{address.city}, {address.state} {address.postcode}
|
||||||
|
</p>
|
||||||
|
<p>{address.country}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface CheckoutField {
|
|||||||
interface DynamicCheckoutFieldProps {
|
interface DynamicCheckoutFieldProps {
|
||||||
field: CheckoutField;
|
field: CheckoutField;
|
||||||
value: string;
|
value: string;
|
||||||
|
valueLabel?: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
countryOptions?: { value: string; label: string }[];
|
countryOptions?: { value: string; label: string }[];
|
||||||
stateOptions?: { value: string; label: string }[];
|
stateOptions?: { value: string; label: string }[];
|
||||||
@@ -41,6 +42,7 @@ interface SearchOption {
|
|||||||
export function DynamicCheckoutField({
|
export function DynamicCheckoutField({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
|
valueLabel,
|
||||||
onChange,
|
onChange,
|
||||||
countryOptions = [],
|
countryOptions = [],
|
||||||
stateOptions = [],
|
stateOptions = [],
|
||||||
@@ -54,9 +56,11 @@ export function DynamicCheckoutField({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a value but no options yet, we might need to load it
|
// If we have a value and a label, inject it into searchOptions so it renders properly when mounted
|
||||||
// This handles pre-selected values
|
if (value && valueLabel && searchOptions.length === 0) {
|
||||||
}, [field.type, field.search_endpoint, value]);
|
setSearchOptions([{ value, label: valueLabel }]);
|
||||||
|
}
|
||||||
|
}, [field.type, field.search_endpoint, value, valueLabel]);
|
||||||
|
|
||||||
// Handle API search for searchable_select
|
// Handle API search for searchable_select
|
||||||
const handleApiSearch = async (searchTerm: string) => {
|
const handleApiSearch = async (searchTerm: string) => {
|
||||||
|
|||||||
26
customer-spa/src/components/SectionBackgroundRenderer.tsx
Normal file
26
customer-spa/src/components/SectionBackgroundRenderer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ interface SharedContentProps {
|
|||||||
textClassName?: string;
|
textClassName?: string;
|
||||||
headingStyle?: React.CSSProperties; // For prose headings override
|
headingStyle?: React.CSSProperties; // For prose headings override
|
||||||
imageStyle?: React.CSSProperties;
|
imageStyle?: React.CSSProperties;
|
||||||
|
cardStyle?: React.CSSProperties; // For boxed layout background
|
||||||
|
|
||||||
// Pro Features (for future)
|
// Pro Features (for future)
|
||||||
buttons?: Array<{ text: string, url: string }>;
|
buttons?: Array<{ text: string, url: string }>;
|
||||||
@@ -44,6 +45,7 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
|||||||
buttons,
|
buttons,
|
||||||
|
|
||||||
imageStyle,
|
imageStyle,
|
||||||
|
cardStyle,
|
||||||
buttonStyle
|
buttonStyle
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
@@ -53,186 +55,129 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
|||||||
const isImageTop = imagePosition === 'top';
|
const isImageTop = imagePosition === 'top';
|
||||||
const isImageBottom = imagePosition === 'bottom';
|
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(
|
const containerClasses = cn(
|
||||||
'w-full mx-auto px-4 sm:px-6 lg:px-8',
|
'w-full mx-auto px-4 sm:px-6 lg:px-8',
|
||||||
containerWidth === 'contained' ? 'max-w-4xl'
|
containerWidth === 'contained' ? 'max-w-4xl' : '',
|
||||||
: containerWidth === 'boxed' ? 'max-w-5xl'
|
containerWidth === 'boxed' ? 'max-w-5xl' : ''
|
||||||
: '' // full = no max-width cap
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const gridClasses = cn(
|
const gridClasses = cn(
|
||||||
'mx-auto',
|
'mx-auto w-full',
|
||||||
hasImage && (isImageLeft || isImageRight)
|
hasImage && (isImageLeft || isImageRight)
|
||||||
? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center'
|
? '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 imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
|
||||||
|
|
||||||
|
const safeTextStyle = { ...textStyle };
|
||||||
|
delete safeTextStyle.textAlign;
|
||||||
|
|
||||||
const proseStyle = {
|
const proseStyle = {
|
||||||
...textStyle,
|
...safeTextStyle,
|
||||||
'--tw-prose-headings': headingStyle?.color,
|
'--tw-prose-headings': headingStyle?.color,
|
||||||
'--tw-prose-body': textStyle?.color,
|
'--tw-prose-body': textStyle?.color,
|
||||||
} as React.CSSProperties;
|
} as React.CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClasses}>
|
<div className={containerClasses}>
|
||||||
{containerWidth === 'boxed' ? (
|
<div className={gridClasses}>
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
|
{/* Image Side */}
|
||||||
<div className={gridClasses}>
|
{hasImage && (
|
||||||
{/* Image Side */}
|
<div className={cn(
|
||||||
{hasImage && (
|
'flex flex-col',
|
||||||
<div className={cn(
|
imageWrapperOrder,
|
||||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
(isImageTop || isImageBottom) && 'mb-8',
|
||||||
imageWrapperOrder,
|
{
|
||||||
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
|
'items-start': (imageStyle as any)?.alignment === 'left',
|
||||||
)} style={imageStyle}>
|
'items-center': (imageStyle as any)?.alignment === 'center',
|
||||||
<img
|
'items-end': (imageStyle as any)?.alignment === 'right',
|
||||||
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={cn(
|
<div className={cn(
|
||||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||||
imageWrapperOrder,
|
)} style={{
|
||||||
(isImageTop || isImageBottom) && 'mb-8'
|
backgroundColor: imageStyle?.backgroundColor,
|
||||||
)} style={imageStyle}>
|
width: imageStyle?.width,
|
||||||
|
height: imageStyle?.height,
|
||||||
|
maxWidth: '100%'
|
||||||
|
}}>
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt={title || 'Section Image'}
|
alt={title || 'Section Image'}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
style={{
|
||||||
|
objectFit: imageStyle?.objectFit,
|
||||||
|
objectPosition: (imageStyle as any)?.objectPosition,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{text && (
|
||||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
<div
|
||||||
{title && (
|
className={cn(
|
||||||
<h2
|
'prose prose-lg max-w-none w-full',
|
||||||
className={cn(
|
'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',
|
||||||
"tracking-tight text-current mb-6",
|
'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',
|
||||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
'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',
|
||||||
titleClassName
|
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||||
)}
|
'prose-p:text-[var(--tw-prose-body)]',
|
||||||
style={titleStyle}
|
'text-[var(--tw-prose-body)]',
|
||||||
>
|
className,
|
||||||
{title}
|
textClassName
|
||||||
</h2>
|
)}
|
||||||
)}
|
style={proseStyle}
|
||||||
|
dangerouslySetInnerHTML={{ __html: text }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{text && (
|
{/* Buttons */}
|
||||||
<div
|
{buttons && buttons.length > 0 && (
|
||||||
className={cn(
|
<div className={cn(
|
||||||
'prose prose-lg max-w-none',
|
"mt-8 flex flex-wrap gap-4",
|
||||||
'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',
|
buttonStyle?.style?.textAlign === 'center' && "justify-center",
|
||||||
'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',
|
buttonStyle?.style?.textAlign === 'right' && "justify-end",
|
||||||
'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',
|
(!buttonStyle?.style?.textAlign || buttonStyle?.style?.textAlign === 'left') && "justify-start"
|
||||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
)}>
|
||||||
'prose-p:text-[var(--tw-prose-body)]',
|
{buttons.map((btn, idx) => (
|
||||||
'text-[var(--tw-prose-body)]',
|
btn.text && btn.url && (
|
||||||
className,
|
<a
|
||||||
textClassName
|
key={idx}
|
||||||
)}
|
href={btn.url}
|
||||||
style={proseStyle}
|
className={cn(
|
||||||
dangerouslySetInnerHTML={{ __html: text }}
|
"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
|
||||||
{/* Buttons */}
|
)}
|
||||||
{buttons && buttons.length > 0 && (
|
style={buttonStyle?.style}
|
||||||
<div className="mt-8 flex flex-wrap gap-4">
|
>
|
||||||
{buttons.map((btn, idx) => (
|
{btn.text}
|
||||||
btn.text && btn.url && (
|
</a>
|
||||||
<a
|
)
|
||||||
key={idx}
|
))}
|
||||||
href={btn.url}
|
</div>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const endpoints = {
|
|||||||
product: (id: number) => `/shop/products/${id}`,
|
product: (id: number) => `/shop/products/${id}`,
|
||||||
categories: '/shop/categories',
|
categories: '/shop/categories',
|
||||||
search: '/shop/search',
|
search: '/shop/search',
|
||||||
|
collection: (slug: string) => `/shop/collections/${slug}`,
|
||||||
},
|
},
|
||||||
cart: {
|
cart: {
|
||||||
get: '/cart',
|
get: '/cart',
|
||||||
@@ -115,6 +116,7 @@ const endpoints = {
|
|||||||
profile: '/account/profile',
|
profile: '/account/profile',
|
||||||
password: '/account/password',
|
password: '/account/password',
|
||||||
addresses: '/account/addresses',
|
addresses: '/account/addresses',
|
||||||
|
affiliateCollections: '/account/affiliate/collections',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -345,10 +345,18 @@ export default function Addresses() {
|
|||||||
<div className="text-sm text-gray-700 space-y-1 mb-4">
|
<div className="text-sm text-gray-700 space-y-1 mb-4">
|
||||||
<p className="font-medium">{address.first_name} {address.last_name}</p>
|
<p className="font-medium">{address.first_name} {address.last_name}</p>
|
||||||
{address.company && <p>{address.company}</p>}
|
{address.company && <p>{address.company}</p>}
|
||||||
<p>{address.address_1}</p>
|
{address.formatted_address ? (
|
||||||
{address.address_2 && <p>{address.address_2}</p>}
|
<p className="whitespace-pre-wrap">{address.formatted_address}</p>
|
||||||
<p>{address.city}, {address.state} {address.postcode}</p>
|
) : (
|
||||||
<p>{address.country}</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.phone && <p className="pt-2">Phone: {address.phone}</p>}
|
||||||
{address.email && <p>Email: {address.email}</p>}
|
{address.email && <p>Email: {address.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -429,6 +437,7 @@ export default function Addresses() {
|
|||||||
key={field.key}
|
key={field.key}
|
||||||
field={field}
|
field={field}
|
||||||
value={getFieldValue(field.key)}
|
value={getFieldValue(field.key)}
|
||||||
|
valueLabel={getFieldValue(field.key + '_label')}
|
||||||
onChange={(v) => setFieldValue(field.key, v)}
|
onChange={(v) => setFieldValue(field.key, v)}
|
||||||
countryOptions={countryOptions}
|
countryOptions={countryOptions}
|
||||||
stateOptions={stateOptions}
|
stateOptions={stateOptions}
|
||||||
|
|||||||
422
customer-spa/src/pages/Account/AffiliateCollections.tsx
Normal file
422
customer-spa/src/pages/Account/AffiliateCollections.tsx
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Plus, Trash2, Edit2, Link as LinkIcon, Search, Copy, CheckCircle, X, ChevronLeft } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
image?: string;
|
||||||
|
price_html?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Collection {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
product_ids: number[];
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AffiliateCollections() {
|
||||||
|
const config = (window as any).woonoowCustomer || {};
|
||||||
|
const enableCuratedCollections = config.affiliateSettings?.enableCuratedCollections !== false;
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [editingCollection, setEditingCollection] = useState<Collection | null>(null);
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [selectedProducts, setSelectedProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: collections, isLoading: isLoadingCollections } = useQuery<Collection[]> ({
|
||||||
|
queryKey: ['affiliate-collections'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res: any = await api.get('/account/affiliate/collections');
|
||||||
|
return Array.isArray(res) ? res : [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: searchResults, isLoading: isSearching } = useQuery({
|
||||||
|
queryKey: ['collection-product-search', debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!debouncedSearch) return [];
|
||||||
|
try {
|
||||||
|
const res: any = await api.get(`/shop/products?search=${encodeURIComponent(debouncedSearch)}&per_page=5`);
|
||||||
|
return res.products || [];
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: debouncedSearch.length > 2,
|
||||||
|
placeholderData: keepPreviousData
|
||||||
|
});
|
||||||
|
|
||||||
|
// When editing, fetch details of products so we can show their names/images
|
||||||
|
const { data: editingProducts } = useQuery({
|
||||||
|
queryKey: ['collection-editing-products', editingCollection?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!editingCollection || editingCollection.product_ids.length === 0) return [];
|
||||||
|
const res: any = await api.get(`/shop/products?include=${editingCollection.product_ids.join(',')}&per_page=20`);
|
||||||
|
return res.products || [];
|
||||||
|
},
|
||||||
|
enabled: !!editingCollection
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-fill form when editingProducts is loaded
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (editingCollection && editingProducts) {
|
||||||
|
setTitle(editingCollection.title);
|
||||||
|
setDescription(editingCollection.description);
|
||||||
|
setSelectedProducts(editingProducts);
|
||||||
|
}
|
||||||
|
}, [editingCollection, editingProducts]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingCollection(null);
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setSelectedProducts([]);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (data: any) => {
|
||||||
|
return api.post('/account/affiliate/collections', data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
|
||||||
|
toast.success('Collection created successfully!');
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err.message || 'Failed to create collection');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, data }: { id: number, data: any }) => {
|
||||||
|
return api.put(`/account/affiliate/collections/${id}`, data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
|
||||||
|
toast.success('Collection updated successfully!');
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err.message || 'Failed to update collection');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
return api.delete(`/account/affiliate/collections/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
|
||||||
|
toast.success('Collection deleted!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!title) {
|
||||||
|
toast.error('Title is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedProducts.length > 20) {
|
||||||
|
toast.error('Maximum 20 products allowed per collection');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
product_ids: selectedProducts.map(p => p.id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingCollection) {
|
||||||
|
updateMutation.mutate({ id: editingCollection.id, data });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleProduct = (product: Product) => {
|
||||||
|
const exists = selectedProducts.find(p => p.id === product.id);
|
||||||
|
if (exists) {
|
||||||
|
setSelectedProducts(prev => prev.filter(p => p.id !== product.id));
|
||||||
|
} else {
|
||||||
|
if (selectedProducts.length >= 20) {
|
||||||
|
toast.error('Maximum 20 products allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedProducts(prev => [...prev, product]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (link: string, id: string) => {
|
||||||
|
navigator.clipboard.writeText(link);
|
||||||
|
setCopiedId(id);
|
||||||
|
toast.success('Link copied to clipboard!');
|
||||||
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingCollections) return <div>Loading collections...</div>;
|
||||||
|
|
||||||
|
if (!enableCuratedCollections) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mb-2">
|
||||||
|
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">My Curated Collections</h2>
|
||||||
|
<p className="text-muted-foreground">This feature has been disabled by the administrator.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mb-2">
|
||||||
|
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">My Curated Collections</h2>
|
||||||
|
<p className="text-muted-foreground">Group your favorite products into a single shareable link.</p>
|
||||||
|
</div>
|
||||||
|
{!isFormOpen && (
|
||||||
|
<Button onClick={() => setIsFormOpen(true)} className="gap-2">
|
||||||
|
<Plus className="w-4 h-4" /> New Collection
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFormOpen && (
|
||||||
|
<div className="bg-card border rounded-lg p-6 space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{editingCollection ? 'Edit Collection' : 'Create New Collection'}
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="icon" onClick={resetForm}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<label className="text-sm font-medium">Collection Title</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., My Summer Favorites"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<label className="text-sm font-medium">Description (Optional)</label>
|
||||||
|
<textarea
|
||||||
|
placeholder="Tell your audience why you love these products..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<label className="text-sm font-medium">Select Products ({selectedProducts.length}/20)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Products Area */}
|
||||||
|
{selectedProducts.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{selectedProducts.map(p => (
|
||||||
|
<div key={p.id} className="flex items-center gap-2 bg-secondary text-secondary-foreground text-xs rounded-full pl-2 pr-1 py-1">
|
||||||
|
<span className="truncate max-w-[150px]">{p.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleProduct(p)}
|
||||||
|
className="hover:bg-background/20 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search products to add..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results Dropdown */}
|
||||||
|
{searchQuery && (
|
||||||
|
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
|
||||||
|
{isSearching && <div className="p-3 text-sm text-center text-muted-foreground">Searching...</div>}
|
||||||
|
{!isSearching && searchResults?.length === 0 && (
|
||||||
|
<div className="p-3 text-sm text-center text-muted-foreground">No products found.</div>
|
||||||
|
)}
|
||||||
|
{searchResults?.map((product: Product) => {
|
||||||
|
const isSelected = selectedProducts.some(p => p.id === product.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={product.id}
|
||||||
|
className={`p-3 flex items-center justify-between hover:bg-muted/50 cursor-pointer transition-colors ${isSelected ? 'bg-primary/5' : ''}`}
|
||||||
|
onClick={() => toggleProduct(product)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
{product.image ? (
|
||||||
|
<img src={product.image} alt={product.name} className="w-10 h-10 object-cover rounded" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 bg-muted rounded flex-shrink-0"></div>
|
||||||
|
)}
|
||||||
|
<div className="truncate">
|
||||||
|
<div className="font-medium truncate text-sm">{product.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground" dangerouslySetInnerHTML={{ __html: product.price_html || '' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSelected && <CheckCircle className="w-4 h-4 text-primary flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button variant="outline" onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{(createMutation.isPending || updateMutation.isPending) ? 'Saving...' : 'Save Collection'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isFormOpen && (!collections || collections.length === 0) ? (
|
||||||
|
<div className="text-center py-12 bg-muted/30 rounded-lg border border-dashed">
|
||||||
|
<h3 className="font-semibold mb-2">No collections yet</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Create a curated list of products to share with your audience.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setIsFormOpen(true)}>
|
||||||
|
Create First Collection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{collections?.map(collection => (
|
||||||
|
<div key={collection.id} className="border rounded-lg p-5 bg-card flex flex-col">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg line-clamp-1">{collection.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{collection.product_ids.length} products
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCollection(collection);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => {
|
||||||
|
if(window.confirm('Delete this collection?')) {
|
||||||
|
deleteMutation.mutate(collection.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collection.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">
|
||||||
|
{collection.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-auto pt-4 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-gray-500">Collection Link (Shows all products)</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={collection.link}
|
||||||
|
className="bg-muted h-9 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 shrink-0"
|
||||||
|
variant={copiedId === `col-${collection.id}` ? "default" : "outline"}
|
||||||
|
onClick={() => copyToClipboard(collection.link, `col-${collection.id}`)}
|
||||||
|
>
|
||||||
|
{copiedId === `col-${collection.id}` ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-gray-500">Smart Link (Redirects to random product)</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={`${window.location.origin}/go/${collection.slug}`}
|
||||||
|
className="bg-muted h-9 text-xs font-mono border-primary/20"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 shrink-0"
|
||||||
|
variant={copiedId === `smart-${collection.id}` ? "default" : "outline"}
|
||||||
|
onClick={() => copyToClipboard(`${window.location.origin}/go/${collection.slug}`, `smart-${collection.id}`)}
|
||||||
|
>
|
||||||
|
{copiedId === `smart-${collection.id}` ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { api } from '@/lib/api/client';
|
import { api } from '@/lib/api/client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard } from 'lucide-react';
|
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard, Tag } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { formatPrice, getCurrencySettings } from '@/lib/currency';
|
import { formatPrice, getCurrencySettings } from '@/lib/currency';
|
||||||
@@ -17,6 +17,7 @@ interface AffiliateProfile {
|
|||||||
global_commission_rate: number;
|
global_commission_rate: number;
|
||||||
total_earnings: number;
|
total_earnings: number;
|
||||||
pending_earnings: number;
|
pending_earnings: number;
|
||||||
|
collections_enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginatedReferrals {
|
interface PaginatedReferrals {
|
||||||
@@ -430,7 +431,25 @@ export default function AffiliateDashboard() {
|
|||||||
|
|
||||||
{/* Referral Link */}
|
{/* Referral Link */}
|
||||||
<div className="bg-white p-6 rounded-lg border">
|
<div className="bg-white p-6 rounded-lg border">
|
||||||
<h3 className="text-lg font-semibold mb-4">Your Referral Link</h3>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">Your Referral Link</h3>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{profile?.collections_enabled !== false && (
|
||||||
|
<Link
|
||||||
|
to="/my-account/affiliate/collections"
|
||||||
|
className="text-sm font-medium text-primary hover:opacity-80 flex items-center"
|
||||||
|
>
|
||||||
|
My Collections & Smart Links <ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to="/my-account/affiliate/links"
|
||||||
|
className="text-sm font-medium text-primary hover:opacity-80 flex items-center"
|
||||||
|
>
|
||||||
|
Build Links <ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={referralLink}
|
value={referralLink}
|
||||||
@@ -509,6 +528,12 @@ export default function AffiliateDashboard() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{(ref.utm_campaign || ref.utm_source) && (
|
||||||
|
<span className="flex items-center gap-1 text-purple-600">
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
{[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
|
|||||||
60
customer-spa/src/pages/Account/AffiliateLinks.tsx
Normal file
60
customer-spa/src/pages/Account/AffiliateLinks.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
import { AffiliateLinkBuilder } from './components/AffiliateLinkBuilder';
|
||||||
|
|
||||||
|
interface AffiliateProfile {
|
||||||
|
status: string;
|
||||||
|
referral_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AffiliateLinks() {
|
||||||
|
const { data: profile, isLoading } = useQuery<AffiliateProfile | null>({
|
||||||
|
queryKey: ['affiliate-profile'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await api.get('/account/affiliate');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="animate-pulse h-64 bg-gray-100 rounded-lg"></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile || profile.status !== 'active') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold">Link Builder</h2>
|
||||||
|
<p className="text-gray-500">You do not have an active affiliate account.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">Affiliate Link Builder</h2>
|
||||||
|
<p className="text-muted-foreground mt-1">Create custom links to products and track your campaigns.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AffiliateLinkBuilder referralCode={profile.referral_code} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Copy, CheckCircle, Link as LinkIcon, Search } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
|
||||||
|
interface AffiliateLinkBuilderProps {
|
||||||
|
referralCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AffiliateLinkBuilder({ referralCode }: AffiliateLinkBuilderProps) {
|
||||||
|
const [linkType, setLinkType] = useState<'store' | 'product' | 'category'>('store');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<{ id: number; name: string; slug: string } | null>(null);
|
||||||
|
|
||||||
|
// UTM parameters
|
||||||
|
const [utmSource, setUtmSource] = useState('');
|
||||||
|
const [utmMedium, setUtmMedium] = useState('');
|
||||||
|
const [utmCampaign, setUtmCampaign] = useState('');
|
||||||
|
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Fetch products or categories based on search
|
||||||
|
const { data: searchResults, isLoading: isSearching } = useQuery({
|
||||||
|
queryKey: ['affiliate-search', linkType, debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!debouncedSearch || linkType === 'store') return [];
|
||||||
|
|
||||||
|
const endpoint = linkType === 'product' ? '/shop/products' : '/shop/categories';
|
||||||
|
try {
|
||||||
|
// Assuming standard WP/WC REST API format for search
|
||||||
|
const res: any = await api.get(`${endpoint}?search=${encodeURIComponent(debouncedSearch)}&per_page=5`);
|
||||||
|
if (linkType === 'product') {
|
||||||
|
return res.products || [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res) ? res : (res.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to search", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: debouncedSearch.length > 2 && linkType !== 'store',
|
||||||
|
placeholderData: keepPreviousData
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset selected item when link type changes
|
||||||
|
const handleLinkTypeChange = (type: 'store' | 'product' | 'category') => {
|
||||||
|
setLinkType(type);
|
||||||
|
setSelectedItem(null);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the final link
|
||||||
|
const buildLink = () => {
|
||||||
|
const config = (window as any).woonoowCustomer || {};
|
||||||
|
const basePath = config.basePath || '';
|
||||||
|
let path = `${basePath}/shop`;
|
||||||
|
|
||||||
|
if (linkType === 'product' && selectedItem) {
|
||||||
|
path = `${basePath}/product/${selectedItem.slug}`;
|
||||||
|
} else if (linkType === 'category' && selectedItem) {
|
||||||
|
path = `${basePath}/shop?category=${selectedItem.slug}`; // using query parameter for category as typical for shop
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`${window.location.origin}${path}`);
|
||||||
|
url.searchParams.set('ref', referralCode);
|
||||||
|
|
||||||
|
if (utmSource) url.searchParams.set('utm_source', utmSource);
|
||||||
|
if (utmMedium) url.searchParams.set('utm_medium', utmMedium);
|
||||||
|
if (utmCampaign) url.searchParams.set('utm_campaign', utmCampaign);
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalLink = buildLink();
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(finalLink);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success('Enriched link copied to clipboard!');
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-6 rounded-lg border mt-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<LinkIcon className="w-5 h-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Affiliate Link Builder</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Create specific links to products or categories, and add campaign tags to track your performance.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Link Type Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">Link Destination</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{['store', 'product', 'category'].map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => handleLinkTypeChange(type as any)}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm border capitalize transition-colors ${
|
||||||
|
linkType === type
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-white hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type === 'store' ? 'General Store' : `Specific ${type}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search / Selection */}
|
||||||
|
{linkType !== 'store' && (
|
||||||
|
<div className="relative">
|
||||||
|
<label className="text-sm font-medium mb-2 block">
|
||||||
|
Select {linkType === 'product' ? 'Product' : 'Category'}
|
||||||
|
</label>
|
||||||
|
{!selectedItem ? (
|
||||||
|
<div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-3 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={`Search for a ${linkType}...`}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSearching && <div className="text-sm text-gray-500 mt-2">Searching...</div>}
|
||||||
|
|
||||||
|
{searchResults && searchResults.length > 0 && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
{searchResults.map((item: any) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50 border-b last:border-0"
|
||||||
|
onClick={() => setSelectedItem({ id: item.id, name: item.name, slug: item.slug })}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between p-3 border rounded-md bg-gray-50">
|
||||||
|
<span className="text-sm font-medium">{selectedItem.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedItem(null)}
|
||||||
|
className="text-xs text-red-500 hover:underline"
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Campaign Tracking (UTMs) */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-3 block">Campaign Tracking (Optional)</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
placeholder="Source (e.g. instagram)"
|
||||||
|
value={utmSource}
|
||||||
|
onChange={(e) => setUtmSource(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
placeholder="Medium (e.g. story, bio)"
|
||||||
|
value={utmMedium}
|
||||||
|
onChange={(e) => setUtmMedium(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
placeholder="Campaign (e.g. summer_sale)"
|
||||||
|
value={utmCampaign}
|
||||||
|
onChange={(e) => setUtmCampaign(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Final Link Output */}
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<label className="text-sm font-medium mb-2 block">Your Generated Link</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={finalLink}
|
||||||
|
readOnly
|
||||||
|
className="bg-gray-50 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleCopy} className="shrink-0 w-32">
|
||||||
|
{copied ? (
|
||||||
|
<><CheckCircle className="w-4 h-4 mr-2" /> Copied</>
|
||||||
|
) : (
|
||||||
|
<><Copy className="w-4 h-4 mr-2" /> Copy Link</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import Subscriptions from './Subscriptions';
|
|||||||
import SubscriptionDetail from './SubscriptionDetail';
|
import SubscriptionDetail from './SubscriptionDetail';
|
||||||
import AffiliateDashboard from './AffiliateDashboard';
|
import AffiliateDashboard from './AffiliateDashboard';
|
||||||
import AffiliateReferrals from './AffiliateReferrals';
|
import AffiliateReferrals from './AffiliateReferrals';
|
||||||
|
import AffiliateLinks from './AffiliateLinks';
|
||||||
|
import { AffiliateCollections } from './AffiliateCollections';
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
@@ -46,6 +48,8 @@ export default function Account() {
|
|||||||
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
||||||
<Route path="affiliate" element={<AffiliateDashboard />} />
|
<Route path="affiliate" element={<AffiliateDashboard />} />
|
||||||
<Route path="affiliate/referrals" element={<AffiliateReferrals />} />
|
<Route path="affiliate/referrals" element={<AffiliateReferrals />} />
|
||||||
|
<Route path="affiliate/links" element={<AffiliateLinks />} />
|
||||||
|
<Route path="affiliate/collections" element={<AffiliateCollections />} />
|
||||||
<Route path="account-details" element={<AccountDetails />} />
|
<Route path="account-details" element={<AccountDetails />} />
|
||||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface SavedAddress {
|
|||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
|
formatted_address?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Checkout() {
|
export default function Checkout() {
|
||||||
@@ -389,7 +390,13 @@ export default function Checkout() {
|
|||||||
state: addressData.state,
|
state: addressData.state,
|
||||||
city: addressData.city,
|
city: addressData.city,
|
||||||
postcode: addressData.postcode,
|
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,
|
items,
|
||||||
});
|
});
|
||||||
@@ -795,7 +802,16 @@ export default function Checkout() {
|
|||||||
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4 text-sm">
|
<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 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>{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>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
@@ -866,7 +882,7 @@ export default function Checkout() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{billingCustomFields.map(field => (
|
{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">
|
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4 text-sm">
|
||||||
<p className="font-semibold">{sel.label}</p>
|
<p className="font-semibold">{sel.label}</p>
|
||||||
<p>{sel.first_name} {sel.last_name}</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>
|
</div>
|
||||||
) : null;
|
) : 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_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_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>}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api/client';
|
import { api } from '@/lib/api/client';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// Section Components
|
// Section Components
|
||||||
import { HeroSection } from './sections/HeroSection';
|
import { HeroSection } from './sections/HeroSection';
|
||||||
@@ -30,7 +31,16 @@ interface SectionStyles {
|
|||||||
backgroundOverlay?: number;
|
backgroundOverlay?: number;
|
||||||
paddingTop?: string;
|
paddingTop?: string;
|
||||||
paddingBottom?: 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 {
|
interface ElementStyle {
|
||||||
@@ -266,15 +276,28 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={section.id}
|
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={{
|
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,
|
paddingTop: section.styles?.paddingTop,
|
||||||
paddingBottom: section.styles?.paddingBottom,
|
paddingBottom: section.styles?.paddingBottom,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Full-bleed background image & overlay */}
|
{/* Full-bleed background image & overlay */}
|
||||||
{section.styles?.backgroundImage && (section.styles.backgroundType === 'image' || !section.styles.backgroundType) && (
|
{section.styles?.backgroundType === 'image' && section.styles?.backgroundImage && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
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 */}
|
{/* Content Wrapper */}
|
||||||
<div className="relative z-10 w-full">
|
{section.styles?.contentWidth === 'boxed' ? (
|
||||||
<SectionComponent
|
<div className="relative z-10 container mx-auto px-4 max-w-5xl">
|
||||||
id={section.id}
|
<div
|
||||||
section={section}
|
className="rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
|
||||||
layout={section.layoutVariant || 'default'}
|
style={{
|
||||||
colorScheme={section.colorScheme || 'default'}
|
backgroundColor: section.styles?.cardBackgroundColor || '#ffffff',
|
||||||
styles={section.styles}
|
paddingTop: section.styles?.cardPaddingTop || undefined,
|
||||||
elementStyles={section.elementStyles}
|
paddingRight: section.styles?.cardPaddingRight || undefined,
|
||||||
{...flattenSectionProps(section.props || {})}
|
paddingBottom: section.styles?.cardPaddingBottom || undefined,
|
||||||
/>
|
paddingLeft: section.styles?.cardPaddingLeft || undefined,
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||||
|
|
||||||
interface BentoItem {
|
interface BentoItem {
|
||||||
label: string;
|
label: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
size?: 'small' | 'medium' | 'large' | 'tall';
|
size?: 'small' | 'medium' | 'large' | 'tall';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +59,6 @@ export function BentoCategoryGrid({
|
|||||||
styles,
|
styles,
|
||||||
elementStyles,
|
elementStyles,
|
||||||
}: BentoCategoryGridProps) {
|
}: BentoCategoryGridProps) {
|
||||||
const sectionBg = getSectionBackground(styles);
|
|
||||||
// Keep initial demo layout stable: merge configured items over demo items by index.
|
// 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.
|
// This prevents the preview grid from "collapsing" when the first item is added.
|
||||||
const displayItems: BentoItem[] = (() => {
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
className="wn-section wn-bento-grid py-12 md:py-16"
|
className={cn("wn-section wn-bento-grid relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
|
||||||
style={sectionBg.style}
|
|
||||||
>
|
>
|
||||||
<div className="container mx-auto px-4 max-w-7xl">
|
<div className="w-full mx-auto px-4 relative z-10">
|
||||||
{title && (
|
{title && (
|
||||||
<h2
|
<h2
|
||||||
className="text-3xl md:text-4xl font-bold mb-8"
|
className={cn(
|
||||||
style={{ color: elementStyles?.title?.color }}
|
"mb-8",
|
||||||
|
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||||
|
!elementStyles?.title?.fontWeight && "font-bold",
|
||||||
|
titleStyle.classNames
|
||||||
|
)}
|
||||||
|
style={titleStyle.style}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -105,6 +138,13 @@ export function BentoCategoryGrid({
|
|||||||
alt={item.label}
|
alt={item.label}
|
||||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
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)} />
|
<div className={cn('absolute inset-0 bg-gradient-to-br', gradientClass)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||||
|
|
||||||
interface CTABannerSectionProps {
|
interface CTABannerSectionProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,7 +23,9 @@ export function CTABannerSection({
|
|||||||
button_url,
|
button_url,
|
||||||
elementStyles,
|
elementStyles,
|
||||||
styles,
|
styles,
|
||||||
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
|
isEditor,
|
||||||
|
}: CTABannerSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
|
||||||
|
|
||||||
const heightMap: Record<string, string> = {
|
const heightMap: Record<string, string> = {
|
||||||
'default': 'py-12 md:py-20',
|
'default': 'py-12 md:py-20',
|
||||||
'small': 'py-8 md:py-12',
|
'small': 'py-8 md:py-12',
|
||||||
@@ -65,7 +68,7 @@ export function CTABannerSection({
|
|||||||
{title && (
|
{title && (
|
||||||
<h2
|
<h2
|
||||||
className={cn(
|
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?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
|
||||||
!elementStyles?.title?.fontWeight && "font-bold",
|
!elementStyles?.title?.fontWeight && "font-bold",
|
||||||
titleStyle.classNames
|
titleStyle.classNames
|
||||||
@@ -78,93 +81,66 @@ export function CTABannerSection({
|
|||||||
|
|
||||||
{text && (
|
{text && (
|
||||||
<p className={cn(
|
<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",
|
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
|
||||||
styles?.contentWidth !== 'boxed' && {
|
styles?.contentWidth !== 'boxed' && {
|
||||||
'text-white/90': colorScheme === 'primary',
|
'text-white/90': colorScheme === 'primary',
|
||||||
'text-gray-600': colorScheme === 'muted',
|
'text-gray-600': colorScheme === 'muted',
|
||||||
|
'text-gray-700': colorScheme === 'default',
|
||||||
},
|
},
|
||||||
styles?.contentWidth === 'boxed' && 'text-gray-600',
|
styles?.contentWidth === 'boxed' && 'text-gray-600',
|
||||||
textStyle.classNames
|
textStyle.classNames
|
||||||
)}
|
)}
|
||||||
style={textStyle.style}
|
style={textStyle.style}
|
||||||
>
|
>
|
||||||
{text}
|
{text || "Description text missing"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{button_text && button_url && (
|
{button_text && button_url && (
|
||||||
<a
|
<div className="w-full mt-4" style={{ textAlign: (btnStyle.style.textAlign as React.CSSProperties['textAlign']) || 'center' }}>
|
||||||
href={button_url}
|
<a
|
||||||
className={cn(
|
href={button_url}
|
||||||
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
|
className={cn(
|
||||||
!btnStyle.style?.backgroundColor && (styles?.contentWidth === 'boxed'
|
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
|
||||||
? 'bg-primary'
|
!btnStyle.style?.backgroundColor && (styles?.contentWidth === 'boxed'
|
||||||
: {
|
? 'bg-primary'
|
||||||
'bg-white': colorScheme === 'primary',
|
: {
|
||||||
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
|
'bg-white': colorScheme === 'primary',
|
||||||
}),
|
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||||
!btnStyle.style?.color && (styles?.contentWidth === 'boxed'
|
}),
|
||||||
? 'text-primary-foreground'
|
!btnStyle.style?.color && (styles?.contentWidth === 'boxed'
|
||||||
: {
|
? 'text-primary-foreground'
|
||||||
'text-primary': colorScheme === 'primary',
|
: {
|
||||||
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
|
'text-primary': colorScheme === 'primary',
|
||||||
}),
|
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||||
btnStyle.classNames
|
}),
|
||||||
)}
|
btnStyle.classNames
|
||||||
style={btnStyle.style}
|
)}
|
||||||
>
|
style={{ ...btnStyle.style, textAlign: undefined }}
|
||||||
{button_text}
|
>
|
||||||
</a>
|
{button_text}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
const isBoxed = styles?.contentWidth === 'boxed';
|
||||||
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
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-cta-banner--${layout}`,
|
||||||
`wn-scheme--${colorScheme}`,
|
`wn-scheme--${colorScheme}`,
|
||||||
heightClasses,
|
heightClasses // Might not be needed if handled by outer, but safe to keep
|
||||||
{
|
|
||||||
'bg-primary text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
|
|
||||||
'bg-secondary text-secondary-foreground': colorScheme === 'secondary' && !hasCustomBackground,
|
|
||||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
|
||||||
}
|
|
||||||
)}
|
)}
|
||||||
style={getBackgroundStyle()}
|
|
||||||
>
|
>
|
||||||
{styles?.contentWidth === 'boxed' ? (
|
<div className="mx-auto px-4 text-center relative z-10 w-full max-w-5xl">
|
||||||
<div className="container mx-auto px-4 max-w-5xl">
|
{innerContent}
|
||||||
<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">
|
</div>
|
||||||
{innerContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={cn(
|
|
||||||
"mx-auto px-4 text-center",
|
|
||||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
|
||||||
)}>
|
|
||||||
{innerContent}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
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 {
|
interface ContactFormSectionProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
sourceType?: string;
|
||||||
|
sourceId?: string;
|
||||||
layout?: string;
|
layout?: string;
|
||||||
colorScheme?: string;
|
colorScheme?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
webhook_url?: string;
|
webhook_url?: string;
|
||||||
redirect_url?: string;
|
redirect_url?: string;
|
||||||
fields?: string[];
|
fields?: ContactFormField[];
|
||||||
elementStyles?: Record<string, any>;
|
elementStyles?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactFormSection({
|
export function ContactFormSection({
|
||||||
id,
|
id,
|
||||||
|
sourceType,
|
||||||
|
sourceId,
|
||||||
layout = 'default',
|
layout = 'default',
|
||||||
colorScheme = 'default',
|
colorScheme = 'default',
|
||||||
title,
|
title,
|
||||||
webhook_url,
|
webhook_url,
|
||||||
redirect_url,
|
redirect_url,
|
||||||
fields = ['name', 'email', 'message'],
|
fields,
|
||||||
elementStyles,
|
elementStyles,
|
||||||
styles,
|
styles,
|
||||||
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
|
isEditor,
|
||||||
const heightMap: Record<string, string> = {
|
}: ContactFormSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
|
||||||
'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');
|
|
||||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Helper to get text styles (including font family)
|
// Helper to get text styles (including font family)
|
||||||
@@ -63,23 +68,82 @@ export function ContactFormSection({
|
|||||||
const fieldsStyle = getTextStyles('fields');
|
const fieldsStyle = getTextStyles('fields');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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>) => {
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Submit to webhook if provided
|
// Submit to webhook proxy if configured
|
||||||
if (webhook_url) {
|
if (webhook_url && !isEditor) {
|
||||||
await fetch(webhook_url, {
|
await api.post('/pages/submit-section-form', {
|
||||||
method: 'POST',
|
source_type: sourceType,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
source_id: sourceId,
|
||||||
body: JSON.stringify(formData),
|
section_id: id,
|
||||||
|
form_data: formData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,40 +161,19 @@ export function ContactFormSection({
|
|||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'wn-section wn-contact-form',
|
'wn-section wn-contact-form relative overflow-hidden w-full',
|
||||||
`wn-scheme--${colorScheme}`,
|
`wn-scheme--${colorScheme}`
|
||||||
heightClasses,
|
|
||||||
{
|
|
||||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
|
||||||
}
|
|
||||||
)}
|
)}
|
||||||
style={getBackgroundStyle()}
|
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div className="mx-auto px-4 relative z-10 w-full">
|
||||||
"mx-auto px-4",
|
|
||||||
styles?.contentWidth === 'full' ? 'w-full'
|
|
||||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
|
||||||
: 'container'
|
|
||||||
)}>
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'max-w-xl mx-auto',
|
'max-w-xl mx-auto',
|
||||||
{
|
{
|
||||||
@@ -139,7 +182,7 @@ export function ContactFormSection({
|
|||||||
)}>
|
)}>
|
||||||
{title && (
|
{title && (
|
||||||
<h2 className={cn(
|
<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?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
|
||||||
!elementStyles?.title?.fontWeight && "font-bold",
|
!elementStyles?.title?.fontWeight && "font-bold",
|
||||||
titleStyle.classNames
|
titleStyle.classNames
|
||||||
@@ -150,55 +193,62 @@ export function ContactFormSection({
|
|||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
|
||||||
{fields.map((field) => {
|
{activeFields.map((field, idx) => {
|
||||||
const fieldLabel = field.charAt(0).toUpperCase() + field.slice(1).replace('_', ' ');
|
const isTextarea = field.type === 'textarea';
|
||||||
const isTextarea = field === 'message' || field === 'content';
|
const fieldError = fieldErrors[field.name];
|
||||||
|
const isTouched = touched[field.name];
|
||||||
|
const showError = isTouched && fieldError;
|
||||||
|
|
||||||
return (
|
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">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
{fieldLabel}
|
{field.label} {field.required && <span className="text-red-500">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{isTextarea ? (
|
{isTextarea ? (
|
||||||
<textarea
|
<textarea
|
||||||
name={field}
|
name={field.name}
|
||||||
value={formData[field] || ''}
|
value={formData[field.name] || ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
rows={5}
|
rows={5}
|
||||||
className={cn(
|
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
|
fieldsStyle.classNames
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: fieldsStyle.style?.backgroundColor,
|
backgroundColor: fieldsStyle.style?.backgroundColor,
|
||||||
color: fieldsStyle.style?.color,
|
color: fieldsStyle.style?.color,
|
||||||
borderColor: fieldsStyle.style?.borderColor,
|
borderColor: showError ? undefined : fieldsStyle.style?.borderColor,
|
||||||
borderRadius: fieldsStyle.style?.borderRadius,
|
borderRadius: fieldsStyle.style?.borderRadius,
|
||||||
}}
|
}}
|
||||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
placeholder={`Enter ${field.label.toLowerCase()}`}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type={field === 'email' ? 'email' : 'text'}
|
type={field.type || 'text'}
|
||||||
name={field}
|
name={field.name}
|
||||||
value={formData[field] || ''}
|
value={formData[field.name] || ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
className={cn(
|
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
|
fieldsStyle.classNames
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: fieldsStyle.style?.backgroundColor,
|
backgroundColor: fieldsStyle.style?.backgroundColor,
|
||||||
color: fieldsStyle.style?.color,
|
color: fieldsStyle.style?.color,
|
||||||
borderColor: fieldsStyle.style?.borderColor,
|
borderColor: showError ? undefined : fieldsStyle.style?.borderColor,
|
||||||
borderRadius: fieldsStyle.style?.borderRadius,
|
borderRadius: fieldsStyle.style?.borderRadius,
|
||||||
}}
|
}}
|
||||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
placeholder={`Enter ${field.label.toLowerCase()}`}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showError && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{fieldError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||||
|
|
||||||
interface ContentSectionProps {
|
interface ContentSectionProps {
|
||||||
id?: string;
|
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) => {
|
const fontWeightToCSS = (className?: string) => {
|
||||||
switch (className) {
|
switch (className) {
|
||||||
case 'font-thin': return '100';
|
case 'font-thin': return '100';
|
||||||
@@ -73,10 +82,10 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
|||||||
const headingRules = [
|
const headingRules = [
|
||||||
hs.color && `color: ${hs.color} !important;`,
|
hs.color && `color: ${hs.color} !important;`,
|
||||||
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !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.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
|
||||||
hs.backgroundColor && `background-color: ${hs.backgroundColor} !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(' ');
|
].filter(Boolean).join(' ');
|
||||||
if (headingRules) {
|
if (headingRules) {
|
||||||
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${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.color && `color: ${ts.color} !important;`,
|
||||||
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
|
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
|
||||||
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !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(' ');
|
].filter(Boolean).join(' ');
|
||||||
if (textRules) {
|
if (textRules) {
|
||||||
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${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) {
|
if (is) {
|
||||||
const imgRules = [
|
const imgRules = [
|
||||||
is.objectFit && `object-fit: ${is.objectFit} !important;`,
|
is.objectFit && `object-fit: ${is.objectFit} !important;`,
|
||||||
|
is.objectPosition && `object-position: ${is.objectPosition} !important;`,
|
||||||
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
|
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
|
||||||
is.width && `width: ${is.width} !important;`,
|
is.width && `width: ${is.width} !important;`,
|
||||||
is.height && `height: ${is.height} !important;`,
|
is.height && `height: ${is.height} !important;`,
|
||||||
@@ -158,24 +168,15 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
|||||||
return styles.join('\n');
|
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'];
|
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||||
// Default to 'default' width if not specified
|
// Default to 'default' width if not specified
|
||||||
const _layout = section.layoutVariant || 'default';
|
const _layout = section.layoutVariant || 'default';
|
||||||
|
|
||||||
const heightPreset = section.styles?.heightPreset || 'default';
|
const heightPreset = section.styles?.heightPreset || 'default';
|
||||||
|
const sectionBg = getSectionBackground(section.styles);
|
||||||
|
|
||||||
const heightMap: Record<string, string> = {
|
const content = propContent !== undefined ? propContent : (section.props?.content?.value ?? '');
|
||||||
'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 || '';
|
|
||||||
|
|
||||||
// Helper to get text styles
|
// Helper to get text styles
|
||||||
const getTextStyles = (elementName: string) => {
|
const getTextStyles = (elementName: string) => {
|
||||||
@@ -203,42 +204,25 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
|
|||||||
const buttonStyle = getTextStyles('button');
|
const buttonStyle = getTextStyles('button');
|
||||||
|
|
||||||
const containerWidth = section.styles?.contentWidth ?? 'contained';
|
const containerWidth = section.styles?.contentWidth ?? 'contained';
|
||||||
const cta_text = propCtaText || section.props?.cta_text?.value || section.props?.cta_text;
|
const cta_text = propCtaText !== undefined ? propCtaText : (section.props?.cta_text?.value ?? '');
|
||||||
const cta_url = propCtaUrl || section.props?.cta_url?.value || section.props?.cta_url;
|
const cta_url = propCtaUrl !== undefined ? propCtaUrl : (section.props?.cta_url?.value ?? '');
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
|
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
|
||||||
<section
|
<div
|
||||||
id={section.id}
|
id={section.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'wn-content',
|
'wn-content relative w-full',
|
||||||
heightClasses,
|
|
||||||
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
|
|
||||||
scheme.text
|
scheme.text
|
||||||
)}
|
)}
|
||||||
style={getBackgroundStyle()}
|
|
||||||
>
|
>
|
||||||
<SharedContentLayout
|
<SharedContentLayout
|
||||||
text={content}
|
text={content}
|
||||||
textStyle={textStyle.style}
|
textStyle={textStyle.style}
|
||||||
headingStyle={headingStyle.style}
|
headingStyle={headingStyle.style}
|
||||||
containerWidth={containerWidth as any}
|
containerWidth={containerWidth as any}
|
||||||
|
cardStyle={{ backgroundColor: section.styles?.cardBackgroundColor }}
|
||||||
className={contentStyle.classNames}
|
className={contentStyle.classNames}
|
||||||
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
||||||
buttonStyle={{
|
buttonStyle={{
|
||||||
@@ -246,7 +230,7 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
|
|||||||
style: buttonStyle.style
|
style: buttonStyle.style
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</section>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||||
|
|
||||||
interface FeatureItem {
|
interface FeatureItem {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -31,16 +32,8 @@ export function FeatureGridSection({
|
|||||||
features = [],
|
features = [],
|
||||||
elementStyles,
|
elementStyles,
|
||||||
styles,
|
styles,
|
||||||
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
|
isEditor,
|
||||||
const heightMap: Record<string, string> = {
|
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any>, isEditor?: boolean }) {
|
||||||
'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');
|
|
||||||
const safeItems = Array.isArray(items) ? items : [];
|
const safeItems = Array.isArray(items) ? items : [];
|
||||||
const safeFeatures = Array.isArray(features) ? features : [];
|
const safeFeatures = Array.isArray(features) ? features : [];
|
||||||
const listItems = safeItems.length > 0 ? safeItems : safeFeatures;
|
const listItems = safeItems.length > 0 ? safeItems : safeFeatures;
|
||||||
@@ -78,43 +71,20 @@ export function FeatureGridSection({
|
|||||||
|
|
||||||
const headingStyle = getTextStyles('heading');
|
const headingStyle = getTextStyles('heading');
|
||||||
const featureItemStyle = getTextStyles('feature_item');
|
const featureItemStyle = getTextStyles('feature_item');
|
||||||
|
const linkStyle = getTextStyles('link');
|
||||||
|
|
||||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
|
||||||
const sectionBg = getSectionBackground(styles);
|
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 (
|
return (
|
||||||
<section
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'wn-section wn-feature-grid',
|
'wn-section wn-feature-grid relative w-full',
|
||||||
`wn-feature-grid--${layout}`,
|
`wn-feature-grid--${layout}`,
|
||||||
`wn-scheme--${colorScheme}`,
|
`wn-scheme--${colorScheme}`
|
||||||
heightClasses,
|
|
||||||
{
|
|
||||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
|
||||||
'text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
|
|
||||||
}
|
|
||||||
)}
|
)}
|
||||||
style={getBackgroundStyle()}
|
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div className="mx-auto px-4 relative z-10 w-full">
|
||||||
"mx-auto px-4",
|
|
||||||
styles?.contentWidth === 'full' ? 'w-full'
|
|
||||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
|
||||||
: 'container'
|
|
||||||
)}>
|
|
||||||
{heading && (
|
{heading && (
|
||||||
<h2
|
<h2
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -167,16 +137,22 @@ export function FeatureGridSection({
|
|||||||
<p className="text-xs text-gray-400 mb-2 uppercase tracking-wider">{item.date}</p>
|
<p className="text-xs text-gray-400 mb-2 uppercase tracking-wider">{item.date}</p>
|
||||||
)}
|
)}
|
||||||
{item.title && (
|
{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}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
{(item.excerpt || item.description) && (
|
{(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}
|
{item.excerpt || item.description}
|
||||||
</p>
|
</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
|
Read more
|
||||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<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>
|
<p className="text-center text-gray-400 text-sm py-8">No related articles found.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||||
|
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +17,7 @@ interface HeroSectionProps {
|
|||||||
export function HeroSection({
|
export function HeroSection({
|
||||||
id,
|
id,
|
||||||
layout = 'default',
|
layout = 'default',
|
||||||
|
colorScheme,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
image,
|
image,
|
||||||
@@ -23,16 +25,8 @@ export function HeroSection({
|
|||||||
cta_url,
|
cta_url,
|
||||||
elementStyles,
|
elementStyles,
|
||||||
styles,
|
styles,
|
||||||
}: HeroSectionProps & { styles?: Record<string, any> }) {
|
isEditor,
|
||||||
const heightMap: Record<string, string> = {
|
}: HeroSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
|
||||||
'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');
|
|
||||||
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
|
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
|
||||||
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
|
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
|
||||||
const isCentered = layout === 'centered' || layout === 'default';
|
const isCentered = layout === 'centered' || layout === 'default';
|
||||||
@@ -83,22 +77,27 @@ export function HeroSection({
|
|||||||
return undefined;
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'wn-section wn-hero',
|
'wn-section wn-hero',
|
||||||
`wn-hero--${layout}`,
|
`wn-hero--${layout}`,
|
||||||
'relative overflow-hidden',
|
'relative overflow-hidden w-full',
|
||||||
heightClasses,
|
!isBoxed && !sectionBg.style?.backgroundColor && !sectionBg.style?.backgroundImage && colorSchemeClasses
|
||||||
)}
|
)}
|
||||||
style={sectionBg.style}
|
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'mx-auto px-4 z-10 relative flex w-full',
|
'mx-auto z-10 relative flex w-full',
|
||||||
styles?.contentWidth === 'full' ? 'w-full'
|
|
||||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
|
||||||
: 'container max-w-7xl',
|
|
||||||
{
|
{
|
||||||
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
|
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
|
||||||
'text-center': isCentered,
|
'text-center': isCentered,
|
||||||
@@ -106,22 +105,31 @@ export function HeroSection({
|
|||||||
)}>
|
)}>
|
||||||
{/* Image - Left */}
|
{/* Image - Left */}
|
||||||
{image && isImageLeft && (
|
{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
|
<div
|
||||||
className="rounded-lg shadow-xl overflow-hidden"
|
className={cn("shadow-xl overflow-hidden", !imageStyle.borderRadius && "rounded-lg")}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: imageStyle.backgroundColor,
|
backgroundColor: imageStyle.backgroundColor,
|
||||||
width: imageStyle.width || 'auto',
|
width: imageStyle.width || 'auto',
|
||||||
maxWidth: '100%'
|
maxWidth: '100%',
|
||||||
|
borderRadius: imageStyle.borderRadius,
|
||||||
|
borderColor: imageStyle.borderColor,
|
||||||
|
borderWidth: imageStyle.borderWidth,
|
||||||
|
borderStyle: imageStyle.borderWidth ? 'solid' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt={title || 'Hero image'}
|
alt={title || 'Hero image'}
|
||||||
className="w-full h-auto block"
|
className="w-full h-full object-cover"
|
||||||
style={{
|
style={{
|
||||||
objectFit: imageStyle.objectFit,
|
objectFit: imageStyle.objectFit,
|
||||||
height: imageStyle.height,
|
objectPosition: imageStyle.objectPosition,
|
||||||
|
height: imageStyle.height || 'auto',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +147,7 @@ export function HeroSection({
|
|||||||
{title && (
|
{title && (
|
||||||
<h1
|
<h1
|
||||||
className={cn(
|
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?.fontSize && "text-4xl md:text-5xl lg:text-6xl",
|
||||||
!elementStyles?.title?.fontWeight && "font-bold",
|
!elementStyles?.title?.fontWeight && "font-bold",
|
||||||
titleStyle.classNames
|
titleStyle.classNames
|
||||||
@@ -153,7 +161,7 @@ export function HeroSection({
|
|||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
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",
|
!elementStyles?.subtitle?.fontSize && "text-lg md:text-xl",
|
||||||
subtitleStyle.classNames
|
subtitleStyle.classNames
|
||||||
)}
|
)}
|
||||||
@@ -166,10 +174,24 @@ export function HeroSection({
|
|||||||
{/* Centered Image */}
|
{/* Centered Image */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-12 mx-auto rounded-lg shadow-xl overflow-hidden",
|
"mt-12 mx-auto shadow-xl overflow-hidden flex flex-col",
|
||||||
imageStyle.width ? "" : "max-w-4xl"
|
!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 && (
|
{image && isCentered && (
|
||||||
<img
|
<img
|
||||||
@@ -177,12 +199,13 @@ export function HeroSection({
|
|||||||
alt={title || 'Hero image'}
|
alt={title || 'Hero image'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-[inherit]",
|
"w-full rounded-[inherit]",
|
||||||
!imageStyle.height && "h-auto",
|
!imageStyle.objectFit && "object-cover",
|
||||||
!imageStyle.objectFit && "object-cover"
|
"h-full"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
objectFit: imageStyle.objectFit,
|
objectFit: imageStyle.objectFit,
|
||||||
height: imageStyle.height,
|
objectPosition: imageStyle.objectPosition,
|
||||||
|
height: imageStyle.height || 'auto',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -190,24 +213,33 @@ export function HeroSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cta_text && cta_url && (
|
{cta_text && cta_url && (
|
||||||
<a
|
<div className="w-full mt-8" style={{ textAlign: ctaStyle.style?.textAlign || (isCentered ? 'center' : 'left') as React.CSSProperties['textAlign'] }}>
|
||||||
href={cta_url}
|
<a
|
||||||
className={cn(
|
href={cta_url}
|
||||||
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors mt-8",
|
className={cn(
|
||||||
!ctaStyle.style?.backgroundColor && "bg-primary",
|
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors",
|
||||||
!ctaStyle.style?.color && "text-primary-foreground",
|
!ctaStyle.style?.backgroundColor && "bg-primary",
|
||||||
ctaStyle.classNames
|
!ctaStyle.style?.color && "text-primary-foreground",
|
||||||
)}
|
ctaStyle.classNames
|
||||||
style={ctaStyle.style}
|
)}
|
||||||
>
|
style={{
|
||||||
{cta_text}
|
...ctaStyle.style,
|
||||||
</a>
|
textAlign: undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cta_text}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image - Right */}
|
{/* Image - Right */}
|
||||||
{image && isImageRight && (
|
{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
|
<div
|
||||||
className="rounded-lg shadow-xl overflow-hidden"
|
className="rounded-lg shadow-xl overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
@@ -219,10 +251,11 @@ export function HeroSection({
|
|||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt={title || 'Hero image'}
|
alt={title || 'Hero image'}
|
||||||
className="w-full h-auto block"
|
className="w-full h-full object-cover"
|
||||||
style={{
|
style={{
|
||||||
objectFit: imageStyle.objectFit,
|
objectFit: imageStyle.objectFit,
|
||||||
height: imageStyle.height,
|
objectPosition: imageStyle.objectPosition,
|
||||||
|
height: imageStyle.height || 'auto',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||||
|
|
||||||
interface ImageTextSectionProps {
|
interface ImageTextSectionProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,7 +24,8 @@ export function ImageTextSection({
|
|||||||
cta_url,
|
cta_url,
|
||||||
elementStyles,
|
elementStyles,
|
||||||
styles,
|
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';
|
const isImageRight = layout === 'image-right' || layout === 'right';
|
||||||
|
|
||||||
// Helper to get text styles (including font family)
|
// Helper to get text styles (including font family)
|
||||||
@@ -55,44 +57,15 @@ export function ImageTextSection({
|
|||||||
|
|
||||||
const imageStyle = elementStyles?.['image'] || {};
|
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);
|
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 (
|
return (
|
||||||
<section
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'wn-section wn-image-text',
|
'wn-section wn-image-text relative w-full',
|
||||||
`wn-scheme--${colorScheme}`,
|
`wn-scheme--${colorScheme}`
|
||||||
!styles?.paddingTop && !styles?.paddingBottom && heightClasses,
|
|
||||||
{
|
|
||||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
|
||||||
}
|
|
||||||
)}
|
)}
|
||||||
style={getBackgroundStyle()}
|
|
||||||
>
|
>
|
||||||
<SharedContentLayout
|
<SharedContentLayout
|
||||||
title={title}
|
title={title}
|
||||||
@@ -104,16 +77,14 @@ export function ImageTextSection({
|
|||||||
titleClassName={titleStyle.classNames}
|
titleClassName={titleStyle.classNames}
|
||||||
textStyle={textStyle.style}
|
textStyle={textStyle.style}
|
||||||
textClassName={textStyle.classNames}
|
textClassName={textStyle.classNames}
|
||||||
imageStyle={{
|
imageStyle={imageStyle}
|
||||||
backgroundColor: imageStyle.backgroundColor,
|
cardStyle={{ backgroundColor: styles?.cardBackgroundColor }}
|
||||||
objectFit: imageStyle.objectFit,
|
|
||||||
}}
|
|
||||||
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
||||||
buttonStyle={{
|
buttonStyle={{
|
||||||
classNames: buttonStyle.classNames,
|
classNames: buttonStyle.classNames,
|
||||||
style: buttonStyle.style
|
style: buttonStyle.style
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||||
|
|
||||||
interface MarqueeBannerProps {
|
interface MarqueeBannerProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,6 +9,7 @@ interface MarqueeBannerProps {
|
|||||||
speed?: number; // seconds for one full cycle
|
speed?: number; // seconds for one full cycle
|
||||||
separator?: string;
|
separator?: string;
|
||||||
styles?: Record<string, any>;
|
styles?: Record<string, any>;
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarqueeBanner({
|
export function MarqueeBanner({
|
||||||
@@ -16,20 +18,46 @@ export function MarqueeBanner({
|
|||||||
speed = 30,
|
speed = 30,
|
||||||
separator = '✦',
|
separator = '✦',
|
||||||
styles,
|
styles,
|
||||||
|
elementStyles,
|
||||||
}: MarqueeBannerProps) {
|
}: MarqueeBannerProps) {
|
||||||
const sectionBg = getSectionBackground(styles);
|
|
||||||
const items = text.split(separator).map(t => t.trim()).filter(Boolean);
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
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={{
|
style={{
|
||||||
backgroundColor: sectionBg.style?.backgroundColor || 'var(--wn-primary, #1a1a1a)',
|
backgroundColor: !hasCustomBg ? 'var(--wn-primary, #1a1a1a)' : undefined,
|
||||||
color: sectionBg.style?.color || '#fff',
|
color: !hasCustomBg ? '#fff' : 'inherit',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex whitespace-nowrap">
|
<div className="flex whitespace-nowrap relative z-10">
|
||||||
{/* Duplicate twice for seamless infinite scroll */}
|
{/* Duplicate twice for seamless infinite scroll */}
|
||||||
{[0, 1].map((i) => (
|
{[0, 1].map((i) => (
|
||||||
<div
|
<div
|
||||||
@@ -39,9 +67,13 @@ export function MarqueeBanner({
|
|||||||
aria-hidden={i === 1}
|
aria-hidden={i === 1}
|
||||||
>
|
>
|
||||||
{items.map((item, idx) => (
|
{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}
|
{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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { apiClient } from '@/lib/api/client';
|
|||||||
import { ProductCard } from '@/components/ProductCard';
|
import { ProductCard } from '@/components/ProductCard';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||||
import type { ProductsResponse } from '@/types/product';
|
import type { ProductsResponse } from '@/types/product';
|
||||||
|
|
||||||
interface ProductCarouselProps {
|
interface ProductCarouselProps {
|
||||||
@@ -39,7 +40,6 @@ export function ProductCarousel({
|
|||||||
elementStyles,
|
elementStyles,
|
||||||
}: ProductCarouselProps) {
|
}: ProductCarouselProps) {
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
const sectionBg = getSectionBackground(styles);
|
|
||||||
|
|
||||||
// Build query params
|
// Build query params
|
||||||
const queryParams = new URLSearchParams({ per_page: String(limit) });
|
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' });
|
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 (
|
return (
|
||||||
<section id={id} className="wn-section wn-product-carousel py-12 md:py-16" style={sectionBg.style}>
|
<section
|
||||||
<div className="container mx-auto px-4 max-w-7xl">
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-end justify-between mb-8">
|
<div className="flex items-end justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
{title && (
|
{title && (
|
||||||
<h2
|
<h2
|
||||||
className="text-3xl md:text-4xl font-bold"
|
className={cn("text-3xl font-bold", titleStyle.classNames)}
|
||||||
style={{ color: elementStyles?.title?.color }}
|
style={titleStyle.style}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
{subtitle && (
|
{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}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{cta_text && cta_url && (
|
{cta_text && (
|
||||||
<Link to={cta_url} className="text-sm font-semibold hover:underline mr-4 whitespace-nowrap">
|
<Link to={cta_url || '#'} className={cn("text-sm font-semibold hover:underline mr-4 whitespace-nowrap", linkStyle.classNames)} style={linkStyle.style}>
|
||||||
{cta_text} →
|
{cta_text} →
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { X, ShoppingCart, Eye } from 'lucide-react';
|
import { X, ShoppingCart, Eye } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { useCartStore } from '@/lib/cart/store';
|
import { useCartStore } from '@/lib/cart/store';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
@@ -47,13 +48,38 @@ export function ShoppableImage({
|
|||||||
styles,
|
styles,
|
||||||
elementStyles,
|
elementStyles,
|
||||||
}: ShoppableImageProps) {
|
}: ShoppableImageProps) {
|
||||||
const sectionBg = getSectionBackground(styles);
|
|
||||||
const [activeHotspot, setActiveHotspot] = useState<number | null>(null);
|
const [activeHotspot, setActiveHotspot] = useState<number | null>(null);
|
||||||
const { addItem, openCart } = useCartStore();
|
const { addItem, openCart } = useCartStore();
|
||||||
|
|
||||||
const displayHotspots = (hotspots && hotspots.length > 0) ? hotspots : DEMO_HOTSPOTS;
|
const displayHotspots = (hotspots && hotspots.length > 0) ? hotspots : DEMO_HOTSPOTS;
|
||||||
const hasImage = !!image;
|
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) => {
|
const handleAddToCart = async (hotspot: Hotspot, e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -81,18 +107,38 @@ export function ShoppableImage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sectionBg = getSectionBackground(styles);
|
||||||
|
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id={id} className="wn-section wn-shoppable-image py-12 md:py-16" style={sectionBg.style}>
|
<section
|
||||||
<div className="container mx-auto px-4 max-w-7xl">
|
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) && (
|
{(title || subtitle) && (
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
{title && (
|
{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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
{subtitle && (
|
{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}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -116,12 +162,14 @@ export function ShoppableImage({
|
|||||||
{/* Hotspot pins */}
|
{/* Hotspot pins */}
|
||||||
{displayHotspots.map((hotspot, idx) => {
|
{displayHotspots.map((hotspot, idx) => {
|
||||||
const isActive = activeHotspot === idx;
|
const isActive = activeHotspot === idx;
|
||||||
|
const xVal = parseFloat(String(hotspot.x ?? 0).replace('%', '')) || 0;
|
||||||
|
const yVal = parseFloat(String(hotspot.y ?? 0).replace('%', '')) || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="absolute"
|
className="absolute"
|
||||||
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
|
style={{ left: `${xVal}%`, top: `${yVal}%`, transform: 'translate(-50%, -50%)' }}
|
||||||
>
|
>
|
||||||
{/* Pulsing pin */}
|
{/* Pulsing pin */}
|
||||||
<button
|
<button
|
||||||
@@ -143,8 +191,8 @@ export function ShoppableImage({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute z-20 w-56 bg-white rounded-xl shadow-2xl border p-3',
|
'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',
|
xVal > 60 ? 'right-full mr-3' : 'left-full ml-3',
|
||||||
hotspot.y > 60 ? 'bottom-0' : 'top-0',
|
yVal > 60 ? 'bottom-0' : 'top-0',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Close */}
|
{/* Close */}
|
||||||
|
|||||||
74
customer-spa/src/pages/Shop/CollectionPage.tsx
Normal file
74
customer-spa/src/pages/Shop/CollectionPage.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
import { ProductCard } from '@/components/ProductCard';
|
||||||
|
|
||||||
|
interface CollectionData {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
products: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionPage() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
|
||||||
|
const { data: collection, isLoading, error } = useQuery<CollectionData>({
|
||||||
|
queryKey: ['affiliate-collection', slug],
|
||||||
|
queryFn: async () => {
|
||||||
|
return api.get(`/shop/collections/${slug || ''}`);
|
||||||
|
},
|
||||||
|
enabled: !!slug
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
<div className="animate-pulse space-y-8">
|
||||||
|
<div className="h-12 bg-muted rounded w-1/3"></div>
|
||||||
|
<div className="h-4 bg-muted rounded w-1/2"></div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="h-64 bg-muted rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !collection) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-16 text-center">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Collection Not Found</h1>
|
||||||
|
<p className="text-muted-foreground">The collection you are looking for does not exist or has been removed.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
<div className="mb-10 text-center">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight mb-3">{collection.title}</h1>
|
||||||
|
{collection.description && (
|
||||||
|
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
{collection.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collection.products.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
|
{collection.products.map(product => (
|
||||||
|
<ProductCard key={product.id} product={product} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 bg-muted/20 rounded-lg">
|
||||||
|
<p className="text-muted-foreground">There are no products in this collection.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Search, Filter, X } from 'lucide-react';
|
import { Search, Filter, X } from 'lucide-react';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useCartStore } from '@/lib/cart/store';
|
import { useCartStore } from '@/lib/cart/store';
|
||||||
@@ -31,10 +31,11 @@ function useBreakpoint() {
|
|||||||
|
|
||||||
export default function Shop() {
|
export default function Shop() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { layout: shopLayout, elements } = useShopSettings();
|
const { layout: shopLayout, elements } = useShopSettings();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState(searchParams.get('category') || '');
|
||||||
const [minPriceInput, setMinPriceInput] = useState('');
|
const [minPriceInput, setMinPriceInput] = useState('');
|
||||||
const [maxPriceInput, setMaxPriceInput] = useState('');
|
const [maxPriceInput, setMaxPriceInput] = useState('');
|
||||||
const minPrice = useDebounce(minPriceInput, 500);
|
const minPrice = useDebounce(minPriceInput, 500);
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
|
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: {
|
theme: {
|
||||||
container: { center: true, padding: "1rem" },
|
container: { center: true, padding: "1rem" },
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@@ -538,6 +538,9 @@ class CheckoutController
|
|||||||
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
|
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
|
// Clear WooCommerce cart after successful order placement
|
||||||
// This ensures the cart page won't re-populate from server session
|
// This ensures the cart page won't re-populate from server session
|
||||||
if (function_exists('WC') && WC()->cart) {
|
if (function_exists('WC') && WC()->cart) {
|
||||||
@@ -1141,4 +1144,105 @@ class CheckoutController
|
|||||||
}
|
}
|
||||||
return $results;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,33 @@ class AffiliateCustomerController
|
|||||||
'callback' => [$this, 'update_payment_details'],
|
'callback' => [$this, 'update_payment_details'],
|
||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Affiliate Collections
|
||||||
|
register_rest_route($this->namespace, '/account/affiliate/collections', [
|
||||||
|
[
|
||||||
|
'methods' => WP_REST_Server::READABLE,
|
||||||
|
'callback' => [$this, 'get_collections'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => [$this, 'create_collection'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route($this->namespace, '/account/affiliate/collections/(?P<id>\d+)', [
|
||||||
|
[
|
||||||
|
'methods' => WP_REST_Server::EDITABLE,
|
||||||
|
'callback' => [$this, 'update_collection'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'methods' => WP_REST_Server::DELETABLE,
|
||||||
|
'callback' => [$this, 'delete_collection'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
]
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function check_permission()
|
public function check_permission()
|
||||||
@@ -89,6 +116,12 @@ class AffiliateCustomerController
|
|||||||
$affiliate['commission_rate'] = $effective_rate;
|
$affiliate['commission_rate'] = $effective_rate;
|
||||||
$affiliate['total_earnings'] = $earnings->total_earnings ?: 0;
|
$affiliate['total_earnings'] = $earnings->total_earnings ?: 0;
|
||||||
$affiliate['pending_earnings'] = $earnings->pending_earnings ?: 0;
|
$affiliate['pending_earnings'] = $earnings->pending_earnings ?: 0;
|
||||||
|
|
||||||
|
if (class_exists('\WooNooW\Modules\Affiliate\AffiliateSettings')) {
|
||||||
|
$affiliate['collections_enabled'] = \WooNooW\Modules\Affiliate\AffiliateSettings::get_setting('woonoow_affiliate_enable_curated_collections', true);
|
||||||
|
} else {
|
||||||
|
$affiliate['collections_enabled'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
return rest_ensure_response($affiliate);
|
return rest_ensure_response($affiliate);
|
||||||
}
|
}
|
||||||
@@ -318,4 +351,125 @@ class AffiliateCustomerController
|
|||||||
|
|
||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Collections ---
|
||||||
|
|
||||||
|
public function get_collections(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
if (class_exists('\WooNooW\Modules\Affiliate\AffiliateSettings') && !\WooNooW\Modules\Affiliate\AffiliateSettings::get_setting('woonoow_affiliate_enable_curated_collections', true)) {
|
||||||
|
return new \WP_Error('rest_forbidden', 'Curated collections are disabled.', ['status' => 403]);
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$affiliate_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT id, referral_code FROM $affiliate_table WHERE user_id = %d", $user_id));
|
||||||
|
if (!$affiliate) {
|
||||||
|
return rest_ensure_response([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$collections = $wpdb->get_results($wpdb->prepare("SELECT * FROM $collections_table WHERE affiliate_id = %d ORDER BY created_at DESC", $affiliate->id), ARRAY_A);
|
||||||
|
|
||||||
|
foreach ($collections as &$collection) {
|
||||||
|
$collection['product_ids'] = $collection['product_ids'] ? json_decode($collection['product_ids'], true) : [];
|
||||||
|
$collection['link'] = site_url("/collection/{$collection['slug']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response($collections);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create_collection(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$affiliate_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT id FROM $affiliate_table WHERE user_id = %d", $user_id));
|
||||||
|
if (!$affiliate) {
|
||||||
|
return new \WP_Error('not_found', 'Affiliate profile not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = sanitize_text_field($request->get_param('title'));
|
||||||
|
$description = sanitize_textarea_field($request->get_param('description'));
|
||||||
|
$product_ids = $request->get_param('product_ids');
|
||||||
|
if (!is_array($product_ids)) $product_ids = [];
|
||||||
|
|
||||||
|
$product_ids = array_map('intval', $product_ids);
|
||||||
|
if (count($product_ids) > 20) {
|
||||||
|
return new \WP_Error('too_many_products', 'A collection can have a maximum of 20 products.', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = sanitize_title($title);
|
||||||
|
// Check unique slug for this affiliate
|
||||||
|
$existing = $wpdb->get_var($wpdb->prepare("SELECT id FROM $collections_table WHERE affiliate_id = %d AND slug = %s", $affiliate->id, $slug));
|
||||||
|
if ($existing) {
|
||||||
|
$slug .= '-' . wp_generate_password(4, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'affiliate_id' => $affiliate->id,
|
||||||
|
'title' => $title,
|
||||||
|
'slug' => $slug,
|
||||||
|
'description' => $description,
|
||||||
|
'product_ids' => json_encode($product_ids)
|
||||||
|
];
|
||||||
|
|
||||||
|
$wpdb->insert($collections_table, $data);
|
||||||
|
$data['id'] = $wpdb->insert_id;
|
||||||
|
$data['product_ids'] = $product_ids;
|
||||||
|
|
||||||
|
return rest_ensure_response($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update_collection(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$id = (int) $request->get_param('id');
|
||||||
|
$affiliate_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT id FROM $affiliate_table WHERE user_id = %d", $user_id));
|
||||||
|
if (!$affiliate) return new \WP_Error('unauthorized', 'Unauthorized', ['status' => 401]);
|
||||||
|
|
||||||
|
$collection = $wpdb->get_row($wpdb->prepare("SELECT id FROM $collections_table WHERE id = %d AND affiliate_id = %d", $id, $affiliate->id));
|
||||||
|
if (!$collection) return new \WP_Error('not_found', 'Collection not found', ['status' => 404]);
|
||||||
|
|
||||||
|
$title = sanitize_text_field($request->get_param('title'));
|
||||||
|
$description = sanitize_textarea_field($request->get_param('description'));
|
||||||
|
$product_ids = $request->get_param('product_ids');
|
||||||
|
if (!is_array($product_ids)) $product_ids = [];
|
||||||
|
|
||||||
|
$product_ids = array_map('intval', $product_ids);
|
||||||
|
if (count($product_ids) > 20) {
|
||||||
|
return new \WP_Error('too_many_products', 'A collection can have a maximum of 20 products.', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$wpdb->update($collections_table, [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'product_ids' => json_encode($product_ids)
|
||||||
|
], ['id' => $id]);
|
||||||
|
|
||||||
|
return rest_ensure_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete_collection(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$id = (int) $request->get_param('id');
|
||||||
|
$affiliate_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT id FROM $affiliate_table WHERE user_id = %d", $user_id));
|
||||||
|
if (!$affiliate) return new \WP_Error('unauthorized', 'Unauthorized', ['status' => 401]);
|
||||||
|
|
||||||
|
$wpdb->delete($collections_table, ['id' => $id, 'affiliate_id' => $affiliate->id]);
|
||||||
|
|
||||||
|
return rest_ensure_response(['success' => true]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ class PagesController
|
|||||||
'permission_callback' => '__return_true',
|
'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)
|
// Get/Save page structure (structural pages)
|
||||||
register_rest_route($namespace, '/pages/(?P<slug>[a-zA-Z0-9_-]+)', [
|
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);
|
$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([
|
return new WP_REST_Response([
|
||||||
'type' => 'content',
|
'type' => 'content',
|
||||||
'cpt' => $type,
|
'cpt' => $type,
|
||||||
'post' => $post_data,
|
'post' => $post_data,
|
||||||
'seo' => $seo,
|
'seo' => $seo,
|
||||||
|
'effective_container_width' => ($settings['general']['container_width'] ?? 'boxed') ?: 'boxed',
|
||||||
'template' => $template ?: ['sections' => []],
|
'template' => $template ?: ['sections' => []],
|
||||||
'rendered' => [
|
'rendered' => [
|
||||||
'sections' => $rendered_sections,
|
'sections' => $rendered_sections,
|
||||||
@@ -662,6 +673,80 @@ class PagesController
|
|||||||
], 200);
|
], 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
|
// Helper Methods
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ class ProductsController
|
|||||||
$category = $request->get_param('category');
|
$category = $request->get_param('category');
|
||||||
$type = $request->get_param('type');
|
$type = $request->get_param('type');
|
||||||
$stock_status = $request->get_param('stock_status');
|
$stock_status = $request->get_param('stock_status');
|
||||||
|
$software_enabled = $request->get_param('software_enabled');
|
||||||
$orderby = $request->get_param('orderby') ?: 'date';
|
$orderby = $request->get_param('orderby') ?: 'date';
|
||||||
$order = $request->get_param('order') ?: 'DESC';
|
$order = $request->get_param('order') ?: 'DESC';
|
||||||
|
|
||||||
@@ -266,11 +267,19 @@ class ProductsController
|
|||||||
|
|
||||||
// Stock status filter
|
// Stock status filter
|
||||||
if ($stock_status) {
|
if ($stock_status) {
|
||||||
$args['meta_query'] = [
|
$args['meta_query'] = $args['meta_query'] ?? [];
|
||||||
[
|
$args['meta_query'][] = [
|
||||||
'key' => '_stock_status',
|
'key' => '_stock_status',
|
||||||
'value' => $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']));
|
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)
|
// Allow plugins to perform additional updates (Level 1 compatibility)
|
||||||
do_action('woonoow/product_updated', $product, $data, $request);
|
do_action('woonoow/product_updated', $product, $data, $request);
|
||||||
|
|
||||||
@@ -819,6 +848,10 @@ class ProductsController
|
|||||||
'permalink' => get_permalink($product->get_id()),
|
'permalink' => get_permalink($product->get_id()),
|
||||||
'date_created' => $product->get_date_created() ? $product->get_date_created()->date('Y-m-d H:i:s') : '',
|
'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') : '',
|
'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_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) ?: '';
|
$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 array (URLs) for frontend - featured + gallery
|
||||||
$images = [];
|
$images = [];
|
||||||
$featured_image_id = $product->get_image_id();
|
$featured_image_id = $product->get_image_id();
|
||||||
@@ -1078,6 +1119,10 @@ class ProductsController
|
|||||||
'image_url' => $image_url,
|
'image_url' => $image_url,
|
||||||
'image' => $image_url, // For form compatibility
|
'image' => $image_url, // For form compatibility
|
||||||
'license_duration_days' => get_post_meta($variation->get_id(), '_license_duration_days', true) ?: '',
|
'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']));
|
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
|
// Manually save attributes using direct database insert
|
||||||
if (!empty($wc_attributes)) {
|
if (!empty($wc_attributes)) {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ class SoftwareController
|
|||||||
return current_user_can('manage_woocommerce');
|
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)
|
// Log the check (optional - for analytics)
|
||||||
do_action('woonoow/software/update_check', $slug, $current_version, $site_url, $license_key);
|
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;
|
$status_code = isset($result['success']) && $result['success'] === false ? 400 : 200;
|
||||||
|
|
||||||
@@ -242,7 +251,7 @@ class SoftwareController
|
|||||||
return [
|
return [
|
||||||
'version' => $v['version'],
|
'version' => $v['version'],
|
||||||
'release_date' => $v['release_date'],
|
'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'],
|
'download_count' => (int) $v['download_count'],
|
||||||
];
|
];
|
||||||
}, $versions),
|
}, $versions),
|
||||||
@@ -283,8 +292,29 @@ class SoftwareController
|
|||||||
$params = $request->get_json_params();
|
$params = $request->get_json_params();
|
||||||
|
|
||||||
$version = sanitize_text_field($params['version'] ?? '');
|
$version = sanitize_text_field($params['version'] ?? '');
|
||||||
$changelog = wp_kses_post($params['changelog'] ?? '');
|
|
||||||
$set_current = (bool) ($params['set_current'] ?? true);
|
$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)) {
|
if (empty($version)) {
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
@@ -318,4 +348,62 @@ class SoftwareController
|
|||||||
'message' => __('Version added successfully', 'woonoow'),
|
'message' => __('Version added successfully', 'woonoow'),
|
||||||
], 201);
|
], 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use WooNooW\Branding;
|
|||||||
use WooNooW\Frontend\Assets as FrontendAssets;
|
use WooNooW\Frontend\Assets as FrontendAssets;
|
||||||
use WooNooW\Frontend\Shortcodes;
|
use WooNooW\Frontend\Shortcodes;
|
||||||
use WooNooW\Frontend\TemplateOverride;
|
use WooNooW\Frontend\TemplateOverride;
|
||||||
|
use WooNooW\Frontend\SmartRotator;
|
||||||
use WooNooW\Frontend\PageAppearance;
|
use WooNooW\Frontend\PageAppearance;
|
||||||
|
|
||||||
class Bootstrap {
|
class Bootstrap {
|
||||||
@@ -47,6 +48,7 @@ class Bootstrap {
|
|||||||
FrontendAssets::init();
|
FrontendAssets::init();
|
||||||
// Note: Shortcodes removed - WC pages now redirect to SPA routes via TemplateOverride
|
// Note: Shortcodes removed - WC pages now redirect to SPA routes via TemplateOverride
|
||||||
TemplateOverride::init();
|
TemplateOverride::init();
|
||||||
|
SmartRotator::init();
|
||||||
new PageAppearance();
|
new PageAppearance();
|
||||||
|
|
||||||
// Activity Log
|
// Activity Log
|
||||||
@@ -75,6 +77,13 @@ class Bootstrap {
|
|||||||
// Load custom variation attributes for WooCommerce admin
|
// Load custom variation attributes for WooCommerce admin
|
||||||
add_action('woocommerce_product_variation_object_read', [self::class, 'load_variation_attributes']);
|
add_action('woocommerce_product_variation_object_read', [self::class, 'load_variation_attributes']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function init_frontend() {
|
||||||
|
Shortcodes::init();
|
||||||
|
TemplateOverride::init();
|
||||||
|
SmartRotator::init();
|
||||||
|
PageAppearance::init();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Properly initialize WooCommerce cart for REST API requests
|
* Properly initialize WooCommerce cart for REST API requests
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ class AddressController {
|
|||||||
|
|
||||||
$addresses = array_values($addresses);
|
$addresses = array_values($addresses);
|
||||||
|
|
||||||
|
foreach ($addresses as &$address) {
|
||||||
|
$address['formatted_address'] = apply_filters('woonoow_format_address', '', $address);
|
||||||
|
}
|
||||||
|
|
||||||
return new WP_REST_Response($addresses, 200);
|
return new WP_REST_Response($addresses, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,19 @@ class Assets
|
|||||||
$base_path = '';
|
$base_path = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also force empty base path for /collection/ routes since they are global
|
||||||
|
$spa_path_var = get_query_var('woonoow_spa_path');
|
||||||
|
if (!empty($spa_path_var) && strpos($spa_path_var, 'collection/') === 0) {
|
||||||
|
$base_path = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle serve_spa_for_frontpage_routes which bypasses WP queries
|
||||||
|
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$path = parse_url($request_uri, PHP_URL_PATH);
|
||||||
|
if (strpos($path, '/collection/') === 0) {
|
||||||
|
$base_path = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Check if BrowserRouter is enabled (default: true for SEO)
|
// Check if BrowserRouter is enabled (default: true for SEO)
|
||||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||||
|
|
||||||
@@ -263,6 +276,9 @@ class Assets
|
|||||||
'useBrowserRouter' => $use_browser_router,
|
'useBrowserRouter' => $use_browser_router,
|
||||||
'frontPageSlug' => $front_page_slug,
|
'frontPageSlug' => $front_page_slug,
|
||||||
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
|
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
|
||||||
|
'affiliateSettings' => [
|
||||||
|
'enableCuratedCollections' => class_exists('\WooNooW\Modules\Affiliate\AffiliateSettings') ? \WooNooW\Modules\Affiliate\AffiliateSettings::get_setting('woonoow_affiliate_enable_curated_collections', true) : false,
|
||||||
|
],
|
||||||
'security' => \WooNooW\Compat\SecuritySettingsProvider::get_public_settings(),
|
'security' => \WooNooW\Compat\SecuritySettingsProvider::get_public_settings(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -460,7 +476,7 @@ class Assets
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check path prefixes
|
// Check path prefixes
|
||||||
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
|
$prefix_routes = ['/shop/', '/my-account/', '/product/', '/collection/'];
|
||||||
foreach ($prefix_routes as $prefix) {
|
foreach ($prefix_routes as $prefix) {
|
||||||
if (strpos($path, $prefix) === 0) {
|
if (strpos($path, $prefix) === 0) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ class ShopController
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Get affiliate collection (public)
|
||||||
|
register_rest_route($namespace, '/shop/collections/(?P<slug>[a-zA-Z0-9-]+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_collection'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,6 +297,57 @@ class ShopController
|
|||||||
return new WP_REST_Response($products, 200);
|
return new WP_REST_Response($products, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get affiliate collection by slug
|
||||||
|
*/
|
||||||
|
public static function get_collection(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$slug = sanitize_title($request->get_param('slug'));
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
|
$collection = $wpdb->get_row($wpdb->prepare("SELECT * FROM $collections_table WHERE slug = %s", $slug));
|
||||||
|
|
||||||
|
if (!$collection) {
|
||||||
|
return new WP_Error('not_found', 'Collection not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$product_ids = $collection->product_ids ? json_decode($collection->product_ids, true) : [];
|
||||||
|
if (!is_array($product_ids)) $product_ids = [];
|
||||||
|
|
||||||
|
$products = [];
|
||||||
|
if (!empty($product_ids)) {
|
||||||
|
$args = [
|
||||||
|
'post_type' => 'product',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => -1,
|
||||||
|
'post__in' => $product_ids,
|
||||||
|
'orderby' => 'post__in',
|
||||||
|
];
|
||||||
|
|
||||||
|
$query = new \WP_Query($args);
|
||||||
|
if ($query->have_posts()) {
|
||||||
|
while ($query->have_posts()) {
|
||||||
|
$query->the_post();
|
||||||
|
$product = wc_get_product(get_the_ID());
|
||||||
|
if ($product) {
|
||||||
|
$products[] = self::format_product($product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wp_reset_postdata();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $collection->id,
|
||||||
|
'title' => $collection->title,
|
||||||
|
'description' => $collection->description,
|
||||||
|
'products' => $products
|
||||||
|
];
|
||||||
|
|
||||||
|
return new WP_REST_Response($data, 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format product data for API response
|
* Format product data for API response
|
||||||
*/
|
*/
|
||||||
|
|||||||
83
includes/Frontend/SmartRotator.php
Normal file
83
includes/Frontend/SmartRotator.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WooNooW\Frontend;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
class SmartRotator
|
||||||
|
{
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
|
add_action('init', [__CLASS__, 'register_rewrite_rules']);
|
||||||
|
add_filter('query_vars', [__CLASS__, 'register_query_vars']);
|
||||||
|
add_action('template_redirect', [__CLASS__, 'handle_redirect']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_rewrite_rules()
|
||||||
|
{
|
||||||
|
// Match /go/collection-slug
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^go/([^/]+)/?$',
|
||||||
|
'index.php?woonoow_go_slug=$matches[1]',
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_query_vars($vars)
|
||||||
|
{
|
||||||
|
$vars[] = 'woonoow_go_slug';
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function handle_redirect()
|
||||||
|
{
|
||||||
|
$slug = get_query_var('woonoow_go_slug');
|
||||||
|
if (empty($slug)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
// Lookup collection and affiliate in one query
|
||||||
|
$query = $wpdb->prepare(
|
||||||
|
"SELECT c.product_ids, a.referral_code
|
||||||
|
FROM {$collections_table} c
|
||||||
|
JOIN {$affiliates_table} a ON c.affiliate_id = a.id
|
||||||
|
WHERE c.slug = %s LIMIT 1",
|
||||||
|
$slug
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $wpdb->get_row($query);
|
||||||
|
|
||||||
|
if (!$result || empty($result->product_ids)) {
|
||||||
|
// Fallback: 404 or redirect to shop
|
||||||
|
wp_safe_redirect(site_url('/shop/'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product_ids = json_decode($result->product_ids, true);
|
||||||
|
if (!is_array($product_ids) || empty($product_ids)) {
|
||||||
|
wp_safe_redirect(site_url('/shop/'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Randomly pick a product
|
||||||
|
$random_product_id = $product_ids[array_rand($product_ids)];
|
||||||
|
|
||||||
|
// Get the permalink for the product
|
||||||
|
$target_url = get_permalink($random_product_id);
|
||||||
|
if (!$target_url) {
|
||||||
|
wp_safe_redirect(site_url('/shop/'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the affiliate referral code
|
||||||
|
$target_url = add_query_arg('ref', $result->referral_code, $target_url);
|
||||||
|
|
||||||
|
// Redirect to the product page
|
||||||
|
wp_redirect($target_url, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -211,6 +211,13 @@ class TemplateOverride
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /collection/* → SPA page (global, independent of frontpage setting)
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^collection/(.*)$',
|
||||||
|
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=collection/$matches[1]',
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
|
||||||
// Register query var for the SPA path
|
// Register query var for the SPA path
|
||||||
add_filter('query_vars', function ($vars) {
|
add_filter('query_vars', function ($vars) {
|
||||||
$vars[] = 'woonoow_spa_path';
|
$vars[] = 'woonoow_spa_path';
|
||||||
@@ -430,7 +437,7 @@ class TemplateOverride
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check path prefixes (for sub-routes)
|
// Check path prefixes (for sub-routes)
|
||||||
$prefix_routes = ['/shop/', '/my-account/', '/product/', '/checkout/'];
|
$prefix_routes = ['/shop/', '/my-account/', '/product/', '/checkout/', '/collection/'];
|
||||||
foreach ($prefix_routes as $prefix) {
|
foreach ($prefix_routes as $prefix) {
|
||||||
if (strpos($path, $prefix) === 0) {
|
if (strpos($path, $prefix) === 0) {
|
||||||
$should_serve_spa = true;
|
$should_serve_spa = true;
|
||||||
@@ -500,7 +507,7 @@ class TemplateOverride
|
|||||||
|
|
||||||
// Check if this is a SPA route
|
// Check if this is a SPA route
|
||||||
// We include /product/ and standard endpoints
|
// We include /product/ and standard endpoints
|
||||||
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account'];
|
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account', '/go/'];
|
||||||
|
|
||||||
foreach ($spa_routes as $route) {
|
foreach ($spa_routes as $route) {
|
||||||
if (strpos($requested_url, $route) !== false) {
|
if (strpos($requested_url, $route) !== false) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class AffiliateManager
|
|||||||
private static $affiliates_table = 'woonoow_affiliates';
|
private static $affiliates_table = 'woonoow_affiliates';
|
||||||
private static $referrals_table = 'woonoow_referrals';
|
private static $referrals_table = 'woonoow_referrals';
|
||||||
private static $payouts_table = 'woonoow_affiliate_payouts';
|
private static $payouts_table = 'woonoow_affiliate_payouts';
|
||||||
|
private static $collections_table = 'woonoow_affiliate_collections';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize
|
* Initialize
|
||||||
@@ -37,6 +38,7 @@ class AffiliateManager
|
|||||||
$affiliates_table = $wpdb->prefix . self::$affiliates_table;
|
$affiliates_table = $wpdb->prefix . self::$affiliates_table;
|
||||||
$referrals_table = $wpdb->prefix . self::$referrals_table;
|
$referrals_table = $wpdb->prefix . self::$referrals_table;
|
||||||
$payouts_table = $wpdb->prefix . self::$payouts_table;
|
$payouts_table = $wpdb->prefix . self::$payouts_table;
|
||||||
|
$collections_table = $wpdb->prefix . self::$collections_table;
|
||||||
|
|
||||||
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
||||||
|
|
||||||
@@ -135,6 +137,23 @@ class AffiliateManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collections Table
|
||||||
|
$sql_collections = "CREATE TABLE $collections_table (
|
||||||
|
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
affiliate_id bigint(20) UNSIGNED NOT NULL,
|
||||||
|
title varchar(255) NOT NULL,
|
||||||
|
slug varchar(255) NOT NULL,
|
||||||
|
description text DEFAULT NULL,
|
||||||
|
product_ids longtext DEFAULT NULL,
|
||||||
|
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY affiliate_slug (affiliate_id, slug),
|
||||||
|
KEY affiliate_id (affiliate_id)
|
||||||
|
) $charset_collate;";
|
||||||
|
|
||||||
|
dbDelta($sql_collections);
|
||||||
|
|
||||||
// Payouts Table
|
// Payouts Table
|
||||||
$sql_payouts = "CREATE TABLE $payouts_table (
|
$sql_payouts = "CREATE TABLE $payouts_table (
|
||||||
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ class AffiliateModule
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create collections table if it doesn't exist
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
if ($wpdb->get_var("SHOW TABLES LIKE '$collections_table'") !== $collections_table) {
|
||||||
|
AffiliateManager::create_tables();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ class AffiliateSettings {
|
|||||||
'description' => __('Automatically approve new affiliate applications.', 'woonoow'),
|
'description' => __('Automatically approve new affiliate applications.', 'woonoow'),
|
||||||
'default' => false,
|
'default' => false,
|
||||||
],
|
],
|
||||||
|
'woonoow_affiliate_enable_curated_collections' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Enable Curated Collections', 'woonoow'),
|
||||||
|
'description' => __('Allow affiliates to create and share custom curated product collections.', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
'woonoow_affiliate_allow_self_referral' => [
|
'woonoow_affiliate_allow_self_referral' => [
|
||||||
'type' => 'toggle',
|
'type' => 'toggle',
|
||||||
'label' => __('Allow Self-Referrals', 'woonoow'),
|
'label' => __('Allow Self-Referrals', 'woonoow'),
|
||||||
|
|||||||
@@ -102,9 +102,37 @@ class AffiliateTracker
|
|||||||
'samesite' => 'Lax'
|
'samesite' => 'Lax'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Capture referral code
|
$referral_code = '';
|
||||||
|
|
||||||
|
// 1. Capture from ?ref= parameter
|
||||||
if (isset($_GET['ref']) && !empty($_GET['ref'])) {
|
if (isset($_GET['ref']) && !empty($_GET['ref'])) {
|
||||||
$referral_code = sanitize_text_field($_GET['ref']);
|
$referral_code = sanitize_text_field($_GET['ref']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Or capture from collection slug in URL (e.g., /collection/my-slug)
|
||||||
|
if (empty($referral_code)) {
|
||||||
|
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$path = parse_url($request_uri, PHP_URL_PATH);
|
||||||
|
|
||||||
|
// Extract collection slug, accounting for possible subdirectories (e.g. /store/collection/slug)
|
||||||
|
if (preg_match('#/collection/([^/]+)#', $path, $matches)) {
|
||||||
|
$collection_slug = sanitize_text_field($matches[1]);
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$collections_table = $wpdb->prefix . 'woonoow_affiliate_collections';
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
$referral_code = $wpdb->get_var($wpdb->prepare("
|
||||||
|
SELECT a.referral_code
|
||||||
|
FROM $collections_table c
|
||||||
|
JOIN $affiliates_table a ON c.affiliate_id = a.id
|
||||||
|
WHERE c.slug = %s
|
||||||
|
", $collection_slug));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the cookie if we found a referral code
|
||||||
|
if (!empty($referral_code)) {
|
||||||
$result = setcookie(self::COOKIE_NAME, $referral_code, $options);
|
$result = setcookie(self::COOKIE_NAME, $referral_code, $options);
|
||||||
$_COOKIE[self::COOKIE_NAME] = $referral_code;
|
$_COOKIE[self::COOKIE_NAME] = $referral_code;
|
||||||
error_log('[AffiliateTracker] Set woonoow_ref cookie: ' . $referral_code . ', result=' . ($result ? 'true' : 'false'));
|
error_log('[AffiliateTracker] Set woonoow_ref cookie: ' . $referral_code . ', result=' . ($result ? 'true' : 'false'));
|
||||||
|
|||||||
@@ -608,7 +608,7 @@ class LicenseManager
|
|||||||
/**
|
/**
|
||||||
* Validate license (check if valid without activating)
|
* 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);
|
$license = self::get_license_by_key($license_key);
|
||||||
|
|
||||||
@@ -626,8 +626,31 @@ class LicenseManager
|
|||||||
$subscription_status = self::get_order_subscription_status($license['order_id']);
|
$subscription_status = self::get_order_subscription_status($license['order_id']);
|
||||||
$is_subscription_valid = $subscription_status === null || in_array($subscription_status, ['active', 'pending-cancel']);
|
$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 [
|
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'],
|
'license_key' => $license['license_key'],
|
||||||
'status' => $license['status'],
|
'status' => $license['status'],
|
||||||
'activation_limit' => (int) $license['activation_limit'],
|
'activation_limit' => (int) $license['activation_limit'],
|
||||||
@@ -639,6 +662,7 @@ class LicenseManager
|
|||||||
'is_expired' => $is_expired,
|
'is_expired' => $is_expired,
|
||||||
'subscription_status' => $subscription_status,
|
'subscription_status' => $subscription_status,
|
||||||
'subscription_active' => $is_subscription_valid,
|
'subscription_active' => $is_subscription_valid,
|
||||||
|
'domain_active' => $is_domain_active,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,10 +127,10 @@ class SoftwareManager
|
|||||||
/**
|
/**
|
||||||
* Check for updates
|
* 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
|
// Validate license
|
||||||
$license_validation = LicenseManager::validate($license_key);
|
$license_validation = LicenseManager::validate($license_key, $site_url);
|
||||||
|
|
||||||
if (!$license_validation['valid']) {
|
if (!$license_validation['valid']) {
|
||||||
return [
|
return [
|
||||||
@@ -255,10 +255,21 @@ class SoftwareManager
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . self::$versions_table;
|
$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",
|
"SELECT * FROM $table WHERE product_id = %d ORDER BY release_date DESC",
|
||||||
$product_id
|
$product_id
|
||||||
), ARRAY_A);
|
), 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;
|
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
|
* Generate secure download token
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class SubscriptionModule
|
|||||||
add_action('deleted_post', [__CLASS__, 'on_post_deleted']);
|
add_action('deleted_post', [__CLASS__, 'on_post_deleted']);
|
||||||
add_action('delete_user', [__CLASS__, 'on_user_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
|
// 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_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);
|
add_filter('woocommerce_product_add_to_cart_text', [__CLASS__, 'subscription_add_to_cart_text'], 10, 2);
|
||||||
@@ -271,6 +274,24 @@ class SubscriptionModule
|
|||||||
$order->save();
|
$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
|
* Modify add to cart button text for subscription products
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ add_filter('woocommerce_checkout_fields', function ($fields) {
|
|||||||
$fields['billing']['billing_last_name']['type'] = 'hidden';
|
$fields['billing']['billing_last_name']['type'] = 'hidden';
|
||||||
$fields['billing']['billing_last_name']['default'] = 'ID';
|
$fields['billing']['billing_last_name']['default'] = 'ID';
|
||||||
$fields['billing']['billing_last_name']['required'] = false;
|
$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'])) {
|
if (isset($fields['billing']['billing_country'])) {
|
||||||
$fields['billing']['billing_country']['type'] = 'hidden';
|
$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']['type'] = 'hidden';
|
||||||
$fields['shipping']['shipping_last_name']['default'] = 'ID';
|
$fields['shipping']['shipping_last_name']['default'] = 'ID';
|
||||||
$fields['shipping']['shipping_last_name']['required'] = false;
|
$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'])) {
|
if (isset($fields['shipping']['shipping_country'])) {
|
||||||
$fields['shipping']['shipping_country']['type'] = 'hidden';
|
$fields['shipping']['shipping_country']['type'] = 'hidden';
|
||||||
@@ -137,7 +147,8 @@ add_filter('woocommerce_checkout_fields', function ($fields) {
|
|||||||
|
|
||||||
// Check if cart needs shipping
|
// Check if cart needs shipping
|
||||||
$needs_shipping = true;
|
$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();
|
$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
|
// Clear shipping cache to force recalculation
|
||||||
WC()->session->set('shipping_for_package_0', false);
|
WC()->session->set('shipping_for_package_0', false);
|
||||||
}, 10, 2);
|
}, 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user