feat/fix: checkout email tracing, UI tweaks for add-to-cart, cart page overflow fix, implement hide admin bar setting

This commit is contained in:
Dwindi Ramadhana
2026-02-27 23:15:10 +07:00
parent 687a2318b0
commit a62037d993
22 changed files with 2711 additions and 294 deletions

View File

@@ -0,0 +1,379 @@
# Software Distribution System Plan (Simplified)
Extends WooNooW Licensing with software versioning, changelog, and update checker API. Works for **any software type** - not just WordPress plugins/themes.
---
## Core Principle
**Reuse existing WooCommerce infrastructure:**
- ✅ Downloadable Product = file hosting (already exists)
- ✅ Licensing Module = license validation (already exists)
- NEW: Version tracking + changelog per product
- NEW: Update checker API endpoint
---
## What We Add (Minimal)
| Field | Type | Location |
|-------|------|----------|
| `Current Version` | Text (e.g., "1.2.3") | Product Edit |
| `Changelog` | Rich Text/Markdown | Product Edit |
| `Software Slug` | Text (unique identifier) | Product Edit |
| `Enable WP Integration` | Checkbox | Product Edit |
**If "Enable WP Integration" is checked:**
| Field | Type |
|-------|------|
| `Requires WP` | Text |
| `Tested WP` | Text |
| `Requires PHP` | Text |
| `Icon URL` | Image |
| `Banner URL` | Image |
---
## Database Schema
**Table: `wp_woonoow_software_versions`**
```sql
CREATE TABLE wp_woonoow_software_versions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id BIGINT UNSIGNED NOT NULL,
version VARCHAR(50) NOT NULL,
changelog LONGTEXT,
release_date DATETIME NOT NULL,
is_current TINYINT(1) DEFAULT 0,
download_count INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_product (product_id),
INDEX idx_current (product_id, is_current),
UNIQUE KEY unique_version (product_id, version)
);
```
**Product Meta (stored in `wp_postmeta`):**
```
_woonoow_software_enabled: yes/no
_woonoow_software_slug: my-awesome-plugin
_woonoow_software_current_version: 1.2.3
_woonoow_software_wp_enabled: yes/no
_woonoow_software_requires_wp: 6.0
_woonoow_software_tested_wp: 6.7
_woonoow_software_requires_php: 7.4
_woonoow_software_icon: attachment_id or URL
_woonoow_software_banner: attachment_id or URL
```
---
## API Endpoints
### 1. Update Check (Generic)
`GET /wp-json/woonoow/v1/software/check`
**Request:**
```json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"slug": "my-software",
"version": "1.0.0",
"site_url": "https://client-site.com"
}
```
**Response (Generic - Any Software):**
```json
{
"success": true,
"update_available": true,
"product": {
"name": "My Awesome Software",
"slug": "my-software"
},
"current_version": "1.0.0",
"latest_version": "1.2.3",
"download_url": "https://vendor.com/wp-json/woonoow/v1/software/download?token=abc123",
"changelog_url": "https://vendor.com/wp-json/woonoow/v1/software/changelog?slug=my-software",
"changelog": "## 1.2.3\n- Fixed bug\n- Added feature",
"release_date": "2026-02-06"
}
```
**Response (WordPress Plugin/Theme - Extended):**
```json
{
"success": true,
"update_available": true,
"product": {
"name": "My WordPress Plugin",
"slug": "my-wp-plugin"
},
"current_version": "1.0.0",
"latest_version": "1.2.3",
"download_url": "https://vendor.com/wp-json/woonoow/v1/software/download?token=abc123",
"changelog_url": "https://vendor.com/wp-json/woonoow/v1/software/changelog?slug=my-wp-plugin",
"changelog": "## 1.2.3\n- Fixed bug",
"release_date": "2026-02-06",
"wordpress": {
"requires": "6.0",
"tested": "6.7",
"requires_php": "7.4",
"icons": {
"1x": "https://vendor.com/icon-128.png",
"2x": "https://vendor.com/icon-256.png"
},
"banners": {
"low": "https://vendor.com/banner-772x250.jpg",
"high": "https://vendor.com/banner-1544x500.jpg"
}
}
}
```
**Response (No Update):**
```json
{
"success": true,
"update_available": false,
"current_version": "1.2.3",
"latest_version": "1.2.3"
}
```
**Response (License Invalid):**
```json
{
"success": false,
"error": "license_expired",
"message": "Your license has expired. Renew to receive updates."
}
```
---
### 2. Download
`GET /wp-json/woonoow/v1/software/download`
**Request:**
```
?token=abc123&license_key=XXXX-XXXX-XXXX-XXXX
```
**Response:** Binary file stream (the downloadable file from WooCommerce product)
**Token Validation:**
- One-time use token
- 5-minute expiry
- Tied to specific license
---
### 3. Changelog
`GET /wp-json/woonoow/v1/software/changelog`
**Request:**
```
?slug=my-software&version=1.2.3 (optional)
```
**Response:**
```json
{
"slug": "my-software",
"versions": [
{
"version": "1.2.3",
"release_date": "2026-02-06",
"changelog": "## 1.2.3\n- Fixed critical bug\n- Added dark mode"
},
{
"version": "1.2.0",
"release_date": "2026-01-15",
"changelog": "## 1.2.0\n- Major feature release"
}
]
}
```
---
## Client Integration Examples
### A. WordPress Plugin (Auto-Updates)
Documented in: **woonoow-docs** → Developer → Software Updates
```php
// Include the updater class (provided by WooNooW)
require_once 'class-woonoow-updater.php';
new WooNooW_Updater([
'api_url' => 'https://your-store.com/',
'slug' => 'my-plugin',
'version' => MY_PLUGIN_VERSION,
'license_key' => get_option('my_plugin_license'),
'plugin_file' => __FILE__,
]);
```
### B. Generic Software (Manual Check)
For desktop apps, SaaS, scripts, etc:
```javascript
// Check for updates
const response = await fetch('https://vendor.com/wp-json/woonoow/v1/software/check', {
method: 'POST',
body: JSON.stringify({
license_key: 'XXXX-XXXX-XXXX-XXXX',
slug: 'my-desktop-app',
version: '1.0.0'
})
});
const data = await response.json();
if (data.update_available) {
console.log(`New version ${data.latest_version} available!`);
console.log(`Download: ${data.download_url}`);
}
```
```python
# Python example
import requests
response = requests.post('https://vendor.com/wp-json/woonoow/v1/software/check', json={
'license_key': 'XXXX-XXXX-XXXX-XXXX',
'slug': 'my-python-tool',
'version': '1.0.0'
})
data = response.json()
if data.get('update_available'):
print(f"Update available: {data['latest_version']}")
```
---
## Implementation Flow
### Product Setup (Seller)
1. Create WooCommerce product (Simple, Downloadable)
2. Enable Licensing ✓
3. Enable Software Distribution ✓
4. Set software slug: `my-software`
5. Upload downloadable file (uses existing WC functionality)
6. Set version: `1.0.0`
7. Add changelog
8. (Optional) Enable WordPress Integration → fill WP-specific fields
### Release New Version
1. Upload new file to product
2. Click "Add New Version"
3. Enter version number: `1.1.0`
4. Write changelog
5. Save → Previous version archived, new version active
### Client Updates
1. Client software calls `/software/check` with license key
2. API validates license → returns latest version info
3. Client downloads via `/software/download` with token
4. Client installs update (WordPress = automatic, others = manual/custom)
---
## Files to Create
```
includes/
├── Api/
│ └── SoftwareController.php # REST endpoints
├── Modules/
│ └── Software/
│ ├── SoftwareModule.php # Bootstrap
│ └── SoftwareManager.php # Core logic
└── Templates/
└── updater/
└── class-woonoow-updater.php # WP client library
admin-spa/src/routes/Settings/
└── Software.tsx # Version manager UI
```
---
## Documentation (woonoow-docs)
Create new section: **Developer → Software Distribution**
1. **Overview** - What is software distribution
2. **Setup Guide** - How to configure products
3. **API Reference** - Endpoint documentation
4. **WordPress Integration** - How to use the updater class
5. **Generic Integration** - Examples for non-WP software
6. **Client Libraries** - Download the updater class
---
## Implementation Phases
### Phase 1: Core ✅
- [x] Database table + product meta fields (`wp_woonoow_software_versions`, `wp_woonoow_software_downloads`)
- [x] SoftwareManager class (`includes/Modules/Software/SoftwareManager.php`)
- [x] SoftwareModule class (`includes/Modules/Software/SoftwareModule.php`)
- [x] SoftwareSettings class (`includes/Modules/SoftwareSettings.php`)
- [x] Product edit fields in WooCommerce
### Phase 2: API ✅
- [x] SoftwareController (`includes/Api/SoftwareController.php`)
- [x] `/software/check` endpoint (GET/POST)
- [x] `/software/download` endpoint with token
- [x] `/software/changelog` endpoint
### Phase 3: Admin UI ✅
- [x] Version release manager in Admin SPA (`routes/Products/SoftwareVersions/index.tsx`)
- [x] Changelog editor (Dialog with Textarea)
- [x] Version history table
### Phase 4: Client Library ✅
- [x] `class-woonoow-updater.php` for WP (`templates/updater/class-woonoow-updater.php`)
- [x] Supports both plugins and themes
- [x] Includes license status checking
### Phase 5: Documentation ✅
- [x] Add docs to woonoow-docs (`contents/docs/developer/software-updates/index.mdx`)
- [x] API reference
- [x] Integration examples (WordPress, JavaScript, Python)
---
## Security
| Measure | Description |
|---------|-------------|
| License Validation | All endpoints verify active license |
| Download Tokens | One-time use, 5-min expiry |
| Rate Limiting | Max 10 requests/minute per license |
| Signed URLs | HMAC signature for download links |
---
## Questions Before Implementation
1. ✅ Generic for any software (not just WP)
2. ✅ Reuse WooCommerce downloadable files
3. ✅ WordPress fields are optional
4. ✅ Document client library in woonoow-docs
Ready to proceed with Phase 1?

View File

@@ -23,6 +23,7 @@ import ProductTags from '@/routes/Products/Tags';
import ProductAttributes from '@/routes/Products/Attributes'; import ProductAttributes from '@/routes/Products/Attributes';
import Licenses from '@/routes/Products/Licenses'; import Licenses from '@/routes/Products/Licenses';
import LicenseDetail from '@/routes/Products/Licenses/Detail'; import LicenseDetail from '@/routes/Products/Licenses/Detail';
import SoftwareVersions from '@/routes/Products/SoftwareVersions';
import SubscriptionsIndex from '@/routes/Subscriptions'; import SubscriptionsIndex from '@/routes/Subscriptions';
import SubscriptionDetail from '@/routes/Subscriptions/Detail'; import SubscriptionDetail from '@/routes/Subscriptions/Detail';
import CouponsIndex from '@/routes/Marketing/Coupons'; import CouponsIndex from '@/routes/Marketing/Coupons';
@@ -590,6 +591,7 @@ function AppRoutes() {
<Route path="/products/attributes" element={<ProductAttributes />} /> <Route path="/products/attributes" element={<ProductAttributes />} />
<Route path="/products/licenses" element={<Licenses />} /> <Route path="/products/licenses" element={<Licenses />} />
<Route path="/products/licenses/:id" element={<LicenseDetail />} /> <Route path="/products/licenses/:id" element={<LicenseDetail />} />
<Route path="/products/software" element={<SoftwareVersions />} />
{/* Orders */} {/* Orders */}
<Route path="/orders" element={<OrdersIndex />} /> <Route path="/orders" element={<OrdersIndex />} />

View File

@@ -81,15 +81,18 @@ export function CanvasSection({
> >
{/* Section content with Styles */} {/* Section content with Styles */}
<div <div
className={cn("relative overflow-hidden rounded-lg", !section.styles?.backgroundColor && "bg-white/50")} className={cn("relative overflow-hidden rounded-lg", !section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50")}
style={{ style={{
backgroundColor: section.styles?.backgroundColor, ...(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,
}} }}
> >
{/* Background Image & Overlay */} {/* Background Image & Overlay */}
{section.styles?.backgroundImage && ( {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"
@@ -101,6 +104,19 @@ export function CanvasSection({
/> />
</> </>
)} )}
{/* 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 }}
/>
</>
)}
{/* Content Wrapper */} {/* Content Wrapper */}
<div className={cn( <div className={cn(

View File

@@ -455,90 +455,170 @@ export function InspectorPanel({
{/* Background */} {/* Background */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Background')}</h4> <h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Background')}</h4>
{/* Background Type Selector */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs">{__('Background Color')}</Label> <Label className="text-xs">{__('Type')}</Label>
<div className="flex gap-2"> <div className="flex gap-1">
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden"> {(['solid', 'gradient', 'image'] as const).map((t) => (
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.backgroundColor || 'transparent' }} /> <button
key={t}
onClick={() => onSectionStylesChange({ backgroundType: t })}
className={cn(
'flex-1 text-xs py-1.5 px-2 rounded-md border transition-colors capitalize',
(selectedSection.styles?.backgroundType || 'solid') === t
? 'bg-blue-50 border-blue-300 text-blue-700 font-medium'
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
)}
>
{t}
</button>
))}
</div>
</div>
{/* Solid Color */}
{(!selectedSection.styles?.backgroundType || selectedSection.styles?.backgroundType === 'solid') && (
<div className="space-y-2">
<Label className="text-xs">{__('Background Color')}</Label>
<div className="flex gap-2">
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.backgroundColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={selectedSection.styles?.backgroundColor || '#ffffff'}
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
/>
</div>
<input <input
type="color" type="text"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0" placeholder="#FFFFFF"
value={selectedSection.styles?.backgroundColor || '#ffffff'} className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
value={selectedSection.styles?.backgroundColor || ''}
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })} onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
/> />
</div> </div>
<input </div>
type="text" )}
placeholder="#FFFFFF"
className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm" {/* Gradient Controls */}
value={selectedSection.styles?.backgroundColor || ''} {selectedSection.styles?.backgroundType === 'gradient' && (
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })} <div className="space-y-3">
{/* Live Preview Swatch */}
<div
className="w-full h-12 rounded-lg border shadow-inner"
style={{
background: `linear-gradient(${selectedSection.styles?.gradientAngle ?? 135}deg, ${selectedSection.styles?.gradientFrom || '#9333ea'}, ${selectedSection.styles?.gradientTo || '#3b82f6'})`
}}
/> />
</div> <div className="grid grid-cols-2 gap-3">
</div> <div className="space-y-1">
<Label className="text-xs">{__('From')}</Label>
<div className="space-y-2"> <div className="flex gap-1.5">
<Label className="text-xs">{__('Background Image')}</Label> <div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}> <div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.gradientFrom || '#9333ea' }} />
{selectedSection.styles?.backgroundImage ? ( <input
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50"> type="color"
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" /> className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> value={selectedSection.styles?.gradientFrom || '#9333ea'}
<span className="text-white text-xs font-medium">{__('Change')}</span> onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
/>
</div>
<input
type="text"
className="flex-1 h-8 rounded-md border border-input bg-background px-2 py-1 text-xs"
value={selectedSection.styles?.gradientFrom || '#9333ea'}
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
/>
</div> </div>
<button
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
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"
>
<Trash2 className="w-3 h-3" />
</button>
</div> </div>
) : ( <div className="space-y-1">
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal"> <Label className="text-xs">{__('To')}</Label>
<Palette className="w-6 h-6" /> <div className="flex gap-1.5">
{__('Select Image')} <div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
</Button> <div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.gradientTo || '#3b82f6' }} />
)} <input
</MediaUploader> type="color"
</div> className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={selectedSection.styles?.gradientTo || '#3b82f6'}
<div className="space-y-3 pt-2"> onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
<div className="flex items-center justify-between"> />
<Label className="text-xs">{__('Overlay Opacity')}</Label> </div>
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span> <input
type="text"
className="flex-1 h-8 rounded-md border border-input bg-background px-2 py-1 text-xs"
value={selectedSection.styles?.gradientTo || '#3b82f6'}
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
/>
</div>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">{__('Angle')}</Label>
<span className="text-xs text-gray-500">{selectedSection.styles?.gradientAngle ?? 135}°</span>
</div>
<Slider
value={[selectedSection.styles?.gradientAngle ?? 135]}
min={0}
max={360}
step={15}
onValueChange={(vals) => onSectionStylesChange({ gradientAngle: vals[0] })}
/>
</div>
</div> </div>
<Slider )}
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
max={100} {/* Image Background */}
step={5} {selectedSection.styles?.backgroundType === 'image' && (
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })} <>
/> <div className="space-y-2">
</div> <Label className="text-xs">{__('Background Image')}</Label>
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
{selectedSection.styles?.backgroundImage ? (
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-white text-xs font-medium">{__('Change')}</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
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"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
) : (
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal">
<Palette className="w-6 h-6" />
{__('Select Image')}
</Button>
)}
</MediaUploader>
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<Label className="text-xs">{__('Overlay Opacity')}</Label>
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span>
</div>
<Slider
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
max={100}
step={5}
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
/>
</div>
</>
)}
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<Label className="text-xs">{__('Section Height')}</Label> <Label className="text-xs">{__('Section Height')}</Label>
<Select <Select
value={selectedSection.styles?.heightPreset || 'default'} value={selectedSection.styles?.heightPreset || 'default'}
onValueChange={(val) => { onValueChange={(val) => {
// Map presets to padding values onSectionStylesChange({ heightPreset: val });
const paddingMap: Record<string, string> = {
'default': '0',
'small': '0',
'medium': '0',
'large': '0',
'screen': '0',
};
const padding = paddingMap[val] || '4rem';
// If screen, we might need a specific flag, but for now lets reuse paddingTop/Bottom or add a new prop.
// To avoid breaking schema, let's use paddingTop as the "preset carrier" or add a new styles prop if possible.
// Since styles key is SectionStyles, let's stick to modifying paddingTop/Bottom for now as a simple preset.
onSectionStylesChange({
paddingTop: padding,
paddingBottom: padding,
heightPreset: val // We'll add this to interface
} as any);
}} }}
> >
<SelectTrigger><SelectValue placeholder="Height" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Height" /></SelectTrigger>

View File

@@ -11,6 +11,10 @@ export interface SectionProp {
export interface SectionStyles { export interface SectionStyles {
backgroundColor?: string; backgroundColor?: string;
backgroundType?: 'solid' | 'gradient' | 'image';
gradientFrom?: string;
gradientTo?: string;
gradientAngle?: number; // 0-360
backgroundImage?: string; backgroundImage?: string;
backgroundOverlay?: number; // 0-100 opacity backgroundOverlay?: number; // 0-100 opacity
paddingTop?: string; paddingTop?: string;

View File

@@ -0,0 +1,347 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Search, Package, Plus, History, Download, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
interface SoftwareProduct {
id: number;
name: string;
slug: string;
current_version: string;
wp_enabled: boolean;
total_downloads: number;
}
interface SoftwareVersion {
id: number;
product_id: number;
version: string;
changelog: string;
release_date: string;
is_current: boolean;
download_count: number;
}
interface ProductsResponse {
products: SoftwareProduct[];
}
interface VersionsResponse {
product_id: number;
config: {
enabled: boolean;
slug: string;
current_version: string;
wp_enabled: boolean;
};
versions: SoftwareVersion[];
}
export default function SoftwareVersions() {
const [search, setSearch] = useState('');
const [selectedProduct, setSelectedProduct] = useState<number | null>(null);
const [isAddVersionOpen, setIsAddVersionOpen] = useState(false);
const [newVersion, setNewVersion] = useState({ version: '', changelog: '' });
const queryClient = useQueryClient();
// Fetch software-enabled products
const { data: productsData, isLoading: productsLoading } = useQuery({
queryKey: ['software-products'],
queryFn: async () => {
const response = await api.get('/products?software_enabled=true&per_page=100');
// Filter products that have software distribution enabled
const products = (response as any).products || [];
return {
products: products.filter((p: any) => p.meta?._woonoow_software_enabled === 'yes').map((p: any) => ({
id: p.id,
name: p.name,
slug: p.meta?._woonoow_software_slug || '',
current_version: p.meta?._woonoow_software_current_version || '',
wp_enabled: p.meta?._woonoow_software_wp_enabled === 'yes',
total_downloads: 0,
}))
} as ProductsResponse;
},
});
// Fetch versions for selected product
const { data: versionsData, isLoading: versionsLoading } = useQuery({
queryKey: ['software-versions', selectedProduct],
queryFn: async () => {
const response = await api.get(`/software/products/${selectedProduct}/versions`);
return response as VersionsResponse;
},
enabled: !!selectedProduct,
});
// Add new version mutation
const addVersion = useMutation({
mutationFn: async (data: { version: string; changelog: string }) => {
return await api.post(`/software/products/${selectedProduct}/versions`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['software-versions', selectedProduct] });
queryClient.invalidateQueries({ queryKey: ['software-products'] });
toast.success(__('Version added successfully'));
setIsAddVersionOpen(false);
setNewVersion({ version: '', changelog: '' });
},
onError: (error: any) => {
toast.error(error.message || __('Failed to add version'));
},
});
const filteredProducts = productsData?.products?.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.slug.toLowerCase().includes(search.toLowerCase())
) || [];
const selectedProductData = productsData?.products?.find(p => p.id === selectedProduct);
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{__('Software Versions')}</h1>
<p className="text-muted-foreground">
{__('Manage software releases, changelogs, and version history')}
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Products List */}
<div className="lg:col-span-1 border rounded-lg bg-card">
<div className="p-4 border-b">
<h2 className="font-semibold mb-3">{__('Software Products')}</h2>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={__('Search products...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div className="max-h-[500px] overflow-y-auto">
{productsLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : filteredProducts.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Package className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{__('No software products found')}</p>
<p className="text-sm mt-1">
{__('Enable software distribution on a downloadable product')}
</p>
</div>
) : (
<div className="divide-y">
{filteredProducts.map((product) => (
<button
key={product.id}
onClick={() => setSelectedProduct(product.id)}
className={`w-full p-4 text-left hover:bg-accent transition-colors ${selectedProduct === product.id ? 'bg-accent' : ''
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{product.name}</p>
<p className="text-sm text-muted-foreground font-mono">
{product.slug}
</p>
</div>
<Badge variant="secondary" className="ml-2">
v{product.current_version || '—'}
</Badge>
</div>
{product.wp_enabled && (
<Badge variant="outline" className="mt-2 text-xs">
WordPress
</Badge>
)}
</button>
))}
</div>
)}
</div>
</div>
{/* Version Details */}
<div className="lg:col-span-2 border rounded-lg bg-card">
{!selectedProduct ? (
<div className="flex items-center justify-center h-full min-h-[400px] text-muted-foreground">
<div className="text-center">
<History className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{__('Select a product to view versions')}</p>
</div>
</div>
) : versionsLoading ? (
<div className="flex items-center justify-center h-full min-h-[400px]">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* Version Header */}
<div className="p-4 border-b flex items-center justify-between">
<div>
<h2 className="font-semibold">{selectedProductData?.name}</h2>
<p className="text-sm text-muted-foreground">
{__('Current version')}: <span className="font-mono">{versionsData?.config?.current_version || '—'}</span>
</p>
</div>
<Dialog open={isAddVersionOpen} onOpenChange={setIsAddVersionOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
{__('New Version')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{__('Add New Version')}</DialogTitle>
<DialogDescription>
{__('Release a new version of')} {selectedProductData?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="version">{__('Version Number')}</Label>
<Input
id="version"
placeholder="1.2.3"
value={newVersion.version}
onChange={(e) => setNewVersion(prev => ({ ...prev, version: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
{__('Use semantic versioning (e.g., 1.0.0, 1.2.3)')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="changelog">{__('Changelog')}</Label>
<Textarea
id="changelog"
placeholder="## What's New&#10;- Added new feature&#10;- Fixed bug"
value={newVersion.changelog}
onChange={(e) => setNewVersion(prev => ({ ...prev, changelog: e.target.value }))}
rows={8}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{__('Supports Markdown formatting')}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddVersionOpen(false)}>
{__('Cancel')}
</Button>
<Button
onClick={() => addVersion.mutate(newVersion)}
disabled={!newVersion.version || addVersion.isPending}
>
{addVersion.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{__('Release Version')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Version History */}
<div className="p-4">
{versionsData?.versions?.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{__('No versions released yet')}</p>
<p className="text-sm mt-1">
{__('Click "New Version" to release your first version')}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Version')}</TableHead>
<TableHead>{__('Release Date')}</TableHead>
<TableHead>{__('Downloads')}</TableHead>
<TableHead>{__('Changelog')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{versionsData?.versions?.map((version) => (
<TableRow key={version.id}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono font-medium">
v{version.version}
</span>
{version.is_current && (
<Badge variant="default" className="text-xs">
{__('Current')}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{version.release_date ? new Date(version.release_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-muted-foreground">
<Download className="w-3 h-3" />
{version.download_count}
</div>
</TableCell>
<TableCell className="max-w-xs">
<p className="text-sm text-muted-foreground truncate">
{version.changelog?.split('\n')[0] || '—'}
</p>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -30,43 +30,43 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
const { layout, elements, addToCart, saleBadge, isLoading } = useShopSettings(); const { layout, elements, addToCart, saleBadge, isLoading } = useShopSettings();
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist } = useWishlist(); const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist } = useWishlist();
const { isEnabled: isModuleEnabled } = useModules(); const { isEnabled: isModuleEnabled } = useModules();
const showWishlist = isModuleEnabled('wishlist') && wishlistEnabled; const showWishlist = isModuleEnabled('wishlist') && wishlistEnabled;
const inWishlist = showWishlist && isInWishlist(product.id); const inWishlist = showWishlist && isInWishlist(product.id);
const handleWishlistClick = async (e: React.MouseEvent) => { const handleWishlistClick = async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
await toggleWishlist(product.id); await toggleWishlist(product.id);
}; };
// Aspect ratio classes // Aspect ratio classes
const aspectRatioClass = { const aspectRatioClass = {
'square': 'aspect-square', 'square': 'aspect-square',
'portrait': 'aspect-[3/4]', 'portrait': 'aspect-[3/4]',
'landscape': 'aspect-[4/3]', 'landscape': 'aspect-[4/3]',
}[layout.aspect_ratio] || 'aspect-square'; }[layout.aspect_ratio] || 'aspect-square';
const isVariable = product.type === 'variable'; const isVariable = product.type === 'variable';
const handleAddToCart = (e: React.MouseEvent) => { const handleAddToCart = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Variable products need to go to product page for attribute selection // Variable products need to go to product page for attribute selection
if (isVariable) { if (isVariable) {
navigate(`/product/${product.slug}`); navigate(`/product/${product.slug}`);
return; return;
} }
onAddToCart?.(product); onAddToCart?.(product);
}; };
// Calculate discount if on sale // Calculate discount if on sale
const discount = product.on_sale && product.regular_price && product.sale_price const discount = product.on_sale && product.regular_price && product.sale_price
? formatDiscount(parseFloat(product.regular_price), parseFloat(product.sale_price)) ? formatDiscount(parseFloat(product.regular_price), parseFloat(product.sale_price))
: null; : null;
// Show skeleton while settings are loading to prevent layout shift // Show skeleton while settings are loading to prevent layout shift
if (isLoading) { if (isLoading) {
return ( return (
@@ -77,22 +77,22 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</div> </div>
); );
} }
// Determine button variant and position based on settings // Determine button variant and position based on settings
const buttonVariant = addToCart.style === 'outline' ? 'outline' : addToCart.style === 'text' ? 'ghost' : 'default'; const buttonVariant = addToCart.style === 'outline' ? 'outline' : addToCart.style === 'text' ? 'ghost' : 'default';
const showButtonOnHover = addToCart.position === 'overlay'; const showButtonOnHover = addToCart.position === 'overlay';
const buttonPosition = addToCart.position; // 'below', 'overlay', 'bottom' const buttonPosition = addToCart.position; // 'below', 'overlay', 'bottom'
const isTextOnly = addToCart.style === 'text'; const isTextOnly = addToCart.style === 'text';
// Card style variations - adapt to column count // Card style variations - adapt to column count
const cardStyle = layout.card_style || 'card'; const cardStyle = layout.card_style || 'card';
const gridCols = parseInt(layout.grid_columns) || 3; const gridCols = parseInt(layout.grid_columns) || 3;
// More columns = cleaner styling // More columns = cleaner styling
const getCardClasses = () => { const getCardClasses = () => {
if (cardStyle === 'minimal') { if (cardStyle === 'minimal') {
return gridCols >= 4 return gridCols >= 4
? 'overflow-hidden hover:opacity-90 transition-opacity' ? 'overflow-hidden hover:opacity-90 transition-opacity'
: 'overflow-hidden hover:opacity-80 transition-opacity border-b border-gray-100 pb-4'; : 'overflow-hidden hover:opacity-80 transition-opacity border-b border-gray-100 pb-4';
} }
if (cardStyle === 'overlay') { if (cardStyle === 'overlay') {
@@ -105,16 +105,16 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
? 'border border-gray-200 rounded-md overflow-hidden hover:shadow-md transition-shadow bg-white' ? 'border border-gray-200 rounded-md overflow-hidden hover:shadow-md transition-shadow bg-white'
: 'border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-white'; : 'border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-white';
}; };
const cardClasses = getCardClasses(); const cardClasses = getCardClasses();
// Text alignment class // Text alignment class
const textAlignClass = { const textAlignClass = {
'left': 'text-left', 'left': 'text-left',
'center': 'text-center', 'center': 'text-center',
'right': 'text-right', 'right': 'text-right',
}[layout.card_text_align || 'left'] || 'text-left'; }[layout.card_text_align || 'left'] || 'text-left';
// Classic Layout - Traditional card with border // Classic Layout - Traditional card with border
if (isClassic) { if (isClassic) {
return ( return (
@@ -133,34 +133,32 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
No Image No Image
</div> </div>
)} )}
{/* Sale Badge */} {/* Sale Badge */}
{elements.sale_badges && product.on_sale && discount && ( {elements.sale_badges && product.on_sale && discount && (
<div <div
className="absolute top-2 right-2 text-white text-xs font-bold px-2 py-1 rounded" className="absolute top-2 right-2 text-white text-xs font-bold px-2 py-1 rounded"
style={{ backgroundColor: saleBadge.color }} style={{ backgroundColor: saleBadge.color }}
> >
{discount} {discount}
</div> </div>
)} )}
{/* Wishlist Button */} {/* Wishlist Button */}
{showWishlist && ( {showWishlist && (
<div className="absolute top-2 left-2 z-10"> <div className="absolute top-2 left-2 z-10">
<button <button
onClick={handleWishlistClick} onClick={handleWishlistClick}
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${ className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${inWishlist ? 'bg-red-50' : 'bg-white'
inWishlist ? 'bg-red-50' : 'bg-white' }`}
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'} title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
> >
<Heart className={`w-4 h-4 block transition-all ${ <Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
inWishlist ? 'fill-red-500 text-red-500' : '' }`} />
}`} />
</button> </button>
</div> </div>
)} )}
{/* Hover/Overlay Button */} {/* Hover/Overlay Button */}
{showButtonOnHover && ( {showButtonOnHover && (
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-300 flex items-center justify-center"> <div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-300 flex items-center justify-center">
@@ -176,13 +174,13 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</div> </div>
)} )}
</div> </div>
{/* Content */} {/* Content */}
<div className={`p-4 flex-1 flex flex-col ${textAlignClass}`}> <div className={`p-4 flex-1 flex flex-col ${textAlignClass}`}>
<h3 className="text-sm font-medium text-gray-900 mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors"> <h3 className="text-sm font-medium text-gray-900 mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
{product.name} {product.name}
</h3> </h3>
{/* Price */} {/* Price */}
<div className={`flex items-center gap-2 mb-3 ${(layout.card_text_align || 'left') === 'center' ? 'justify-center' : (layout.card_text_align || 'left') === 'right' ? 'justify-end' : ''}`}> <div className={`flex items-center gap-2 mb-3 ${(layout.card_text_align || 'left') === 'center' ? 'justify-center' : (layout.card_text_align || 'left') === 'right' ? 'justify-end' : ''}`}>
{product.on_sale && product.regular_price ? ( {product.on_sale && product.regular_price ? (
@@ -200,7 +198,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</span> </span>
)} )}
</div> </div>
{/* Add to Cart Button - Below Image */} {/* Add to Cart Button - Below Image */}
{!showButtonOnHover && ( {!showButtonOnHover && (
<Button <Button
@@ -218,7 +216,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</Link> </Link>
); );
} }
// Modern Layout - Minimalist, clean // Modern Layout - Minimalist, clean
if (isModern) { if (isModern) {
return ( return (
@@ -237,34 +235,32 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
No Image No Image
</div> </div>
)} )}
{/* Sale Badge */} {/* Sale Badge */}
{elements.sale_badges && product.on_sale && discount && ( {elements.sale_badges && product.on_sale && discount && (
<div <div
className="absolute top-4 left-4 text-white text-xs font-medium px-3 py-1" className="absolute top-4 left-4 text-white text-xs font-medium px-3 py-1"
style={{ backgroundColor: saleBadge.color }} style={{ backgroundColor: saleBadge.color }}
> >
{discount} {discount}
</div> </div>
)} )}
{/* Wishlist Button */} {/* Wishlist Button */}
{showWishlist && ( {showWishlist && (
<div className="absolute top-4 right-4 z-10"> <div className="absolute top-4 right-4 z-10">
<button <button
onClick={handleWishlistClick} onClick={handleWishlistClick}
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${ className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${inWishlist ? 'bg-red-50' : 'bg-white'
inWishlist ? 'bg-red-50' : 'bg-white' }`}
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'} title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
> >
<Heart className={`w-4 h-4 block transition-all ${ <Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
inWishlist ? 'fill-red-500 text-red-500' : '' }`} />
}`} />
</button> </button>
</div> </div>
)} )}
{/* Hover Overlay - Only show if position is hover/overlay */} {/* Hover Overlay - Only show if position is hover/overlay */}
{showButtonOnHover && ( {showButtonOnHover && (
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-300 flex items-center justify-center"> <div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-300 flex items-center justify-center">
@@ -280,13 +276,13 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</div> </div>
)} )}
</div> </div>
{/* Content */} {/* Content */}
<div className="text-center"> <div className="text-center">
<h3 className="text-sm font-medium text-gray-900 mb-2 leading-snug group-hover:text-primary transition-colors"> <h3 className="text-sm font-medium text-gray-900 mb-2 leading-snug group-hover:text-primary transition-colors">
{product.name} {product.name}
</h3> </h3>
{/* Price */} {/* Price */}
<div className="flex items-center justify-center gap-2 mb-3"> <div className="flex items-center justify-center gap-2 mb-3">
{product.on_sale && product.regular_price ? ( {product.on_sale && product.regular_price ? (
@@ -304,7 +300,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</span> </span>
)} )}
</div> </div>
{/* Add to Cart Button - Below or Bottom */} {/* Add to Cart Button - Below or Bottom */}
{!showButtonOnHover && ( {!showButtonOnHover && (
<div className="flex flex-col mt-auto"> <div className="flex flex-col mt-auto">
@@ -316,10 +312,10 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
disabled={product.stock_status === 'outofstock'} disabled={product.stock_status === 'outofstock'}
> >
{!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />} {!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'} {product.stock_status === 'outofstock' ? 'Out of Stock' : isVariable ? 'Select Options' : 'Add to Cart'}
</Button> </Button>
)} )}
{buttonPosition === 'bottom' && ( {buttonPosition === 'bottom' && (
<Button <Button
onClick={handleAddToCart} onClick={handleAddToCart}
@@ -328,7 +324,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
disabled={product.stock_status === 'outofstock'} disabled={product.stock_status === 'outofstock'}
> >
{!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />} {!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'} {product.stock_status === 'outofstock' ? 'Out of Stock' : isVariable ? 'Select Options' : 'Add to Cart'}
</Button> </Button>
)} )}
</div> </div>
@@ -338,7 +334,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</Link> </Link>
); );
} }
// Boutique Layout - Luxury, elegant // Boutique Layout - Luxury, elegant
if (isBoutique) { if (isBoutique) {
return ( return (
@@ -357,41 +353,39 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
No Image No Image
</div> </div>
)} )}
{/* Sale Badge */} {/* Sale Badge */}
{elements.sale_badges && product.on_sale && discount && ( {elements.sale_badges && product.on_sale && discount && (
<div <div
className="absolute top-6 right-6 text-white text-xs font-medium px-4 py-2 tracking-wider" className="absolute top-6 right-6 text-white text-xs font-medium px-4 py-2 tracking-wider"
style={{ backgroundColor: saleBadge.color }} style={{ backgroundColor: saleBadge.color }}
> >
{discount} {discount}
</div> </div>
)} )}
{/* Wishlist Button */} {/* Wishlist Button */}
{showWishlist && ( {showWishlist && (
<div className="absolute top-6 left-6 z-10"> <div className="absolute top-6 left-6 z-10">
<button <button
onClick={handleWishlistClick} onClick={handleWishlistClick}
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${ className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${inWishlist ? 'bg-red-50' : 'bg-white'
inWishlist ? 'bg-red-50' : 'bg-white' }`}
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'} title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
> >
<Heart className={`w-4 h-4 block transition-all ${ <Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
inWishlist ? 'fill-red-500 text-red-500' : '' }`} />
}`} />
</button> </button>
</div> </div>
)} )}
</div> </div>
{/* Content */} {/* Content */}
<div className="text-center font-serif"> <div className="text-center font-serif">
<h3 className="text-sm font-medium text-gray-900 mb-3 tracking-wide leading-snug group-hover:text-primary transition-colors"> <h3 className="text-sm font-medium text-gray-900 mb-3 tracking-wide leading-snug group-hover:text-primary transition-colors">
{product.name} {product.name}
</h3> </h3>
{/* Price */} {/* Price */}
<div className="flex items-center justify-center gap-3 mb-4"> <div className="flex items-center justify-center gap-3 mb-4">
{product.on_sale && product.regular_price ? ( {product.on_sale && product.regular_price ? (
@@ -409,7 +403,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</span> </span>
)} )}
</div> </div>
{/* Add to Cart Button */} {/* Add to Cart Button */}
<Button <Button
onClick={handleAddToCart} onClick={handleAddToCart}
@@ -424,7 +418,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
</Link> </Link>
); );
} }
// Launch Layout - Funnel optimized (shouldn't show product grid, but just in case) // Launch Layout - Funnel optimized (shouldn't show product grid, but just in case)
return ( return (
<Link to={`/product/${product.slug}`} className="group"> <Link to={`/product/${product.slug}`} className="group">
@@ -441,25 +435,23 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
No Image No Image
</div> </div>
)} )}
{/* Wishlist Button */} {/* Wishlist Button */}
{showWishlist && ( {showWishlist && (
<div className="absolute top-3 right-3 z-10"> <div className="absolute top-3 right-3 z-10">
<button <button
onClick={handleWishlistClick} onClick={handleWishlistClick}
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${ className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${inWishlist ? 'bg-red-50' : 'bg-white'
inWishlist ? 'bg-red-50' : 'bg-white' }`}
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'} title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
> >
<Heart className={`w-4 h-4 block transition-all ${ <Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
inWishlist ? 'fill-red-500 text-red-500' : '' }`} />
}`} />
</button> </button>
</div> </div>
)} )}
</div> </div>
<div className="p-4 text-center"> <div className="p-4 text-center">
<h3 className="text-sm font-medium text-gray-900 mb-2 leading-snug">{product.name}</h3> <h3 className="text-sm font-medium text-gray-900 mb-2 leading-snug">{product.name}</h3>
<div className="text-lg font-bold mb-3" style={{ color: 'var(--color-primary)' }}> <div className="text-lg font-bold mb-3" style={{ color: 'var(--color-primary)' }}>

View File

@@ -0,0 +1,47 @@
/**
* Shared utility to compute background styles from section styles.
* Used by all customer SPA section components.
*/
export function getSectionBackground(styles?: Record<string, any>): {
style: React.CSSProperties;
hasOverlay: boolean;
overlayOpacity: number;
backgroundImage?: string;
} {
if (!styles) {
return { style: {}, hasOverlay: false, overlayOpacity: 0 };
}
const bgType = styles.backgroundType || 'solid';
let style: React.CSSProperties = {};
let hasOverlay = false;
let overlayOpacity = 0;
let backgroundImage: string | undefined;
switch (bgType) {
case 'gradient':
style.background = `linear-gradient(${styles.gradientAngle ?? 135}deg, ${styles.gradientFrom || '#9333ea'}, ${styles.gradientTo || '#3b82f6'})`;
break;
case 'image':
if (styles.backgroundImage) {
backgroundImage = styles.backgroundImage;
overlayOpacity = (styles.backgroundOverlay || 0) / 100;
hasOverlay = overlayOpacity > 0;
}
break;
case 'solid':
default:
if (styles.backgroundColor) {
style.backgroundColor = styles.backgroundColor;
}
// Legacy: if backgroundImage exists without explicit type
if (!styles.backgroundType && styles.backgroundImage) {
backgroundImage = styles.backgroundImage;
overlayOpacity = (styles.backgroundOverlay || 0) / 100;
hasOverlay = overlayOpacity > 0;
}
break;
}
return { style, hasOverlay, overlayOpacity, backgroundImage };
}

View File

@@ -173,55 +173,84 @@ export default function Cart() {
{cart.items.map((item: CartItem) => ( {cart.items.map((item: CartItem) => (
<div <div
key={item.key} key={item.key}
className="flex gap-4 p-4 border rounded-lg bg-white" className="flex flex-col sm:flex-row gap-4 p-4 border rounded-lg bg-white relative"
> >
{/* Product Image */} <div className="flex gap-4 sm:flex-1">
{elements.product_images && ( {/* Product Image */}
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-gray-100"> {elements.product_images && (
{item.image ? ( <div className="relative w-20 h-20 sm:w-24 sm:h-24 flex-shrink-0 rounded-lg overflow-hidden bg-gray-100">
<img {item.image ? (
src={item.image} <img
alt={item.name} src={item.image}
className="block w-full !h-full object-cover object-center" alt={item.name}
/> className="block w-full !h-full object-cover object-center"
) : ( />
<div className="w-full !h-full flex items-center justify-center text-gray-400 text-xs"> ) : (
No Image <div className="w-full !h-full flex items-center justify-center text-gray-400 text-xs">
</div> No Image
)} </div>
</div> )}
)}
{/* Product Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg mb-1 truncate">
{item.name}
</h3>
{/* Variation Attributes */}
{item.attributes && Object.keys(item.attributes).length > 0 && (
<div className="text-sm text-gray-500 mb-1">
{Object.entries(item.attributes).map(([key, value]) => {
// Format attribute name: capitalize first letter
const formattedKey = key.charAt(0).toUpperCase() + key.slice(1);
return (
<span key={key} className="mr-3">
{formattedKey}: <span className="font-medium">{value}</span>
</span>
);
})}
</div> </div>
)} )}
<p className="text-gray-600 mb-2"> {/* Product Info */}
{formatPrice(item.price)} <div className="flex-1 min-w-0 pr-8 sm:pr-0">
</p> <h3 className="font-semibold text-base sm:text-lg mb-1 truncate">
{item.name}
</h3>
{/* Quantity Controls */} {/* Variation Attributes */}
{item.attributes && Object.keys(item.attributes).length > 0 && (
<div className="text-xs sm:text-sm text-gray-500 mb-1 flex flex-wrap gap-x-3 gap-y-1">
{Object.entries(item.attributes).map(([key, value]) => {
const formattedKey = key.charAt(0).toUpperCase() + key.slice(1);
return (
<span key={key}>
{formattedKey}: <span className="font-medium">{value}</span>
</span>
);
})}
</div>
)}
<p className="text-gray-600 mb-2 font-medium">
{formatPrice(item.price)}
</p>
{/* Quantity Controls - Desktop */}
<div className="hidden sm:flex items-center gap-2 mt-2">
<button
onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)}
className="font-[inherit] p-1 hover:bg-gray-100 rounded"
>
<Minus className="h-4 w-4" />
</button>
<input
type="number"
value={item.quantity}
onChange={(e) =>
handleUpdateQuantity(item.key, parseInt(e.target.value) || 1)
}
className="w-16 text-center border rounded py-1"
min="1"
/>
<button
onClick={() => handleUpdateQuantity(item.key, item.quantity + 1)}
className="font-[inherit] p-1 hover:bg-gray-100 rounded"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* Mobile Quantity & Item Total Container */}
<div className="flex items-center justify-between sm:hidden pt-3 border-t border-gray-100">
{/* Quantity Controls - Mobile */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)} onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)}
className="font-[inherit] p-1 hover:bg-gray-100 rounded" className="font-[inherit] p-1 hover:bg-gray-100 rounded border"
> >
<Minus className="h-4 w-4" /> <Minus className="h-4 w-4" />
</button> </button>
@@ -231,27 +260,30 @@ export default function Cart() {
onChange={(e) => onChange={(e) =>
handleUpdateQuantity(item.key, parseInt(e.target.value) || 1) handleUpdateQuantity(item.key, parseInt(e.target.value) || 1)
} }
className="w-16 text-center border rounded py-1" className="w-12 text-center border rounded py-1"
min="1" min="1"
/> />
<button <button
onClick={() => handleUpdateQuantity(item.key, item.quantity + 1)} onClick={() => handleUpdateQuantity(item.key, item.quantity + 1)}
className="font-[inherit] p-1 hover:bg-gray-100 rounded" className="font-[inherit] p-1 hover:bg-gray-100 rounded border"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</button> </button>
</div> </div>
<p className="font-bold text-base">
{formatPrice(item.price * item.quantity)}
</p>
</div> </div>
{/* Item Total & Remove */} {/* Desktop Item Total & Remove + Mobile Absolute Remove */}
<div className="flex flex-col items-end justify-between"> <div className="absolute top-2 right-2 sm:static sm:flex sm:flex-col sm:items-end sm:justify-between">
<button <button
onClick={() => handleRemoveItem(item.key)} onClick={() => handleRemoveItem(item.key)}
className="font-[inherit] text-red-600 hover:text-red-700 p-2" className="font-[inherit] text-gray-400 hover:text-red-600 p-2 sm:p-1"
> >
<Trash2 className="h-5 w-5" /> <Trash2 className="h-5 w-5" />
</button> </button>
<p className="font-bold text-lg"> <p className="hidden sm:block font-bold text-lg">
{formatPrice(item.price * item.quantity)} {formatPrice(item.price * item.quantity)}
</p> </p>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
interface HeroSectionProps { interface HeroSectionProps {
id: string; id: string;
@@ -28,7 +29,8 @@ export function HeroSection({
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';
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage; const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get text styles (including font family) // Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => { const getTextStyles = (elementName: string) => {
@@ -71,8 +73,9 @@ export function HeroSection({
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24'; const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
// Helper to get background style for dynamic schemes // Helper to get background style for dynamic schemes
const getBackgroundStyle = () => { const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return undefined; // If user set custom bg via Design tab, use that
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'gradient') { if (colorScheme === 'gradient') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' }; return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
} }

View File

@@ -909,8 +909,8 @@ export default function Product() {
{/* Sticky CTA Bar */} {/* Sticky CTA Bar */}
{layout.sticky_add_to_cart && stockStatus === 'instock' && ( {layout.sticky_add_to_cart && stockStatus === 'instock' && (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-200 p-3 shadow-2xl z-50"> <div className="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-200 p-3 shadow-2xl z-50">
<div className="flex items-center gap-3"> <div className="max-w-6xl mx-auto flex items-center justify-between gap-3 px-2">
<div className="flex-1"> <div className="flex-1 flex flex-col justify-center min-w-0">
{/* Show selected variation for variable products */} {/* Show selected variation for variable products */}
{product.type === 'variable' && Object.keys(selectedAttributes).length > 0 && ( {product.type === 'variable' && Object.keys(selectedAttributes).length > 0 && (
<div className="text-xs text-gray-600 mb-1 flex items-center gap-1 flex-wrap"> <div className="text-xs text-gray-600 mb-1 flex items-center gap-1 flex-wrap">

View File

@@ -112,6 +112,7 @@ class AppearanceController
$general_data = [ $general_data = [
'spa_mode' => sanitize_text_field($request->get_param('spaMode')), 'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
'spa_page' => absint($request->get_param('spaPage') ?? 0), 'spa_page' => absint($request->get_param('spaPage') ?? 0),
'hide_admin_bar' => (bool) $request->get_param('hideAdminBar'),
'container_width' => sanitize_text_field($request->get_param('containerWidth') ?? 'boxed'), 'container_width' => sanitize_text_field($request->get_param('containerWidth') ?? 'boxed'),
'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'), 'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'),

View File

@@ -93,18 +93,13 @@ class OnboardingController extends WP_REST_Controller
if (!empty($params['create_home_page']) && $params['create_home_page'] === true) { if (!empty($params['create_home_page']) && $params['create_home_page'] === true) {
$page_id = $this->create_magic_homepage(); $page_id = $this->create_magic_homepage();
if ($page_id) { if ($page_id) {
update_option('page_on_front', $page_id); // Set as SPA entry page only (don't modify WP front page)
update_option('show_on_front', 'page');
// Set as SPA entry page
update_option('woonoow_spa_entry_page', $page_id); update_option('woonoow_spa_entry_page', $page_id);
} }
} elseif (!empty($params['entry_page_id'])) { } elseif (!empty($params['entry_page_id'])) {
$page_id = absint($params['entry_page_id']); $page_id = absint($params['entry_page_id']);
// Set as SPA entry page only (don't modify WP front page)
update_option('woonoow_spa_entry_page', $page_id); update_option('woonoow_spa_entry_page', $page_id);
// Optionally set as front page if requested? The user just selected "Where should customers land".
// Let's assume for the wizard flow, selecting it implies setting it as front page too for consistency.
update_option('page_on_front', $page_id);
update_option('show_on_front', 'page');
} }
// 3. Appearance Settings // 3. Appearance Settings

View File

@@ -28,6 +28,7 @@ use WooNooW\Api\CampaignsController;
use WooNooW\Api\DocsController; use WooNooW\Api\DocsController;
use WooNooW\Api\LicensesController; use WooNooW\Api\LicensesController;
use WooNooW\Api\SubscriptionsController; use WooNooW\Api\SubscriptionsController;
use WooNooW\Api\SoftwareController;
use WooNooW\Frontend\ShopController; use WooNooW\Frontend\ShopController;
use WooNooW\Frontend\CartController as FrontendCartController; use WooNooW\Frontend\CartController as FrontendCartController;
use WooNooW\Frontend\AccountController; use WooNooW\Frontend\AccountController;
@@ -171,6 +172,9 @@ class Routes
// Subscriptions controller (subscription module) // Subscriptions controller (subscription module)
SubscriptionsController::register_routes(); SubscriptionsController::register_routes();
// Software controller (software distribution module)
SoftwareController::register_routes();
// Modules controller // Modules controller
$modules_controller = new ModulesController(); $modules_controller = new ModulesController();
$modules_controller->register_routes(); $modules_controller->register_routes();

View File

@@ -0,0 +1,321 @@
<?php
/**
* Software Distribution API Controller
*
* REST API endpoints for software update checking and downloads.
*
* @package WooNooW\Api
*/
namespace WooNooW\Api;
if (!defined('ABSPATH')) exit;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\Software\SoftwareManager;
use WooNooW\Modules\Licensing\LicenseManager;
class SoftwareController
{
/**
* Register REST routes
*/
public static function register_routes()
{
$namespace = 'woonoow/v1';
// Public endpoints (authenticated via license key)
// Check for updates
register_rest_route($namespace, '/software/check', [
'methods' => 'GET',
'callback' => [__CLASS__, 'check_update'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => ['required' => true, 'type' => 'string'],
'slug' => ['required' => true, 'type' => 'string'],
'version' => ['required' => true, 'type' => 'string'],
'site_url' => ['required' => false, 'type' => 'string'],
],
]);
// Also support POST for update check (some clients prefer this)
register_rest_route($namespace, '/software/check', [
'methods' => 'POST',
'callback' => [__CLASS__, 'check_update'],
'permission_callback' => '__return_true',
]);
// Download file
register_rest_route($namespace, '/software/download', [
'methods' => 'GET',
'callback' => [__CLASS__, 'download'],
'permission_callback' => '__return_true',
'args' => [
'token' => ['required' => true, 'type' => 'string'],
],
]);
// Get changelog
register_rest_route($namespace, '/software/changelog', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_changelog'],
'permission_callback' => '__return_true',
'args' => [
'slug' => ['required' => true, 'type' => 'string'],
'version' => ['required' => false, 'type' => 'string'],
],
]);
// Admin endpoints (requires manage_woocommerce)
// Get all versions for a product
register_rest_route($namespace, '/software/products/(?P<product_id>\d+)/versions', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_versions'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
// Add new version
register_rest_route($namespace, '/software/products/(?P<product_id>\d+)/versions', [
'methods' => 'POST',
'callback' => [__CLASS__, 'add_version'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
}
/**
* Check for updates
*/
public static function check_update(WP_REST_Request $request)
{
// Check if module is enabled
if (!ModuleRegistry::is_enabled('software')) {
return new WP_REST_Response([
'success' => false,
'error' => 'module_disabled',
'message' => __('Software distribution module is not enabled', 'woonoow'),
], 503);
}
// Get parameters from GET or POST body
$params = $request->get_method() === 'POST'
? $request->get_json_params()
: $request->get_query_params();
$license_key = sanitize_text_field($params['license_key'] ?? '');
$slug = sanitize_text_field($params['slug'] ?? '');
$current_version = sanitize_text_field($params['version'] ?? '');
$site_url = esc_url_raw($params['site_url'] ?? '');
if (empty($license_key) || empty($slug) || empty($current_version)) {
return new WP_REST_Response([
'success' => false,
'error' => 'missing_params',
'message' => __('Missing required parameters: license_key, slug, version', 'woonoow'),
], 400);
}
// Log the check (optional - for analytics)
do_action('woonoow/software/update_check', $slug, $current_version, $site_url, $license_key);
$result = SoftwareManager::check_update($license_key, $slug, $current_version);
$status_code = isset($result['success']) && $result['success'] === false ? 400 : 200;
return new WP_REST_Response($result, $status_code);
}
/**
* Download file
*/
public static function download(WP_REST_Request $request)
{
// Check if module is enabled
if (!ModuleRegistry::is_enabled('software')) {
return new WP_REST_Response([
'success' => false,
'error' => 'module_disabled',
'message' => __('Software distribution module is not enabled', 'woonoow'),
], 503);
}
$token = sanitize_text_field($request->get_param('token'));
if (empty($token)) {
return new WP_REST_Response([
'success' => false,
'error' => 'missing_token',
'message' => __('Download token is required', 'woonoow'),
], 400);
}
// Validate token
$download = SoftwareManager::validate_download_token($token);
if (is_wp_error($download)) {
return new WP_REST_Response([
'success' => false,
'error' => $download->get_error_code(),
'message' => $download->get_error_message(),
], 403);
}
// Validate license is still active
$license = LicenseManager::get_license($download['license_id']);
if (!$license || $license['status'] !== 'active') {
return new WP_REST_Response([
'success' => false,
'error' => 'license_inactive',
'message' => __('License is no longer active', 'woonoow'),
], 403);
}
// Serve the file
SoftwareManager::serve_file($download['product_id']);
// Note: serve_file calls exit, so this won't be reached
return new WP_REST_Response(['success' => true], 200);
}
/**
* Get changelog
*/
public static function get_changelog(WP_REST_Request $request)
{
$slug = sanitize_text_field($request->get_param('slug'));
$version = sanitize_text_field($request->get_param('version'));
if (empty($slug)) {
return new WP_REST_Response([
'success' => false,
'error' => 'missing_slug',
'message' => __('Software slug is required', 'woonoow'),
], 400);
}
// Get product by slug
$product = SoftwareManager::get_product_by_slug($slug);
if (!$product) {
return new WP_REST_Response([
'success' => false,
'error' => 'product_not_found',
'message' => __('Software product not found', 'woonoow'),
], 404);
}
// Get all versions or specific version
if ($version) {
$changelog = SoftwareManager::get_version_changelog($product->get_id(), $version);
if (!$changelog) {
return new WP_REST_Response([
'success' => false,
'error' => 'version_not_found',
'message' => __('Version not found', 'woonoow'),
], 404);
}
return new WP_REST_Response([
'slug' => $slug,
'version' => $changelog['version'],
'release_date' => $changelog['release_date'],
'changelog' => $changelog['changelog'],
], 200);
}
// Get all versions
$versions = SoftwareManager::get_all_versions($product->get_id());
return new WP_REST_Response([
'slug' => $slug,
'versions' => array_map(function ($v) {
return [
'version' => $v['version'],
'release_date' => $v['release_date'],
'changelog' => $v['changelog'],
'download_count' => (int) $v['download_count'],
];
}, $versions),
], 200);
}
/**
* Get versions for a product (admin)
*/
public static function get_versions(WP_REST_Request $request)
{
$product_id = (int) $request->get_param('product_id');
$product = wc_get_product($product_id);
if (!$product) {
return new WP_REST_Response([
'success' => false,
'error' => 'product_not_found',
], 404);
}
$versions = SoftwareManager::get_all_versions($product_id);
$config = SoftwareManager::get_product_config($product_id);
return new WP_REST_Response([
'product_id' => $product_id,
'config' => $config,
'versions' => $versions,
], 200);
}
/**
* Add new version (admin)
*/
public static function add_version(WP_REST_Request $request)
{
$product_id = (int) $request->get_param('product_id');
$params = $request->get_json_params();
$version = sanitize_text_field($params['version'] ?? '');
$changelog = wp_kses_post($params['changelog'] ?? '');
$set_current = (bool) ($params['set_current'] ?? true);
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::add_version($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,
'version_id' => $result,
'message' => __('Version added successfully', 'woonoow'),
], 201);
}
}

View File

@@ -150,6 +150,7 @@ class NavigationRegistry
['label' => __('Categories', 'woonoow'), 'mode' => 'spa', 'path' => '/products/categories'], ['label' => __('Categories', 'woonoow'), 'mode' => 'spa', 'path' => '/products/categories'],
['label' => __('Tags', 'woonoow'), 'mode' => 'spa', 'path' => '/products/tags'], ['label' => __('Tags', 'woonoow'), 'mode' => 'spa', 'path' => '/products/tags'],
['label' => __('Attributes', 'woonoow'), 'mode' => 'spa', 'path' => '/products/attributes'], ['label' => __('Attributes', 'woonoow'), 'mode' => 'spa', 'path' => '/products/attributes'],
['label' => __('Software Versions', 'woonoow'), 'mode' => 'spa', 'path' => '/products/software'],
], ],
], ],
[ [

View File

@@ -1,4 +1,5 @@
<?php <?php
namespace WooNooW\Frontend; namespace WooNooW\Frontend;
/** /**
@@ -19,16 +20,16 @@ class PageSSR
if (empty($structure) || empty($structure['sections'])) { if (empty($structure) || empty($structure['sections'])) {
return ''; return '';
} }
$html = ''; $html = '';
foreach ($structure['sections'] as $section) { foreach ($structure['sections'] as $section) {
$html .= self::render_section($section, $post_data); $html .= self::render_section($section, $post_data);
} }
return $html; return $html;
} }
/** /**
* Render a single section to HTML * Render a single section to HTML
* *
@@ -42,13 +43,13 @@ class PageSSR
$props = $section['props'] ?? []; $props = $section['props'] ?? [];
$layout = $section['layoutVariant'] ?? 'default'; $layout = $section['layoutVariant'] ?? 'default';
$color_scheme = $section['colorScheme'] ?? 'default'; $color_scheme = $section['colorScheme'] ?? 'default';
// Resolve all props (replace dynamic placeholders with actual values) // Resolve all props (replace dynamic placeholders with actual values)
$resolved_props = self::resolve_props($props, $post_data); $resolved_props = self::resolve_props($props, $post_data);
// Generate section ID for anchor links // Generate section ID for anchor links
$section_id = $section['id'] ?? 'section-' . uniqid(); $section_id = $section['id'] ?? 'section-' . uniqid();
$element_styles = $section['elementStyles'] ?? []; $element_styles = $section['elementStyles'] ?? [];
$styles = $section['styles'] ?? []; // Section wrapper styles (bg, overlay) $styles = $section['styles'] ?? []; // Section wrapper styles (bg, overlay)
@@ -57,11 +58,11 @@ class PageSSR
if (method_exists(__CLASS__, $method)) { if (method_exists(__CLASS__, $method)) {
return self::$method($resolved_props, $layout, $color_scheme, $section_id, $element_styles, $styles); return self::$method($resolved_props, $layout, $color_scheme, $section_id, $element_styles, $styles);
} }
// Fallback: generic section wrapper // Fallback: generic section wrapper
return self::render_generic($resolved_props, $type, $section_id); return self::render_generic($resolved_props, $type, $section_id);
} }
/** /**
* Resolve props - replace dynamic placeholders with actual values * Resolve props - replace dynamic placeholders with actual values
* *
@@ -72,15 +73,15 @@ class PageSSR
public static function resolve_props($props, $post_data = null) public static function resolve_props($props, $post_data = null)
{ {
$resolved = []; $resolved = [];
foreach ($props as $key => $prop) { foreach ($props as $key => $prop) {
if (!is_array($prop)) { if (!is_array($prop)) {
$resolved[$key] = $prop; $resolved[$key] = $prop;
continue; continue;
} }
$type = $prop['type'] ?? 'static'; $type = $prop['type'] ?? 'static';
if ($type === 'static') { if ($type === 'static') {
$resolved[$key] = $prop['value'] ?? ''; $resolved[$key] = $prop['value'] ?? '';
} elseif ($type === 'dynamic' && $post_data) { } elseif ($type === 'dynamic' && $post_data) {
@@ -90,27 +91,28 @@ class PageSSR
$resolved[$key] = $prop['value'] ?? ''; $resolved[$key] = $prop['value'] ?? '';
} }
} }
return $resolved; return $resolved;
} }
// ======================================== // ========================================
// Section Renderers // Section Renderers
// ======================================== // ========================================
/** /**
* Helper to generate style attribute string * Helper to generate style attribute string
*/ */
private static function generate_style_attr($styles) { private static function generate_style_attr($styles)
{
if (empty($styles)) return ''; if (empty($styles)) return '';
$css = []; $css = [];
if (!empty($styles['color'])) $css[] = "color: {$styles['color']}"; if (!empty($styles['color'])) $css[] = "color: {$styles['color']}";
if (!empty($styles['backgroundColor'])) $css[] = "background-color: {$styles['backgroundColor']}"; if (!empty($styles['backgroundColor'])) $css[] = "background-color: {$styles['backgroundColor']}";
if (!empty($styles['fontSize'])) $css[] = "font-size: {$styles['fontSize']}"; // Note: assumes value has unit or is handled by class, but inline style works for specific values if (!empty($styles['fontSize'])) $css[] = "font-size: {$styles['fontSize']}"; // Note: assumes value has unit or is handled by class, but inline style works for specific values
// Add more mapping if needed, or rely on frontend to send valid CSS values // Add more mapping if needed, or rely on frontend to send valid CSS values
return empty($css) ? '' : 'style="' . implode(';', $css) . '"'; return empty($css) ? '' : 'style="' . implode(';', $css) . '"';
} }
/** /**
@@ -123,26 +125,37 @@ class PageSSR
$image = esc_url($props['image'] ?? ''); $image = esc_url($props['image'] ?? '');
$cta_text = esc_html($props['cta_text'] ?? ''); $cta_text = esc_html($props['cta_text'] ?? '');
$cta_url = esc_url($props['cta_url'] ?? ''); $cta_url = esc_url($props['cta_url'] ?? '');
// Section Styles (Background & Spacing) // Section Styles (Background & Spacing)
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? ''; $bg_color = $section_styles['backgroundColor'] ?? '';
$bg_image = $section_styles['backgroundImage'] ?? ''; $bg_image = $section_styles['backgroundImage'] ?? '';
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0; $overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
$pt = $section_styles['paddingTop'] ?? ''; $pt = $section_styles['paddingTop'] ?? '';
$pb = $section_styles['paddingBottom'] ?? ''; $pb = $section_styles['paddingBottom'] ?? '';
$height_preset = $section_styles['heightPreset'] ?? ''; $height_preset = $section_styles['heightPreset'] ?? '';
$section_css = ""; $section_css = "";
if ($bg_color) $section_css .= "background-color: {$bg_color};"; if ($bg_type === 'gradient') {
if ($bg_image) $section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;"; $from = $section_styles['gradientFrom'] ?? '#9333ea';
$to = $section_styles['gradientTo'] ?? '#3b82f6';
$angle = $section_styles['gradientAngle'] ?? 135;
$section_css .= "background: linear-gradient({$angle}deg, {$from}, {$to});";
} elseif ($bg_type === 'image' && $bg_image) {
$section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
} else {
if ($bg_color) $section_css .= "background-color: {$bg_color};";
// Legacy: image without explicit type
if ($bg_image && !$bg_type) $section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
}
if ($pt) $section_css .= "padding-top: {$pt};"; if ($pt) $section_css .= "padding-top: {$pt};";
if ($pb) $section_css .= "padding-bottom: {$pb};"; if ($pb) $section_css .= "padding-bottom: {$pb};";
if ($height_preset === 'screen') $section_css .= "min-height: 100vh; display: flex; align-items: center;"; if ($height_preset === 'screen') $section_css .= "min-height: 100vh; display: flex; align-items: center;";
$section_attr = $section_css ? "style=\"{$section_css}\"" : ""; $section_attr = $section_css ? "style=\"{$section_css}\"" : "";
$html = "<section id=\"{$id}\" class=\"wn-section wn-hero wn-hero--{$layout} wn-scheme--{$color_scheme}\" {$section_attr}>"; $html = "<section id=\"{$id}\" class=\"wn-section wn-hero wn-hero--{$layout} wn-scheme--{$color_scheme}\" {$section_attr}>";
// Overlay // Overlay
if ($overlay_opacity > 0) { if ($overlay_opacity > 0) {
$opacity = $overlay_opacity / 100; $opacity = $overlay_opacity / 100;
@@ -156,9 +169,9 @@ class PageSSR
// Image (if not background) // Image (if not background)
if ($image && !$bg_image && $layout !== 'default') { if ($image && !$bg_image && $layout !== 'default') {
$html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-hero__image\" />"; $html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-hero__image\" />";
} }
$html .= '<div class="wn-hero__content" style="position: relative; z-index: 10;">'; $html .= '<div class="wn-hero__content" style="position: relative; z-index: 10;">';
if ($title) { if ($title) {
$html .= "<h1 class=\"wn-hero__title\" {$title_style}>{$title}</h1>"; $html .= "<h1 class=\"wn-hero__title\" {$title_style}>{$title}</h1>";
@@ -171,31 +184,32 @@ class PageSSR
} }
$html .= '</div>'; $html .= '</div>';
$html .= '</section>'; $html .= '</section>';
return $html; return $html;
} }
/** /**
* Universal Row Renderer (Shared logic for Content & ImageText) * Universal Row Renderer (Shared logic for Content & ImageText)
*/ */
private static function render_universal_row($props, $layout, $color_scheme, $element_styles, $options = []) { private static function render_universal_row($props, $layout, $color_scheme, $element_styles, $options = [])
{
$title = esc_html($props['title']['value'] ?? ($props['title'] ?? '')); $title = esc_html($props['title']['value'] ?? ($props['title'] ?? ''));
$text = $props['text']['value'] ?? ($props['text'] ?? ($props['content']['value'] ?? ($props['content'] ?? ''))); // Handle both props/values $text = $props['text']['value'] ?? ($props['text'] ?? ($props['content']['value'] ?? ($props['content'] ?? ''))); // Handle both props/values
$image = esc_url($props['image']['value'] ?? ($props['image'] ?? '')); $image = esc_url($props['image']['value'] ?? ($props['image'] ?? ''));
// Options // Options
$has_image = !empty($image); $has_image = !empty($image);
$image_pos = $layout ?: 'left'; $image_pos = $layout ?: 'left';
// Element Styles // Element Styles
$title_style = self::generate_style_attr($element_styles['title'] ?? []); $title_style = self::generate_style_attr($element_styles['title'] ?? []);
$text_style = self::generate_style_attr($element_styles['text'] ?? ($element_styles['content'] ?? [])); $text_style = self::generate_style_attr($element_styles['text'] ?? ($element_styles['content'] ?? []));
// Wrapper Classes // Wrapper Classes
$wrapper_class = "wn-max-w-7xl wn-mx-auto wn-px-4"; $wrapper_class = "wn-max-w-7xl wn-mx-auto wn-px-4";
$grid_class = "wn-mx-auto"; $grid_class = "wn-mx-auto";
if ($has_image && in_array($image_pos, ['left', 'right', 'image-left', 'image-right'])) { if ($has_image && in_array($image_pos, ['left', 'right', 'image-left', 'image-right'])) {
$grid_class .= " wn-grid wn-grid-cols-1 wn-lg-grid-cols-2 wn-gap-12 wn-items-center"; $grid_class .= " wn-grid wn-grid-cols-1 wn-lg-grid-cols-2 wn-gap-12 wn-items-center";
} else { } else {
@@ -204,7 +218,7 @@ class PageSSR
$html = "<div class=\"{$wrapper_class}\">"; $html = "<div class=\"{$wrapper_class}\">";
$html .= "<div class=\"{$grid_class}\">"; $html .= "<div class=\"{$grid_class}\">";
// Image Output // Image Output
$image_html = ""; $image_html = "";
if ($current_pos_right = ($image_pos === 'right' || $image_pos === 'image-right')) { if ($current_pos_right = ($image_pos === 'right' || $image_pos === 'image-right')) {
@@ -218,7 +232,7 @@ class PageSSR
$image_html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-absolute wn-inset-0 wn-w-full wn-h-full wn-object-cover\" />"; $image_html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-absolute wn-inset-0 wn-w-full wn-h-full wn-object-cover\" />";
$image_html .= "</div>"; $image_html .= "</div>";
} }
// Content Output // Content Output
$content_html = "<div class=\"wn-flex wn-flex-col\">"; $content_html = "<div class=\"wn-flex wn-flex-col\">";
if ($title) { if ($title) {
@@ -232,14 +246,14 @@ class PageSSR
// Render based on order (Grid handles order via CSS classes for left/right, but fallback for DOM order) // Render based on order (Grid handles order via CSS classes for left/right, but fallback for DOM order)
if ($has_image) { if ($has_image) {
// For grid layout, we output both. CSS order handles visual. // For grid layout, we output both. CSS order handles visual.
$html .= $image_html . $content_html; $html .= $image_html . $content_html;
} else { } else {
$html .= $content_html; $html .= $content_html;
} }
$html .= "</div></div>"; $html .= "</div></div>";
return $html; return $html;
} }
@@ -253,72 +267,88 @@ class PageSSR
$content = apply_filters('the_content', $content); $content = apply_filters('the_content', $content);
// Normalize prop structure for universal renderer if needed // Normalize prop structure for universal renderer if needed
if (is_string($props['content'])) { if (is_string($props['content'])) {
$props['content'] = ['value' => $content]; $props['content'] = ['value' => $content];
} else { } else {
$props['content']['value'] = $content; $props['content']['value'] = $content;
} }
// Section Styles (Background) // Section Styles (Background)
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? ''; $bg_color = $section_styles['backgroundColor'] ?? '';
$padding = $section_styles['paddingTop'] ?? ''; $padding = $section_styles['paddingTop'] ?? '';
$height_preset = $section_styles['heightPreset'] ?? ''; $height_preset = $section_styles['heightPreset'] ?? '';
$css = ""; $css = "";
if($bg_color) $css .= "background-color:{$bg_color};"; if ($bg_type === 'gradient') {
$from = $section_styles['gradientFrom'] ?? '#9333ea';
$to = $section_styles['gradientTo'] ?? '#3b82f6';
$angle = $section_styles['gradientAngle'] ?? 135;
$css .= "background: linear-gradient({$angle}deg, {$from}, {$to});";
} elseif ($bg_color) {
$css .= "background-color:{$bg_color};";
}
// Height Logic // Height Logic
if ($height_preset === 'screen') { if ($height_preset === 'screen') {
$css .= "min-height: 100vh; display: flex; align-items: center;"; $css .= "min-height: 100vh; display: flex; align-items: center;";
$padding = '5rem'; // Default padding for screen to avoid edge collision $padding = '5rem'; // Default padding for screen to avoid edge collision
} elseif ($height_preset === 'small') { } elseif ($height_preset === 'small') {
$padding = '2rem'; $padding = '2rem';
} elseif ($height_preset === 'large') { } elseif ($height_preset === 'large') {
$padding = '8rem'; $padding = '8rem';
} elseif ($height_preset === 'medium') { } elseif ($height_preset === 'medium') {
$padding = '4rem'; $padding = '4rem';
} }
if($padding) $css .= "padding:{$padding} 0;"; if ($padding) $css .= "padding:{$padding} 0;";
$style_attr = $css ? "style=\"{$css}\"" : ""; $style_attr = $css ? "style=\"{$css}\"" : "";
$inner_html = self::render_universal_row($props, 'left', $color_scheme, $element_styles); $inner_html = self::render_universal_row($props, 'left', $color_scheme, $element_styles);
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>"; return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
} }
/** /**
* Render Image + Text section * Render Image + Text section
*/ */
public static function render_image_text($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = []) public static function render_image_text($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{ {
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? ''; $bg_color = $section_styles['backgroundColor'] ?? '';
$padding = $section_styles['paddingTop'] ?? ''; $padding = $section_styles['paddingTop'] ?? '';
$height_preset = $section_styles['heightPreset'] ?? ''; $height_preset = $section_styles['heightPreset'] ?? '';
$css = ""; $css = "";
if($bg_color) $css .= "background-color:{$bg_color};"; if ($bg_type === 'gradient') {
$from = $section_styles['gradientFrom'] ?? '#9333ea';
$to = $section_styles['gradientTo'] ?? '#3b82f6';
$angle = $section_styles['gradientAngle'] ?? 135;
$css .= "background: linear-gradient({$angle}deg, {$from}, {$to});";
} elseif ($bg_color) {
$css .= "background-color:{$bg_color};";
}
// Height Logic // Height Logic
if ($height_preset === 'screen') { if ($height_preset === 'screen') {
$css .= "min-height: 100vh; display: flex; align-items: center;"; $css .= "min-height: 100vh; display: flex; align-items: center;";
$padding = '5rem'; $padding = '5rem';
} elseif ($height_preset === 'small') { } elseif ($height_preset === 'small') {
$padding = '2rem'; $padding = '2rem';
} elseif ($height_preset === 'large') { } elseif ($height_preset === 'large') {
$padding = '8rem'; $padding = '8rem';
} elseif ($height_preset === 'medium') { } elseif ($height_preset === 'medium') {
$padding = '4rem'; $padding = '4rem';
} }
if($padding) $css .= "padding:{$padding} 0;"; if ($padding) $css .= "padding:{$padding} 0;";
$style_attr = $css ? "style=\"{$css}\"" : ""; $style_attr = $css ? "style=\"{$css}\"" : "";
$inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles); $inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles);
return "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>"; return "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
} }
/** /**
* Render Feature Grid section * Render Feature Grid section
*/ */
@@ -326,26 +356,26 @@ class PageSSR
{ {
$heading = esc_html($props['heading'] ?? ''); $heading = esc_html($props['heading'] ?? '');
$items = $props['items'] ?? []; $items = $props['items'] ?? [];
$html = "<section id=\"{$id}\" class=\"wn-section wn-feature-grid wn-feature-grid--{$layout} wn-scheme--{$color_scheme}\">"; $html = "<section id=\"{$id}\" class=\"wn-section wn-feature-grid wn-feature-grid--{$layout} wn-scheme--{$color_scheme}\">";
if ($heading) { if ($heading) {
$html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>"; $html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>";
} }
// Feature Item Styles (Card) // Feature Item Styles (Card)
$item_style_attr = self::generate_style_attr($element_styles['feature_item'] ?? []); // BG, Border, Shadow handled by CSS classes mostly, but colors here $item_style_attr = self::generate_style_attr($element_styles['feature_item'] ?? []); // BG, Border, Shadow handled by CSS classes mostly, but colors here
$item_bg = $element_styles['feature_item']['backgroundColor'] ?? ''; $item_bg = $element_styles['feature_item']['backgroundColor'] ?? '';
$html .= '<div class="wn-feature-grid__items">'; $html .= '<div class="wn-feature-grid__items">';
foreach ($items as $item) { foreach ($items as $item) {
$item_title = esc_html($item['title'] ?? ''); $item_title = esc_html($item['title'] ?? '');
$item_desc = esc_html($item['description'] ?? ''); $item_desc = esc_html($item['description'] ?? '');
$item_icon = esc_html($item['icon'] ?? ''); $item_icon = esc_html($item['icon'] ?? '');
// Allow overriding item specific style if needed, but for now global // Allow overriding item specific style if needed, but for now global
$html .= "<div class=\"wn-feature-grid__item\" {$item_style_attr}>"; $html .= "<div class=\"wn-feature-grid__item\" {$item_style_attr}>";
// Render Icon SVG // Render Icon SVG
if ($item_icon) { if ($item_icon) {
$icon_svg = self::get_icon_svg($item_icon); $icon_svg = self::get_icon_svg($item_icon);
@@ -353,7 +383,7 @@ class PageSSR
$html .= "<div class=\"wn-feature-grid__icon\">{$icon_svg}</div>"; $html .= "<div class=\"wn-feature-grid__icon\">{$icon_svg}</div>";
} }
} }
if ($item_title) { if ($item_title) {
// Feature title style // Feature title style
$f_title_style = self::generate_style_attr($element_styles['feature_title'] ?? []); $f_title_style = self::generate_style_attr($element_styles['feature_title'] ?? []);
@@ -368,10 +398,10 @@ class PageSSR
} }
$html .= '</div>'; $html .= '</div>';
$html .= '</section>'; $html .= '</section>';
return $html; return $html;
} }
/** /**
* Render CTA Banner section * Render CTA Banner section
*/ */
@@ -381,10 +411,10 @@ class PageSSR
$text = esc_html($props['text'] ?? ''); $text = esc_html($props['text'] ?? '');
$button_text = esc_html($props['button_text'] ?? ''); $button_text = esc_html($props['button_text'] ?? '');
$button_url = esc_url($props['button_url'] ?? ''); $button_url = esc_url($props['button_url'] ?? '');
$html = "<section id=\"{$id}\" class=\"wn-section wn-cta-banner wn-cta-banner--{$layout} wn-scheme--{$color_scheme}\">"; $html = "<section id=\"{$id}\" class=\"wn-section wn-cta-banner wn-cta-banner--{$layout} wn-scheme--{$color_scheme}\">";
$html .= '<div class="wn-cta-banner__content">'; $html .= '<div class="wn-cta-banner__content">';
if ($title) { if ($title) {
$html .= "<h2 class=\"wn-cta-banner__title\">{$title}</h2>"; $html .= "<h2 class=\"wn-cta-banner__title\">{$title}</h2>";
} }
@@ -394,13 +424,13 @@ class PageSSR
if ($button_text && $button_url) { if ($button_text && $button_url) {
$html .= "<a href=\"{$button_url}\" class=\"wn-cta-banner__button\">{$button_text}</a>"; $html .= "<a href=\"{$button_url}\" class=\"wn-cta-banner__button\">{$button_text}</a>";
} }
$html .= '</div>'; $html .= '</div>';
$html .= '</section>'; $html .= '</section>';
return $html; return $html;
} }
/** /**
* Render Contact Form section * Render Contact Form section
*/ */
@@ -410,13 +440,13 @@ class PageSSR
$webhook_url = esc_url($props['webhook_url'] ?? ''); $webhook_url = esc_url($props['webhook_url'] ?? '');
$redirect_url = esc_url($props['redirect_url'] ?? ''); $redirect_url = esc_url($props['redirect_url'] ?? '');
$fields = $props['fields'] ?? ['name', 'email', 'message']; $fields = $props['fields'] ?? ['name', 'email', 'message'];
// Extract styles // Extract styles
$btn_bg = $element_styles['button']['backgroundColor'] ?? ''; $btn_bg = $element_styles['button']['backgroundColor'] ?? '';
$btn_color = $element_styles['button']['color'] ?? ''; $btn_color = $element_styles['button']['color'] ?? '';
$field_bg = $element_styles['fields']['backgroundColor'] ?? ''; $field_bg = $element_styles['fields']['backgroundColor'] ?? '';
$field_color = $element_styles['fields']['color'] ?? ''; $field_color = $element_styles['fields']['color'] ?? '';
$btn_style = ""; $btn_style = "";
if ($btn_bg) $btn_style .= "background-color: {$btn_bg};"; if ($btn_bg) $btn_style .= "background-color: {$btn_bg};";
if ($btn_color) $btn_style .= "color: {$btn_color};"; if ($btn_color) $btn_style .= "color: {$btn_color};";
@@ -428,14 +458,14 @@ class PageSSR
$field_attr = $field_style ? "style=\"{$field_style}\"" : ""; $field_attr = $field_style ? "style=\"{$field_style}\"" : "";
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">"; $html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">";
if ($title) { if ($title) {
$html .= "<h2 class=\"wn-contact-form__title\">{$title}</h2>"; $html .= "<h2 class=\"wn-contact-form__title\">{$title}</h2>";
} }
// Form is rendered but won't work for bots (they just see the structure) // Form is rendered but won't work for bots (they just see the structure)
$html .= '<form class="wn-contact-form__form" method="post">'; $html .= '<form class="wn-contact-form__form" method="post">';
foreach ($fields as $field) { foreach ($fields as $field) {
$field_label = ucfirst(str_replace('_', ' ', $field)); $field_label = ucfirst(str_replace('_', ' ', $field));
$html .= '<div class="wn-contact-form__field">'; $html .= '<div class="wn-contact-form__field">';
@@ -447,18 +477,19 @@ class PageSSR
} }
$html .= '</div>'; $html .= '</div>';
} }
$html .= "<button type=\"submit\" {$btn_attr}>Submit</button>"; $html .= "<button type=\"submit\" {$btn_attr}>Submit</button>";
$html .= '</form>'; $html .= '</form>';
$html .= '</section>'; $html .= '</section>';
return $html; return $html;
} }
/** /**
* Helper to get SVG for known icons * Helper to get SVG for known icons
*/ */
private static function get_icon_svg($name) { private static function get_icon_svg($name)
{
$icons = [ $icons = [
'Star' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', 'Star' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
'Zap' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>', 'Zap' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
@@ -473,7 +504,7 @@ class PageSSR
return $icons[$name] ?? $icons['Star']; return $icons[$name] ?? $icons['Star'];
} }
/** /**
* Generic section fallback * Generic section fallback
*/ */
@@ -485,7 +516,7 @@ class PageSSR
$content .= "<div class=\"wn-{$type}__{$key}\">" . wp_kses_post($value) . "</div>"; $content .= "<div class=\"wn-{$type}__{$key}\">" . wp_kses_post($value) . "</div>";
} }
} }
return "<section id=\"{$id}\" class=\"wn-section wn-{$type}\">{$content}</section>"; return "<section id=\"{$id}\" class=\"wn-section wn-{$type}\">{$content}</section>";
} }
} }

View File

@@ -0,0 +1,456 @@
<?php
/**
* Software Distribution Manager
*
* Handles software versioning, downloads, and update checking.
*
* @package WooNooW\Modules\Software
*/
namespace WooNooW\Modules\Software;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\Licensing\LicenseManager;
class SoftwareManager
{
private static $versions_table = 'woonoow_software_versions';
private static $downloads_table = 'woonoow_software_downloads';
/**
* Initialize
*/
public static function init()
{
if (!ModuleRegistry::is_enabled('software')) {
return;
}
// Nothing to hook yet - API endpoints handle requests
}
/**
* Create database tables
*/
public static function create_tables()
{
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$versions_table = $wpdb->prefix . self::$versions_table;
$downloads_table = $wpdb->prefix . self::$downloads_table;
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
// Software versions table
$sql_versions = "CREATE TABLE $versions_table (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
product_id bigint(20) UNSIGNED NOT NULL,
version varchar(50) NOT NULL,
changelog longtext,
release_date datetime NOT NULL,
is_current tinyint(1) DEFAULT 0,
download_count int(11) DEFAULT 0,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_product (product_id),
KEY idx_current (product_id, is_current),
UNIQUE KEY unique_version (product_id, version)
) $charset_collate;";
dbDelta($sql_versions);
// Download tokens table (for secure downloads)
$sql_downloads = "CREATE TABLE $downloads_table (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
token varchar(64) NOT NULL,
license_id bigint(20) UNSIGNED NOT NULL,
product_id bigint(20) UNSIGNED NOT NULL,
version_id bigint(20) UNSIGNED,
ip_address varchar(45),
expires_at datetime NOT NULL,
used_at datetime DEFAULT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY token (token),
KEY license_id (license_id),
KEY expires_at (expires_at)
) $charset_collate;";
dbDelta($sql_downloads);
}
/**
* Get product software configuration
*/
public static function get_product_config($product_id)
{
$enabled = get_post_meta($product_id, '_woonoow_software_enabled', true) === 'yes';
if (!$enabled) {
return null;
}
return [
'enabled' => true,
'slug' => get_post_meta($product_id, '_woonoow_software_slug', true),
'current_version' => get_post_meta($product_id, '_woonoow_software_current_version', true),
'wp_enabled' => get_post_meta($product_id, '_woonoow_software_wp_enabled', true) === 'yes',
'requires_wp' => get_post_meta($product_id, '_woonoow_software_requires_wp', true),
'tested_wp' => get_post_meta($product_id, '_woonoow_software_tested_wp', true),
'requires_php' => get_post_meta($product_id, '_woonoow_software_requires_php', true),
'icon' => get_post_meta($product_id, '_woonoow_software_icon', true),
'banner' => get_post_meta($product_id, '_woonoow_software_banner', true),
];
}
/**
* Get product by software slug
*/
public static function get_product_by_slug($slug)
{
global $wpdb;
$product_id = $wpdb->get_var($wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta}
WHERE meta_key = '_woonoow_software_slug' AND meta_value = %s
LIMIT 1",
$slug
));
return $product_id ? wc_get_product($product_id) : null;
}
/**
* Check for updates
*/
public static function check_update($license_key, $slug, $current_version)
{
// Validate license
$license_validation = LicenseManager::validate($license_key);
if (!$license_validation['valid']) {
return [
'success' => false,
'error' => $license_validation['error'] ?? 'invalid_license',
'message' => $license_validation['message'] ?? __('Invalid license key', 'woonoow'),
];
}
// Get product by slug
$product = self::get_product_by_slug($slug);
if (!$product) {
return [
'success' => false,
'error' => 'product_not_found',
'message' => __('Software product not found', 'woonoow'),
];
}
$config = self::get_product_config($product->get_id());
if (!$config || !$config['enabled']) {
return [
'success' => false,
'error' => 'software_disabled',
'message' => __('Software distribution is not enabled for this product', 'woonoow'),
];
}
$latest_version = $config['current_version'];
$update_available = version_compare($current_version, $latest_version, '<');
// Get changelog for latest version
$changelog = self::get_version_changelog($product->get_id(), $latest_version);
// Build response
$response = [
'success' => true,
'update_available' => $update_available,
'product' => [
'name' => $product->get_name(),
'slug' => $config['slug'],
],
'current_version' => $current_version,
'latest_version' => $latest_version,
'changelog' => $changelog['changelog'] ?? '',
'release_date' => $changelog['release_date'] ?? null,
];
// Add download URL if update available
if ($update_available) {
$license = LicenseManager::get_license_by_key($license_key);
$token = self::generate_download_token($license['id'], $product->get_id());
$response['download_url'] = rest_url('woonoow/v1/software/download') . '?token=' . $token;
$response['changelog_url'] = rest_url('woonoow/v1/software/changelog') . '?slug=' . $config['slug'];
}
// Add WordPress-specific fields if enabled
if ($config['wp_enabled']) {
$response['wordpress'] = [
'requires' => $config['requires_wp'] ?: null,
'tested' => $config['tested_wp'] ?: null,
'requires_php' => $config['requires_php'] ?: null,
];
// Add icons/banners if set
if ($config['icon']) {
$icon_url = is_numeric($config['icon'])
? wp_get_attachment_url($config['icon'])
: $config['icon'];
$response['wordpress']['icons'] = [
'1x' => $icon_url,
'2x' => $icon_url,
];
}
if ($config['banner']) {
$banner_url = is_numeric($config['banner'])
? wp_get_attachment_url($config['banner'])
: $config['banner'];
$response['wordpress']['banners'] = [
'low' => $banner_url,
'high' => $banner_url,
];
}
}
return $response;
}
/**
* Get version changelog
*/
public static function get_version_changelog($product_id, $version = null)
{
global $wpdb;
$table = $wpdb->prefix . self::$versions_table;
if ($version) {
return $wpdb->get_row($wpdb->prepare(
"SELECT version, changelog, release_date FROM $table
WHERE product_id = %d AND version = %s",
$product_id,
$version
), ARRAY_A);
}
// Get current version
return $wpdb->get_row($wpdb->prepare(
"SELECT version, changelog, release_date FROM $table
WHERE product_id = %d AND is_current = 1",
$product_id
), ARRAY_A);
}
/**
* Get all versions for a product
*/
public static function get_all_versions($product_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$versions_table;
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table WHERE product_id = %d ORDER BY release_date DESC",
$product_id
), ARRAY_A);
}
/**
* Add new version
*/
public static function add_version($product_id, $version, $changelog, $set_current = true)
{
global $wpdb;
$table = $wpdb->prefix . self::$versions_table;
// Check if version already exists
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $table WHERE product_id = %d AND version = %s",
$product_id,
$version
));
if ($exists) {
return new \WP_Error('version_exists', __('Version already exists', 'woonoow'));
}
// If setting as current, unset previous current
if ($set_current) {
$wpdb->update(
$table,
['is_current' => 0],
['product_id' => $product_id]
);
}
// Insert new version
$wpdb->insert($table, [
'product_id' => $product_id,
'version' => $version,
'changelog' => $changelog,
'release_date' => current_time('mysql'),
'is_current' => $set_current ? 1 : 0,
]);
// Update product meta
if ($set_current) {
update_post_meta($product_id, '_woonoow_software_current_version', $version);
}
do_action('woonoow/software/version_added', $wpdb->insert_id, $product_id, $version);
return $wpdb->insert_id;
}
/**
* Generate secure download token
*/
public static function generate_download_token($license_id, $product_id, $version_id = null)
{
global $wpdb;
$table = $wpdb->prefix . self::$downloads_table;
$token = bin2hex(random_bytes(32));
$expires_at = gmdate('Y-m-d H:i:s', time() + 300); // 5 minutes
$wpdb->insert($table, [
'token' => $token,
'license_id' => $license_id,
'product_id' => $product_id,
'version_id' => $version_id,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'expires_at' => $expires_at,
]);
return $token;
}
/**
* Validate and consume download token
*/
public static function validate_download_token($token)
{
global $wpdb;
$table = $wpdb->prefix . self::$downloads_table;
$download = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table WHERE token = %s AND used_at IS NULL AND expires_at > %s",
$token,
current_time('mysql')
), ARRAY_A);
if (!$download) {
return new \WP_Error('invalid_token', __('Invalid or expired download token', 'woonoow'));
}
// Mark as used
$wpdb->update(
$table,
['used_at' => current_time('mysql')],
['id' => $download['id']]
);
// Increment download count
$versions_table = $wpdb->prefix . self::$versions_table;
if ($download['version_id']) {
$wpdb->query($wpdb->prepare(
"UPDATE $versions_table SET download_count = download_count + 1 WHERE id = %d",
$download['version_id']
));
}
return $download;
}
/**
* Get downloadable file for product
* Uses WooCommerce's existing downloadable files
*/
public static function get_downloadable_file($product_id)
{
$product = wc_get_product($product_id);
if (!$product || !$product->is_downloadable()) {
return null;
}
$downloads = $product->get_downloads();
if (empty($downloads)) {
return null;
}
// Return first downloadable file
$download = reset($downloads);
return [
'id' => $download->get_id(),
'name' => $download->get_name(),
'file' => $download->get_file(),
];
}
/**
* Serve downloadable file
*/
public static function serve_file($product_id)
{
$file_data = self::get_downloadable_file($product_id);
if (!$file_data) {
wp_die(__('No downloadable file found', 'woonoow'), '', ['response' => 404]);
}
$file_path = $file_data['file'];
// Handle different file types
if (strpos($file_path, home_url()) === 0) {
// Local file - convert URL to path
$upload_dir = wp_upload_dir();
$file_path = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $file_path);
}
if (!file_exists($file_path)) {
// Try as attachment
$file_path = get_attached_file(attachment_url_to_postid($file_data['file']));
}
if (!$file_path || !file_exists($file_path)) {
wp_die(__('File not found', 'woonoow'), '', ['response' => 404]);
}
// Serve file
$filename = basename($file_path);
$mime_type = mime_content_type($file_path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime_type);
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($file_path));
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
readfile($file_path);
exit;
}
/**
* Clean up expired tokens
*/
public static function cleanup_expired_tokens()
{
global $wpdb;
$table = $wpdb->prefix . self::$downloads_table;
$wpdb->query($wpdb->prepare(
"DELETE FROM $table WHERE expires_at < %s",
current_time('mysql')
));
}
}

View File

@@ -0,0 +1,200 @@
<?php
/**
* Software Distribution Module Bootstrap
*
* @package WooNooW\Modules\Software
*/
namespace WooNooW\Modules\Software;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\SoftwareSettings;
class SoftwareModule
{
/**
* Initialize the software distribution module
*/
public static function init()
{
// Register settings schema
SoftwareSettings::init();
// Initialize manager if module is enabled
self::maybe_init_manager();
// Install tables on module enable
add_action('woonoow/module/enabled', [__CLASS__, 'on_module_enabled']);
// Add product meta fields
add_action('woocommerce_product_options_general_product_data', [__CLASS__, 'add_product_software_fields']);
add_action('woocommerce_process_product_meta', [__CLASS__, 'save_product_software_fields']);
}
/**
* Initialize manager if module is enabled
*/
public static function maybe_init_manager()
{
if (ModuleRegistry::is_enabled('software')) {
self::ensure_tables();
SoftwareManager::init();
}
}
/**
* Ensure database tables exist
*/
private static function ensure_tables()
{
global $wpdb;
$table = $wpdb->prefix . 'woonoow_software_versions';
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
SoftwareManager::create_tables();
}
}
/**
* Handle module enable
*/
public static function on_module_enabled($module_id)
{
if ($module_id === 'software') {
SoftwareManager::create_tables();
}
}
/**
* Add software distribution fields to product edit page
*/
public static function add_product_software_fields()
{
global $post;
if (!ModuleRegistry::is_enabled('software')) {
return;
}
// Check if licensing is enabled for this product
$licensing_enabled = get_post_meta($post->ID, '_woonoow_licensing_enabled', true) === 'yes';
echo '<div class="options_group show_if_downloadable">';
// Software Distribution section header
echo '<p class="form-field"><strong>' . esc_html__('Software Distribution', 'woonoow') . '</strong></p>';
woocommerce_wp_checkbox([
'id' => '_woonoow_software_enabled',
'label' => __('Enable Software Updates', 'woonoow'),
'description' => __('Allow customers to check for updates via API', 'woonoow'),
]);
woocommerce_wp_text_input([
'id' => '_woonoow_software_slug',
'label' => __('Software Slug', 'woonoow'),
'description' => __('Unique identifier (e.g., "my-plugin"). Used in update check API.', 'woonoow'),
'desc_tip' => true,
'placeholder' => 'my-software',
]);
woocommerce_wp_text_input([
'id' => '_woonoow_software_current_version',
'label' => __('Current Version', 'woonoow'),
'description' => __('Latest version number (e.g., "1.2.3")', 'woonoow'),
'desc_tip' => true,
'placeholder' => '1.0.0',
]);
// WordPress Integration section
echo '<p class="form-field"><em>' . esc_html__('WordPress Integration (Optional)', 'woonoow') . '</em></p>';
woocommerce_wp_checkbox([
'id' => '_woonoow_software_wp_enabled',
'label' => __('WordPress Plugin/Theme', 'woonoow'),
'description' => __('Enable WordPress-specific update fields', 'woonoow'),
]);
// WordPress-specific fields (shown via JS when checkbox is checked)
echo '<div class="woonoow-wp-fields" style="' . (get_post_meta($post->ID, '_woonoow_software_wp_enabled', true) === 'yes' ? '' : 'display:none;') . '">';
woocommerce_wp_text_input([
'id' => '_woonoow_software_requires_wp',
'label' => __('Requires WP', 'woonoow'),
'placeholder' => '6.0',
]);
woocommerce_wp_text_input([
'id' => '_woonoow_software_tested_wp',
'label' => __('Tested WP', 'woonoow'),
'placeholder' => '6.7',
]);
woocommerce_wp_text_input([
'id' => '_woonoow_software_requires_php',
'label' => __('Requires PHP', 'woonoow'),
'placeholder' => '7.4',
]);
echo '</div>'; // .woonoow-wp-fields
// Inline JS to toggle WP fields
?>
<script>
jQuery(function($) {
$('#_woonoow_software_wp_enabled').on('change', function() {
if ($(this).is(':checked')) {
$('.woonoow-wp-fields').slideDown();
} else {
$('.woonoow-wp-fields').slideUp();
}
});
});
</script>
<?php
echo '</div>'; // .options_group
}
/**
* Save software distribution fields
*/
public static function save_product_software_fields($post_id)
{
// Software enabled
$software_enabled = isset($_POST['_woonoow_software_enabled']) ? 'yes' : 'no';
update_post_meta($post_id, '_woonoow_software_enabled', $software_enabled);
// Software slug
if (isset($_POST['_woonoow_software_slug'])) {
$slug = sanitize_title($_POST['_woonoow_software_slug']);
update_post_meta($post_id, '_woonoow_software_slug', $slug);
}
// Current version
if (isset($_POST['_woonoow_software_current_version'])) {
$version = sanitize_text_field($_POST['_woonoow_software_current_version']);
update_post_meta($post_id, '_woonoow_software_current_version', $version);
}
// WordPress integration
$wp_enabled = isset($_POST['_woonoow_software_wp_enabled']) ? 'yes' : 'no';
update_post_meta($post_id, '_woonoow_software_wp_enabled', $wp_enabled);
// WordPress-specific fields
$wp_fields = [
'_woonoow_software_requires_wp',
'_woonoow_software_tested_wp',
'_woonoow_software_requires_php',
];
foreach ($wp_fields as $field) {
if (isset($_POST[$field])) {
update_post_meta($post_id, $field, sanitize_text_field($_POST[$field]));
}
}
}
}

View File

@@ -0,0 +1,113 @@
<?php
/**
* Software Distribution Settings Schema
*
* @package WooNooW\Modules
*/
namespace WooNooW\Modules;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
class SoftwareSettings
{
private static $option_key = 'woonoow_module_software_settings';
/**
* Initialize settings
*/
public static function init()
{
// Register module with ModuleRegistry
add_filter('woonoow/modules/registry', [__CLASS__, 'register_module']);
}
/**
* Register the software module
*/
public static function register_module($modules)
{
$modules['software'] = [
'id' => 'software',
'name' => __('Software Distribution', 'woonoow'),
'description' => __('Sell and distribute software with version tracking, changelogs, and automatic update checking. Works with any software type.', 'woonoow'),
'icon' => 'Package',
'category' => 'sales',
'requires' => ['licensing'], // Depends on licensing module
'settings' => self::get_settings_schema(),
];
return $modules;
}
/**
* Get settings schema
*/
public static function get_settings_schema()
{
return [
[
'id' => 'rate_limit',
'type' => 'number',
'label' => __('API Rate Limit', 'woonoow'),
'description' => __('Maximum update check requests per minute per license', 'woonoow'),
'default' => 10,
'min' => 1,
'max' => 100,
],
[
'id' => 'token_expiry',
'type' => 'number',
'label' => __('Download Token Expiry', 'woonoow'),
'description' => __('Minutes until download token expires (default: 5)', 'woonoow'),
'default' => 5,
'min' => 1,
'max' => 60,
],
[
'id' => 'cache_ttl',
'type' => 'number',
'label' => __('Client Cache TTL', 'woonoow'),
'description' => __('Hours to cache update check results on client (default: 12)', 'woonoow'),
'default' => 12,
'min' => 1,
'max' => 168,
],
];
}
/**
* Get current settings
*/
public static function get_settings()
{
$defaults = [
'rate_limit' => 10,
'token_expiry' => 5,
'cache_ttl' => 12,
];
$settings = get_option(self::$option_key, []);
return wp_parse_args($settings, $defaults);
}
/**
* Save settings
*/
public static function save_settings($settings)
{
$sanitized = [
'rate_limit' => absint($settings['rate_limit'] ?? 10),
'token_expiry' => absint($settings['token_expiry'] ?? 5),
'cache_ttl' => absint($settings['cache_ttl'] ?? 12),
];
update_option(self::$option_key, $sanitized);
return $sanitized;
}
}

View File

@@ -0,0 +1,391 @@
<?php
/**
* WooNooW Software Updater
*
* Embed this class in your WordPress plugin or theme to enable
* automatic updates from your WooNooW-powered store.
*
* @version 1.0.0
* @package WooNooW_Updater
* @link https://woonoow.com/docs/developer/software-updates
*
* USAGE:
*
* 1. Copy this file to your plugin/theme (e.g., includes/class-woonoow-updater.php)
*
* 2. Include and initialize in your main plugin file:
*
* require_once plugin_dir_path(__FILE__) . 'includes/class-woonoow-updater.php';
*
* new WooNooW_Updater([
* 'api_url' => 'https://your-store.com/',
* 'slug' => 'my-plugin',
* 'version' => MY_PLUGIN_VERSION,
* 'license_key' => get_option('my_plugin_license_key'),
* 'plugin_file' => __FILE__, // For plugins
* // OR
* 'theme_slug' => 'my-theme', // For themes
* ]);
*/
if (!class_exists('WooNooW_Updater')) {
class WooNooW_Updater
{
/**
* @var string API base URL
*/
private $api_url;
/**
* @var string Software slug
*/
private $slug;
/**
* @var string Plugin file path (for plugins)
*/
private $plugin_file;
/**
* @var string Theme slug (for themes)
*/
private $theme_slug;
/**
* @var string Current version
*/
private $version;
/**
* @var string License key
*/
private $license_key;
/**
* @var string Cache key for transient
*/
private $cache_key;
/**
* @var int Cache TTL in seconds (default: 12 hours)
*/
private $cache_ttl = 43200;
/**
* @var bool Is this a theme update?
*/
private $is_theme = false;
/**
* Initialize the updater
*
* @param array $config Configuration array
*/
public function __construct($config)
{
$this->api_url = trailingslashit($config['api_url'] ?? '');
$this->slug = $config['slug'] ?? '';
$this->version = $config['version'] ?? '1.0.0';
$this->license_key = $config['license_key'] ?? '';
$this->cache_key = 'woonoow_update_' . md5($this->slug);
// Determine if plugin or theme
if (!empty($config['theme_slug'])) {
$this->is_theme = true;
$this->theme_slug = $config['theme_slug'];
} else {
$this->plugin_file = $config['plugin_file'] ?? '';
}
// Set cache TTL if provided
if (!empty($config['cache_ttl'])) {
$this->cache_ttl = (int) $config['cache_ttl'] * 3600; // Convert hours to seconds
}
// Don't proceed if no API URL or license
if (empty($this->api_url) || empty($this->slug)) {
return;
}
// Hook into WordPress update system
if ($this->is_theme) {
add_filter('pre_set_site_transient_update_themes', [$this, 'check_theme_update']);
add_filter('themes_api', [$this, 'theme_info'], 20, 3);
} else {
add_filter('pre_set_site_transient_update_plugins', [$this, 'check_plugin_update']);
add_filter('plugins_api', [$this, 'plugin_info'], 20, 3);
}
// Clear cache on upgrade
add_action('upgrader_process_complete', [$this, 'clear_cache'], 10, 2);
}
/**
* Check for plugin updates
*
* @param object $transient Update transient
* @return object Modified transient
*/
public function check_plugin_update($transient)
{
if (empty($transient->checked)) {
return $transient;
}
$remote = $this->get_remote_info();
if ($remote && !empty($remote->latest_version)) {
if (version_compare($this->version, $remote->latest_version, '<')) {
$plugin_file = plugin_basename($this->plugin_file);
$transient->response[$plugin_file] = (object) [
'slug' => $this->slug,
'plugin' => $plugin_file,
'new_version' => $remote->latest_version,
'package' => $remote->download_url ?? '',
'url' => $remote->homepage ?? '',
'tested' => $remote->wordpress->tested ?? '',
'requires' => $remote->wordpress->requires ?? '',
'requires_php' => $remote->wordpress->requires_php ?? '',
'icons' => (array) ($remote->wordpress->icons ?? []),
'banners' => (array) ($remote->wordpress->banners ?? []),
];
} else {
// No update available
$transient->no_update[plugin_basename($this->plugin_file)] = (object) [
'slug' => $this->slug,
'plugin' => plugin_basename($this->plugin_file),
'new_version' => $this->version,
];
}
}
return $transient;
}
/**
* Check for theme updates
*
* @param object $transient Update transient
* @return object Modified transient
*/
public function check_theme_update($transient)
{
if (empty($transient->checked)) {
return $transient;
}
$remote = $this->get_remote_info();
if ($remote && !empty($remote->latest_version)) {
if (version_compare($this->version, $remote->latest_version, '<')) {
$transient->response[$this->theme_slug] = [
'theme' => $this->theme_slug,
'new_version' => $remote->latest_version,
'package' => $remote->download_url ?? '',
'url' => $remote->homepage ?? '',
'requires' => $remote->wordpress->requires ?? '',
'requires_php' => $remote->wordpress->requires_php ?? '',
];
}
}
return $transient;
}
/**
* Provide plugin information for details popup
*
* @param mixed $result Default result
* @param string $action Action being performed
* @param object $args Arguments
* @return mixed Plugin info or default result
*/
public function plugin_info($result, $action, $args)
{
if ($action !== 'plugin_information' || !isset($args->slug) || $args->slug !== $this->slug) {
return $result;
}
$remote = $this->get_remote_info();
if (!$remote) {
return $result;
}
return (object) [
'name' => $remote->product->name ?? $this->slug,
'slug' => $this->slug,
'version' => $remote->latest_version,
'tested' => $remote->wordpress->tested ?? '',
'requires' => $remote->wordpress->requires ?? '',
'requires_php' => $remote->wordpress->requires_php ?? '',
'author' => $remote->author ?? '',
'homepage' => $remote->homepage ?? '',
'download_link' => $remote->download_url ?? '',
'sections' => [
'description' => $remote->description ?? '',
'changelog' => nl2br($remote->changelog ?? ''),
],
'banners' => (array) ($remote->wordpress->banners ?? []),
'icons' => (array) ($remote->wordpress->icons ?? []),
'last_updated' => $remote->release_date ?? '',
];
}
/**
* Provide theme information for details popup
*
* @param mixed $result Default result
* @param string $action Action being performed
* @param object $args Arguments
* @return mixed Theme info or default result
*/
public function theme_info($result, $action, $args)
{
if ($action !== 'theme_information' || !isset($args->slug) || $args->slug !== $this->theme_slug) {
return $result;
}
$remote = $this->get_remote_info();
if (!$remote) {
return $result;
}
return (object) [
'name' => $remote->product->name ?? $this->theme_slug,
'slug' => $this->theme_slug,
'version' => $remote->latest_version,
'requires' => $remote->wordpress->requires ?? '',
'requires_php' => $remote->wordpress->requires_php ?? '',
'author' => $remote->author ?? '',
'homepage' => $remote->homepage ?? '',
'download_link' => $remote->download_url ?? '',
'sections' => [
'description' => $remote->description ?? '',
'changelog' => nl2br($remote->changelog ?? ''),
],
'last_updated' => $remote->release_date ?? '',
];
}
/**
* Get remote version info from API
*
* @return object|null Remote info or null on failure
*/
private function get_remote_info()
{
// Check cache first
$cached = get_transient($this->cache_key);
if ($cached !== false) {
if ($cached === 'no_update') {
return null;
}
return $cached;
}
// Make API request
$response = wp_remote_post($this->api_url . 'wp-json/woonoow/v1/software/check', [
'timeout' => 15,
'headers' => [
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'license_key' => $this->license_key,
'slug' => $this->slug,
'version' => $this->version,
'site_url' => home_url(),
'wp_version' => get_bloginfo('version'),
'php_version' => phpversion(),
]),
]);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
// Cache failure for shorter time to retry sooner
set_transient($this->cache_key, 'no_update', 3600);
return null;
}
$data = json_decode(wp_remote_retrieve_body($response));
if (!$data || empty($data->success)) {
set_transient($this->cache_key, 'no_update', 3600);
return null;
}
if (empty($data->update_available)) {
set_transient($this->cache_key, 'no_update', $this->cache_ttl);
return null;
}
// Cache the update info
set_transient($this->cache_key, $data, $this->cache_ttl);
return $data;
}
/**
* Clear update cache after upgrade
*
* @param object $upgrader Upgrader instance
* @param array $options Upgrade options
*/
public function clear_cache($upgrader, $options)
{
if ($this->is_theme) {
if ($options['action'] === 'update' && $options['type'] === 'theme') {
if (isset($options['themes']) && in_array($this->theme_slug, $options['themes'])) {
delete_transient($this->cache_key);
}
}
} else {
if ($options['action'] === 'update' && $options['type'] === 'plugin') {
if (isset($options['plugins']) && in_array(plugin_basename($this->plugin_file), $options['plugins'])) {
delete_transient($this->cache_key);
}
}
}
}
/**
* Force check for updates (bypass cache)
*/
public function force_check()
{
delete_transient($this->cache_key);
return $this->get_remote_info();
}
/**
* Get license status
*
* @return array|null License status or null on failure
*/
public function get_license_status()
{
if (empty($this->license_key)) {
return ['valid' => false, 'error' => 'no_license'];
}
$response = wp_remote_post($this->api_url . 'wp-json/woonoow/v1/licenses/validate', [
'timeout' => 15,
'headers' => [
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'license_key' => $this->license_key,
]),
]);
if (is_wp_error($response)) {
return null;
}
return json_decode(wp_remote_retrieve_body($response), true);
}
}
}

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* Plugin Name: WooNooW * Plugin Name: WooNooW
* Description: The modern experience layer for WooCommerce (no migration, no risk). * Description: The modern experience layer for WooCommerce (no migration, no risk).
@@ -25,7 +26,7 @@ spl_autoload_register(function ($class) {
}); });
// Load translations on init hook (WordPress 6.7+ requirement) // Load translations on init hook (WordPress 6.7+ requirement)
add_action('init', function() { add_action('init', function () {
load_plugin_textdomain('woonoow', false, dirname(plugin_basename(__FILE__)) . '/languages'); load_plugin_textdomain('woonoow', false, dirname(plugin_basename(__FILE__)) . '/languages');
}); });
@@ -37,17 +38,18 @@ add_action('plugins_loaded', function () {
return; return;
} }
WooNooW\Core\Bootstrap::init(); WooNooW\Core\Bootstrap::init();
// Initialize module settings // Initialize module settings
WooNooW\Modules\NewsletterSettings::init(); WooNooW\Modules\NewsletterSettings::init();
WooNooW\Modules\WishlistSettings::init(); WooNooW\Modules\WishlistSettings::init();
WooNooW\Modules\Licensing\LicensingModule::init(); WooNooW\Modules\Licensing\LicensingModule::init();
WooNooW\Modules\Subscription\SubscriptionModule::init(); WooNooW\Modules\Subscription\SubscriptionModule::init();
WooNooW\Modules\Software\SoftwareModule::init();
}); });
// Activation/Deactivation hooks // Activation/Deactivation hooks
// Activation/Deactivation hooks // Activation/Deactivation hooks
register_activation_hook(__FILE__, function() { register_activation_hook(__FILE__, function () {
WooNooW\Core\Installer::activate(); WooNooW\Core\Installer::activate();
WooNooW\Setup\DefaultPages::create_pages(); WooNooW\Setup\DefaultPages::create_pages();
}); });