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

View File

@@ -81,15 +81,18 @@ export function CanvasSection({
>
{/* Section content with Styles */}
<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={{
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,
paddingBottom: section.styles?.paddingBottom,
}}
>
{/* Background Image & Overlay */}
{section.styles?.backgroundImage && (
{section.styles?.backgroundType === 'image' && section.styles?.backgroundImage && (
<>
<div
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 */}
<div className={cn(

View File

@@ -455,6 +455,30 @@ export function InspectorPanel({
{/* Background */}
<div className="space-y-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Background')}</h4>
{/* Background Type Selector */}
<div className="space-y-2">
<Label className="text-xs">{__('Type')}</Label>
<div className="flex gap-1">
{(['solid', 'gradient', 'image'] as const).map((t) => (
<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">
@@ -476,7 +500,79 @@ export function InspectorPanel({
/>
</div>
</div>
)}
{/* Gradient Controls */}
{selectedSection.styles?.backgroundType === 'gradient' && (
<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 className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">{__('From')}</Label>
<div className="flex gap-1.5">
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.gradientFrom || '#9333ea' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={selectedSection.styles?.gradientFrom || '#9333ea'}
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>
<div className="space-y-1">
<Label className="text-xs">{__('To')}</Label>
<div className="flex gap-1.5">
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.gradientTo || '#3b82f6' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={selectedSection.styles?.gradientTo || '#3b82f6'}
onChange={(e) => onSectionStylesChange({ gradientTo: 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?.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>
)}
{/* Image Background */}
{selectedSection.styles?.backgroundType === 'image' && (
<>
<div className="space-y-2">
<Label className="text-xs">{__('Background Image')}</Label>
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
@@ -514,31 +610,15 @@ export function InspectorPanel({
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
/>
</div>
</>
)}
<div className="space-y-2 pt-2">
<Label className="text-xs">{__('Section Height')}</Label>
<Select
value={selectedSection.styles?.heightPreset || 'default'}
onValueChange={(val) => {
// Map presets to padding values
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);
onSectionStylesChange({ heightPreset: val });
}}
>
<SelectTrigger><SelectValue placeholder="Height" /></SelectTrigger>

View File

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

@@ -149,13 +149,11 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
<div className="absolute top-2 left-2 z-10">
<button
onClick={handleWishlistClick}
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'
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'
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
>
<Heart className={`w-4 h-4 block transition-all ${
inWishlist ? 'fill-red-500 text-red-500' : ''
<Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
}`} />
</button>
</div>
@@ -253,13 +251,11 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
<div className="absolute top-4 right-4 z-10">
<button
onClick={handleWishlistClick}
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'
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'
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
>
<Heart className={`w-4 h-4 block transition-all ${
inWishlist ? 'fill-red-500 text-red-500' : ''
<Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
}`} />
</button>
</div>
@@ -316,7 +312,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
disabled={product.stock_status === 'outofstock'}
>
{!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>
)}
@@ -328,7 +324,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
disabled={product.stock_status === 'outofstock'}
>
{!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>
)}
</div>
@@ -373,13 +369,11 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
<div className="absolute top-6 left-6 z-10">
<button
onClick={handleWishlistClick}
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'
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'
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
>
<Heart className={`w-4 h-4 block transition-all ${
inWishlist ? 'fill-red-500 text-red-500' : ''
<Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
}`} />
</button>
</div>
@@ -447,13 +441,11 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
<div className="absolute top-3 right-3 z-10">
<button
onClick={handleWishlistClick}
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'
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'
}`}
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
>
<Heart className={`w-4 h-4 block transition-all ${
inWishlist ? 'fill-red-500 text-red-500' : ''
<Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
}`} />
</button>
</div>

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,11 +173,12 @@ export default function Cart() {
{cart.items.map((item: CartItem) => (
<div
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"
>
<div className="flex gap-4 sm:flex-1">
{/* Product Image */}
{elements.product_images && (
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-gray-100">
<div className="relative w-20 h-20 sm:w-24 sm:h-24 flex-shrink-0 rounded-lg overflow-hidden bg-gray-100">
{item.image ? (
<img
src={item.image}
@@ -193,19 +194,18 @@ export default function Cart() {
)}
{/* Product Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg mb-1 truncate">
<div className="flex-1 min-w-0 pr-8 sm:pr-0">
<h3 className="font-semibold text-base sm: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">
<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]) => {
// Format attribute name: capitalize first letter
const formattedKey = key.charAt(0).toUpperCase() + key.slice(1);
return (
<span key={key} className="mr-3">
<span key={key}>
{formattedKey}: <span className="font-medium">{value}</span>
</span>
);
@@ -213,12 +213,12 @@ export default function Cart() {
</div>
)}
<p className="text-gray-600 mb-2">
<p className="text-gray-600 mb-2 font-medium">
{formatPrice(item.price)}
</p>
{/* Quantity Controls */}
<div className="flex items-center gap-2">
{/* 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"
@@ -242,16 +242,48 @@ export default function Cart() {
</button>
</div>
</div>
</div>
{/* Item Total & Remove */}
<div className="flex flex-col items-end justify-between">
{/* 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">
<button
onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)}
className="font-[inherit] p-1 hover:bg-gray-100 rounded border"
>
<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-12 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 border"
>
<Plus className="h-4 w-4" />
</button>
</div>
<p className="font-bold text-base">
{formatPrice(item.price * item.quantity)}
</p>
</div>
{/* Desktop Item Total & Remove + Mobile Absolute Remove */}
<div className="absolute top-2 right-2 sm:static sm:flex sm:flex-col sm:items-end sm:justify-between">
<button
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" />
</button>
<p className="font-bold text-lg">
<p className="hidden sm:block font-bold text-lg">
{formatPrice(item.price * item.quantity)}
</p>
</div>

View File

@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
interface HeroSectionProps {
id: string;
@@ -28,7 +29,8 @@ export function HeroSection({
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
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)
const getTextStyles = (elementName: string) => {
@@ -71,8 +73,9 @@ export function HeroSection({
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (hasCustomBackground) return undefined;
const getBackgroundStyle = (): React.CSSProperties | undefined => {
// If user set custom bg via Design tab, use that
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'gradient') {
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 */}
{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="flex items-center gap-3">
<div className="flex-1">
<div className="max-w-6xl mx-auto flex items-center justify-between gap-3 px-2">
<div className="flex-1 flex flex-col justify-center min-w-0">
{/* Show selected variation for variable products */}
{product.type === 'variable' && Object.keys(selectedAttributes).length > 0 && (
<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 = [
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
'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'),
'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) {
$page_id = $this->create_magic_homepage();
if ($page_id) {
update_option('page_on_front', $page_id);
update_option('show_on_front', 'page');
// Set as SPA entry page
// Set as SPA entry page only (don't modify WP front page)
update_option('woonoow_spa_entry_page', $page_id);
}
} elseif (!empty($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);
// 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

View File

@@ -28,6 +28,7 @@ use WooNooW\Api\CampaignsController;
use WooNooW\Api\DocsController;
use WooNooW\Api\LicensesController;
use WooNooW\Api\SubscriptionsController;
use WooNooW\Api\SoftwareController;
use WooNooW\Frontend\ShopController;
use WooNooW\Frontend\CartController as FrontendCartController;
use WooNooW\Frontend\AccountController;
@@ -171,6 +172,9 @@ class Routes
// Subscriptions controller (subscription module)
SubscriptionsController::register_routes();
// Software controller (software distribution module)
SoftwareController::register_routes();
// Modules controller
$modules_controller = new ModulesController();
$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' => __('Tags', 'woonoow'), 'mode' => 'spa', 'path' => '/products/tags'],
['label' => __('Attributes', 'woonoow'), 'mode' => 'spa', 'path' => '/products/attributes'],
['label' => __('Software Versions', 'woonoow'), 'mode' => 'spa', 'path' => '/products/software'],
],
],
[

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Frontend;
/**
@@ -101,7 +102,8 @@ class PageSSR
/**
* Helper to generate style attribute string
*/
private static function generate_style_attr($styles) {
private static function generate_style_attr($styles)
{
if (empty($styles)) return '';
$css = [];
@@ -125,6 +127,7 @@ class PageSSR
$cta_url = esc_url($props['cta_url'] ?? '');
// Section Styles (Background & Spacing)
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$bg_image = $section_styles['backgroundImage'] ?? '';
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
@@ -133,8 +136,18 @@ class PageSSR
$height_preset = $section_styles['heightPreset'] ?? '';
$section_css = "";
if ($bg_type === 'gradient') {
$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};";
if ($bg_image) $section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
// 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 ($pb) $section_css .= "padding-bottom: {$pb};";
if ($height_preset === 'screen') $section_css .= "min-height: 100vh; display: flex; align-items: center;";
@@ -179,7 +192,8 @@ class PageSSR
/**
* 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'] ?? ''));
$text = $props['text']['value'] ?? ($props['text'] ?? ($props['content']['value'] ?? ($props['content'] ?? ''))); // Handle both props/values
$image = esc_url($props['image']['value'] ?? ($props['image'] ?? ''));
@@ -259,12 +273,20 @@ class PageSSR
}
// Section Styles (Background)
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$padding = $section_styles['paddingTop'] ?? '';
$height_preset = $section_styles['heightPreset'] ?? '';
$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
if ($height_preset === 'screen') {
@@ -292,12 +314,20 @@ class PageSSR
*/
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'] ?? '';
$padding = $section_styles['paddingTop'] ?? '';
$height_preset = $section_styles['heightPreset'] ?? '';
$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
if ($height_preset === 'screen') {
@@ -458,7 +488,8 @@ class PageSSR
/**
* Helper to get SVG for known icons
*/
private static function get_icon_svg($name) {
private static function get_icon_svg($name)
{
$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>',
'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>',

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
/**
* Plugin Name: WooNooW
* Description: The modern experience layer for WooCommerce (no migration, no risk).
@@ -43,6 +44,7 @@ add_action('plugins_loaded', function () {
WooNooW\Modules\WishlistSettings::init();
WooNooW\Modules\Licensing\LicensingModule::init();
WooNooW\Modules\Subscription\SubscriptionModule::init();
WooNooW\Modules\Software\SoftwareModule::init();
});
// Activation/Deactivation hooks