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:
379
.agent/plans/software-distribution.md
Normal file
379
.agent/plans/software-distribution.md
Normal 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?
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
347
admin-spa/src/routes/Products/SoftwareVersions/index.tsx
Normal file
347
admin-spa/src/routes/Products/SoftwareVersions/index.tsx
Normal 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 - Added new feature - 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
47
customer-spa/src/lib/sectionStyles.ts
Normal file
47
customer-spa/src/lib/sectionStyles.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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))' };
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
321
includes/Api/SoftwareController.php
Normal file
321
includes/Api/SoftwareController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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') {
|
||||
@@ -278,7 +300,7 @@ class PageSSR
|
||||
$padding = '4rem';
|
||||
}
|
||||
|
||||
if($padding) $css .= "padding:{$padding} 0;";
|
||||
if ($padding) $css .= "padding:{$padding} 0;";
|
||||
|
||||
$style_attr = $css ? "style=\"{$css}\"" : "";
|
||||
|
||||
@@ -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') {
|
||||
@@ -311,7 +341,7 @@ class PageSSR
|
||||
$padding = '4rem';
|
||||
}
|
||||
|
||||
if($padding) $css .= "padding:{$padding} 0;";
|
||||
if ($padding) $css .= "padding:{$padding} 0;";
|
||||
$style_attr = $css ? "style=\"{$css}\"" : "";
|
||||
|
||||
$inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles);
|
||||
@@ -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>',
|
||||
|
||||
456
includes/Modules/Software/SoftwareManager.php
Normal file
456
includes/Modules/Software/SoftwareManager.php
Normal 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')
|
||||
));
|
||||
}
|
||||
}
|
||||
200
includes/Modules/Software/SoftwareModule.php
Normal file
200
includes/Modules/Software/SoftwareModule.php
Normal 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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
includes/Modules/SoftwareSettings.php
Normal file
113
includes/Modules/SoftwareSettings.php
Normal 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;
|
||||
}
|
||||
}
|
||||
391
templates/updater/class-woonoow-updater.php
Normal file
391
templates/updater/class-woonoow-updater.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Plugin Name: WooNooW
|
||||
* 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)
|
||||
add_action('init', function() {
|
||||
add_action('init', function () {
|
||||
load_plugin_textdomain('woonoow', false, dirname(plugin_basename(__FILE__)) . '/languages');
|
||||
});
|
||||
|
||||
@@ -43,11 +44,12 @@ 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
|
||||
// Activation/Deactivation hooks
|
||||
register_activation_hook(__FILE__, function() {
|
||||
register_activation_hook(__FILE__, function () {
|
||||
WooNooW\Core\Installer::activate();
|
||||
WooNooW\Setup\DefaultPages::create_pages();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user