finalizing subscription moduile, ready to test
This commit is contained in:
173
.agent/reports/subscription-flow-audit-2026-01-29.md
Normal file
173
.agent/reports/subscription-flow-audit-2026-01-29.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Subscription Module Comprehensive Audit Report
|
||||||
|
|
||||||
|
**Date:** 2026-01-29
|
||||||
|
**Scope:** Full module trace including orders, notifications, permissions, payment gateway integration, auto/manual renewal, early renewal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
I performed a comprehensive audit of the subscription module and implemented fixes for all Critical and Warning issues.
|
||||||
|
|
||||||
|
**Total Issues Found: 11**
|
||||||
|
- **CRITICAL:** 2 ✅ FIXED
|
||||||
|
- **WARNING:** 5 ✅ FIXED
|
||||||
|
- **INFO:** 4 (No action required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixes Implemented
|
||||||
|
|
||||||
|
### ✅ Critical Issue #1: `handle_renewal_success` Now Sets Status to Active
|
||||||
|
|
||||||
|
**File:** [SubscriptionManager.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L708-L719)
|
||||||
|
|
||||||
|
**Change:**
|
||||||
|
```diff
|
||||||
|
$wpdb->update(
|
||||||
|
self::$table_subscriptions,
|
||||||
|
[
|
||||||
|
+ 'status' => 'active',
|
||||||
|
'next_payment_date' => $next_payment,
|
||||||
|
'last_payment_date' => current_time('mysql'),
|
||||||
|
'failed_payment_count' => 0,
|
||||||
|
],
|
||||||
|
['id' => $subscription_id],
|
||||||
|
- ['%s', '%s', '%d'],
|
||||||
|
+ ['%s', '%s', '%s', '%d'],
|
||||||
|
['%d']
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Critical Issue #2: Added Renewal Reminder Handler
|
||||||
|
|
||||||
|
**File:** [SubscriptionModule.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php)
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
1. Added action hook registration:
|
||||||
|
```php
|
||||||
|
add_action('woonoow/subscription/renewal_reminder', [__CLASS__, 'on_renewal_reminder'], 10, 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Added event registration:
|
||||||
|
```php
|
||||||
|
$events['subscription_renewal_reminder'] = [
|
||||||
|
'id' => 'subscription_renewal_reminder',
|
||||||
|
'label' => __('Subscription Renewal Reminder', 'woonoow'),
|
||||||
|
// ...
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Added handler method:
|
||||||
|
```php
|
||||||
|
public static function on_renewal_reminder($subscription)
|
||||||
|
{
|
||||||
|
if (!$subscription || !isset($subscription->id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::send_subscription_notification('subscription_renewal_reminder', $subscription->id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Warning Issue #3: Added Duplicate Renewal Order Prevention
|
||||||
|
|
||||||
|
**File:** [SubscriptionManager.php::renew](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L511-L535)
|
||||||
|
|
||||||
|
**Change:** Before creating a new renewal order, the system now checks for existing pending orders:
|
||||||
|
```php
|
||||||
|
$existing_pending = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT so.order_id FROM ... WHERE ... AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')",
|
||||||
|
$subscription_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($existing_pending) {
|
||||||
|
return ['success' => true, 'order_id' => (int) $existing_pending->order_id, 'status' => 'existing'];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also allowed `on-hold` subscriptions to renew (in addition to `active`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Warning Issue #4: Removed Duplicate Route Registration
|
||||||
|
|
||||||
|
**File:** [CheckoutController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/CheckoutController.php)
|
||||||
|
|
||||||
|
**Change:** Removed duplicate `/checkout/pay-order/{id}` route registration (was registered twice).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Warning Issue #5: Added `has_settings` to Subscription Module
|
||||||
|
|
||||||
|
**File:** [ModuleRegistry.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/ModuleRegistry.php#L64-L78)
|
||||||
|
|
||||||
|
**Change:**
|
||||||
|
```diff
|
||||||
|
'subscription' => [
|
||||||
|
// ...
|
||||||
|
'default_enabled' => false,
|
||||||
|
+ 'has_settings' => true,
|
||||||
|
'features' => [...],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Now subscription settings will appear in Admin SPA > Settings > Modules > Subscription.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Issue #10: Replaced Transient Tracking with Database Column
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- [SubscriptionManager.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php) - Added `reminder_sent_at` column
|
||||||
|
- [SubscriptionScheduler.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionScheduler.php) - Updated to use database column
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
1. Added column to table schema:
|
||||||
|
```sql
|
||||||
|
reminder_sent_at DATETIME DEFAULT NULL,
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Updated scheduler logic:
|
||||||
|
```php
|
||||||
|
// Query now includes:
|
||||||
|
AND (reminder_sent_at IS NULL OR reminder_sent_at < last_payment_date OR ...)
|
||||||
|
|
||||||
|
// After sending:
|
||||||
|
$wpdb->update($table, ['reminder_sent_at' => current_time('mysql')], ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining INFO Issues (No Action Required)
|
||||||
|
|
||||||
|
| # | Issue | Status |
|
||||||
|
|---|-------|--------|
|
||||||
|
| 6 | Payment gateway integration is placeholder only | Phase 2 - needs separate adapter classes |
|
||||||
|
| 7 | ThankYou page doesn't display subscription info | Enhancement for future |
|
||||||
|
| 9 | "Renew Early" only for active subscriptions | Confirmed as acceptable UX |
|
||||||
|
| 11 | API permissions correctly configured | Verified ✓ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Files Modified
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `SubscriptionManager.php` | • Fixed `handle_renewal_success` to set status<br>• Added duplicate order prevention<br>• Added `reminder_sent_at` column |
|
||||||
|
| `SubscriptionModule.php` | • Added renewal reminder hook<br>• Added event registration<br>• Added handler method |
|
||||||
|
| `SubscriptionScheduler.php` | • Replaced transient tracking with database column |
|
||||||
|
| `CheckoutController.php` | • Removed duplicate route registration |
|
||||||
|
| `ModuleRegistry.php` | • Added `has_settings => true` for subscription |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Migration Note
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> The `reminder_sent_at` column has been added to the subscriptions table schema. Since `dbDelta()` is used, it should be added automatically on next module re-enable or table check. However, for existing installations, you may need to:
|
||||||
|
> 1. Disable and re-enable the Subscription module in Admin SPA, OR
|
||||||
|
> 2. Run: `ALTER TABLE wp_woonoow_subscriptions ADD COLUMN reminder_sent_at DATETIME DEFAULT NULL;`
|
||||||
@@ -23,6 +23,8 @@ import ProductTags from '@/routes/Products/Tags';
|
|||||||
import ProductAttributes from '@/routes/Products/Attributes';
|
import ProductAttributes from '@/routes/Products/Attributes';
|
||||||
import Licenses from '@/routes/Products/Licenses';
|
import Licenses from '@/routes/Products/Licenses';
|
||||||
import LicenseDetail from '@/routes/Products/Licenses/Detail';
|
import LicenseDetail from '@/routes/Products/Licenses/Detail';
|
||||||
|
import SubscriptionsIndex from '@/routes/Subscriptions';
|
||||||
|
import SubscriptionDetail from '@/routes/Subscriptions/Detail';
|
||||||
import CouponsIndex from '@/routes/Marketing/Coupons';
|
import CouponsIndex from '@/routes/Marketing/Coupons';
|
||||||
import CouponNew from '@/routes/Marketing/Coupons/New';
|
import CouponNew from '@/routes/Marketing/Coupons/New';
|
||||||
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
||||||
@@ -31,7 +33,7 @@ import CustomerNew from '@/routes/Customers/New';
|
|||||||
import CustomerEdit from '@/routes/Customers/Edit';
|
import CustomerEdit from '@/routes/Customers/Edit';
|
||||||
import CustomerDetail from '@/routes/Customers/Detail';
|
import CustomerDetail from '@/routes/Customers/Detail';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2, PanelLeftClose, PanelLeft, HelpCircle } from 'lucide-react';
|
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2, PanelLeftClose, PanelLeft, HelpCircle, ExternalLink, Repeat } from 'lucide-react';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { useShortcuts } from "@/hooks/useShortcuts";
|
import { useShortcuts } from "@/hooks/useShortcuts";
|
||||||
import { CommandPalette } from "@/components/CommandPalette";
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
@@ -156,6 +158,7 @@ function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
'palette': Palette,
|
'palette': Palette,
|
||||||
'settings': SettingsIcon,
|
'settings': SettingsIcon,
|
||||||
'help-circle': HelpCircle,
|
'help-circle': HelpCircle,
|
||||||
|
'repeat': Repeat,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get navigation tree from backend
|
// Get navigation tree from backend
|
||||||
@@ -211,6 +214,7 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
|||||||
'mail': Mail,
|
'mail': Mail,
|
||||||
'palette': Palette,
|
'palette': Palette,
|
||||||
'settings': SettingsIcon,
|
'settings': SettingsIcon,
|
||||||
|
'repeat': Repeat,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get navigation tree from backend
|
// Get navigation tree from backend
|
||||||
@@ -476,6 +480,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
) : (
|
) : (
|
||||||
<div className="font-semibold">{siteTitle}</div>
|
<div className="font-semibold">{siteTitle}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={window.WNW_CONFIG?.storeUrl || '/store/'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ml-2 inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={__('Visit Store')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{__('Store')}</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-sm opacity-70 hidden sm:block">{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}</div>
|
<div className="text-sm opacity-70 hidden sm:block">{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}</div>
|
||||||
@@ -577,6 +592,10 @@ function AppRoutes() {
|
|||||||
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
|
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
|
||||||
<Route path="/orders/:id/label" element={<OrderLabel />} />
|
<Route path="/orders/:id/label" element={<OrderLabel />} />
|
||||||
|
|
||||||
|
{/* Subscriptions */}
|
||||||
|
<Route path="/subscriptions" element={<SubscriptionsIndex />} />
|
||||||
|
<Route path="/subscriptions/:id" element={<SubscriptionDetail />} />
|
||||||
|
|
||||||
{/* Coupons (under Marketing) */}
|
{/* Coupons (under Marketing) */}
|
||||||
<Route path="/coupons" element={<CouponsIndex />} />
|
<Route path="/coupons" element={<CouponsIndex />} />
|
||||||
<Route path="/coupons/new" element={<CouponNew />} />
|
<Route path="/coupons/new" element={<CouponNew />} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-[999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-[99999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -28,19 +28,45 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
|||||||
const AlertDialogContent = React.forwardRef<
|
const AlertDialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => {
|
||||||
<AlertDialogPortal>
|
// Get or create portal container inside the app for proper CSS scoping
|
||||||
<AlertDialogOverlay />
|
const getPortalContainer = () => {
|
||||||
<AlertDialogPrimitive.Content
|
const appContainer = document.getElementById('woonoow-admin-app');
|
||||||
ref={ref}
|
if (!appContainer) return document.body;
|
||||||
className={cn(
|
|
||||||
"fixed left-[50%] top-[50%] z-[999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
let portalRoot = document.getElementById('woonoow-dialog-portal');
|
||||||
className
|
if (!portalRoot) {
|
||||||
)}
|
portalRoot = document.createElement('div');
|
||||||
{...props}
|
portalRoot.id = 'woonoow-dialog-portal';
|
||||||
/>
|
// Copy theme class from documentElement for proper CSS variable inheritance
|
||||||
</AlertDialogPortal>
|
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||||
))
|
portalRoot.className = themeClass;
|
||||||
|
appContainer.appendChild(portalRoot);
|
||||||
|
} else {
|
||||||
|
// Update theme class in case it changed
|
||||||
|
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||||
|
if (!portalRoot.classList.contains(themeClass)) {
|
||||||
|
portalRoot.classList.remove('light', 'dark');
|
||||||
|
portalRoot.classList.add(themeClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return portalRoot;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal container={getPortalContainer()}>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
})
|
||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const AlertDialogHeader = ({
|
const AlertDialogHeader = ({
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ export default function MorePage() {
|
|||||||
|
|
||||||
{/* Exit Fullscreen / Logout */}
|
{/* Exit Fullscreen / Logout */}
|
||||||
<div className=" py-6 space-y-3">
|
<div className=" py-6 space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.open(window.WNW_CONFIG?.storeUrl || '/store/', '_blank')}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-3"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-5 h-5" />
|
||||||
|
{__('Visit Store')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{isStandalone && (
|
{isStandalone && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin'}
|
onClick={() => window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin'}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export type ProductFormData = {
|
|||||||
licensing_enabled?: boolean;
|
licensing_enabled?: boolean;
|
||||||
license_activation_limit?: string;
|
license_activation_limit?: string;
|
||||||
license_duration_days?: string;
|
license_duration_days?: string;
|
||||||
|
// Subscription
|
||||||
|
subscription_enabled?: boolean;
|
||||||
|
subscription_period?: 'day' | 'week' | 'month' | 'year';
|
||||||
|
subscription_interval?: string;
|
||||||
|
subscription_trial_days?: string;
|
||||||
|
subscription_signup_fee?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -89,6 +95,12 @@ export function ProductFormTabbed({
|
|||||||
const [licensingEnabled, setLicensingEnabled] = useState(initial?.licensing_enabled || false);
|
const [licensingEnabled, setLicensingEnabled] = useState(initial?.licensing_enabled || false);
|
||||||
const [licenseActivationLimit, setLicenseActivationLimit] = useState(initial?.license_activation_limit || '');
|
const [licenseActivationLimit, setLicenseActivationLimit] = useState(initial?.license_activation_limit || '');
|
||||||
const [licenseDurationDays, setLicenseDurationDays] = useState(initial?.license_duration_days || '');
|
const [licenseDurationDays, setLicenseDurationDays] = useState(initial?.license_duration_days || '');
|
||||||
|
// Subscription state
|
||||||
|
const [subscriptionEnabled, setSubscriptionEnabled] = useState(initial?.subscription_enabled || false);
|
||||||
|
const [subscriptionPeriod, setSubscriptionPeriod] = useState<'day' | 'week' | 'month' | 'year'>(initial?.subscription_period || 'month');
|
||||||
|
const [subscriptionInterval, setSubscriptionInterval] = useState(initial?.subscription_interval || '1');
|
||||||
|
const [subscriptionTrialDays, setSubscriptionTrialDays] = useState(initial?.subscription_trial_days || '');
|
||||||
|
const [subscriptionSignupFee, setSubscriptionSignupFee] = useState(initial?.subscription_signup_fee || '');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Update form state when initial data changes (for edit mode)
|
// Update form state when initial data changes (for edit mode)
|
||||||
@@ -119,6 +131,12 @@ export function ProductFormTabbed({
|
|||||||
setLicensingEnabled(initial.licensing_enabled || false);
|
setLicensingEnabled(initial.licensing_enabled || false);
|
||||||
setLicenseActivationLimit(initial.license_activation_limit || '');
|
setLicenseActivationLimit(initial.license_activation_limit || '');
|
||||||
setLicenseDurationDays(initial.license_duration_days || '');
|
setLicenseDurationDays(initial.license_duration_days || '');
|
||||||
|
// Subscription
|
||||||
|
setSubscriptionEnabled(initial.subscription_enabled || false);
|
||||||
|
setSubscriptionPeriod(initial.subscription_period || 'month');
|
||||||
|
setSubscriptionInterval(initial.subscription_interval || '1');
|
||||||
|
setSubscriptionTrialDays(initial.subscription_trial_days || '');
|
||||||
|
setSubscriptionSignupFee(initial.subscription_signup_fee || '');
|
||||||
}
|
}
|
||||||
}, [initial, mode]);
|
}, [initial, mode]);
|
||||||
|
|
||||||
@@ -181,6 +199,12 @@ export function ProductFormTabbed({
|
|||||||
licensing_enabled: licensingEnabled,
|
licensing_enabled: licensingEnabled,
|
||||||
license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined,
|
license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined,
|
||||||
license_duration_days: licensingEnabled ? licenseDurationDays : undefined,
|
license_duration_days: licensingEnabled ? licenseDurationDays : undefined,
|
||||||
|
// Subscription
|
||||||
|
subscription_enabled: subscriptionEnabled,
|
||||||
|
subscription_period: subscriptionEnabled ? subscriptionPeriod : undefined,
|
||||||
|
subscription_interval: subscriptionEnabled ? subscriptionInterval : undefined,
|
||||||
|
subscription_trial_days: subscriptionEnabled ? subscriptionTrialDays : undefined,
|
||||||
|
subscription_signup_fee: subscriptionEnabled ? subscriptionSignupFee : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await onSubmit(payload);
|
await onSubmit(payload);
|
||||||
@@ -237,6 +261,16 @@ export function ProductFormTabbed({
|
|||||||
setLicenseActivationLimit={setLicenseActivationLimit}
|
setLicenseActivationLimit={setLicenseActivationLimit}
|
||||||
licenseDurationDays={licenseDurationDays}
|
licenseDurationDays={licenseDurationDays}
|
||||||
setLicenseDurationDays={setLicenseDurationDays}
|
setLicenseDurationDays={setLicenseDurationDays}
|
||||||
|
subscriptionEnabled={subscriptionEnabled}
|
||||||
|
setSubscriptionEnabled={setSubscriptionEnabled}
|
||||||
|
subscriptionPeriod={subscriptionPeriod}
|
||||||
|
setSubscriptionPeriod={setSubscriptionPeriod}
|
||||||
|
subscriptionInterval={subscriptionInterval}
|
||||||
|
setSubscriptionInterval={setSubscriptionInterval}
|
||||||
|
subscriptionTrialDays={subscriptionTrialDays}
|
||||||
|
setSubscriptionTrialDays={setSubscriptionTrialDays}
|
||||||
|
subscriptionSignupFee={subscriptionSignupFee}
|
||||||
|
setSubscriptionSignupFee={setSubscriptionSignupFee}
|
||||||
/>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key } from 'lucide-react';
|
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key, Repeat } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getStoreCurrency } from '@/lib/currency';
|
import { getStoreCurrency } from '@/lib/currency';
|
||||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
@@ -50,6 +50,17 @@ type GeneralTabProps = {
|
|||||||
setLicenseActivationLimit?: (value: string) => void;
|
setLicenseActivationLimit?: (value: string) => void;
|
||||||
licenseDurationDays?: string;
|
licenseDurationDays?: string;
|
||||||
setLicenseDurationDays?: (value: string) => void;
|
setLicenseDurationDays?: (value: string) => void;
|
||||||
|
// Subscription
|
||||||
|
subscriptionEnabled?: boolean;
|
||||||
|
setSubscriptionEnabled?: (value: boolean) => void;
|
||||||
|
subscriptionPeriod?: 'day' | 'week' | 'month' | 'year';
|
||||||
|
setSubscriptionPeriod?: (value: 'day' | 'week' | 'month' | 'year') => void;
|
||||||
|
subscriptionInterval?: string;
|
||||||
|
setSubscriptionInterval?: (value: string) => void;
|
||||||
|
subscriptionTrialDays?: string;
|
||||||
|
setSubscriptionTrialDays?: (value: string) => void;
|
||||||
|
subscriptionSignupFee?: string;
|
||||||
|
setSubscriptionSignupFee?: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GeneralTab({
|
export function GeneralTab({
|
||||||
@@ -84,6 +95,16 @@ export function GeneralTab({
|
|||||||
setLicenseActivationLimit,
|
setLicenseActivationLimit,
|
||||||
licenseDurationDays,
|
licenseDurationDays,
|
||||||
setLicenseDurationDays,
|
setLicenseDurationDays,
|
||||||
|
subscriptionEnabled,
|
||||||
|
setSubscriptionEnabled,
|
||||||
|
subscriptionPeriod,
|
||||||
|
setSubscriptionPeriod,
|
||||||
|
subscriptionInterval,
|
||||||
|
setSubscriptionInterval,
|
||||||
|
subscriptionTrialDays,
|
||||||
|
setSubscriptionTrialDays,
|
||||||
|
subscriptionSignupFee,
|
||||||
|
setSubscriptionSignupFee,
|
||||||
}: GeneralTabProps) {
|
}: GeneralTabProps) {
|
||||||
const savingsPercent =
|
const savingsPercent =
|
||||||
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
||||||
@@ -481,6 +502,92 @@ export function GeneralTab({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Subscription option */}
|
||||||
|
{setSubscriptionEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="subscription-enabled"
|
||||||
|
checked={subscriptionEnabled || false}
|
||||||
|
onCheckedChange={(checked) => setSubscriptionEnabled(checked as boolean)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="subscription-enabled" className="cursor-pointer font-normal flex items-center gap-1">
|
||||||
|
<Repeat className="h-3 w-3" />
|
||||||
|
{__('Enable subscription for this product')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscription settings panel */}
|
||||||
|
{subscriptionEnabled && (
|
||||||
|
<div className="ml-6 p-3 bg-muted/50 rounded-lg space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">{__('Billing Period')}</Label>
|
||||||
|
<Select
|
||||||
|
value={subscriptionPeriod || 'month'}
|
||||||
|
onValueChange={(v: any) => setSubscriptionPeriod?.(v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="day">{__('Day')}</SelectItem>
|
||||||
|
<SelectItem value="week">{__('Week')}</SelectItem>
|
||||||
|
<SelectItem value="month">{__('Month')}</SelectItem>
|
||||||
|
<SelectItem value="year">{__('Year')}</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">{__('Billing Interval')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="1"
|
||||||
|
value={subscriptionInterval || '1'}
|
||||||
|
onChange={(e) => setSubscriptionInterval?.(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{__('e.g., 1 = every month, 3 = every 3 months')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">{__('Free Trial Days')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder={__('0 = no trial')}
|
||||||
|
value={subscriptionTrialDays || ''}
|
||||||
|
onChange={(e) => setSubscriptionTrialDays?.(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">{__('Sign-up Fee')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={subscriptionSignupFee || ''}
|
||||||
|
onChange={(e) => setSubscriptionSignupFee?.(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{__('One-time fee charged on first order')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -527,6 +634,6 @@ export function GeneralTab({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
401
admin-spa/src/routes/Subscriptions/Detail.tsx
Normal file
401
admin-spa/src/routes/Subscriptions/Detail.tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Play, Pause, XCircle, RefreshCw, Calendar, User, Package, CreditCard, Clock, FileText } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface SubscriptionOrder {
|
||||||
|
id: number;
|
||||||
|
subscription_id: number;
|
||||||
|
order_id: number;
|
||||||
|
order_type: 'parent' | 'renewal' | 'switch' | 'resubscribe';
|
||||||
|
order_status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Subscription {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
order_id: number;
|
||||||
|
product_id: number;
|
||||||
|
variation_id: number | null;
|
||||||
|
product_name: string;
|
||||||
|
product_image: string;
|
||||||
|
user_name: string;
|
||||||
|
user_email: string;
|
||||||
|
status: string;
|
||||||
|
billing_period: string;
|
||||||
|
billing_interval: number;
|
||||||
|
billing_schedule: string;
|
||||||
|
recurring_amount: string;
|
||||||
|
start_date: string;
|
||||||
|
trial_end_date: string | null;
|
||||||
|
next_payment_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
last_payment_date: string | null;
|
||||||
|
payment_method: string;
|
||||||
|
pause_count: number;
|
||||||
|
failed_payment_count: number;
|
||||||
|
cancel_reason: string | null;
|
||||||
|
created_at: string;
|
||||||
|
can_pause: boolean;
|
||||||
|
can_resume: boolean;
|
||||||
|
can_cancel: boolean;
|
||||||
|
orders: SubscriptionOrder[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
'pending': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'active': 'bg-green-100 text-green-800',
|
||||||
|
'on-hold': 'bg-blue-100 text-blue-800',
|
||||||
|
'cancelled': 'bg-gray-100 text-gray-800',
|
||||||
|
'expired': 'bg-red-100 text-red-800',
|
||||||
|
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
'pending': __('Pending'),
|
||||||
|
'active': __('Active'),
|
||||||
|
'on-hold': __('On Hold'),
|
||||||
|
'cancelled': __('Cancelled'),
|
||||||
|
'expired': __('Expired'),
|
||||||
|
'pending-cancel': __('Pending Cancel'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderTypeLabels: Record<string, string> = {
|
||||||
|
'parent': __('Initial Order'),
|
||||||
|
'renewal': __('Renewal'),
|
||||||
|
'switch': __('Plan Switch'),
|
||||||
|
'resubscribe': __('Resubscribe'),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchSubscription(id: string) {
|
||||||
|
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}`, {
|
||||||
|
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch subscription');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscriptionAction(id: number, action: string, reason?: string) {
|
||||||
|
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': window.WNW_API.nonce,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.message || `Failed to ${action} subscription`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscriptionDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
|
|
||||||
|
const { data: subscription, isLoading, error } = useQuery<Subscription>({
|
||||||
|
queryKey: ['subscription', id],
|
||||||
|
queryFn: () => fetchSubscription(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (subscription) {
|
||||||
|
setPageHeader(__('Subscription') + ' #' + subscription.id);
|
||||||
|
}
|
||||||
|
return () => clearPageHeader();
|
||||||
|
}, [subscription, setPageHeader, clearPageHeader]);
|
||||||
|
|
||||||
|
const actionMutation = useMutation({
|
||||||
|
mutationFn: ({ action, reason }: { action: string; reason?: string }) =>
|
||||||
|
subscriptionAction(parseInt(id!), action, reason),
|
||||||
|
onSuccess: (_, { action }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
|
toast.success(__(`Subscription ${action}d successfully`));
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAction = (action: string) => {
|
||||||
|
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionMutation.mutate({ action });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Skeleton className="h-48" />
|
||||||
|
<Skeleton className="h-48" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !subscription) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-500">{__('Failed to load subscription')}</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => navigate('/subscriptions')}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
{__('Back to Subscriptions')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Back button and actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/subscriptions')}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
{__('Back')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{subscription.can_pause && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAction('pause')}
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
>
|
||||||
|
<Pause className="w-4 h-4 mr-2" />
|
||||||
|
{__('Pause')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{subscription.can_resume && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAction('resume')}
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{__('Resume')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{subscription.status === 'active' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAction('renew')}
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
{__('Renew Now')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{subscription.can_cancel && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleAction('cancel')}
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
{__('Cancel')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status and product info */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* Subscription Info */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>{__('Subscription Details')}</CardTitle>
|
||||||
|
<Badge className={statusColors[subscription.status] || 'bg-gray-100'}>
|
||||||
|
{statusLabels[subscription.status] || subscription.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{subscription.product_image ? (
|
||||||
|
<img
|
||||||
|
src={subscription.product_image}
|
||||||
|
alt={subscription.product_name}
|
||||||
|
className="w-16 h-16 object-cover rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 bg-muted rounded flex items-center justify-center">
|
||||||
|
<Package className="w-8 h-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">{subscription.product_name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{subscription.billing_schedule}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold mt-1">
|
||||||
|
{window.WNW_STORE?.currency_symbol}{subscription.recurring_amount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Start Date')}</div>
|
||||||
|
<div>{new Date(subscription.start_date).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
{subscription.next_payment_date && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Next Payment')}</div>
|
||||||
|
<div>{new Date(subscription.next_payment_date).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{subscription.trial_end_date && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Trial End')}</div>
|
||||||
|
<div>{new Date(subscription.trial_end_date).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{subscription.end_date && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('End Date')}</div>
|
||||||
|
<div>{new Date(subscription.end_date).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subscription.cancel_reason && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Cancel Reason')}</div>
|
||||||
|
<div className="text-red-600">{subscription.cancel_reason}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Customer Info */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{__('Customer')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-muted rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-5 h-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{subscription.user_name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{subscription.user_email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Payment Method')}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard className="w-4 h-4" />
|
||||||
|
{subscription.payment_method || __('Not set')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Pause Count')}</div>
|
||||||
|
<div>{subscription.pause_count}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Failed Payments')}</div>
|
||||||
|
<div className={subscription.failed_payment_count > 0 ? 'text-red-600' : ''}>
|
||||||
|
{subscription.failed_payment_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Parent Order')}</div>
|
||||||
|
<Link
|
||||||
|
to={`/orders/${subscription.order_id}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
#{subscription.order_id}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Related Orders */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{__('Related Orders')}</CardTitle>
|
||||||
|
<CardDescription>{__('All orders associated with this subscription')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{__('Order')}</TableHead>
|
||||||
|
<TableHead>{__('Type')}</TableHead>
|
||||||
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
<TableHead>{__('Date')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{subscription.orders?.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||||
|
{__('No orders found')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
subscription.orders?.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
to={`/orders/${order.order_id}`}
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
#{order.order_id}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{orderTypeLabels[order.order_type] || order.order_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="capitalize">{order.order_status?.replace('wc-', '')}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(order.created_at).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
332
admin-spa/src/routes/Subscriptions/index.tsx
Normal file
332
admin-spa/src/routes/Subscriptions/index.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||||
|
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Calendar, User, Package } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Subscription {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
order_id: number;
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
user_name: string;
|
||||||
|
user_email: string;
|
||||||
|
status: 'pending' | 'active' | 'on-hold' | 'cancelled' | 'expired' | 'pending-cancel';
|
||||||
|
billing_schedule: string;
|
||||||
|
recurring_amount: string;
|
||||||
|
next_payment_date: string | null;
|
||||||
|
created_at: string;
|
||||||
|
can_pause: boolean;
|
||||||
|
can_resume: boolean;
|
||||||
|
can_cancel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
'pending': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'active': 'bg-green-100 text-green-800',
|
||||||
|
'on-hold': 'bg-blue-100 text-blue-800',
|
||||||
|
'cancelled': 'bg-gray-100 text-gray-800',
|
||||||
|
'expired': 'bg-red-100 text-red-800',
|
||||||
|
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
'pending': __('Pending'),
|
||||||
|
'active': __('Active'),
|
||||||
|
'on-hold': __('On Hold'),
|
||||||
|
'cancelled': __('Cancelled'),
|
||||||
|
'expired': __('Expired'),
|
||||||
|
'pending-cancel': __('Pending Cancel'),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchSubscriptions(params: Record<string, string>) {
|
||||||
|
const url = new URL(window.WNW_API.root + '/subscriptions');
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value) url.searchParams.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch subscriptions');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscriptionAction(id: number, action: 'cancel' | 'pause' | 'resume' | 'renew', reason?: string) {
|
||||||
|
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': window.WNW_API.nonce,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.message || `Failed to ${action} subscription`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscriptionsIndex() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
|
|
||||||
|
const status = searchParams.get('status') || '';
|
||||||
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageHeader(__('Subscriptions'));
|
||||||
|
return () => clearPageHeader();
|
||||||
|
}, [setPageHeader, clearPageHeader]);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['subscriptions', { status, page }],
|
||||||
|
queryFn: () => fetchSubscriptions({ status, page: String(page), per_page: '20' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionMutation = useMutation({
|
||||||
|
mutationFn: ({ id, action, reason }: { id: number; action: 'cancel' | 'pause' | 'resume' | 'renew'; reason?: string }) =>
|
||||||
|
subscriptionAction(id, action, reason),
|
||||||
|
onSuccess: (_, { action }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
|
toast.success(__(`Subscription ${action}d successfully`));
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAction = (id: number, action: 'cancel' | 'pause' | 'resume' | 'renew') => {
|
||||||
|
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionMutation.mutate({ id, action });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusFilter = (value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (value === 'all') {
|
||||||
|
params.delete('status');
|
||||||
|
} else {
|
||||||
|
params.set('status', value);
|
||||||
|
}
|
||||||
|
params.delete('page');
|
||||||
|
setSearchParams(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscriptions: Subscription[] = data?.subscriptions || [];
|
||||||
|
const total = data?.total || 0;
|
||||||
|
const totalPages = Math.ceil(total / 20);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select value={status || 'all'} onValueChange={handleStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder={__('Filter by status')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{__('All Statuses')}</SelectItem>
|
||||||
|
<SelectItem value="active">{__('Active')}</SelectItem>
|
||||||
|
<SelectItem value="on-hold">{__('On Hold')}</SelectItem>
|
||||||
|
<SelectItem value="pending">{__('Pending')}</SelectItem>
|
||||||
|
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
|
||||||
|
<SelectItem value="expired">{__('Expired')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{__('Total')}: {total} {__('subscriptions')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[80px]">{__('ID')}</TableHead>
|
||||||
|
<TableHead>{__('Customer')}</TableHead>
|
||||||
|
<TableHead>{__('Product')}</TableHead>
|
||||||
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
<TableHead>{__('Billing')}</TableHead>
|
||||||
|
<TableHead>{__('Next Payment')}</TableHead>
|
||||||
|
<TableHead className="w-[60px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
[...Array(5)].map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell><Skeleton className="h-4 w-12" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-40" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-8" /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : subscriptions.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
|
<Repeat className="w-8 h-8 opacity-50" />
|
||||||
|
<p>{__('No subscriptions found')}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
subscriptions.map((sub) => (
|
||||||
|
<TableRow key={sub.id}>
|
||||||
|
<TableCell className="font-medium">#{sub.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{sub.user_name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{sub.user_email}</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{sub.product_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={statusColors[sub.status] || 'bg-gray-100'}>
|
||||||
|
{statusLabels[sub.status] || sub.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
{sub.billing_schedule}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{sub.next_payment_date ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
{new Date(sub.next_payment_date).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => navigate(`/subscriptions/${sub.id}`)}>
|
||||||
|
<Eye className="w-4 h-4 mr-2" />
|
||||||
|
{__('View Details')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{sub.can_pause && (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction(sub.id, 'pause')}>
|
||||||
|
<Pause className="w-4 h-4 mr-2" />
|
||||||
|
{__('Pause')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{sub.can_resume && (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction(sub.id, 'resume')}>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{__('Resume')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{sub.status === 'active' && (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction(sub.id, 'renew')}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
{__('Renew Now')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{sub.can_cancel && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleAction(sub.id, 'cancel')}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
{__('Cancel')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set('page', String(page - 1));
|
||||||
|
setSearchParams(params);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{__('Previous')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{__('Page')} {page} {__('of')} {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set('page', String(page + 1));
|
||||||
|
setSearchParams(params);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{__('Next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "woonoow/woonoow",
|
|
||||||
"type": "wordpress-plugin",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"WooNooW\\": "plugin/includes/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^8.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
composer.lock
generated
20
composer.lock
generated
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"_readme": [
|
|
||||||
"This file locks the dependencies of your project to a known state",
|
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
|
||||||
"This file is @generated automatically"
|
|
||||||
],
|
|
||||||
"content-hash": "c8dfaf9b12dfc28774a5f4e2e71e84af",
|
|
||||||
"packages": [],
|
|
||||||
"packages-dev": [],
|
|
||||||
"aliases": [],
|
|
||||||
"minimum-stability": "stable",
|
|
||||||
"stability-flags": {},
|
|
||||||
"prefer-stable": false,
|
|
||||||
"prefer-lowest": false,
|
|
||||||
"platform": {
|
|
||||||
"php": "^8.1"
|
|
||||||
},
|
|
||||||
"platform-dev": {},
|
|
||||||
"plugin-api-version": "2.9.0"
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,7 @@ import Wishlist from './pages/Wishlist';
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import ForgotPassword from './pages/ForgotPassword';
|
import ForgotPassword from './pages/ForgotPassword';
|
||||||
import ResetPassword from './pages/ResetPassword';
|
import ResetPassword from './pages/ResetPassword';
|
||||||
|
import OrderPay from './pages/OrderPay';
|
||||||
import { DynamicPageRenderer } from './pages/DynamicPage';
|
import { DynamicPageRenderer } from './pages/DynamicPage';
|
||||||
|
|
||||||
// Create QueryClient instance
|
// Create QueryClient instance
|
||||||
@@ -101,6 +102,8 @@ function AppRoutes() {
|
|||||||
<Route path="/cart" element={<Cart />} />
|
<Route path="/cart" element={<Cart />} />
|
||||||
<Route path="/checkout" element={<Checkout />} />
|
<Route path="/checkout" element={<Checkout />} />
|
||||||
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
||||||
|
<Route path="/checkout/order-received/:orderId" element={<ThankYou />} />
|
||||||
|
<Route path="/checkout/pay/:orderId" element={<OrderPay />} />
|
||||||
|
|
||||||
{/* Wishlist - Public route accessible to guests */}
|
{/* Wishlist - Public route accessible to guests */}
|
||||||
<Route path="/wishlist" element={<Wishlist />} />
|
<Route path="/wishlist" element={<Wishlist />} />
|
||||||
|
|||||||
86
customer-spa/src/components/SubscriptionTimeline.tsx
Normal file
86
customer-spa/src/components/SubscriptionTimeline.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SubscriptionData {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
billing_period: string;
|
||||||
|
billing_interval: number;
|
||||||
|
start_date: string;
|
||||||
|
next_payment_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
subscription: SubscriptionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubscriptionTimeline: React.FC<Props> = ({ subscription }) => {
|
||||||
|
const formatDate = (dateString: string | null) => {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMonth = subscription.billing_period === 'month';
|
||||||
|
const intervalLabel = `${subscription.billing_interval} ${subscription.billing_period}${subscription.billing_interval > 1 ? 's' : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-6">Subscription Timeline</h2>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{/* Connecting Line */}
|
||||||
|
<div className="absolute top-4 left-4 right-4 h-0.5 bg-gray-200" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="relative flex justify-between">
|
||||||
|
{/* Start Node */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-green-100 border-2 border-green-500 flex items-center justify-center z-10 shrink-0">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<div className="text-sm font-medium text-gray-900">Started</div>
|
||||||
|
<div className="text-xs text-gray-500">{formatDate(subscription.start_date)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle Info (Interval) */}
|
||||||
|
<div className="hidden sm:flex flex-col items-center justify-start pt-1">
|
||||||
|
<div className="bg-white px-2 text-xs text-gray-400 font-medium uppercase tracking-wider">
|
||||||
|
Every {intervalLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Payment (Active/Due) Node */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative w-8 h-8 rounded-full bg-blue-100 border-2 border-blue-600 flex items-center justify-center z-10 shrink-0 animate-pulse-ring">
|
||||||
|
{/* Pulse Effect */}
|
||||||
|
<span className="absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-20 animate-ping"></span>
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<div className="text-sm font-medium text-blue-700 font-bold">Payment Due</div>
|
||||||
|
<div className="text-xs text-blue-600 font-medium">Now</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Future Node */}
|
||||||
|
<div className="flex flex-col items-center opacity-60">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-100 border-2 border-gray-300 flex items-center justify-center z-10 shrink-0">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<div className="text-sm font-medium text-gray-500">Next Renewal</div>
|
||||||
|
<div className="text-xs text-gray-400">{subscription.next_payment_date ? formatDate(subscription.next_payment_date) : '...'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubscriptionTimeline;
|
||||||
@@ -115,9 +115,19 @@ export default function OrderDetails() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold">Order #{order.order_number}</h1>
|
<h1 className="text-2xl font-bold">Order #{order.order_number}</h1>
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
|
<div className="flex items-center gap-3">
|
||||||
{order.status.replace('-', ' ').toUpperCase()}
|
{['pending', 'failed', 'on-hold'].includes(order.status) && (
|
||||||
</span>
|
<Link
|
||||||
|
to={`/checkout/pay/${order.id}`}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Pay Now
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
|
||||||
|
{order.status.replace('-', ' ').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-gray-600 mb-6">
|
<div className="text-sm text-gray-600 mb-6">
|
||||||
|
|||||||
377
customer-spa/src/pages/Account/SubscriptionDetail.tsx
Normal file
377
customer-spa/src/pages/Account/SubscriptionDetail.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { ArrowLeft, Repeat, Pause, Play, XCircle, Calendar, Package, CreditCard, FileText } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import SEOHead from '@/components/SEOHead';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
|
||||||
|
interface SubscriptionOrder {
|
||||||
|
id: number;
|
||||||
|
order_id: number;
|
||||||
|
order_type: 'parent' | 'renewal' | 'switch' | 'resubscribe';
|
||||||
|
order_status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Subscription {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
product_image: string;
|
||||||
|
status: string;
|
||||||
|
billing_period: string;
|
||||||
|
billing_interval: number;
|
||||||
|
billing_schedule: string;
|
||||||
|
recurring_amount: string;
|
||||||
|
start_date: string;
|
||||||
|
trial_end_date: string | null;
|
||||||
|
next_payment_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
last_payment_date: string | null;
|
||||||
|
payment_method: string;
|
||||||
|
pause_count: number;
|
||||||
|
can_pause: boolean;
|
||||||
|
can_resume: boolean;
|
||||||
|
can_cancel: boolean;
|
||||||
|
orders: SubscriptionOrder[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStyles: Record<string, string> = {
|
||||||
|
'pending': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'active': 'bg-green-100 text-green-800',
|
||||||
|
'on-hold': 'bg-blue-100 text-blue-800',
|
||||||
|
'cancelled': 'bg-gray-100 text-gray-800',
|
||||||
|
'expired': 'bg-red-100 text-red-800',
|
||||||
|
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
'pending': 'Pending',
|
||||||
|
'active': 'Active',
|
||||||
|
'on-hold': 'On Hold',
|
||||||
|
'cancelled': 'Cancelled',
|
||||||
|
'expired': 'Expired',
|
||||||
|
'pending-cancel': 'Pending Cancel',
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderTypeLabels: Record<string, string> = {
|
||||||
|
'parent': 'Initial Order',
|
||||||
|
'renewal': 'Renewal',
|
||||||
|
'switch': 'Plan Switch',
|
||||||
|
'resubscribe': 'Resubscribe',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SubscriptionDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
const { data: subscription, isLoading, error } = useQuery<Subscription>({
|
||||||
|
queryKey: ['account-subscription', id],
|
||||||
|
queryFn: () => api.get(`/account/subscriptions/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionMutation = useMutation({
|
||||||
|
mutationFn: (action: string) => api.post(`/account/subscriptions/${id}/${action}`),
|
||||||
|
onSuccess: (_, action) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['account-subscription', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
'pause': 'paused',
|
||||||
|
'resume': 'resumed',
|
||||||
|
'cancel': 'cancelled',
|
||||||
|
};
|
||||||
|
toast.success(`Subscription ${actionLabels[action] || action} successfully`);
|
||||||
|
setActionLoading(false);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.message || 'Action failed');
|
||||||
|
setActionLoading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAction = (action: string) => {
|
||||||
|
setActionLoading(true);
|
||||||
|
actionMutation.mutate(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenew = async () => {
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post<{ order_id: number; status: string }>(`/account/subscriptions/${id}/renew`);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['account-subscription', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
|
||||||
|
|
||||||
|
toast.success('Renewal order created');
|
||||||
|
|
||||||
|
if (response.order_id) {
|
||||||
|
// Determine destination based on functionality
|
||||||
|
// If manual payment required or just improved UX, go to payment page
|
||||||
|
navigate(`/order-pay/${response.order_id}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to renew');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find pending renewal order
|
||||||
|
const pendingRenewalOrder = subscription?.orders?.find(
|
||||||
|
o => o.order_type === 'renewal' &&
|
||||||
|
['pending', 'wc-pending', 'on-hold', 'wc-on-hold', 'failed', 'wc-failed'].includes(o.order_status)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !subscription) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-500">Failed to load subscription</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => navigate('/my-account/subscriptions')}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Subscriptions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SEOHead title={`Subscription #${subscription.id}`} description="Subscription details" />
|
||||||
|
|
||||||
|
{/* Back button */}
|
||||||
|
<Link
|
||||||
|
to="/my-account/subscriptions"
|
||||||
|
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Back to Subscriptions
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Repeat className="h-6 w-6" />
|
||||||
|
Subscription #{subscription.id}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
Started {new Date(subscription.start_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyles[subscription.status] || 'bg-gray-100'}`}>
|
||||||
|
{statusLabels[subscription.status] || subscription.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info Card */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{subscription.product_image ? (
|
||||||
|
<img
|
||||||
|
src={subscription.product_image}
|
||||||
|
alt={subscription.product_name}
|
||||||
|
className="w-20 h-20 object-cover rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-20 h-20 bg-gray-100 rounded flex items-center justify-center">
|
||||||
|
<Package className="h-10 w-10 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-semibold">{subscription.product_name}</h2>
|
||||||
|
<p className="text-gray-500">{subscription.billing_schedule}</p>
|
||||||
|
<p className="text-2xl font-bold mt-2">
|
||||||
|
{formatPrice(subscription.recurring_amount)}
|
||||||
|
<span className="text-sm font-normal text-gray-500">
|
||||||
|
/{subscription.billing_period}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Billing Details */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="font-semibold mb-4">Billing Details</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Start Date</p>
|
||||||
|
<p className="font-medium">{new Date(subscription.start_date).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
{subscription.next_payment_date && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Next Payment</p>
|
||||||
|
<p className="font-medium flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
{new Date(subscription.next_payment_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{subscription.trial_end_date && new Date(subscription.trial_end_date) > new Date() && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Trial Ends</p>
|
||||||
|
<p className="font-medium text-blue-600">
|
||||||
|
{new Date(subscription.trial_end_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{subscription.end_date && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">End Date</p>
|
||||||
|
<p className="font-medium">{new Date(subscription.end_date).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Payment Method</p>
|
||||||
|
<p className="font-medium flex items-center gap-1">
|
||||||
|
<CreditCard className="h-4 w-4 text-gray-400" />
|
||||||
|
{subscription.payment_method || 'Not set'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Times Paused</p>
|
||||||
|
<p className="font-medium">{subscription.pause_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{(subscription.can_pause || subscription.can_resume || subscription.can_cancel) && (
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="font-semibold mb-4">Manage Subscription</h3>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{subscription.can_pause && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAction('pause')}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
<Pause className="h-4 w-4 mr-2" />
|
||||||
|
Pause Subscription
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{subscription.can_resume && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAction('resume')}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Resume Subscription
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Early Renewal Button */}
|
||||||
|
{subscription.status === 'active' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRenew}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
<Repeat className="h-4 w-4 mr-2" />
|
||||||
|
Renew Early
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pay Pending Order Button */}
|
||||||
|
{pendingRenewalOrder && (
|
||||||
|
<Button
|
||||||
|
className='bg-green-600 hover:bg-green-700'
|
||||||
|
onClick={() => navigate(`/order-pay/${pendingRenewalOrder.order_id}`)}
|
||||||
|
>
|
||||||
|
<CreditCard className="h-4 w-4 mr-2" />
|
||||||
|
Pay Now (#{pendingRenewalOrder.order_id})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subscription.can_cancel && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="text-red-500 hover:text-red-600 border-red-200 hover:border-red-300"
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4 mr-2" />
|
||||||
|
Cancel Subscription
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Cancel Subscription</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to cancel this subscription?
|
||||||
|
You will lose access at the end of your current billing period.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep Subscription</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleAction('cancel')}
|
||||||
|
className="bg-red-500 hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Yes, Cancel
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Related Orders */}
|
||||||
|
{subscription.orders && subscription.orders.length > 0 && (
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Payment History
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{subscription.orders.map((order) => (
|
||||||
|
<Link
|
||||||
|
key={order.id}
|
||||||
|
to={`/my-account/orders/${order.order_id}`}
|
||||||
|
className="flex items-center justify-between p-3 rounded border hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-medium">Order #{order.order_id}</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-gray-100 rounded">
|
||||||
|
{orderTypeLabels[order.order_type] || order.order_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{new Date(order.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
customer-spa/src/pages/Account/Subscriptions.tsx
Normal file
244
customer-spa/src/pages/Account/Subscriptions.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Repeat, ChevronRight, Pause, Play, XCircle, Calendar, Package } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import SEOHead from '@/components/SEOHead';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
|
||||||
|
interface Subscription {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
product_image: string;
|
||||||
|
status: 'pending' | 'active' | 'on-hold' | 'cancelled' | 'expired' | 'pending-cancel';
|
||||||
|
billing_schedule: string;
|
||||||
|
recurring_amount: string;
|
||||||
|
next_payment_date: string | null;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string | null;
|
||||||
|
can_pause: boolean;
|
||||||
|
can_resume: boolean;
|
||||||
|
can_cancel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStyles: Record<string, string> = {
|
||||||
|
'pending': 'bg-yellow-100 text-yellow-800',
|
||||||
|
'active': 'bg-green-100 text-green-800',
|
||||||
|
'on-hold': 'bg-blue-100 text-blue-800',
|
||||||
|
'cancelled': 'bg-gray-100 text-gray-800',
|
||||||
|
'expired': 'bg-red-100 text-red-800',
|
||||||
|
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
'pending': 'Pending',
|
||||||
|
'active': 'Active',
|
||||||
|
'on-hold': 'On Hold',
|
||||||
|
'cancelled': 'Cancelled',
|
||||||
|
'expired': 'Expired',
|
||||||
|
'pending-cancel': 'Pending Cancel',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Subscriptions() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: subscriptions = [], isLoading } = useQuery<Subscription[]>({
|
||||||
|
queryKey: ['account-subscriptions'],
|
||||||
|
queryFn: () => api.get('/account/subscriptions'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionMutation = useMutation({
|
||||||
|
mutationFn: ({ id, action }: { id: number; action: string }) =>
|
||||||
|
api.post(`/account/subscriptions/${id}/${action}`),
|
||||||
|
onSuccess: (_, { action }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
'pause': 'paused',
|
||||||
|
'resume': 'resumed',
|
||||||
|
'cancel': 'cancelled',
|
||||||
|
};
|
||||||
|
toast.success(`Subscription ${actionLabels[action] || action} successfully`);
|
||||||
|
setActionLoading(null);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.message || 'Action failed');
|
||||||
|
setActionLoading(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAction = (id: number, action: string) => {
|
||||||
|
setActionLoading(id);
|
||||||
|
actionMutation.mutate({ id, action });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SEOHead title="My Subscriptions" description="Manage your subscriptions" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Repeat className="h-6 w-6" />
|
||||||
|
My Subscriptions
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Manage your recurring subscriptions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subscriptions.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border p-12 text-center">
|
||||||
|
<Repeat className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p className="text-gray-500">You don't have any subscriptions yet.</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Purchase a subscription product to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{subscriptions.map((sub) => (
|
||||||
|
<div key={sub.id} className="bg-white rounded-lg border overflow-hidden">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Product Image */}
|
||||||
|
{sub.product_image ? (
|
||||||
|
<img
|
||||||
|
src={sub.product_image}
|
||||||
|
alt={sub.product_name}
|
||||||
|
className="w-16 h-16 object-cover rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 bg-gray-100 rounded flex items-center justify-center">
|
||||||
|
<Package className="h-8 w-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subscription Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{sub.product_name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{sub.billing_schedule}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusStyles[sub.status] || 'bg-gray-100'}`}>
|
||||||
|
{statusLabels[sub.status] || sub.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-6 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Amount: </span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatPrice(sub.recurring_amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sub.next_payment_date && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-gray-500">Next: </span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{new Date(sub.next_payment_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-4 pt-4 border-t flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{sub.can_pause && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAction(sub.id, 'pause')}
|
||||||
|
disabled={actionLoading === sub.id}
|
||||||
|
>
|
||||||
|
<Pause className="h-4 w-4 mr-1" />
|
||||||
|
Pause
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{sub.can_resume && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAction(sub.id, 'resume')}
|
||||||
|
disabled={actionLoading === sub.id}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-1" />
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{sub.can_cancel && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-500 hover:text-red-600"
|
||||||
|
disabled={actionLoading === sub.id}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Cancel Subscription</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to cancel this subscription?
|
||||||
|
You will lose access at the end of your current billing period.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep Subscription</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleAction(sub.id, 'cancel')}
|
||||||
|
className="bg-red-500 hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Yes, Cancel
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/my-account/subscriptions/${sub.id}`}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { ReactNode, useState, useEffect } from 'react';
|
import React, { ReactNode, useState, useEffect } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key } from 'lucide-react';
|
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key, Repeat } from 'lucide-react';
|
||||||
import { useModules } from '@/hooks/useModules';
|
import { useModules } from '@/hooks/useModules';
|
||||||
import { api } from '@/lib/api/client';
|
import { api } from '@/lib/api/client';
|
||||||
import {
|
import {
|
||||||
@@ -50,17 +50,19 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
const allMenuItems = [
|
const allMenuItems = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
||||||
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
|
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
|
||||||
|
{ id: 'subscriptions', label: 'Subscriptions', path: '/my-account/subscriptions', icon: Repeat },
|
||||||
|
{ id: 'licenses', label: 'Licenses', path: '/my-account/licenses', icon: Key },
|
||||||
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
|
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
|
||||||
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
||||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
|
||||||
{ id: 'licenses', label: 'Licenses', path: '/my-account/licenses', icon: Key },
|
|
||||||
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
||||||
|
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
|
// Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
|
||||||
const menuItems = allMenuItems.filter(item => {
|
const menuItems = allMenuItems.filter(item => {
|
||||||
if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
|
if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
|
||||||
if (item.id === 'licenses') return isEnabled('licensing');
|
if (item.id === 'licenses') return isEnabled('licensing');
|
||||||
|
if (item.id === 'subscriptions') return isEnabled('subscription');
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import Addresses from './Addresses';
|
|||||||
import Wishlist from './Wishlist';
|
import Wishlist from './Wishlist';
|
||||||
import AccountDetails from './AccountDetails';
|
import AccountDetails from './AccountDetails';
|
||||||
import Licenses from './Licenses';
|
import Licenses from './Licenses';
|
||||||
|
import Subscriptions from './Subscriptions';
|
||||||
|
import SubscriptionDetail from './SubscriptionDetail';
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
@@ -32,6 +34,8 @@ export default function Account() {
|
|||||||
<Route path="addresses" element={<Addresses />} />
|
<Route path="addresses" element={<Addresses />} />
|
||||||
<Route path="wishlist" element={<Wishlist />} />
|
<Route path="wishlist" element={<Wishlist />} />
|
||||||
<Route path="licenses" element={<Licenses />} />
|
<Route path="licenses" element={<Licenses />} />
|
||||||
|
<Route path="subscriptions" element={<Subscriptions />} />
|
||||||
|
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
||||||
<Route path="account-details" element={<AccountDetails />} />
|
<Route path="account-details" element={<AccountDetails />} />
|
||||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
254
customer-spa/src/pages/OrderPay/index.tsx
Normal file
254
customer-spa/src/pages/OrderPay/index.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import SubscriptionTimeline from '../../components/SubscriptionTimeline';
|
||||||
|
|
||||||
|
// Define types based on CheckoutController response
|
||||||
|
// Define types based on CheckoutController response
|
||||||
|
interface BaseResponse {
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderDetailsResponse extends BaseResponse {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
status: string;
|
||||||
|
created_via: string;
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
currency_symbol: string;
|
||||||
|
items: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
qty: number;
|
||||||
|
total: number;
|
||||||
|
image?: string;
|
||||||
|
}>;
|
||||||
|
available_gateways: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon?: string;
|
||||||
|
}>;
|
||||||
|
subscription?: {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
billing_period: string;
|
||||||
|
billing_interval: number;
|
||||||
|
start_date: string;
|
||||||
|
next_payment_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentResponse extends BaseResponse {
|
||||||
|
redirect?: string;
|
||||||
|
messages?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrderPay: React.FC = () => {
|
||||||
|
const { orderId } = useParams<{ orderId: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const orderKey = searchParams.get('key');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [order, setOrder] = useState<OrderDetailsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedGateway, setSelectedGateway] = useState<string>('');
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderId) {
|
||||||
|
fetchOrder();
|
||||||
|
}
|
||||||
|
}, [orderId]);
|
||||||
|
|
||||||
|
const fetchOrder = async () => {
|
||||||
|
try {
|
||||||
|
const endpoint = `/checkout/order/${orderId}${orderKey ? `?key=${orderKey}` : ''}`;
|
||||||
|
const response = await api.get<OrderDetailsResponse>(endpoint);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
toast.error(response.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.ok) {
|
||||||
|
setOrder(response as OrderDetailsResponse);
|
||||||
|
if (response.available_gateways?.length > 0) {
|
||||||
|
setSelectedGateway(response.available_gateways[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch order', error);
|
||||||
|
toast.error('Failed to load order details');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePayment = async () => {
|
||||||
|
if (!selectedGateway) {
|
||||||
|
toast.error('Please select a payment method');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post<PaymentResponse>(`/checkout/pay-order/${orderId}`, {
|
||||||
|
payment_method: selectedGateway,
|
||||||
|
key: orderKey
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok && response.redirect) {
|
||||||
|
window.location.href = response.redirect;
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || 'Payment failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Payment error', error);
|
||||||
|
toast.error('An error occurred while processing payment');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to format price with proper currency locale
|
||||||
|
const formatPrice = (amount: number, currency: string) => {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback
|
||||||
|
return `${currency} ${amount.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center">Loading order details...</div>;
|
||||||
|
if (!order) return <div className="p-8 text-center text-red-500">Order not found</div>;
|
||||||
|
|
||||||
|
const isRenewal = order.created_via === 'subscription_renewal';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Complete Payment</h1>
|
||||||
|
|
||||||
|
{order.subscription && (
|
||||||
|
<SubscriptionTimeline subscription={order.subscription} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRenewal && !order.subscription && (
|
||||||
|
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
This is a payment for your <span className="font-bold">subscription renewal</span>.
|
||||||
|
Completing this payment will extend your subscription period.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Order Summary <span className="text-gray-400 font-normal">#{order.number}</span></h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{order.items.map((item) => (
|
||||||
|
<div key={item.id} className="flex justify-between items-center border-b pb-4 last:border-0 last:pb-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{item.image ? (
|
||||||
|
<img src={item.image} alt={item.name} className="w-16 h-16 object-cover rounded bg-gray-100" />
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 bg-gray-100 rounded flex items-center justify-center text-gray-400">
|
||||||
|
<span className="text-xs">No img</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-lg">{item.name}</div>
|
||||||
|
<div className="text-sm text-gray-500">Qty: {item.qty}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-lg">
|
||||||
|
{formatPrice(item.total, order.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<div className="flex justify-between items-center text-gray-600 mb-2">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>{formatPrice(order.total, order.currency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center font-bold text-xl mt-4">
|
||||||
|
<span>Total to Pay</span>
|
||||||
|
<span className="text-primary">{formatPrice(order.total, order.currency)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Select Payment Method</h2>
|
||||||
|
|
||||||
|
{order.available_gateways.length === 0 ? (
|
||||||
|
<div className="p-4 bg-yellow-50 text-yellow-700 rounded-md border border-yellow-200">
|
||||||
|
No payment methods are available for this order. Please contact support.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
{order.available_gateways.map((gateway) => (
|
||||||
|
<label
|
||||||
|
key={gateway.id}
|
||||||
|
className={`flex items-start p-4 border rounded-lg cursor-pointer transition-all ${selectedGateway === gateway.id
|
||||||
|
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="payment_method"
|
||||||
|
value={gateway.id}
|
||||||
|
checked={selectedGateway === gateway.id}
|
||||||
|
onChange={(e) => setSelectedGateway(e.target.value)}
|
||||||
|
className="h-4 w-4 text-primary border-gray-300 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm">
|
||||||
|
<span className="block font-medium text-gray-900 text-base">
|
||||||
|
{gateway.title || gateway.id}
|
||||||
|
</span>
|
||||||
|
{gateway.description && (
|
||||||
|
<span className="block text-gray-500 mt-1">
|
||||||
|
{gateway.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePayment}
|
||||||
|
disabled={processing || !selectedGateway}
|
||||||
|
className="w-full bg-primary text-white py-4 px-6 rounded-lg font-bold text-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
{processing ? 'Processing Payment...' : `Pay ${formatPrice(order.total, order.currency)}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrderPay;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace WooNooW\Api;
|
namespace WooNooW\Api;
|
||||||
|
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
@@ -8,40 +9,44 @@ use WC_Product;
|
|||||||
use WC_Shipping_Zones;
|
use WC_Shipping_Zones;
|
||||||
use WC_Shipping_Rate;
|
use WC_Shipping_Rate;
|
||||||
|
|
||||||
if (!defined('ABSPATH')) { exit; }
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
class CheckoutController {
|
class CheckoutController
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register REST routes for checkout quote & submit
|
* Register REST routes for checkout quote & submit
|
||||||
*/
|
*/
|
||||||
public static function register() {
|
public static function register()
|
||||||
|
{
|
||||||
$namespace = 'woonoow/v1';
|
$namespace = 'woonoow/v1';
|
||||||
register_rest_route($namespace, '/checkout/quote', [
|
register_rest_route($namespace, '/checkout/quote', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [ new self(), 'quote' ],
|
'callback' => [new self(), 'quote'],
|
||||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], // consider nonce check later
|
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'], // consider nonce check later
|
||||||
]);
|
]);
|
||||||
register_rest_route($namespace, '/checkout/submit', [
|
register_rest_route($namespace, '/checkout/submit', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [ new self(), 'submit' ],
|
'callback' => [new self(), 'submit'],
|
||||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], // consider capability/nonce
|
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'], // consider capability/nonce
|
||||||
]);
|
]);
|
||||||
register_rest_route($namespace, '/checkout/fields', [
|
register_rest_route($namespace, '/checkout/fields', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [ new self(), 'get_fields' ],
|
'callback' => [new self(), 'get_fields'],
|
||||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
|
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'],
|
||||||
]);
|
]);
|
||||||
// Public countries endpoint for customer checkout form
|
// Public countries endpoint for customer checkout form
|
||||||
register_rest_route($namespace, '/countries', [
|
register_rest_route($namespace, '/countries', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [ new self(), 'get_countries' ],
|
'callback' => [new self(), 'get_countries'],
|
||||||
'permission_callback' => '__return_true', // Public - needed for checkout
|
'permission_callback' => '__return_true', // Public - needed for checkout
|
||||||
]);
|
]);
|
||||||
// Public order view endpoint for thank you page
|
// Public order view endpoint for thank you page
|
||||||
register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [
|
register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [ new self(), 'get_order' ],
|
'callback' => [new self(), 'get_order'],
|
||||||
'permission_callback' => '__return_true', // Public, validated via order_key
|
'permission_callback' => '__return_true', // Public, validated via order_key
|
||||||
'args' => [
|
'args' => [
|
||||||
'key' => [
|
'key' => [
|
||||||
@@ -53,8 +58,14 @@ class CheckoutController {
|
|||||||
// Get available shipping rates for given address
|
// Get available shipping rates for given address
|
||||||
register_rest_route($namespace, '/checkout/shipping-rates', [
|
register_rest_route($namespace, '/checkout/shipping-rates', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [ new self(), 'get_shipping_rates' ],
|
'callback' => [new self(), 'get_shipping_rates'],
|
||||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
|
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'],
|
||||||
|
]);
|
||||||
|
// Process payment for an existing order (e.g. renewal)
|
||||||
|
register_rest_route($namespace, '/checkout/pay-order/(?P<id>\d+)', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [new self(), 'pay_order'],
|
||||||
|
'permission_callback' => '__return_true', // Validated via order key/owner in method
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +79,8 @@ class CheckoutController {
|
|||||||
* shipping_method: "flat_rate:1" | "free_shipping:3" | ...
|
* shipping_method: "flat_rate:1" | "free_shipping:3" | ...
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function quote(WP_REST_Request $r): array {
|
public function quote(WP_REST_Request $r): array
|
||||||
|
{
|
||||||
$__t0 = microtime(true);
|
$__t0 = microtime(true);
|
||||||
$payload = $this->sanitize_payload($r);
|
$payload = $this->sanitize_payload($r);
|
||||||
|
|
||||||
@@ -162,7 +174,8 @@ class CheckoutController {
|
|||||||
* Validates access via order_key (for guests) or logged-in customer ID
|
* Validates access via order_key (for guests) or logged-in customer ID
|
||||||
* GET /checkout/order/{id}?key=wc_order_xxx
|
* GET /checkout/order/{id}?key=wc_order_xxx
|
||||||
*/
|
*/
|
||||||
public function get_order(WP_REST_Request $r): array {
|
public function get_order(WP_REST_Request $r): array
|
||||||
|
{
|
||||||
$order_id = absint($r['id']);
|
$order_id = absint($r['id']);
|
||||||
$order_key = sanitize_text_field($r->get_param('key') ?? '');
|
$order_key = sanitize_text_field($r->get_param('key') ?? '');
|
||||||
|
|
||||||
@@ -175,9 +188,12 @@ class CheckoutController {
|
|||||||
return ['error' => __('Order not found', 'woonoow')];
|
return ['error' => __('Order not found', 'woonoow')];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate access: order_key must match OR user must be logged in and own the order
|
// Validate access: order_key must match OR user must be logged in and own the order (or be admin)
|
||||||
$valid_key = $order_key && hash_equals($order->get_order_key(), $order_key);
|
$valid_key = $order_key && hash_equals($order->get_order_key(), $order_key);
|
||||||
$valid_owner = is_user_logged_in() && get_current_user_id() === $order->get_customer_id();
|
$valid_owner = is_user_logged_in() && (
|
||||||
|
get_current_user_id() === $order->get_customer_id() ||
|
||||||
|
current_user_can('manage_woocommerce')
|
||||||
|
);
|
||||||
|
|
||||||
if (!$valid_key && !$valid_owner) {
|
if (!$valid_key && !$valid_owner) {
|
||||||
return ['error' => __('Unauthorized access to order', 'woonoow')];
|
return ['error' => __('Unauthorized access to order', 'woonoow')];
|
||||||
@@ -230,6 +246,7 @@ class CheckoutController {
|
|||||||
'id' => $order->get_id(),
|
'id' => $order->get_id(),
|
||||||
'number' => $order->get_order_number(),
|
'number' => $order->get_order_number(),
|
||||||
'status' => $order->get_status(),
|
'status' => $order->get_status(),
|
||||||
|
'created_via' => $order->get_created_via(),
|
||||||
'subtotal' => (float) $order->get_subtotal(),
|
'subtotal' => (float) $order->get_subtotal(),
|
||||||
'discount_total' => (float) $order->get_discount_total(),
|
'discount_total' => (float) $order->get_discount_total(),
|
||||||
'shipping_total' => (float) $order->get_shipping_total(),
|
'shipping_total' => (float) $order->get_shipping_total(),
|
||||||
@@ -249,6 +266,28 @@ class CheckoutController {
|
|||||||
'phone' => $order->get_billing_phone(),
|
'phone' => $order->get_billing_phone(),
|
||||||
],
|
],
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
|
'subscription' => $this->get_subscription_for_response($order),
|
||||||
|
'available_gateways' => $this->get_available_gateways_for_order($order),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get_subscription_for_response($order)
|
||||||
|
{
|
||||||
|
if (!class_exists('\WooNooW\Modules\Subscription\SubscriptionManager')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$sub = \WooNooW\Modules\Subscription\SubscriptionManager::get_by_order_id($order->get_id());
|
||||||
|
if (!$sub) return null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $sub->id,
|
||||||
|
'status' => $sub->status,
|
||||||
|
'billing_period' => $sub->billing_period,
|
||||||
|
'billing_interval' => (int) $sub->billing_interval,
|
||||||
|
'start_date' => $sub->start_date,
|
||||||
|
'next_payment_date' => $sub->next_payment_date,
|
||||||
|
'end_date' => $sub->end_date,
|
||||||
|
'recurring_amount' => (float) $sub->recurring_amount,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +302,8 @@ class CheckoutController {
|
|||||||
* payment_method: "cod" | "bacs" | ...
|
* payment_method: "cod" | "bacs" | ...
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function submit(WP_REST_Request $r): array {
|
public function submit(WP_REST_Request $r): array
|
||||||
|
{
|
||||||
$__t0 = microtime(true);
|
$__t0 = microtime(true);
|
||||||
$payload = $this->sanitize_payload($r);
|
$payload = $this->sanitize_payload($r);
|
||||||
|
|
||||||
@@ -479,12 +519,73 @@ class CheckoutController {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process payment for an existing order
|
||||||
|
* POST /checkout/pay-order/{id}
|
||||||
|
*/
|
||||||
|
public function pay_order(WP_REST_Request $r): array
|
||||||
|
{
|
||||||
|
$order_id = absint($r['id']);
|
||||||
|
$order = wc_get_order($order_id);
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
return ['error' => __('Order not found', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate access
|
||||||
|
$key = $r->get_param('key'); // optional if logged in
|
||||||
|
$valid_key = $key && hash_equals($order->get_order_key(), $key);
|
||||||
|
$valid_owner = is_user_logged_in() && (
|
||||||
|
get_current_user_id() === $order->get_customer_id() ||
|
||||||
|
current_user_can('manage_woocommerce')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$valid_key && !$valid_owner) {
|
||||||
|
return ['error' => __('Unauthorized access', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($order->is_paid()) {
|
||||||
|
return ['error' => __('Order already paid', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payment_method = wc_clean($r->get_param('payment_method'));
|
||||||
|
if (empty($payment_method)) {
|
||||||
|
return ['error' => __('Payment method required', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update payment method
|
||||||
|
$available = WC()->payment_gateways()->get_available_payment_gateways();
|
||||||
|
if (!isset($available[$payment_method])) {
|
||||||
|
return ['error' => __('Invalid payment method', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateway = $available[$payment_method];
|
||||||
|
$order->set_payment_method($gateway);
|
||||||
|
$order->save();
|
||||||
|
|
||||||
|
// Process payment
|
||||||
|
$result = $gateway->process_payment($order_id);
|
||||||
|
|
||||||
|
if (isset($result['result']) && $result['result'] === 'success') {
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'redirect' => $result['redirect'] ?? $order->get_checkout_order_received_url(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'error' => __('Payment failed', 'woonoow') . (isset($result['result']) ? ': ' . $result['result'] : ''),
|
||||||
|
'messages' => wc_get_notices('error'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get checkout fields with all filters applied
|
* Get checkout fields with all filters applied
|
||||||
* Accepts: { items: [...], is_digital_only?: bool }
|
* Accepts: { items: [...], is_digital_only?: bool }
|
||||||
* Returns fields with required, hidden, etc. based on addons + cart context
|
* Returns fields with required, hidden, etc. based on addons + cart context
|
||||||
*/
|
*/
|
||||||
public function get_fields(WP_REST_Request $r): array {
|
public function get_fields(WP_REST_Request $r): array
|
||||||
|
{
|
||||||
$json = $r->get_json_params();
|
$json = $r->get_json_params();
|
||||||
$items = isset($json['items']) && is_array($json['items']) ? $json['items'] : [];
|
$items = isset($json['items']) && is_array($json['items']) ? $json['items'] : [];
|
||||||
$is_digital_only = isset($json['is_digital_only']) ? (bool) $json['is_digital_only'] : false;
|
$is_digital_only = isset($json['is_digital_only']) ? (bool) $json['is_digital_only'] : false;
|
||||||
@@ -534,7 +635,7 @@ class CheckoutController {
|
|||||||
'priority' => $field['priority'] ?? 10,
|
'priority' => $field['priority'] ?? 10,
|
||||||
'options' => $field['options'] ?? null, // For select fields
|
'options' => $field['options'] ?? null, // For select fields
|
||||||
'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields
|
'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields
|
||||||
'autocomplete'=> $field['autocomplete'] ?? '',
|
'autocomplete' => $field['autocomplete'] ?? '',
|
||||||
'validate' => $field['validate'] ?? [],
|
'validate' => $field['validate'] ?? [],
|
||||||
// New fields for dynamic rendering
|
// New fields for dynamic rendering
|
||||||
'input_class' => $field['input_class'] ?? [],
|
'input_class' => $field['input_class'] ?? [],
|
||||||
@@ -549,7 +650,7 @@ class CheckoutController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by priority
|
// Sort by priority
|
||||||
usort($formatted, function($a, $b) {
|
usort($formatted, function ($a, $b) {
|
||||||
return $a['priority'] <=> $b['priority'];
|
return $a['priority'] <=> $b['priority'];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -564,7 +665,8 @@ class CheckoutController {
|
|||||||
* Get list of standard WooCommerce field keys
|
* Get list of standard WooCommerce field keys
|
||||||
* Plugins can extend this list via the 'woonoow_standard_checkout_field_keys' filter
|
* Plugins can extend this list via the 'woonoow_standard_checkout_field_keys' filter
|
||||||
*/
|
*/
|
||||||
private function get_standard_field_keys(): array {
|
private function get_standard_field_keys(): array
|
||||||
|
{
|
||||||
$keys = [
|
$keys = [
|
||||||
'billing_first_name',
|
'billing_first_name',
|
||||||
'billing_last_name',
|
'billing_last_name',
|
||||||
@@ -600,14 +702,19 @@ class CheckoutController {
|
|||||||
|
|
||||||
/** ----------------- Helpers ----------------- **/
|
/** ----------------- Helpers ----------------- **/
|
||||||
|
|
||||||
private function accurate_quote_via_wc_cart(array $payload): array {
|
private function accurate_quote_via_wc_cart(array $payload): array
|
||||||
if (!WC()->customer) { WC()->customer = new \WC_Customer(get_current_user_id(), true); }
|
{
|
||||||
if (!WC()->cart) { WC()->cart = new \WC_Cart(); }
|
if (!WC()->customer) {
|
||||||
|
WC()->customer = new \WC_Customer(get_current_user_id(), true);
|
||||||
|
}
|
||||||
|
if (!WC()->cart) {
|
||||||
|
WC()->cart = new \WC_Cart();
|
||||||
|
}
|
||||||
|
|
||||||
// Address context for taxes/shipping rules - set temporarily without saving to user profile
|
// Address context for taxes/shipping rules - set temporarily without saving to user profile
|
||||||
$ship = !empty($payload['shipping']) ? $payload['shipping'] : $payload['billing'];
|
$ship = !empty($payload['shipping']) ? $payload['shipping'] : $payload['billing'];
|
||||||
if (!empty($payload['billing'])) {
|
if (!empty($payload['billing'])) {
|
||||||
foreach (['country','state','postcode','city','address_1','address_2'] as $k) {
|
foreach (['country', 'state', 'postcode', 'city', 'address_1', 'address_2'] as $k) {
|
||||||
$setter = 'set_billing_' . $k;
|
$setter = 'set_billing_' . $k;
|
||||||
if (method_exists(WC()->customer, $setter) && isset($payload['billing'][$k])) {
|
if (method_exists(WC()->customer, $setter) && isset($payload['billing'][$k])) {
|
||||||
WC()->customer->{$setter}(wc_clean($payload['billing'][$k]));
|
WC()->customer->{$setter}(wc_clean($payload['billing'][$k]));
|
||||||
@@ -615,7 +722,7 @@ class CheckoutController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!empty($ship)) {
|
if (!empty($ship)) {
|
||||||
foreach (['country','state','postcode','city','address_1','address_2'] as $k) {
|
foreach (['country', 'state', 'postcode', 'city', 'address_1', 'address_2'] as $k) {
|
||||||
$setter = 'set_shipping_' . $k;
|
$setter = 'set_shipping_' . $k;
|
||||||
if (method_exists(WC()->customer, $setter) && isset($ship[$k])) {
|
if (method_exists(WC()->customer, $setter) && isset($ship[$k])) {
|
||||||
WC()->customer->{$setter}(wc_clean($ship[$k]));
|
WC()->customer->{$setter}(wc_clean($ship[$k]));
|
||||||
@@ -685,7 +792,8 @@ class CheckoutController {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sanitize_payload(WP_REST_Request $r): array {
|
private function sanitize_payload(WP_REST_Request $r): array
|
||||||
|
{
|
||||||
$json = $r->get_json_params();
|
$json = $r->get_json_params();
|
||||||
|
|
||||||
$items = isset($json['items']) && is_array($json['items']) ? $json['items'] : [];
|
$items = isset($json['items']) && is_array($json['items']) ? $json['items'] : [];
|
||||||
@@ -704,7 +812,7 @@ class CheckoutController {
|
|||||||
];
|
];
|
||||||
}, $items),
|
}, $items),
|
||||||
'billing' => $billing,
|
'billing' => $billing,
|
||||||
'shipping'=> $shipping,
|
'shipping' => $shipping,
|
||||||
'coupons' => $coupons,
|
'coupons' => $coupons,
|
||||||
'shipping_method' => isset($json['shipping_method']) ? wc_clean($json['shipping_method']) : null,
|
'shipping_method' => isset($json['shipping_method']) ? wc_clean($json['shipping_method']) : null,
|
||||||
'payment_method' => isset($json['payment_method']) ? wc_clean($json['payment_method']) : null,
|
'payment_method' => isset($json['payment_method']) ? wc_clean($json['payment_method']) : null,
|
||||||
@@ -716,7 +824,8 @@ class CheckoutController {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function load_product(array $line) {
|
private function load_product(array $line)
|
||||||
|
{
|
||||||
$pid = (int)($line['variation_id'] ?? 0) ?: (int)($line['product_id'] ?? 0);
|
$pid = (int)($line['variation_id'] ?? 0) ?: (int)($line['product_id'] ?? 0);
|
||||||
if (!$pid) {
|
if (!$pid) {
|
||||||
return new WP_Error('bad_item', __('Invalid product id', 'woonoow'));
|
return new WP_Error('bad_item', __('Invalid product id', 'woonoow'));
|
||||||
@@ -728,8 +837,9 @@ class CheckoutController {
|
|||||||
return $product;
|
return $product;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function only_address_fields(array $src): array {
|
private function only_address_fields(array $src): array
|
||||||
$keys = ['first_name','last_name','company','address_1','address_2','city','state','postcode','country','email','phone'];
|
{
|
||||||
|
$keys = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
|
||||||
$out = [];
|
$out = [];
|
||||||
foreach ($keys as $k) {
|
foreach ($keys as $k) {
|
||||||
if (isset($src[$k])) $out[$k] = wc_clean(wp_unslash($src[$k]));
|
if (isset($src[$k])) $out[$k] = wc_clean(wp_unslash($src[$k]));
|
||||||
@@ -737,7 +847,8 @@ class CheckoutController {
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function estimate_shipping(array $address, ?string $chosen_method): float {
|
private function estimate_shipping(array $address, ?string $chosen_method): float
|
||||||
|
{
|
||||||
$country = wc_clean($address['country'] ?? '');
|
$country = wc_clean($address['country'] ?? '');
|
||||||
$postcode = wc_clean($address['postcode'] ?? '');
|
$postcode = wc_clean($address['postcode'] ?? '');
|
||||||
$state = wc_clean($address['state'] ?? '');
|
$state = wc_clean($address['state'] ?? '');
|
||||||
@@ -745,12 +856,14 @@ class CheckoutController {
|
|||||||
|
|
||||||
$cache_key = 'wnw_ship_' . md5(json_encode([$country, $state, $postcode, $city, (string) $chosen_method]));
|
$cache_key = 'wnw_ship_' . md5(json_encode([$country, $state, $postcode, $city, (string) $chosen_method]));
|
||||||
$cached = wp_cache_get($cache_key, 'woonoow');
|
$cached = wp_cache_get($cache_key, 'woonoow');
|
||||||
if ($cached !== false) { return (float) $cached; }
|
if ($cached !== false) {
|
||||||
|
return (float) $cached;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$country) return 0.0;
|
if (!$country) return 0.0;
|
||||||
|
|
||||||
$packages = [[
|
$packages = [[
|
||||||
'destination' => compact('country','state','postcode','city'),
|
'destination' => compact('country', 'state', 'postcode', 'city'),
|
||||||
'contents_cost' => 0, // not exact in v0
|
'contents_cost' => 0, // not exact in v0
|
||||||
'contents' => [],
|
'contents' => [],
|
||||||
'applied_coupons' => [],
|
'applied_coupons' => [],
|
||||||
@@ -778,7 +891,8 @@ class CheckoutController {
|
|||||||
return $cost;
|
return $cost;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function find_shipping_rate_for_order(WC_Order $order, string $chosen) {
|
private function find_shipping_rate_for_order(WC_Order $order, string $chosen)
|
||||||
|
{
|
||||||
$shipping = $order->get_address('shipping');
|
$shipping = $order->get_address('shipping');
|
||||||
$packages = [[
|
$packages = [[
|
||||||
'destination' => [
|
'destination' => [
|
||||||
@@ -810,7 +924,8 @@ class CheckoutController {
|
|||||||
* Get countries and states for checkout form
|
* Get countries and states for checkout form
|
||||||
* Public endpoint - no authentication required
|
* Public endpoint - no authentication required
|
||||||
*/
|
*/
|
||||||
public function get_countries(): array {
|
public function get_countries(): array
|
||||||
|
{
|
||||||
$wc_countries = WC()->countries;
|
$wc_countries = WC()->countries;
|
||||||
|
|
||||||
// Get allowed selling countries
|
// Get allowed selling countries
|
||||||
@@ -849,7 +964,8 @@ class CheckoutController {
|
|||||||
* POST /checkout/shipping-rates
|
* POST /checkout/shipping-rates
|
||||||
* Body: { shipping: { country, state, city, postcode, destination_id? }, items: [...] }
|
* Body: { shipping: { country, state, city, postcode, destination_id? }, items: [...] }
|
||||||
*/
|
*/
|
||||||
public function get_shipping_rates(WP_REST_Request $r): array {
|
public function get_shipping_rates(WP_REST_Request $r): array
|
||||||
|
{
|
||||||
$payload = $r->get_json_params();
|
$payload = $r->get_json_params();
|
||||||
$shipping = $payload['shipping'] ?? [];
|
$shipping = $payload['shipping'] ?? [];
|
||||||
$items = $payload['items'] ?? [];
|
$items = $payload['items'] ?? [];
|
||||||
@@ -962,4 +1078,27 @@ class CheckoutController {
|
|||||||
'zone_name' => $zone->get_zone_name(),
|
'zone_name' => $zone->get_zone_name(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function get_available_gateways_for_order(WC_Order $order): array
|
||||||
|
{
|
||||||
|
// Mock cart for gateways that check cart total
|
||||||
|
if (!WC()->cart) {
|
||||||
|
WC()->initialize_cart();
|
||||||
|
}
|
||||||
|
// We can't easily bake the order into the cart, but many gateways just check 'needs_payment'
|
||||||
|
// or country.
|
||||||
|
|
||||||
|
$gateways = WC()->payment_gateways()->get_available_payment_gateways();
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($gateways as $gateway) {
|
||||||
|
$results[] = [
|
||||||
|
'id' => $gateway->id,
|
||||||
|
'title' => $gateway->get_title() ?: $gateway->method_title ?: ucfirst($gateway->id), // Fallbacks
|
||||||
|
'description' => $gateway->get_description(),
|
||||||
|
'icon' => $gateway->get_icon(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -424,6 +424,23 @@ class ProductsController {
|
|||||||
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
|
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscription meta
|
||||||
|
if (isset($data['subscription_enabled'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_subscription_enabled', $data['subscription_enabled'] ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
if (isset($data['subscription_period'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_subscription_period', sanitize_key($data['subscription_period']));
|
||||||
|
}
|
||||||
|
if (isset($data['subscription_interval'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_subscription_interval', absint($data['subscription_interval']));
|
||||||
|
}
|
||||||
|
if (isset($data['subscription_trial_days'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_subscription_trial_days', absint($data['subscription_trial_days']));
|
||||||
|
}
|
||||||
|
if (isset($data['subscription_signup_fee'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', self::sanitize_number($data['subscription_signup_fee']));
|
||||||
|
}
|
||||||
|
|
||||||
// Handle variations for variable products
|
// Handle variations for variable products
|
||||||
if ($type === 'variable' && !empty($data['attributes']) && is_array($data['attributes'])) {
|
if ($type === 'variable' && !empty($data['attributes']) && is_array($data['attributes'])) {
|
||||||
self::save_product_attributes($product, $data['attributes']);
|
self::save_product_attributes($product, $data['attributes']);
|
||||||
@@ -568,6 +585,23 @@ class ProductsController {
|
|||||||
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
|
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscription meta
|
||||||
|
if (isset($data['subscription_enabled'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_subscription_enabled', $data['subscription_enabled'] ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
if (isset($data['subscription_period'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_subscription_period', sanitize_key($data['subscription_period']));
|
||||||
|
}
|
||||||
|
if (isset($data['subscription_interval'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_subscription_interval', absint($data['subscription_interval']));
|
||||||
|
}
|
||||||
|
if (isset($data['subscription_trial_days'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_subscription_trial_days', absint($data['subscription_trial_days']));
|
||||||
|
}
|
||||||
|
if (isset($data['subscription_signup_fee'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', self::sanitize_number($data['subscription_signup_fee']));
|
||||||
|
}
|
||||||
|
|
||||||
// Allow plugins to perform additional updates (Level 1 compatibility)
|
// Allow plugins to perform additional updates (Level 1 compatibility)
|
||||||
do_action('woonoow/product_updated', $product, $data, $request);
|
do_action('woonoow/product_updated', $product, $data, $request);
|
||||||
|
|
||||||
@@ -767,6 +801,13 @@ class ProductsController {
|
|||||||
$data['license_activation_limit'] = get_post_meta($product->get_id(), '_license_activation_limit', true) ?: '';
|
$data['license_activation_limit'] = get_post_meta($product->get_id(), '_license_activation_limit', true) ?: '';
|
||||||
$data['license_duration_days'] = get_post_meta($product->get_id(), '_license_duration_days', true) ?: '';
|
$data['license_duration_days'] = get_post_meta($product->get_id(), '_license_duration_days', true) ?: '';
|
||||||
|
|
||||||
|
// Subscription fields
|
||||||
|
$data['subscription_enabled'] = get_post_meta($product->get_id(), '_woonoow_subscription_enabled', true) === 'yes';
|
||||||
|
$data['subscription_period'] = get_post_meta($product->get_id(), '_woonoow_subscription_period', true) ?: 'month';
|
||||||
|
$data['subscription_interval'] = get_post_meta($product->get_id(), '_woonoow_subscription_interval', true) ?: '1';
|
||||||
|
$data['subscription_trial_days'] = get_post_meta($product->get_id(), '_woonoow_subscription_trial_days', true) ?: '';
|
||||||
|
$data['subscription_signup_fee'] = get_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', true) ?: '';
|
||||||
|
|
||||||
// Images array (URLs) for frontend - featured + gallery
|
// Images array (URLs) for frontend - featured + gallery
|
||||||
$images = [];
|
$images = [];
|
||||||
$featured_image_id = $product->get_image_id();
|
$featured_image_id = $product->get_image_id();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use WooNooW\Api\ModuleSettingsController;
|
|||||||
use WooNooW\Api\CampaignsController;
|
use WooNooW\Api\CampaignsController;
|
||||||
use WooNooW\Api\DocsController;
|
use WooNooW\Api\DocsController;
|
||||||
use WooNooW\Api\LicensesController;
|
use WooNooW\Api\LicensesController;
|
||||||
|
use WooNooW\Api\SubscriptionsController;
|
||||||
use WooNooW\Frontend\ShopController;
|
use WooNooW\Frontend\ShopController;
|
||||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||||
use WooNooW\Frontend\AccountController;
|
use WooNooW\Frontend\AccountController;
|
||||||
@@ -163,6 +164,9 @@ class Routes {
|
|||||||
// Licenses controller (licensing module)
|
// Licenses controller (licensing module)
|
||||||
LicensesController::register_routes();
|
LicensesController::register_routes();
|
||||||
|
|
||||||
|
// Subscriptions controller (subscription module)
|
||||||
|
SubscriptionsController::register_routes();
|
||||||
|
|
||||||
// Modules controller
|
// Modules controller
|
||||||
$modules_controller = new ModulesController();
|
$modules_controller = new ModulesController();
|
||||||
$modules_controller->register_routes();
|
$modules_controller->register_routes();
|
||||||
|
|||||||
476
includes/Api/SubscriptionsController.php
Normal file
476
includes/Api/SubscriptionsController.php
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscriptions API Controller
|
||||||
|
*
|
||||||
|
* REST API endpoints for subscription management.
|
||||||
|
*
|
||||||
|
* @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\Subscription\SubscriptionManager;
|
||||||
|
|
||||||
|
class SubscriptionsController
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register REST routes
|
||||||
|
*/
|
||||||
|
public static function register_routes()
|
||||||
|
{
|
||||||
|
// Check if module is enabled
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
register_rest_route('woonoow/v1', '/subscriptions', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_subscriptions'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_subscription'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)', [
|
||||||
|
'methods' => 'PUT',
|
||||||
|
'callback' => [__CLASS__, 'update_subscription'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/cancel', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'cancel_subscription'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/renew', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'renew_subscription'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/pause', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'pause_subscription'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/resume', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'resume_subscription'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Customer routes
|
||||||
|
register_rest_route('woonoow/v1', '/account/subscriptions', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_customer_subscriptions'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return is_user_logged_in();
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_customer_subscription'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return is_user_logged_in();
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/cancel', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'customer_cancel'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return is_user_logged_in();
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/pause', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'customer_pause'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return is_user_logged_in();
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/resume', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'customer_resume'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return is_user_logged_in();
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/renew', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'customer_renew'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return is_user_logged_in();
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all subscriptions (admin)
|
||||||
|
*/
|
||||||
|
public static function get_subscriptions(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$args = [
|
||||||
|
'status' => $request->get_param('status'),
|
||||||
|
'product_id' => $request->get_param('product_id'),
|
||||||
|
'user_id' => $request->get_param('user_id'),
|
||||||
|
'limit' => $request->get_param('per_page') ?: 20,
|
||||||
|
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 20),
|
||||||
|
];
|
||||||
|
|
||||||
|
$subscriptions = SubscriptionManager::get_all($args);
|
||||||
|
$total = SubscriptionManager::count(['status' => $args['status']]);
|
||||||
|
|
||||||
|
// Enrich with product and user info
|
||||||
|
$enriched = [];
|
||||||
|
foreach ($subscriptions as $subscription) {
|
||||||
|
$enriched[] = self::enrich_subscription($subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'subscriptions' => $enriched,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $request->get_param('page') ?: 1,
|
||||||
|
'per_page' => $args['limit'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single subscription (admin)
|
||||||
|
*/
|
||||||
|
public static function get_subscription(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||||
|
|
||||||
|
if (!$subscription) {
|
||||||
|
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$enriched = self::enrich_subscription($subscription);
|
||||||
|
$enriched['orders'] = SubscriptionManager::get_orders($subscription->id);
|
||||||
|
|
||||||
|
return new WP_REST_Response($enriched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update subscription (admin)
|
||||||
|
*/
|
||||||
|
public static function update_subscription(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||||
|
|
||||||
|
if (!$subscription) {
|
||||||
|
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->get_json_params();
|
||||||
|
$allowed_fields = ['status', 'next_payment_date', 'end_date', 'billing_period', 'billing_interval'];
|
||||||
|
|
||||||
|
$update_data = [];
|
||||||
|
$format = [];
|
||||||
|
|
||||||
|
foreach ($allowed_fields as $field) {
|
||||||
|
if (isset($data[$field])) {
|
||||||
|
$update_data[$field] = $data[$field];
|
||||||
|
$format[] = is_numeric($data[$field]) ? '%d' : '%s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($update_data)) {
|
||||||
|
return new WP_Error('no_data', __('No valid fields to update', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
$updated = $wpdb->update($table, $update_data, ['id' => $subscription->id], $format, ['%d']);
|
||||||
|
|
||||||
|
if ($updated === false) {
|
||||||
|
return new WP_Error('update_failed', __('Failed to update subscription', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel subscription (admin)
|
||||||
|
*/
|
||||||
|
public static function cancel_subscription(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->get_json_params();
|
||||||
|
$reason = $data['reason'] ?? 'Cancelled by admin';
|
||||||
|
|
||||||
|
$result = SubscriptionManager::cancel($request->get_param('id'), $reason);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_Error('cancel_failed', __('Failed to cancel subscription', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renew subscription (admin - force immediate renewal)
|
||||||
|
*/
|
||||||
|
public static function renew_subscription(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$result = SubscriptionManager::renew($request->get_param('id'));
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_Error('renew_failed', __('Failed to process renewal', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(['success' => true, 'order_id' => $result['order_id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause subscription (admin)
|
||||||
|
*/
|
||||||
|
public static function pause_subscription(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$result = SubscriptionManager::pause($request->get_param('id'));
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_Error('pause_failed', __('Failed to pause subscription', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume subscription (admin)
|
||||||
|
*/
|
||||||
|
public static function resume_subscription(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$result = SubscriptionManager::resume($request->get_param('id'));
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_Error('resume_failed', __('Failed to resume subscription', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customer's subscriptions
|
||||||
|
*/
|
||||||
|
public static function get_customer_subscriptions(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$subscriptions = SubscriptionManager::get_by_user($user_id, [
|
||||||
|
'status' => $request->get_param('status'),
|
||||||
|
'limit' => $request->get_param('per_page') ?: 20,
|
||||||
|
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 20),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Enrich each subscription
|
||||||
|
$enriched = [];
|
||||||
|
foreach ($subscriptions as $subscription) {
|
||||||
|
$enriched[] = self::enrich_subscription($subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response($enriched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customer's subscription detail
|
||||||
|
*/
|
||||||
|
public static function get_customer_subscription(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||||
|
|
||||||
|
if (!$subscription || $subscription->user_id != $user_id) {
|
||||||
|
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$enriched = self::enrich_subscription($subscription);
|
||||||
|
$enriched['orders'] = SubscriptionManager::get_orders($subscription->id);
|
||||||
|
|
||||||
|
return new WP_REST_Response($enriched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer cancel their own subscription
|
||||||
|
*/
|
||||||
|
public static function customer_cancel(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
// Check if customer cancellation is allowed
|
||||||
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
|
if (empty($settings['allow_customer_cancel'])) {
|
||||||
|
return new WP_Error('not_allowed', __('Customer cancellation is not allowed', 'woonoow'), ['status' => 403]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||||
|
|
||||||
|
if (!$subscription || $subscription->user_id != $user_id) {
|
||||||
|
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->get_json_params();
|
||||||
|
$reason = $data['reason'] ?? 'Cancelled by customer';
|
||||||
|
|
||||||
|
$result = SubscriptionManager::cancel($subscription->id, $reason);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_Error('cancel_failed', __('Failed to cancel subscription', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer pause their own subscription
|
||||||
|
*/
|
||||||
|
public static function customer_pause(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
// Check if customer pause is allowed
|
||||||
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
|
if (empty($settings['allow_customer_pause'])) {
|
||||||
|
return new WP_Error('not_allowed', __('Customer pause is not allowed', 'woonoow'), ['status' => 403]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||||
|
|
||||||
|
if (!$subscription || $subscription->user_id != $user_id) {
|
||||||
|
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = SubscriptionManager::pause($subscription->id);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_Error('pause_failed', __('Failed to pause subscription. Maximum pauses may have been reached.', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer resume their own subscription
|
||||||
|
*/
|
||||||
|
public static function customer_resume(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||||
|
|
||||||
|
if (!$subscription || $subscription->user_id != $user_id) {
|
||||||
|
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = SubscriptionManager::resume($subscription->id);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_Error('resume_failed', __('Failed to resume subscription', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer renew their own subscription (Early Renewal)
|
||||||
|
*/
|
||||||
|
public static function customer_renew(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||||
|
|
||||||
|
if (!$subscription || $subscription->user_id != $user_id) {
|
||||||
|
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if subscription is active (for early renewal) or on-hold with no pending payment
|
||||||
|
if ($subscription->status !== 'active' && $subscription->status !== 'on-hold') {
|
||||||
|
return new WP_Error('not_allowed', __('Only active subscriptions can be renewed early', 'woonoow'), ['status' => 403]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger renewal
|
||||||
|
$result = SubscriptionManager::renew($subscription->id);
|
||||||
|
|
||||||
|
// SubscriptionManager::renew returns array (success) or false (failed)
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_Error('renew_failed', __('Failed to create renewal order', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'order_id' => $result['order_id'],
|
||||||
|
'status' => $result['status']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich subscription with product and user info
|
||||||
|
*/
|
||||||
|
private static function enrich_subscription($subscription)
|
||||||
|
{
|
||||||
|
$enriched = (array) $subscription;
|
||||||
|
|
||||||
|
// Add product info
|
||||||
|
$product_id = $subscription->variation_id ?: $subscription->product_id;
|
||||||
|
$product = wc_get_product($product_id);
|
||||||
|
$enriched['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
|
||||||
|
$enriched['product_image'] = $product ? wp_get_attachment_url($product->get_image_id()) : '';
|
||||||
|
|
||||||
|
// Add user info
|
||||||
|
$user = get_userdata($subscription->user_id);
|
||||||
|
$enriched['user_email'] = $user ? $user->user_email : '';
|
||||||
|
$enriched['user_name'] = $user ? $user->display_name : __('Unknown User', 'woonoow');
|
||||||
|
|
||||||
|
// Add computed fields
|
||||||
|
$enriched['is_active'] = $subscription->status === 'active';
|
||||||
|
$enriched['can_pause'] = $subscription->status === 'active';
|
||||||
|
$enriched['can_resume'] = $subscription->status === 'on-hold';
|
||||||
|
$enriched['can_cancel'] = in_array($subscription->status, ['active', 'on-hold', 'pending']);
|
||||||
|
|
||||||
|
// Format billing info
|
||||||
|
$period_labels = [
|
||||||
|
'day' => __('day', 'woonoow'),
|
||||||
|
'week' => __('week', 'woonoow'),
|
||||||
|
'month' => __('month', 'woonoow'),
|
||||||
|
'year' => __('year', 'woonoow'),
|
||||||
|
];
|
||||||
|
$interval = $subscription->billing_interval > 1 ? $subscription->billing_interval . ' ' : '';
|
||||||
|
$period = $period_labels[$subscription->billing_period] ?? $subscription->billing_period;
|
||||||
|
if ($subscription->billing_interval > 1) {
|
||||||
|
$period .= 's'; // Pluralize
|
||||||
|
}
|
||||||
|
$enriched['billing_schedule'] = sprintf(__('Every %s%s', 'woonoow'), $interval, $period);
|
||||||
|
|
||||||
|
return $enriched;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ if ( ! defined('ABSPATH') ) exit;
|
|||||||
*/
|
*/
|
||||||
class NavigationRegistry {
|
class NavigationRegistry {
|
||||||
const NAV_OPTION = 'wnw_nav_tree';
|
const NAV_OPTION = 'wnw_nav_tree';
|
||||||
const NAV_VERSION = '1.2.0'; // Added Menus (Menu Editor)
|
const NAV_VERSION = '1.3.0'; // Added Subscriptions section
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize hooks
|
* Initialize hooks
|
||||||
@@ -132,6 +132,8 @@ class NavigationRegistry {
|
|||||||
// Future: Drafts, Recurring, etc.
|
// Future: Drafts, Recurring, etc.
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
// Subscriptions - only if module enabled
|
||||||
|
...self::get_subscriptions_section(),
|
||||||
[
|
[
|
||||||
'key' => 'products',
|
'key' => 'products',
|
||||||
'label' => __('Products', 'woonoow'),
|
'label' => __('Products', 'woonoow'),
|
||||||
@@ -242,6 +244,30 @@ class NavigationRegistry {
|
|||||||
return $children;
|
return $children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscriptions navigation section
|
||||||
|
* Returns empty array if module is not enabled
|
||||||
|
*
|
||||||
|
* @return array Subscriptions section or empty array
|
||||||
|
*/
|
||||||
|
private static function get_subscriptions_section(): array {
|
||||||
|
if (!\WooNooW\Core\ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'key' => 'subscriptions',
|
||||||
|
'label' => __('Subscriptions', 'woonoow'),
|
||||||
|
'path' => '/subscriptions',
|
||||||
|
'icon' => 'repeat',
|
||||||
|
'children' => [
|
||||||
|
['label' => __('All Subscriptions', 'woonoow'), 'mode' => 'spa', 'path' => '/subscriptions', 'exact' => true],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the complete navigation tree
|
* Get the complete navigation tree
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module Registry
|
* Module Registry
|
||||||
*
|
*
|
||||||
@@ -10,14 +11,16 @@
|
|||||||
|
|
||||||
namespace WooNooW\Core;
|
namespace WooNooW\Core;
|
||||||
|
|
||||||
class ModuleRegistry {
|
class ModuleRegistry
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get built-in modules
|
* Get built-in modules
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private static function get_builtin_modules() {
|
private static function get_builtin_modules()
|
||||||
|
{
|
||||||
$modules = [
|
$modules = [
|
||||||
'newsletter' => [
|
'newsletter' => [
|
||||||
'id' => 'newsletter',
|
'id' => 'newsletter',
|
||||||
@@ -68,6 +71,7 @@ class ModuleRegistry {
|
|||||||
'category' => 'products',
|
'category' => 'products',
|
||||||
'icon' => 'refresh-cw',
|
'icon' => 'refresh-cw',
|
||||||
'default_enabled' => false,
|
'default_enabled' => false,
|
||||||
|
'has_settings' => true,
|
||||||
'features' => [
|
'features' => [
|
||||||
__('Recurring billing', 'woonoow'),
|
__('Recurring billing', 'woonoow'),
|
||||||
__('Subscription management', 'woonoow'),
|
__('Subscription management', 'woonoow'),
|
||||||
@@ -100,7 +104,8 @@ class ModuleRegistry {
|
|||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private static function get_addon_modules() {
|
private static function get_addon_modules()
|
||||||
|
{
|
||||||
$addons = apply_filters('woonoow/addon_registry', []);
|
$addons = apply_filters('woonoow/addon_registry', []);
|
||||||
$modules = [];
|
$modules = [];
|
||||||
|
|
||||||
@@ -129,7 +134,8 @@ class ModuleRegistry {
|
|||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_all_modules() {
|
public static function get_all_modules()
|
||||||
|
{
|
||||||
$builtin = self::get_builtin_modules();
|
$builtin = self::get_builtin_modules();
|
||||||
$addons = self::get_addon_modules();
|
$addons = self::get_addon_modules();
|
||||||
|
|
||||||
@@ -141,7 +147,8 @@ class ModuleRegistry {
|
|||||||
*
|
*
|
||||||
* @return array Associative array of category_id => label
|
* @return array Associative array of category_id => label
|
||||||
*/
|
*/
|
||||||
public static function get_categories() {
|
public static function get_categories()
|
||||||
|
{
|
||||||
$all_modules = self::get_all_modules();
|
$all_modules = self::get_all_modules();
|
||||||
$categories = [];
|
$categories = [];
|
||||||
|
|
||||||
@@ -155,7 +162,7 @@ class ModuleRegistry {
|
|||||||
|
|
||||||
// Sort by predefined order
|
// Sort by predefined order
|
||||||
$order = ['marketing', 'customers', 'products', 'shipping', 'payments', 'analytics', 'other'];
|
$order = ['marketing', 'customers', 'products', 'shipping', 'payments', 'analytics', 'other'];
|
||||||
uksort($categories, function($a, $b) use ($order) {
|
uksort($categories, function ($a, $b) use ($order) {
|
||||||
$pos_a = array_search($a, $order);
|
$pos_a = array_search($a, $order);
|
||||||
$pos_b = array_search($b, $order);
|
$pos_b = array_search($b, $order);
|
||||||
if ($pos_a === false) $pos_a = 999;
|
if ($pos_a === false) $pos_a = 999;
|
||||||
@@ -172,7 +179,8 @@ class ModuleRegistry {
|
|||||||
* @param string $category Category ID
|
* @param string $category Category ID
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private static function get_category_label($category) {
|
private static function get_category_label($category)
|
||||||
|
{
|
||||||
$labels = [
|
$labels = [
|
||||||
'marketing' => __('Marketing & Sales', 'woonoow'),
|
'marketing' => __('Marketing & Sales', 'woonoow'),
|
||||||
'customers' => __('Customer Experience', 'woonoow'),
|
'customers' => __('Customer Experience', 'woonoow'),
|
||||||
@@ -191,7 +199,8 @@ class ModuleRegistry {
|
|||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_grouped_modules() {
|
public static function get_grouped_modules()
|
||||||
|
{
|
||||||
$all_modules = self::get_all_modules();
|
$all_modules = self::get_all_modules();
|
||||||
$grouped = [];
|
$grouped = [];
|
||||||
|
|
||||||
@@ -211,7 +220,8 @@ class ModuleRegistry {
|
|||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_enabled_modules() {
|
public static function get_enabled_modules()
|
||||||
|
{
|
||||||
$enabled = get_option('woonoow_enabled_modules', null);
|
$enabled = get_option('woonoow_enabled_modules', null);
|
||||||
|
|
||||||
// First time - use defaults
|
// First time - use defaults
|
||||||
@@ -235,7 +245,8 @@ class ModuleRegistry {
|
|||||||
* @param string $module_id
|
* @param string $module_id
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function is_enabled($module_id) {
|
public static function is_enabled($module_id)
|
||||||
|
{
|
||||||
$enabled = self::get_enabled_modules();
|
$enabled = self::get_enabled_modules();
|
||||||
return in_array($module_id, $enabled);
|
return in_array($module_id, $enabled);
|
||||||
}
|
}
|
||||||
@@ -246,7 +257,8 @@ class ModuleRegistry {
|
|||||||
* @param string $module_id
|
* @param string $module_id
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function enable($module_id) {
|
public static function enable($module_id)
|
||||||
|
{
|
||||||
$modules = self::get_all_modules();
|
$modules = self::get_all_modules();
|
||||||
|
|
||||||
if (!isset($modules[$module_id])) {
|
if (!isset($modules[$module_id])) {
|
||||||
@@ -278,7 +290,8 @@ class ModuleRegistry {
|
|||||||
* @param string $module_id
|
* @param string $module_id
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function disable($module_id) {
|
public static function disable($module_id)
|
||||||
|
{
|
||||||
$enabled = self::get_enabled_modules();
|
$enabled = self::get_enabled_modules();
|
||||||
|
|
||||||
if (in_array($module_id, $enabled)) {
|
if (in_array($module_id, $enabled)) {
|
||||||
@@ -304,7 +317,8 @@ class ModuleRegistry {
|
|||||||
* @param string $category
|
* @param string $category
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_by_category($category) {
|
public static function get_by_category($category)
|
||||||
|
{
|
||||||
$modules = self::get_all_modules();
|
$modules = self::get_all_modules();
|
||||||
$enabled = self::get_enabled_modules();
|
$enabled = self::get_enabled_modules();
|
||||||
|
|
||||||
@@ -315,7 +329,6 @@ class ModuleRegistry {
|
|||||||
$result[] = $module;
|
$result[] = $module;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,14 +337,55 @@ class ModuleRegistry {
|
|||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_all_with_status() {
|
public static function get_all_with_status()
|
||||||
|
{
|
||||||
$modules = self::get_all_modules();
|
$modules = self::get_all_modules();
|
||||||
$enabled = self::get_enabled_modules();
|
$enabled = self::get_enabled_modules();
|
||||||
|
|
||||||
foreach ($modules as $id => $module) {
|
foreach ($modules as $id => &$module) {
|
||||||
$modules[$id]['enabled'] = in_array($id, $enabled);
|
$module['enabled'] = in_array($id, $enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $modules;
|
return $modules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get module settings
|
||||||
|
*
|
||||||
|
* @param string $module_id
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_settings($module_id)
|
||||||
|
{
|
||||||
|
$settings = get_option("woonoow_module_{$module_id}_settings", []);
|
||||||
|
|
||||||
|
// Apply defaults from schema if available
|
||||||
|
$schema = apply_filters('woonoow/module_settings_schema', []);
|
||||||
|
|
||||||
|
if (isset($schema[$module_id])) {
|
||||||
|
$defaults = self::get_schema_defaults($schema[$module_id]);
|
||||||
|
$settings = wp_parse_args($settings, $defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default values from schema
|
||||||
|
*
|
||||||
|
* @param array $schema
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private static function get_schema_defaults($schema)
|
||||||
|
{
|
||||||
|
$defaults = [];
|
||||||
|
|
||||||
|
foreach ($schema as $key => $field) {
|
||||||
|
if (isset($field['default'])) {
|
||||||
|
$defaults[$key] = $field['default'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $defaults;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email Renderer
|
* Email Renderer
|
||||||
*
|
*
|
||||||
@@ -9,7 +10,10 @@
|
|||||||
|
|
||||||
namespace WooNooW\Core\Notifications;
|
namespace WooNooW\Core\Notifications;
|
||||||
|
|
||||||
class EmailRenderer {
|
|
||||||
|
|
||||||
|
class EmailRenderer
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instance
|
* Instance
|
||||||
@@ -19,7 +23,8 @@ class EmailRenderer {
|
|||||||
/**
|
/**
|
||||||
* Get instance
|
* Get instance
|
||||||
*/
|
*/
|
||||||
public static function instance() {
|
public static function instance()
|
||||||
|
{
|
||||||
if (null === self::$instance) {
|
if (null === self::$instance) {
|
||||||
self::$instance = new self();
|
self::$instance = new self();
|
||||||
}
|
}
|
||||||
@@ -31,11 +36,12 @@ class EmailRenderer {
|
|||||||
*
|
*
|
||||||
* @param string $event_id Event ID (order_placed, order_processing, etc.)
|
* @param string $event_id Event ID (order_placed, order_processing, etc.)
|
||||||
* @param string $recipient_type Recipient type (staff, customer)
|
* @param string $recipient_type Recipient type (staff, customer)
|
||||||
* @param mixed $data Order, Product, or Customer object
|
* @param WC_Order|WC_Product|WC_Customer|mixed $data Order, Product, or Customer object
|
||||||
* @param array $extra_data Additional data
|
* @param array $extra_data Additional data
|
||||||
* @return array|null ['to', 'subject', 'body']
|
* @return array|null ['to', 'subject', 'body']
|
||||||
*/
|
*/
|
||||||
public function render($event_id, $recipient_type, $data, $extra_data = []) {
|
public function render($event_id, $recipient_type, $data, $extra_data = [])
|
||||||
|
{
|
||||||
// Get template settings
|
// Get template settings
|
||||||
$template_settings = $this->get_template_settings($event_id, $recipient_type);
|
$template_settings = $this->get_template_settings($event_id, $recipient_type);
|
||||||
|
|
||||||
@@ -80,7 +86,8 @@ class EmailRenderer {
|
|||||||
* @param string $recipient_type
|
* @param string $recipient_type
|
||||||
* @return array|null
|
* @return array|null
|
||||||
*/
|
*/
|
||||||
private function get_template_settings($event_id, $recipient_type) {
|
private function get_template_settings($event_id, $recipient_type)
|
||||||
|
{
|
||||||
// Get saved template (with recipient_type for proper default template lookup)
|
// Get saved template (with recipient_type for proper default template lookup)
|
||||||
$template = TemplateProvider::get_template($event_id, 'email', $recipient_type);
|
$template = TemplateProvider::get_template($event_id, 'email', $recipient_type);
|
||||||
|
|
||||||
@@ -111,17 +118,18 @@ class EmailRenderer {
|
|||||||
* @param mixed $data
|
* @param mixed $data
|
||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
private function get_recipient_email($recipient_type, $data) {
|
private function get_recipient_email($recipient_type, $data)
|
||||||
|
{
|
||||||
if ($recipient_type === 'staff') {
|
if ($recipient_type === 'staff') {
|
||||||
return get_option('admin_email');
|
return get_option('admin_email');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customer
|
// Customer
|
||||||
if ($data instanceof \WC_Order) {
|
if ($data instanceof WC_Order) {
|
||||||
return $data->get_billing_email();
|
return $data->get_billing_email();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($data instanceof \WC_Customer) {
|
if ($data instanceof WC_Customer) {
|
||||||
return $data->get_email();
|
return $data->get_email();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +144,8 @@ class EmailRenderer {
|
|||||||
* @param array $extra_data
|
* @param array $extra_data
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private function get_variables($event_id, $data, $extra_data = []) {
|
private function get_variables($event_id, $data, $extra_data = [])
|
||||||
|
{
|
||||||
$variables = [
|
$variables = [
|
||||||
'site_name' => get_bloginfo('name'),
|
'site_name' => get_bloginfo('name'),
|
||||||
'site_title' => get_bloginfo('name'),
|
'site_title' => get_bloginfo('name'),
|
||||||
@@ -149,7 +158,7 @@ class EmailRenderer {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Order variables
|
// Order variables
|
||||||
if ($data instanceof \WC_Order) {
|
if ($data instanceof WC_Order) {
|
||||||
// Calculate estimated delivery (3-5 business days from now)
|
// Calculate estimated delivery (3-5 business days from now)
|
||||||
$estimated_delivery = date('F j', strtotime('+3 days')) . '-' . date('j', strtotime('+5 days'));
|
$estimated_delivery = date('F j', strtotime('+3 days')) . '-' . date('j', strtotime('+5 days'));
|
||||||
|
|
||||||
@@ -237,7 +246,7 @@ class EmailRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Product variables
|
// Product variables
|
||||||
if ($data instanceof \WC_Product) {
|
if ($data instanceof WC_Product) {
|
||||||
$variables = array_merge($variables, [
|
$variables = array_merge($variables, [
|
||||||
'product_id' => $data->get_id(),
|
'product_id' => $data->get_id(),
|
||||||
'product_name' => $data->get_name(),
|
'product_name' => $data->get_name(),
|
||||||
@@ -250,7 +259,7 @@ class EmailRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Customer variables
|
// Customer variables
|
||||||
if ($data instanceof \WC_Customer) {
|
if ($data instanceof WC_Customer) {
|
||||||
// Get temp password from user meta (stored during auto-registration)
|
// Get temp password from user meta (stored during auto-registration)
|
||||||
$user_temp_password = get_user_meta($data->get_id(), '_woonoow_temp_password', true);
|
$user_temp_password = get_user_meta($data->get_id(), '_woonoow_temp_password', true);
|
||||||
|
|
||||||
@@ -295,7 +304,8 @@ class EmailRenderer {
|
|||||||
* @param string $content
|
* @param string $content
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function parse_cards($content) {
|
private function parse_cards($content)
|
||||||
|
{
|
||||||
// Use a single unified regex to match BOTH syntaxes in document order
|
// Use a single unified regex to match BOTH syntaxes in document order
|
||||||
// This ensures cards are rendered in the order they appear
|
// This ensures cards are rendered in the order they appear
|
||||||
$combined_pattern = '/\[card(?::(\w+)|([^\]]*)?)\](.*?)\[\/card\]/s';
|
$combined_pattern = '/\[card(?::(\w+)|([^\]]*)?)\](.*?)\[\/card\]/s';
|
||||||
@@ -339,7 +349,8 @@ class EmailRenderer {
|
|||||||
* @param string $attr_string
|
* @param string $attr_string
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private function parse_card_attributes($attr_string) {
|
private function parse_card_attributes($attr_string)
|
||||||
|
{
|
||||||
$attributes = [
|
$attributes = [
|
||||||
'type' => 'default',
|
'type' => 'default',
|
||||||
'bg' => null,
|
'bg' => null,
|
||||||
@@ -365,7 +376,8 @@ class EmailRenderer {
|
|||||||
* @param array $attributes
|
* @param array $attributes
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function render_card($content, $attributes) {
|
private function render_card($content, $attributes)
|
||||||
|
{
|
||||||
$type = $attributes['type'] ?? 'default';
|
$type = $attributes['type'] ?? 'default';
|
||||||
$bg = $attributes['bg'] ?? null;
|
$bg = $attributes['bg'] ?? null;
|
||||||
|
|
||||||
@@ -385,7 +397,7 @@ class EmailRenderer {
|
|||||||
|
|
||||||
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
|
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
|
||||||
// Helper function to generate button HTML
|
// Helper function to generate button HTML
|
||||||
$generateButtonHtml = function($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
|
$generateButtonHtml = function ($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
|
||||||
if ($style === 'outline') {
|
if ($style === 'outline') {
|
||||||
// Outline button - transparent background with border
|
// Outline button - transparent background with border
|
||||||
$button_style = sprintf(
|
$button_style = sprintf(
|
||||||
@@ -414,7 +426,7 @@ class EmailRenderer {
|
|||||||
// NEW FORMAT: [button:style](url)Text[/button]
|
// NEW FORMAT: [button:style](url)Text[/button]
|
||||||
$content = preg_replace_callback(
|
$content = preg_replace_callback(
|
||||||
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
|
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
|
||||||
function($matches) use ($generateButtonHtml) {
|
function ($matches) use ($generateButtonHtml) {
|
||||||
$style = $matches[1]; // solid or outline
|
$style = $matches[1]; // solid or outline
|
||||||
$url = $matches[2];
|
$url = $matches[2];
|
||||||
$text = trim($matches[3]);
|
$text = trim($matches[3]);
|
||||||
@@ -426,7 +438,7 @@ class EmailRenderer {
|
|||||||
// OLD FORMAT: [button url="..." style="solid|outline"]Text[/button]
|
// OLD FORMAT: [button url="..." style="solid|outline"]Text[/button]
|
||||||
$content = preg_replace_callback(
|
$content = preg_replace_callback(
|
||||||
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](solid|outline)["\'])?\]([^\[]+)\[\/button\]/',
|
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](solid|outline)["\'])?\]([^\[]+)\[\/button\]/',
|
||||||
function($matches) use ($generateButtonHtml) {
|
function ($matches) use ($generateButtonHtml) {
|
||||||
$url = $matches[1];
|
$url = $matches[1];
|
||||||
$style = $matches[2] ?? 'solid';
|
$style = $matches[2] ?? 'solid';
|
||||||
$text = trim($matches[3]);
|
$text = trim($matches[3]);
|
||||||
@@ -499,7 +511,8 @@ class EmailRenderer {
|
|||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function render_card_spacing() {
|
private function render_card_spacing()
|
||||||
|
{
|
||||||
return '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
return '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
<tr><td class="card-spacing" style="height: 24px; font-size: 24px; line-height: 24px;"> </td></tr>
|
<tr><td class="card-spacing" style="height: 24px; font-size: 24px; line-height: 24px;"> </td></tr>
|
||||||
</table>';
|
</table>';
|
||||||
@@ -512,7 +525,8 @@ class EmailRenderer {
|
|||||||
* @param array $variables
|
* @param array $variables
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function replace_variables($text, $variables) {
|
private function replace_variables($text, $variables)
|
||||||
|
{
|
||||||
foreach ($variables as $key => $value) {
|
foreach ($variables as $key => $value) {
|
||||||
$text = str_replace('{' . $key . '}', $value, $text);
|
$text = str_replace('{' . $key . '}', $value, $text);
|
||||||
}
|
}
|
||||||
@@ -525,7 +539,8 @@ class EmailRenderer {
|
|||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function get_design_template() {
|
private function get_design_template()
|
||||||
|
{
|
||||||
// Use single base template (theme-agnostic)
|
// Use single base template (theme-agnostic)
|
||||||
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
|
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
|
||||||
|
|
||||||
@@ -549,7 +564,8 @@ class EmailRenderer {
|
|||||||
* @param array $variables All variables
|
* @param array $variables All variables
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function render_html($template_path, $content, $subject, $variables) {
|
private function render_html($template_path, $content, $subject, $variables)
|
||||||
|
{
|
||||||
if (!file_exists($template_path)) {
|
if (!file_exists($template_path)) {
|
||||||
// Fallback to plain HTML
|
// Fallback to plain HTML
|
||||||
return $content;
|
return $content;
|
||||||
@@ -649,7 +665,8 @@ class EmailRenderer {
|
|||||||
* @param string $color 'white' or 'black'
|
* @param string $color 'white' or 'black'
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function get_social_icon_url($platform, $color = 'white') {
|
private function get_social_icon_url($platform, $color = 'white')
|
||||||
|
{
|
||||||
// Use plugin URL constant if available, otherwise calculate from file path
|
// Use plugin URL constant if available, otherwise calculate from file path
|
||||||
if (defined('WOONOOW_URL')) {
|
if (defined('WOONOOW_URL')) {
|
||||||
$plugin_url = WOONOOW_URL;
|
$plugin_url = WOONOOW_URL;
|
||||||
|
|||||||
375
includes/Core/Notifications/TemplateProvider.bak.php
Normal file
375
includes/Core/Notifications/TemplateProvider.bak.php
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Notification Template Provider
|
||||||
|
*
|
||||||
|
* Manages notification templates for all channels.
|
||||||
|
*
|
||||||
|
* @package WooNooW\Core\Notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Core\Notifications;
|
||||||
|
|
||||||
|
use WooNooW\Email\DefaultTemplates as EmailDefaultTemplates;
|
||||||
|
|
||||||
|
class TemplateProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option key for storing templates
|
||||||
|
*/
|
||||||
|
const OPTION_KEY = 'woonoow_notification_templates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all templates
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_templates() {
|
||||||
|
$templates = get_option(self::OPTION_KEY, []);
|
||||||
|
|
||||||
|
// Merge with defaults
|
||||||
|
$defaults = self::get_default_templates();
|
||||||
|
|
||||||
|
return array_merge($defaults, $templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template for specific event and channel
|
||||||
|
*
|
||||||
|
* @param string $event_id Event ID
|
||||||
|
* @param string $channel_id Channel ID
|
||||||
|
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
||||||
|
* @return array|null
|
||||||
|
*/
|
||||||
|
public static function get_template($event_id, $channel_id, $recipient_type = 'customer') {
|
||||||
|
$templates = self::get_templates();
|
||||||
|
|
||||||
|
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
||||||
|
|
||||||
|
if (isset($templates[$key])) {
|
||||||
|
return $templates[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return default if exists
|
||||||
|
$defaults = self::get_default_templates();
|
||||||
|
|
||||||
|
if (isset($defaults[$key])) {
|
||||||
|
return $defaults[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save template
|
||||||
|
*
|
||||||
|
* @param string $event_id Event ID
|
||||||
|
* @param string $channel_id Channel ID
|
||||||
|
* @param array $template Template data
|
||||||
|
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function save_template($event_id, $channel_id, $template, $recipient_type = 'customer') {
|
||||||
|
$templates = get_option(self::OPTION_KEY, []);
|
||||||
|
|
||||||
|
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
||||||
|
|
||||||
|
$templates[$key] = [
|
||||||
|
'event_id' => $event_id,
|
||||||
|
'channel_id' => $channel_id,
|
||||||
|
'recipient_type' => $recipient_type,
|
||||||
|
'subject' => $template['subject'] ?? '',
|
||||||
|
'body' => $template['body'] ?? '',
|
||||||
|
'variables' => $template['variables'] ?? [],
|
||||||
|
'updated_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return update_option(self::OPTION_KEY, $templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete template (revert to default)
|
||||||
|
*
|
||||||
|
* @param string $event_id Event ID
|
||||||
|
* @param string $channel_id Channel ID
|
||||||
|
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function delete_template($event_id, $channel_id, $recipient_type = 'customer') {
|
||||||
|
$templates = get_option(self::OPTION_KEY, []);
|
||||||
|
|
||||||
|
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
||||||
|
|
||||||
|
if (isset($templates[$key])) {
|
||||||
|
unset($templates[$key]);
|
||||||
|
return update_option(self::OPTION_KEY, $templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WooCommerce email template content
|
||||||
|
*
|
||||||
|
* @param string $email_id WooCommerce email ID
|
||||||
|
* @return array|null
|
||||||
|
*/
|
||||||
|
private static function get_wc_email_template($email_id) {
|
||||||
|
if (!function_exists('WC')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mailer = \WC()->mailer();
|
||||||
|
$emails = $mailer->get_emails();
|
||||||
|
|
||||||
|
if (isset($emails[$email_id])) {
|
||||||
|
$email = $emails[$email_id];
|
||||||
|
return [
|
||||||
|
'subject' => $email->get_subject(),
|
||||||
|
'heading' => $email->get_heading(),
|
||||||
|
'enabled' => $email->is_enabled(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default templates
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_default_templates() {
|
||||||
|
$templates = [];
|
||||||
|
|
||||||
|
// Get all events from EventRegistry (single source of truth)
|
||||||
|
$all_events = EventRegistry::get_all_events();
|
||||||
|
|
||||||
|
// Get email templates from DefaultTemplates
|
||||||
|
$allEmailTemplates = EmailDefaultTemplates::get_all_templates();
|
||||||
|
|
||||||
|
foreach ($all_events as $event) {
|
||||||
|
$event_id = $event['id'];
|
||||||
|
$recipient_type = $event['recipient_type'];
|
||||||
|
// Get template body from the new clean markdown source
|
||||||
|
$body = $allEmailTemplates[$recipient_type][$event_id] ?? '';
|
||||||
|
$subject = EmailDefaultTemplates::get_default_subject($recipient_type, $event_id);
|
||||||
|
|
||||||
|
// If template doesn't exist, create a simple fallback
|
||||||
|
if (empty($body)) {
|
||||||
|
$body = "[card]\n\n## Notification\n\nYou have a new notification about {$event_id}.\n\n[/card]";
|
||||||
|
$subject = __('Notification from {store_name}', 'woonoow');
|
||||||
|
}
|
||||||
|
|
||||||
|
$templates["{$recipient_type}_{$event_id}_email"] = [
|
||||||
|
'event_id' => $event_id,
|
||||||
|
'channel_id' => 'email',
|
||||||
|
'recipient_type' => $recipient_type,
|
||||||
|
'subject' => $subject,
|
||||||
|
'body' => $body,
|
||||||
|
'variables' => self::get_variables_for_event($event_id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add push notification templates
|
||||||
|
$templates['staff_order_placed_push'] = [
|
||||||
|
'event_id' => 'order_placed',
|
||||||
|
'channel_id' => 'push',
|
||||||
|
'recipient_type' => 'staff',
|
||||||
|
'subject' => __('New Order #{order_number}', 'woonoow'),
|
||||||
|
'body' => __('New order from {customer_name} - {order_total}', 'woonoow'),
|
||||||
|
'variables' => self::get_order_variables(),
|
||||||
|
];
|
||||||
|
$templates['customer_order_processing_push'] = [
|
||||||
|
'event_id' => 'order_processing',
|
||||||
|
'channel_id' => 'push',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'subject' => __('Order Processing', 'woonoow'),
|
||||||
|
'body' => __('Your order #{order_number} is being processed', 'woonoow'),
|
||||||
|
'variables' => self::get_order_variables(),
|
||||||
|
];
|
||||||
|
$templates['customer_order_completed_push'] = [
|
||||||
|
'event_id' => 'order_completed',
|
||||||
|
'channel_id' => 'push',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'subject' => __('Order Completed', 'woonoow'),
|
||||||
|
'body' => __('Your order #{order_number} has been completed!', 'woonoow'),
|
||||||
|
'variables' => self::get_order_variables(),
|
||||||
|
];
|
||||||
|
$templates['staff_order_cancelled_push'] = [
|
||||||
|
'event_id' => 'order_cancelled',
|
||||||
|
'channel_id' => 'push',
|
||||||
|
'recipient_type' => 'staff',
|
||||||
|
'subject' => __('Order Cancelled', 'woonoow'),
|
||||||
|
'body' => __('Order #{order_number} has been cancelled', 'woonoow'),
|
||||||
|
'variables' => self::get_order_variables(),
|
||||||
|
];
|
||||||
|
$templates['customer_order_refunded_push'] = [
|
||||||
|
'event_id' => 'order_refunded',
|
||||||
|
'channel_id' => 'push',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'subject' => __('Order Refunded', 'woonoow'),
|
||||||
|
'body' => __('Your order #{order_number} has been refunded', 'woonoow'),
|
||||||
|
'variables' => self::get_order_variables(),
|
||||||
|
];
|
||||||
|
$templates['staff_low_stock_push'] = [
|
||||||
|
'event_id' => 'low_stock',
|
||||||
|
'channel_id' => 'push',
|
||||||
|
'recipient_type' => 'staff',
|
||||||
|
'subject' => __('Low Stock Alert', 'woonoow'),
|
||||||
|
'body' => __('{product_name} is running low on stock', 'woonoow'),
|
||||||
|
'variables' => self::get_product_variables(),
|
||||||
|
];
|
||||||
|
$templates['staff_out_of_stock_push'] = [
|
||||||
|
'event_id' => 'out_of_stock',
|
||||||
|
'channel_id' => 'push',
|
||||||
|
'recipient_type' => 'staff',
|
||||||
|
'subject' => __('Out of Stock Alert', 'woonoow'),
|
||||||
|
'body' => __('{product_name} is now out of stock', 'woonoow'),
|
||||||
|
'variables' => self::get_product_variables(),
|
||||||
|
];
|
||||||
|
$templates['customer_new_customer_push'] = [
|
||||||
|
'event_id' => 'new_customer',
|
||||||
|
'channel_id' => 'push',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'subject' => __('Welcome!', 'woonoow'),
|
||||||
|
'body' => __('Welcome to {store_name}, {customer_name}!', 'woonoow'),
|
||||||
|
'variables' => self::get_customer_variables(),
|
||||||
|
];
|
||||||
|
$templates['customer_customer_note_push'] = [
|
||||||
|
'event_id' => 'customer_note',
|
||||||
|
'channel_id' => 'push',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'subject' => __('Order Note Added', 'woonoow'),
|
||||||
|
'body' => __('A note has been added to order #{order_number}', 'woonoow'),
|
||||||
|
'variables' => self::get_order_variables(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get variables for a specific event
|
||||||
|
*
|
||||||
|
* @param string $event_id Event ID
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private static function get_variables_for_event($event_id) {
|
||||||
|
// Product events
|
||||||
|
if (in_array($event_id, ['low_stock', 'out_of_stock'])) {
|
||||||
|
return self::get_product_variables();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer events (but not order-related)
|
||||||
|
if ($event_id === 'new_customer') {
|
||||||
|
return self::get_customer_variables();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription events
|
||||||
|
if (strpos($event_id, 'subscription_') === 0) {
|
||||||
|
return self::get_subscription_variables();
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other events are order-related
|
||||||
|
return self::get_order_variables();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available order variables
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_order_variables() {
|
||||||
|
return [
|
||||||
|
'order_number' => __('Order Number', 'woonoow'),
|
||||||
|
'order_total' => __('Order Total', 'woonoow'),
|
||||||
|
'order_status' => __('Order Status', 'woonoow'),
|
||||||
|
'order_date' => __('Order Date', 'woonoow'),
|
||||||
|
'order_url' => __('Order URL', 'woonoow'),
|
||||||
|
'order_items_list' => __('Order Items (formatted list)', 'woonoow'),
|
||||||
|
'order_items_table' => __('Order Items (formatted table)', 'woonoow'),
|
||||||
|
'payment_method' => __('Payment Method', 'woonoow'),
|
||||||
|
'payment_url' => __('Payment URL (for pending payments)', 'woonoow'),
|
||||||
|
'shipping_method' => __('Shipping Method', 'woonoow'),
|
||||||
|
'tracking_number' => __('Tracking Number', 'woonoow'),
|
||||||
|
'refund_amount' => __('Refund Amount', 'woonoow'),
|
||||||
|
'customer_name' => __('Customer Name', 'woonoow'),
|
||||||
|
'customer_email' => __('Customer Email', 'woonoow'),
|
||||||
|
'customer_phone' => __('Customer Phone', 'woonoow'),
|
||||||
|
'billing_address' => __('Billing Address', 'woonoow'),
|
||||||
|
'shipping_address' => __('Shipping Address', 'woonoow'),
|
||||||
|
'store_name' => __('Store Name', 'woonoow'),
|
||||||
|
'store_url' => __('Store URL', 'woonoow'),
|
||||||
|
'store_email' => __('Store Email', 'woonoow'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available product variables
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_product_variables() {
|
||||||
|
return [
|
||||||
|
'product_name' => __('Product Name', 'woonoow'),
|
||||||
|
'product_sku' => __('Product SKU', 'woonoow'),
|
||||||
|
'product_url' => __('Product URL', 'woonoow'),
|
||||||
|
'stock_quantity' => __('Stock Quantity', 'woonoow'),
|
||||||
|
'store_name' => __('Store Name', 'woonoow'),
|
||||||
|
'store_url' => __('Store URL', 'woonoow'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available customer variables
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_customer_variables() {
|
||||||
|
return [
|
||||||
|
'customer_name' => __('Customer Name', 'woonoow'),
|
||||||
|
'customer_email' => __('Customer Email', 'woonoow'),
|
||||||
|
'customer_phone' => __('Customer Phone', 'woonoow'),
|
||||||
|
'store_name' => __('Store Name', 'woonoow'),
|
||||||
|
'store_url' => __('Store URL', 'woonoow'),
|
||||||
|
'store_email' => __('Store Email', 'woonoow'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available subscription variables
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_subscription_variables() {
|
||||||
|
return [
|
||||||
|
'subscription_id' => __('Subscription ID', 'woonoow'),
|
||||||
|
'subscription_status' => __('Subscription Status', 'woonoow'),
|
||||||
|
'product_name' => __('Product Name', 'woonoow'),
|
||||||
|
'billing_period' => __('Billing Period (e.g., Monthly)', 'woonoow'),
|
||||||
|
'recurring_amount' => __('Recurring Amount', 'woonoow'),
|
||||||
|
'next_payment_date' => __('Next Payment Date', 'woonoow'),
|
||||||
|
'end_date' => __('Subscription End Date', 'woonoow'),
|
||||||
|
'cancel_reason' => __('Cancellation Reason', 'woonoow'),
|
||||||
|
'customer_name' => __('Customer Name', 'woonoow'),
|
||||||
|
'customer_email' => __('Customer Email', 'woonoow'),
|
||||||
|
'store_name' => __('Store Name', 'woonoow'),
|
||||||
|
'store_url' => __('Store URL', 'woonoow'),
|
||||||
|
'my_account_url' => __('My Account URL', 'woonoow'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace variables in template
|
||||||
|
*
|
||||||
|
* @param string $content Content with variables
|
||||||
|
* @param array $data Data to replace variables
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function replace_variables($content, $data) {
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$content = str_replace('{' . $key . '}', $value, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,32 +107,6 @@ class TemplateProvider {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get WooCommerce email template content
|
|
||||||
*
|
|
||||||
* @param string $email_id WooCommerce email ID
|
|
||||||
* @return array|null
|
|
||||||
*/
|
|
||||||
private static function get_wc_email_template($email_id) {
|
|
||||||
if (!function_exists('WC')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mailer = WC()->mailer();
|
|
||||||
$emails = $mailer->get_emails();
|
|
||||||
|
|
||||||
if (isset($emails[$email_id])) {
|
|
||||||
$email = $emails[$email_id];
|
|
||||||
return [
|
|
||||||
'subject' => $email->get_subject(),
|
|
||||||
'heading' => $email->get_heading(),
|
|
||||||
'enabled' => $email->is_enabled(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default templates
|
* Get default templates
|
||||||
*
|
*
|
||||||
@@ -264,6 +238,11 @@ class TemplateProvider {
|
|||||||
return self::get_customer_variables();
|
return self::get_customer_variables();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscription events
|
||||||
|
if (strpos($event_id, 'subscription_') === 0) {
|
||||||
|
return self::get_subscription_variables();
|
||||||
|
}
|
||||||
|
|
||||||
// All other events are order-related
|
// All other events are order-related
|
||||||
return self::get_order_variables();
|
return self::get_order_variables();
|
||||||
}
|
}
|
||||||
@@ -330,6 +309,29 @@ class TemplateProvider {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available subscription variables
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_subscription_variables() {
|
||||||
|
return [
|
||||||
|
'subscription_id' => __('Subscription ID', 'woonoow'),
|
||||||
|
'subscription_status' => __('Subscription Status', 'woonoow'),
|
||||||
|
'product_name' => __('Product Name', 'woonoow'),
|
||||||
|
'billing_period' => __('Billing Period (e.g., Monthly)', 'woonoow'),
|
||||||
|
'recurring_amount' => __('Recurring Amount', 'woonoow'),
|
||||||
|
'next_payment_date' => __('Next Payment Date', 'woonoow'),
|
||||||
|
'end_date' => __('Subscription End Date', 'woonoow'),
|
||||||
|
'cancel_reason' => __('Cancellation Reason', 'woonoow'),
|
||||||
|
'customer_name' => __('Customer Name', 'woonoow'),
|
||||||
|
'customer_email' => __('Customer Email', 'woonoow'),
|
||||||
|
'store_name' => __('Store Name', 'woonoow'),
|
||||||
|
'store_url' => __('Store URL', 'woonoow'),
|
||||||
|
'my_account_url' => __('My Account URL', 'woonoow'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace variables in template
|
* Replace variables in template
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace WooNooW\Frontend;
|
namespace WooNooW\Frontend;
|
||||||
|
|
||||||
use WooNooW\Frontend\PageSSR;
|
use WooNooW\Frontend\PageSSR;
|
||||||
@@ -20,7 +21,7 @@ class TemplateOverride
|
|||||||
add_action('init', [__CLASS__, 'register_spa_rewrite_rules']);
|
add_action('init', [__CLASS__, 'register_spa_rewrite_rules']);
|
||||||
|
|
||||||
// Flush rewrite rules when relevant settings change
|
// Flush rewrite rules when relevant settings change
|
||||||
add_action('update_option_woonoow_appearance_settings', function($old_value, $new_value) {
|
add_action('update_option_woonoow_appearance_settings', function ($old_value, $new_value) {
|
||||||
$old_general = $old_value['general'] ?? [];
|
$old_general = $old_value['general'] ?? [];
|
||||||
$new_general = $new_value['general'] ?? [];
|
$new_general = $new_value['general'] ?? [];
|
||||||
|
|
||||||
@@ -130,12 +131,17 @@ class TemplateOverride
|
|||||||
'top'
|
'top'
|
||||||
);
|
);
|
||||||
|
|
||||||
// /checkout → SPA page
|
// /checkout, /checkout/* → SPA page
|
||||||
add_rewrite_rule(
|
add_rewrite_rule(
|
||||||
'^checkout/?$',
|
'^checkout/?$',
|
||||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout',
|
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout',
|
||||||
'top'
|
'top'
|
||||||
);
|
);
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^checkout/(.*)$',
|
||||||
|
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout/$matches[1]',
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
|
||||||
// /my-account, /my-account/* → SPA page
|
// /my-account, /my-account/* → SPA page
|
||||||
add_rewrite_rule(
|
add_rewrite_rule(
|
||||||
@@ -165,6 +171,24 @@ class TemplateOverride
|
|||||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
|
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
|
||||||
'top'
|
'top'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// /order-pay/* → SPA page
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^order-pay/(.*)$',
|
||||||
|
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=order-pay/$matches[1]',
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
|
||||||
|
// /order-pay/* → SPA page
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^order-pay/(.*)$',
|
||||||
|
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=order-pay/$matches[1]',
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
|
||||||
|
// /order-pay/* → SPA page (moved to checkout/pay/ in new structure)
|
||||||
|
// Removed direct order-pay rule to favor checkout subpath
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Rewrite /slug to serve the SPA page (base URL)
|
// Rewrite /slug to serve the SPA page (base URL)
|
||||||
add_rewrite_rule(
|
add_rewrite_rule(
|
||||||
@@ -183,7 +207,7 @@ class TemplateOverride
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register query var for the SPA path
|
// Register query var for the SPA path
|
||||||
add_filter('query_vars', function($vars) {
|
add_filter('query_vars', function ($vars) {
|
||||||
$vars[] = 'woonoow_spa_path';
|
$vars[] = 'woonoow_spa_path';
|
||||||
return $vars;
|
return $vars;
|
||||||
});
|
});
|
||||||
@@ -274,7 +298,7 @@ class TemplateOverride
|
|||||||
$spa_url = trailingslashit(get_permalink($spa_page_id));
|
$spa_url = trailingslashit(get_permalink($spa_page_id));
|
||||||
|
|
||||||
// Helper function to build route URL based on router type
|
// Helper function to build route URL based on router type
|
||||||
$build_route = function($path) use ($spa_url, $use_browser_router) {
|
$build_route = function ($path) use ($spa_url, $use_browser_router) {
|
||||||
if ($use_browser_router) {
|
if ($use_browser_router) {
|
||||||
// Path format: /store/cart
|
// Path format: /store/cart
|
||||||
return $spa_url . ltrim($path, '/');
|
return $spa_url . ltrim($path, '/');
|
||||||
@@ -305,6 +329,14 @@ class TemplateOverride
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (is_checkout() && !is_order_received_page()) {
|
if (is_checkout() && !is_order_received_page()) {
|
||||||
|
// Check for order-pay endpoint
|
||||||
|
if (is_wc_endpoint_url('order-pay')) {
|
||||||
|
global $wp;
|
||||||
|
$order_id = $wp->query_vars['order-pay'];
|
||||||
|
wp_redirect($build_route('order-pay/' . $order_id), 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
wp_redirect($build_route('checkout'), 302);
|
wp_redirect($build_route('checkout'), 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -385,7 +417,9 @@ class TemplateOverride
|
|||||||
'/my-account', // Account page
|
'/my-account', // Account page
|
||||||
'/login', // Login page
|
'/login', // Login page
|
||||||
'/register', // Register page
|
'/register', // Register page
|
||||||
|
'/register', // Register page
|
||||||
'/reset-password', // Password reset
|
'/reset-password', // Password reset
|
||||||
|
'/order-pay', // Order pay page
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check for exact matches or path prefixes
|
// Check for exact matches or path prefixes
|
||||||
@@ -397,7 +431,7 @@ class TemplateOverride
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check path prefixes (for sub-routes)
|
// Check path prefixes (for sub-routes)
|
||||||
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
|
$prefix_routes = ['/shop/', '/my-account/', '/product/', '/checkout/'];
|
||||||
foreach ($prefix_routes as $prefix) {
|
foreach ($prefix_routes as $prefix) {
|
||||||
if (strpos($path, $prefix) === 0) {
|
if (strpos($path, $prefix) === 0) {
|
||||||
$should_serve_spa = true;
|
$should_serve_spa = true;
|
||||||
@@ -848,60 +882,112 @@ class TemplateOverride
|
|||||||
// Try to get Yoast/Rank Math SEO data
|
// Try to get Yoast/Rank Math SEO data
|
||||||
if ($type === 'page') {
|
if ($type === 'page') {
|
||||||
$seo_title = get_post_meta($page_id, '_yoast_wpseo_title', true) ?:
|
$seo_title = get_post_meta($page_id, '_yoast_wpseo_title', true) ?:
|
||||||
get_post_meta($page_id, 'rank_math_title', true) ?: $seo_title;
|
get_post_meta($page_id, 'rank_math_title', true) ?: $seo_title;
|
||||||
$seo_description = get_post_meta($page_id, '_yoast_wpseo_metadesc', true) ?:
|
$seo_description = get_post_meta($page_id, '_yoast_wpseo_metadesc', true) ?:
|
||||||
get_post_meta($page_id, 'rank_math_description', true) ?: '';
|
get_post_meta($page_id, 'rank_math_description', true) ?: '';
|
||||||
} elseif ($post_obj) {
|
} elseif ($post_obj) {
|
||||||
$seo_title = get_post_meta($post_obj->ID, '_yoast_wpseo_title', true) ?:
|
$seo_title = get_post_meta($post_obj->ID, '_yoast_wpseo_title', true) ?:
|
||||||
get_post_meta($post_obj->ID, 'rank_math_title', true) ?: $seo_title;
|
get_post_meta($post_obj->ID, 'rank_math_title', true) ?: $seo_title;
|
||||||
$seo_description = get_post_meta($post_obj->ID, '_yoast_wpseo_metadesc', true) ?:
|
$seo_description = get_post_meta($post_obj->ID, '_yoast_wpseo_metadesc', true) ?:
|
||||||
get_post_meta($post_obj->ID, 'rank_math_description', true) ?:
|
get_post_meta($post_obj->ID, 'rank_math_description', true) ?:
|
||||||
wp_trim_words(wp_strip_all_tags($post_obj->post_content), 30);
|
wp_trim_words(wp_strip_all_tags($post_obj->post_content), 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output SSR HTML - start output buffering for caching
|
// Output SSR HTML - start output buffering for caching
|
||||||
ob_start();
|
ob_start();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html <?php language_attributes(); ?>>
|
<html <?php language_attributes(); ?>>
|
||||||
<head>
|
|
||||||
<meta charset="<?php bloginfo('charset'); ?>">
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta charset="<?php bloginfo('charset'); ?>">
|
||||||
<title><?php echo esc_html($seo_title); ?></title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<?php if ($seo_description): ?>
|
<title><?php echo esc_html($seo_title); ?></title>
|
||||||
<meta name="description" content="<?php echo esc_attr($seo_description); ?>">
|
<?php if ($seo_description): ?>
|
||||||
<?php endif; ?>
|
<meta name="description" content="<?php echo esc_attr($seo_description); ?>">
|
||||||
<link rel="canonical" href="<?php echo esc_url(get_permalink($post_obj ?: $page_id)); ?>">
|
<?php endif; ?>
|
||||||
<meta property="og:title" content="<?php echo esc_attr($seo_title); ?>">
|
<link rel="canonical" href="<?php echo esc_url(get_permalink($post_obj ?: $page_id)); ?>">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:title" content="<?php echo esc_attr($seo_title); ?>">
|
||||||
<meta property="og:url" content="<?php echo esc_url(get_permalink($post_obj ?: $page_id)); ?>">
|
<meta property="og:type" content="website">
|
||||||
<?php if ($seo_description): ?>
|
<meta property="og:url" content="<?php echo esc_url(get_permalink($post_obj ?: $page_id)); ?>">
|
||||||
<meta property="og:description" content="<?php echo esc_attr($seo_description); ?>">
|
<?php if ($seo_description): ?>
|
||||||
<?php endif; ?>
|
<meta property="og:description" content="<?php echo esc_attr($seo_description); ?>">
|
||||||
<style>
|
<?php endif; ?>
|
||||||
/* Minimal SSR styles for bots */
|
<style>
|
||||||
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; margin: 0; padding: 0; }
|
/* Minimal SSR styles for bots */
|
||||||
.wn-ssr { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
body {
|
||||||
.wn-section { padding: 40px 0; }
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
.wn-section h1, .wn-section h2 { margin-bottom: 16px; }
|
line-height: 1.6;
|
||||||
.wn-section p { margin-bottom: 12px; }
|
margin: 0;
|
||||||
.wn-section img { max-width: 100%; height: auto; }
|
padding: 0;
|
||||||
.wn-hero { background: #f5f5f5; padding: 60px 20px; text-align: center; }
|
}
|
||||||
.wn-cta-banner { background: #4f46e5; color: white; padding: 40px 20px; text-align: center; }
|
|
||||||
.wn-cta-banner a { color: white; text-decoration: underline; }
|
.wn-ssr {
|
||||||
.wn-feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 24px; }
|
max-width: 1200px;
|
||||||
.wn-feature-item { padding: 20px; border: 1px solid #e5e5e5; border-radius: 8px; }
|
margin: 0 auto;
|
||||||
</style>
|
padding: 20px;
|
||||||
<?php wp_head(); ?>
|
}
|
||||||
</head>
|
|
||||||
<body <?php body_class('wn-ssr-page'); ?>>
|
.wn-section {
|
||||||
<div class="wn-ssr">
|
padding: 40px 0;
|
||||||
<?php echo $html; ?>
|
}
|
||||||
</div>
|
|
||||||
<?php wp_footer(); ?>
|
.wn-section h1,
|
||||||
</body>
|
.wn-section h2 {
|
||||||
</html>
|
margin-bottom: 16px;
|
||||||
<?php
|
}
|
||||||
|
|
||||||
|
.wn-section p {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wn-section img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wn-hero {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wn-cta-banner {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wn-cta-banner a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wn-feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wn-feature-item {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<?php wp_head(); ?>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body <?php body_class('wn-ssr-page'); ?>>
|
||||||
|
<div class="wn-ssr">
|
||||||
|
<?php echo $html; ?>
|
||||||
|
</div>
|
||||||
|
<?php wp_footer(); ?>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
// Get buffered output
|
// Get buffered output
|
||||||
$output = ob_get_clean();
|
$output = ob_get_clean();
|
||||||
|
|
||||||
@@ -946,4 +1032,3 @@ class TemplateOverride
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -323,6 +323,12 @@ class LicenseManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check subscription status if linked
|
||||||
|
$subscription_status = self::get_order_subscription_status($license['order_id']);
|
||||||
|
if ($subscription_status !== null && !in_array($subscription_status, ['active', 'pending-cancel'])) {
|
||||||
|
return new \WP_Error('subscription_inactive', __('Subscription is not active', 'woonoow'));
|
||||||
|
}
|
||||||
|
|
||||||
// Check activation limit
|
// Check activation limit
|
||||||
if ($license['activation_limit'] > 0 && $license['activation_count'] >= $license['activation_limit']) {
|
if ($license['activation_limit'] > 0 && $license['activation_count'] >= $license['activation_limit']) {
|
||||||
return new \WP_Error('activation_limit_reached', __('Activation limit reached', 'woonoow'));
|
return new \WP_Error('activation_limit_reached', __('Activation limit reached', 'woonoow'));
|
||||||
@@ -433,8 +439,12 @@ class LicenseManager {
|
|||||||
|
|
||||||
$is_expired = $license['expires_at'] && strtotime($license['expires_at']) < time();
|
$is_expired = $license['expires_at'] && strtotime($license['expires_at']) < time();
|
||||||
|
|
||||||
|
// Check subscription status if linked
|
||||||
|
$subscription_status = self::get_order_subscription_status($license['order_id']);
|
||||||
|
$is_subscription_valid = $subscription_status === null || in_array($subscription_status, ['active', 'pending-cancel']);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'valid' => $license['status'] === 'active' && !$is_expired,
|
'valid' => $license['status'] === 'active' && !$is_expired && $is_subscription_valid,
|
||||||
'license_key' => $license['license_key'],
|
'license_key' => $license['license_key'],
|
||||||
'status' => $license['status'],
|
'status' => $license['status'],
|
||||||
'activation_limit' => (int) $license['activation_limit'],
|
'activation_limit' => (int) $license['activation_limit'],
|
||||||
@@ -444,9 +454,52 @@ class LicenseManager {
|
|||||||
: -1,
|
: -1,
|
||||||
'expires_at' => $license['expires_at'],
|
'expires_at' => $license['expires_at'],
|
||||||
'is_expired' => $is_expired,
|
'is_expired' => $is_expired,
|
||||||
|
'subscription_status' => $subscription_status,
|
||||||
|
'subscription_active' => $is_subscription_valid,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an order has a linked subscription and return its status
|
||||||
|
*
|
||||||
|
* @param int $order_id
|
||||||
|
* @return string|null Subscription status or null if no subscription
|
||||||
|
*/
|
||||||
|
public static function get_order_subscription_status($order_id) {
|
||||||
|
// Check if subscription module is enabled
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||||
|
|
||||||
|
// Check if table exists
|
||||||
|
$table_exists = $wpdb->get_var("SHOW TABLES LIKE '$table'");
|
||||||
|
if (!$table_exists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find subscription linked to this order
|
||||||
|
$subscription_id = $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT subscription_id FROM $table WHERE order_id = %d LIMIT 1",
|
||||||
|
$order_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!$subscription_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subscription status
|
||||||
|
$subscriptions_table = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
$status = $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT status FROM $subscriptions_table WHERE id = %d",
|
||||||
|
$subscription_id
|
||||||
|
));
|
||||||
|
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke license
|
* Revoke license
|
||||||
*/
|
*/
|
||||||
|
|||||||
893
includes/Modules/Subscription/SubscriptionManager.php
Normal file
893
includes/Modules/Subscription/SubscriptionManager.php
Normal file
@@ -0,0 +1,893 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription Manager
|
||||||
|
*
|
||||||
|
* Core business logic for subscription management
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules\Subscription
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules\Subscription;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
use WooNooW\Core\ModuleRegistry;
|
||||||
|
|
||||||
|
class SubscriptionManager
|
||||||
|
{
|
||||||
|
|
||||||
|
/** @var string Subscriptions table name */
|
||||||
|
private static $table_subscriptions;
|
||||||
|
|
||||||
|
/** @var string Subscription orders table name */
|
||||||
|
private static $table_subscription_orders;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the manager
|
||||||
|
*/
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
self::$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
self::$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create database tables
|
||||||
|
*/
|
||||||
|
public static function create_tables()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$charset_collate = $wpdb->get_charset_collate();
|
||||||
|
$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||||
|
|
||||||
|
$sql_subscriptions = "CREATE TABLE $table_subscriptions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
variation_id BIGINT UNSIGNED DEFAULT NULL,
|
||||||
|
status ENUM('pending', 'active', 'on-hold', 'cancelled', 'expired', 'pending-cancel') DEFAULT 'pending',
|
||||||
|
billing_period ENUM('day', 'week', 'month', 'year') NOT NULL,
|
||||||
|
billing_interval INT UNSIGNED DEFAULT 1,
|
||||||
|
recurring_amount DECIMAL(12,4) DEFAULT 0,
|
||||||
|
start_date DATETIME NOT NULL,
|
||||||
|
trial_end_date DATETIME DEFAULT NULL,
|
||||||
|
next_payment_date DATETIME DEFAULT NULL,
|
||||||
|
end_date DATETIME DEFAULT NULL,
|
||||||
|
last_payment_date DATETIME DEFAULT NULL,
|
||||||
|
payment_method VARCHAR(100) DEFAULT NULL,
|
||||||
|
payment_meta LONGTEXT,
|
||||||
|
cancel_reason TEXT DEFAULT NULL,
|
||||||
|
pause_count INT UNSIGNED DEFAULT 0,
|
||||||
|
failed_payment_count INT UNSIGNED DEFAULT 0,
|
||||||
|
reminder_sent_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_order_id (order_id),
|
||||||
|
INDEX idx_product_id (product_id),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_next_payment (next_payment_date)
|
||||||
|
) $charset_collate;";
|
||||||
|
|
||||||
|
$sql_orders = "CREATE TABLE $table_subscription_orders (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
subscription_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
order_type ENUM('parent', 'renewal', 'switch', 'resubscribe') DEFAULT 'renewal',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_subscription (subscription_id),
|
||||||
|
INDEX idx_order (order_id)
|
||||||
|
) $charset_collate;";
|
||||||
|
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
dbDelta($sql_subscriptions);
|
||||||
|
dbDelta($sql_orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create subscription from order item
|
||||||
|
*
|
||||||
|
* @param \WC_Order $order
|
||||||
|
* @param \WC_Order_Item_Product $item
|
||||||
|
* @return int|false Subscription ID or false on failure
|
||||||
|
*/
|
||||||
|
public static function create_from_order($order, $item)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$product_id = $item->get_product_id();
|
||||||
|
$variation_id = $item->get_variation_id();
|
||||||
|
$user_id = $order->get_user_id();
|
||||||
|
|
||||||
|
if (!$user_id) {
|
||||||
|
// Guest orders not supported for subscriptions
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subscription settings from product
|
||||||
|
$billing_period = get_post_meta($product_id, '_woonoow_subscription_period', true) ?: 'month';
|
||||||
|
$billing_interval = absint(get_post_meta($product_id, '_woonoow_subscription_interval', true)) ?: 1;
|
||||||
|
$trial_days = absint(get_post_meta($product_id, '_woonoow_subscription_trial_days', true));
|
||||||
|
$subscription_length = absint(get_post_meta($product_id, '_woonoow_subscription_length', true));
|
||||||
|
|
||||||
|
// Calculate dates
|
||||||
|
$now = current_time('mysql');
|
||||||
|
$start_date = $now;
|
||||||
|
$trial_end_date = null;
|
||||||
|
|
||||||
|
if ($trial_days > 0) {
|
||||||
|
$trial_end_date = date('Y-m-d H:i:s', strtotime($now . " + $trial_days days"));
|
||||||
|
$next_payment_date = $trial_end_date;
|
||||||
|
} else {
|
||||||
|
$next_payment_date = self::calculate_next_payment_date($now, $billing_period, $billing_interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate end date if subscription has fixed length
|
||||||
|
$end_date = null;
|
||||||
|
if ($subscription_length > 0) {
|
||||||
|
$end_date = self::calculate_end_date($start_date, $billing_period, $billing_interval, $subscription_length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recurring amount (product price)
|
||||||
|
$product = $item->get_product();
|
||||||
|
$recurring_amount = $product ? $product->get_price() : $item->get_total();
|
||||||
|
|
||||||
|
// Get payment method
|
||||||
|
$payment_method = $order->get_payment_method();
|
||||||
|
$payment_meta = json_encode([
|
||||||
|
'method_title' => $order->get_payment_method_title(),
|
||||||
|
'customer_id' => $order->get_customer_id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Insert subscription
|
||||||
|
$inserted = $wpdb->insert(
|
||||||
|
self::$table_subscriptions,
|
||||||
|
[
|
||||||
|
'user_id' => $user_id,
|
||||||
|
'order_id' => $order->get_id(),
|
||||||
|
'product_id' => $product_id,
|
||||||
|
'variation_id' => $variation_id ?: null,
|
||||||
|
'status' => 'active',
|
||||||
|
'billing_period' => $billing_period,
|
||||||
|
'billing_interval' => $billing_interval,
|
||||||
|
'recurring_amount' => $recurring_amount,
|
||||||
|
'start_date' => $start_date,
|
||||||
|
'trial_end_date' => $trial_end_date,
|
||||||
|
'next_payment_date' => $next_payment_date,
|
||||||
|
'end_date' => $end_date,
|
||||||
|
'last_payment_date' => $now,
|
||||||
|
'payment_method' => $payment_method,
|
||||||
|
'payment_meta' => $payment_meta,
|
||||||
|
],
|
||||||
|
['%d', '%d', '%d', '%d', '%s', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$inserted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription_id = $wpdb->insert_id;
|
||||||
|
|
||||||
|
// Link parent order to subscription
|
||||||
|
$wpdb->insert(
|
||||||
|
self::$table_subscription_orders,
|
||||||
|
[
|
||||||
|
'subscription_id' => $subscription_id,
|
||||||
|
'order_id' => $order->get_id(),
|
||||||
|
'order_type' => 'parent',
|
||||||
|
],
|
||||||
|
['%d', '%d', '%s']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger action
|
||||||
|
do_action('woonoow/subscription/created', $subscription_id, $order, $item);
|
||||||
|
|
||||||
|
return $subscription_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscription by ID
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
* @return object|null
|
||||||
|
*/
|
||||||
|
public static function get($subscription_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
return $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM " . self::$table_subscriptions . " WHERE id = %d",
|
||||||
|
$subscription_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscription by related order ID (parent or renewal)
|
||||||
|
*
|
||||||
|
* @param int $order_id
|
||||||
|
* @return object|null
|
||||||
|
*/
|
||||||
|
public static function get_by_order_id($order_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||||
|
|
||||||
|
// Join subscriptions table to get full subscription data
|
||||||
|
return $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT s.*
|
||||||
|
FROM $table_subscriptions s
|
||||||
|
JOIN $table_subscription_orders so ON s.id = so.subscription_id
|
||||||
|
WHERE so.order_id = %d",
|
||||||
|
$order_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscriptions by user
|
||||||
|
*
|
||||||
|
* @param int $user_id
|
||||||
|
* @param array $args
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_by_user($user_id, $args = [])
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$defaults = [
|
||||||
|
'status' => null,
|
||||||
|
'limit' => 20,
|
||||||
|
'offset' => 0,
|
||||||
|
];
|
||||||
|
$args = wp_parse_args($args, $defaults);
|
||||||
|
|
||||||
|
$where = "WHERE user_id = %d";
|
||||||
|
$params = [$user_id];
|
||||||
|
|
||||||
|
if ($args['status']) {
|
||||||
|
$where .= " AND status = %s";
|
||||||
|
$params[] = $args['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = $wpdb->prepare(
|
||||||
|
"SELECT * FROM " . self::$table_subscriptions . " $where ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||||
|
array_merge($params, [$args['limit'], $args['offset']])
|
||||||
|
);
|
||||||
|
|
||||||
|
return $wpdb->get_results($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all subscriptions (admin)
|
||||||
|
*
|
||||||
|
* @param array $args
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_all($args = [])
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$defaults = [
|
||||||
|
'status' => null,
|
||||||
|
'product_id' => null,
|
||||||
|
'user_id' => null,
|
||||||
|
'limit' => 20,
|
||||||
|
'offset' => 0,
|
||||||
|
'search' => null,
|
||||||
|
];
|
||||||
|
$args = wp_parse_args($args, $defaults);
|
||||||
|
|
||||||
|
$where = "WHERE 1=1";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($args['status']) {
|
||||||
|
$where .= " AND status = %s";
|
||||||
|
$params[] = $args['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($args['product_id']) {
|
||||||
|
$where .= " AND product_id = %d";
|
||||||
|
$params[] = $args['product_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($args['user_id']) {
|
||||||
|
$where .= " AND user_id = %d";
|
||||||
|
$params[] = $args['user_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = "ORDER BY created_at DESC";
|
||||||
|
$limit = "LIMIT " . intval($args['limit']) . " OFFSET " . intval($args['offset']);
|
||||||
|
|
||||||
|
$sql = "SELECT * FROM " . self::$table_subscriptions . " $where $order $limit";
|
||||||
|
|
||||||
|
if (!empty($params)) {
|
||||||
|
$sql = $wpdb->prepare($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $wpdb->get_results($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count subscriptions
|
||||||
|
*
|
||||||
|
* @param array $args
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public static function count($args = [])
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$where = "WHERE 1=1";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($args['status'])) {
|
||||||
|
$where .= " AND status = %s";
|
||||||
|
$params[] = $args['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT COUNT(*) FROM " . self::$table_subscriptions . " $where";
|
||||||
|
|
||||||
|
if (!empty($params)) {
|
||||||
|
$sql = $wpdb->prepare($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $wpdb->get_var($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update subscription status
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
* @param string $status
|
||||||
|
* @param string|null $reason
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function update_status($subscription_id, $status, $reason = null)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$data = ['status' => $status];
|
||||||
|
$format = ['%s'];
|
||||||
|
|
||||||
|
if ($reason !== null) {
|
||||||
|
$data['cancel_reason'] = $reason;
|
||||||
|
$format[] = '%s';
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $wpdb->update(
|
||||||
|
self::$table_subscriptions,
|
||||||
|
$data,
|
||||||
|
['id' => $subscription_id],
|
||||||
|
$format,
|
||||||
|
['%d']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($updated !== false) {
|
||||||
|
do_action('woonoow/subscription/status_changed', $subscription_id, $status, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updated !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel subscription
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
* @param string $reason
|
||||||
|
* @param bool $immediate Force immediate cancellation
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function cancel($subscription_id, $reason = '', $immediate = false)
|
||||||
|
{
|
||||||
|
$subscription = self::get($subscription_id);
|
||||||
|
if (!$subscription || in_array($subscription->status, ['cancelled', 'expired'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to pending-cancel if there's time left
|
||||||
|
$new_status = 'cancelled';
|
||||||
|
$now = current_time('mysql');
|
||||||
|
|
||||||
|
if (!$immediate && $subscription->next_payment_date && $subscription->next_payment_date > $now) {
|
||||||
|
$new_status = 'pending-cancel';
|
||||||
|
}
|
||||||
|
|
||||||
|
$success = self::update_status($subscription_id, $new_status, $reason);
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
if ($new_status === 'pending-cancel') {
|
||||||
|
do_action('woonoow/subscription/pending_cancel', $subscription_id, $reason);
|
||||||
|
} else {
|
||||||
|
do_action('woonoow/subscription/cancelled', $subscription_id, $reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause subscription
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function pause($subscription_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$subscription = self::get($subscription_id);
|
||||||
|
if (!$subscription || $subscription->status !== 'active') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max pause count
|
||||||
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
|
$max_pause = $settings['max_pause_count'] ?? 3;
|
||||||
|
|
||||||
|
if ($max_pause > 0 && $subscription->pause_count >= $max_pause) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $wpdb->update(
|
||||||
|
self::$table_subscriptions,
|
||||||
|
[
|
||||||
|
'status' => 'on-hold',
|
||||||
|
'pause_count' => $subscription->pause_count + 1,
|
||||||
|
],
|
||||||
|
['id' => $subscription_id],
|
||||||
|
['%s', '%d'],
|
||||||
|
['%d']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($updated !== false) {
|
||||||
|
do_action('woonoow/subscription/paused', $subscription_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updated !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume subscription
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function resume($subscription_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$subscription = self::get($subscription_id);
|
||||||
|
if (!$subscription || !in_array($subscription->status, ['on-hold', 'pending-cancel'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$update_data = ['status' => 'active'];
|
||||||
|
$format = ['%s'];
|
||||||
|
|
||||||
|
// Only recalculate payment date if resuming from on-hold
|
||||||
|
if ($subscription->status === 'on-hold') {
|
||||||
|
// Recalculate next payment date from now
|
||||||
|
$next_payment = self::calculate_next_payment_date(
|
||||||
|
current_time('mysql'),
|
||||||
|
$subscription->billing_period,
|
||||||
|
$subscription->billing_interval
|
||||||
|
);
|
||||||
|
$update_data['next_payment_date'] = $next_payment;
|
||||||
|
$format[] = '%s';
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $wpdb->update(
|
||||||
|
self::$table_subscriptions,
|
||||||
|
$update_data,
|
||||||
|
['id' => $subscription_id],
|
||||||
|
$format,
|
||||||
|
['%d']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($updated !== false) {
|
||||||
|
do_action('woonoow/subscription/resumed', $subscription_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updated !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process renewal for a subscription
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Process renewal for a subscription
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function renew($subscription_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$subscription = self::get($subscription_id);
|
||||||
|
if (!$subscription || !in_array($subscription->status, ['active', 'on-hold'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing pending renewal order to prevent duplicates
|
||||||
|
$existing_pending = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT so.order_id FROM " . self::$table_subscription_orders . " so
|
||||||
|
JOIN {$wpdb->posts} p ON so.order_id = p.ID
|
||||||
|
WHERE so.subscription_id = %d
|
||||||
|
AND so.order_type = 'renewal'
|
||||||
|
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')",
|
||||||
|
$subscription_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($existing_pending) {
|
||||||
|
return ['success' => true, 'order_id' => (int) $existing_pending->order_id, 'status' => 'existing'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create renewal order
|
||||||
|
$renewal_order = self::create_renewal_order($subscription);
|
||||||
|
if (!$renewal_order) {
|
||||||
|
// Failed to create order
|
||||||
|
self::handle_renewal_failure($subscription_id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process payment
|
||||||
|
// Result can be: true (paid), false (failed), or 'manual' (waiting for payment)
|
||||||
|
$payment_result = self::process_renewal_payment($subscription, $renewal_order);
|
||||||
|
|
||||||
|
if ($payment_result === true) {
|
||||||
|
self::handle_renewal_success($subscription_id, $renewal_order);
|
||||||
|
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'complete'];
|
||||||
|
} elseif ($payment_result === 'manual') {
|
||||||
|
// Manual payment required
|
||||||
|
|
||||||
|
// CHECK: Is this an early renewal? (Next payment date is in future)
|
||||||
|
$now = current_time('mysql');
|
||||||
|
$is_early_renewal = $subscription->next_payment_date && $subscription->next_payment_date > $now;
|
||||||
|
|
||||||
|
if ($is_early_renewal) {
|
||||||
|
// Early renewal: Keep active, just waiting for payment
|
||||||
|
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'manual'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal/Overdue renewal: Set to on-hold
|
||||||
|
self::update_status($subscription_id, 'on-hold', 'awaiting_payment');
|
||||||
|
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'manual'];
|
||||||
|
} else {
|
||||||
|
// Auto-debit failed
|
||||||
|
self::handle_renewal_failure($subscription_id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a renewal order
|
||||||
|
*
|
||||||
|
* @param object $subscription
|
||||||
|
* @return \WC_Order|false
|
||||||
|
*/
|
||||||
|
private static function create_renewal_order($subscription)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// Get original order
|
||||||
|
$parent_order = wc_get_order($subscription->order_id);
|
||||||
|
if (!$parent_order) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new order
|
||||||
|
$renewal_order = wc_create_order([
|
||||||
|
'customer_id' => $subscription->user_id,
|
||||||
|
'status' => 'pending',
|
||||||
|
'parent' => $subscription->order_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($renewal_order)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add product
|
||||||
|
$product = wc_get_product($subscription->variation_id ?: $subscription->product_id);
|
||||||
|
if ($product) {
|
||||||
|
$renewal_order->add_product($product, 1, [
|
||||||
|
'total' => $subscription->recurring_amount,
|
||||||
|
'subtotal' => $subscription->recurring_amount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy billing/shipping from parent
|
||||||
|
$renewal_order->set_address($parent_order->get_address('billing'), 'billing');
|
||||||
|
$renewal_order->set_address($parent_order->get_address('shipping'), 'shipping');
|
||||||
|
$renewal_order->set_payment_method($subscription->payment_method);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
$renewal_order->calculate_totals();
|
||||||
|
$renewal_order->save();
|
||||||
|
|
||||||
|
// Link to subscription
|
||||||
|
$wpdb->insert(
|
||||||
|
self::$table_subscription_orders,
|
||||||
|
[
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'order_id' => $renewal_order->get_id(),
|
||||||
|
'order_type' => 'renewal',
|
||||||
|
],
|
||||||
|
['%d', '%d', '%s']
|
||||||
|
);
|
||||||
|
|
||||||
|
return $renewal_order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process payment for renewal order
|
||||||
|
*
|
||||||
|
* @param object $subscription
|
||||||
|
* @param \WC_Order $order
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Process payment for renewal order
|
||||||
|
*
|
||||||
|
* @param object $subscription
|
||||||
|
* @param \WC_Order $order
|
||||||
|
* @return bool|string True if paid, false if failed, 'manual' if waiting
|
||||||
|
*/
|
||||||
|
private static function process_renewal_payment($subscription, $order)
|
||||||
|
{
|
||||||
|
// Allow plugins to override payment processing completely
|
||||||
|
// Return true/false/'manual' to bypass default logic
|
||||||
|
$pre = apply_filters('woonoow_pre_process_subscription_payment', null, $subscription, $order);
|
||||||
|
if ($pre !== null) {
|
||||||
|
return $pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get payment gateway
|
||||||
|
$gateways = WC()->payment_gateways()->get_available_payment_gateways();
|
||||||
|
$gateway_id = $subscription->payment_method;
|
||||||
|
|
||||||
|
if (!isset($gateways[$gateway_id])) {
|
||||||
|
// Payment method not available - treat as failure so user can fix
|
||||||
|
$order->update_status('failed', __('Payment method not available for renewal', 'woonoow'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateway = $gateways[$gateway_id];
|
||||||
|
|
||||||
|
// 1. Try Auto-Debit if supported
|
||||||
|
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
|
||||||
|
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
|
||||||
|
if (!is_wp_error($result) && $result) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// If explicit failure from auto-debit, return false (will trigger retry logic)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Allow other plugins to handle auto-debit via filter (e.g. Stripe/PayPal adapters)
|
||||||
|
$external_result = apply_filters('woonoow_process_subscription_payment', null, $gateway, $order, $subscription);
|
||||||
|
if ($external_result !== null) {
|
||||||
|
return $external_result ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback: Manual Payment
|
||||||
|
// Set order to pending-payment
|
||||||
|
$order->update_status('pending', __('Awaiting manual renewal payment', 'woonoow'));
|
||||||
|
|
||||||
|
// Send renewal payment email to customer
|
||||||
|
do_action('woonoow/subscription/renewal_payment_due', $subscription->id, $order);
|
||||||
|
|
||||||
|
return 'manual'; // Return special status
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful renewal
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
* @param \WC_Order $order
|
||||||
|
*/
|
||||||
|
public static function handle_renewal_success($subscription_id, $order)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$subscription = self::get($subscription_id);
|
||||||
|
|
||||||
|
// Calculate next payment date
|
||||||
|
// For early renewal, start from the current next_payment_date if it's in the future
|
||||||
|
// Otherwise start from now (for expired/overdue subscriptions)
|
||||||
|
$now = current_time('mysql');
|
||||||
|
$base_date = $now;
|
||||||
|
|
||||||
|
if ($subscription->next_payment_date && $subscription->next_payment_date > $now) {
|
||||||
|
$base_date = $subscription->next_payment_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
$next_payment = self::calculate_next_payment_date(
|
||||||
|
$base_date,
|
||||||
|
$subscription->billing_period,
|
||||||
|
$subscription->billing_interval
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if subscription should end
|
||||||
|
if ($subscription->end_date && strtotime($next_payment) > strtotime($subscription->end_date)) {
|
||||||
|
$next_payment = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wpdb->update(
|
||||||
|
self::$table_subscriptions,
|
||||||
|
[
|
||||||
|
'status' => 'active',
|
||||||
|
'next_payment_date' => $next_payment,
|
||||||
|
'last_payment_date' => current_time('mysql'),
|
||||||
|
'failed_payment_count' => 0,
|
||||||
|
],
|
||||||
|
['id' => $subscription_id],
|
||||||
|
['%s', '%s', '%s', '%d'],
|
||||||
|
['%d']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Complete the order
|
||||||
|
$order->payment_complete();
|
||||||
|
|
||||||
|
do_action('woonoow/subscription/renewed', $subscription_id, $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle failed renewal
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
*/
|
||||||
|
private static function handle_renewal_failure($subscription_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$subscription = self::get($subscription_id);
|
||||||
|
$new_failed_count = $subscription->failed_payment_count + 1;
|
||||||
|
|
||||||
|
// Get settings
|
||||||
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
|
$max_attempts = $settings['expire_after_failed_attempts'] ?? 3;
|
||||||
|
|
||||||
|
if ($new_failed_count >= $max_attempts) {
|
||||||
|
// Mark as expired
|
||||||
|
$wpdb->update(
|
||||||
|
self::$table_subscriptions,
|
||||||
|
[
|
||||||
|
'status' => 'expired',
|
||||||
|
'failed_payment_count' => $new_failed_count,
|
||||||
|
],
|
||||||
|
['id' => $subscription_id],
|
||||||
|
['%s', '%d'],
|
||||||
|
['%d']
|
||||||
|
);
|
||||||
|
|
||||||
|
do_action('woonoow/subscription/expired', $subscription_id, 'payment_failed');
|
||||||
|
} else {
|
||||||
|
// Just increment failed count
|
||||||
|
$wpdb->update(
|
||||||
|
self::$table_subscriptions,
|
||||||
|
['failed_payment_count' => $new_failed_count],
|
||||||
|
['id' => $subscription_id],
|
||||||
|
['%d'],
|
||||||
|
['%d']
|
||||||
|
);
|
||||||
|
|
||||||
|
do_action('woonoow/subscription/renewal_failed', $subscription_id, $new_failed_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate next payment date
|
||||||
|
*
|
||||||
|
* @param string $from_date
|
||||||
|
* @param string $period
|
||||||
|
* @param int $interval
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function calculate_next_payment_date($from_date, $period, $interval = 1)
|
||||||
|
{
|
||||||
|
$interval = max(1, $interval);
|
||||||
|
|
||||||
|
switch ($period) {
|
||||||
|
case 'day':
|
||||||
|
$modifier = "+ $interval days";
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
$modifier = "+ $interval weeks";
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
$modifier = "+ $interval months";
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
$modifier = "+ $interval years";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$modifier = "+ 1 month";
|
||||||
|
}
|
||||||
|
|
||||||
|
return date('Y-m-d H:i:s', strtotime($from_date . ' ' . $modifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate subscription end date
|
||||||
|
*
|
||||||
|
* @param string $start_date
|
||||||
|
* @param string $period
|
||||||
|
* @param int $interval
|
||||||
|
* @param int $length
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function calculate_end_date($start_date, $period, $interval, $length)
|
||||||
|
{
|
||||||
|
$total_periods = $interval * $length;
|
||||||
|
|
||||||
|
switch ($period) {
|
||||||
|
case 'day':
|
||||||
|
$modifier = "+ $total_periods days";
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
$modifier = "+ $total_periods weeks";
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
$modifier = "+ $total_periods months";
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
$modifier = "+ $total_periods years";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$modifier = "+ $total_periods months";
|
||||||
|
}
|
||||||
|
|
||||||
|
return date('Y-m-d H:i:s', strtotime($start_date . ' ' . $modifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscriptions due for renewal
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_due_renewals()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$now = current_time('mysql');
|
||||||
|
|
||||||
|
return $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT * FROM " . self::$table_subscriptions . "
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND next_payment_date IS NOT NULL
|
||||||
|
AND next_payment_date <= %s
|
||||||
|
ORDER BY next_payment_date ASC",
|
||||||
|
$now
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscription orders
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_orders($subscription_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
return $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT so.*, p.post_status as order_status
|
||||||
|
FROM " . self::$table_subscription_orders . " so
|
||||||
|
LEFT JOIN {$wpdb->posts} p ON so.order_id = p.ID
|
||||||
|
WHERE so.subscription_id = %d
|
||||||
|
ORDER BY so.created_at DESC",
|
||||||
|
$subscription_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
563
includes/Modules/Subscription/SubscriptionModule.php
Normal file
563
includes/Modules/Subscription/SubscriptionModule.php
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription Module Bootstrap
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules\Subscription
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules\Subscription;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
use WooNooW\Core\ModuleRegistry;
|
||||||
|
use WooNooW\Modules\SubscriptionSettings;
|
||||||
|
|
||||||
|
class SubscriptionModule
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the subscription module
|
||||||
|
*/
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
|
// Register settings schema
|
||||||
|
SubscriptionSettings::init();
|
||||||
|
|
||||||
|
// Initialize manager immediately since we're already in plugins_loaded
|
||||||
|
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_subscription_fields']);
|
||||||
|
add_action('woocommerce_process_product_meta', [__CLASS__, 'save_product_subscription_fields']);
|
||||||
|
|
||||||
|
// Hook into order completion to create subscriptions
|
||||||
|
add_action('woocommerce_order_status_completed', [__CLASS__, 'maybe_create_subscription'], 10, 1);
|
||||||
|
add_action('woocommerce_order_status_processing', [__CLASS__, 'maybe_create_subscription'], 10, 1);
|
||||||
|
|
||||||
|
// Hook into order status change to handle manual renewal payments
|
||||||
|
add_action('woocommerce_order_status_changed', [__CLASS__, 'on_order_status_changed'], 10, 3);
|
||||||
|
|
||||||
|
// Modify add to cart button text for subscription products
|
||||||
|
add_filter('woocommerce_product_single_add_to_cart_text', [__CLASS__, 'subscription_add_to_cart_text'], 10, 2);
|
||||||
|
add_filter('woocommerce_product_add_to_cart_text', [__CLASS__, 'subscription_add_to_cart_text'], 10, 2);
|
||||||
|
|
||||||
|
// Register subscription notification events
|
||||||
|
add_filter('woonoow_notification_events_registry', [__CLASS__, 'register_notification_events']);
|
||||||
|
|
||||||
|
// Hook subscription lifecycle events to send notifications
|
||||||
|
add_action('woonoow/subscription/pending_cancel', [__CLASS__, 'on_pending_cancel'], 10, 2);
|
||||||
|
add_action('woonoow/subscription/cancelled', [__CLASS__, 'on_cancelled'], 10, 2);
|
||||||
|
add_action('woonoow/subscription/expired', [__CLASS__, 'on_expired'], 10, 2);
|
||||||
|
add_action('woonoow/subscription/paused', [__CLASS__, 'on_paused'], 10, 1);
|
||||||
|
add_action('woonoow/subscription/resumed', [__CLASS__, 'on_resumed'], 10, 1);
|
||||||
|
add_action('woonoow/subscription/renewal_failed', [__CLASS__, 'on_renewal_failed'], 10, 2);
|
||||||
|
add_action('woonoow/subscription/renewal_payment_due', [__CLASS__, 'on_renewal_payment_due'], 10, 2);
|
||||||
|
add_action('woonoow/subscription/renewal_reminder', [__CLASS__, 'on_renewal_reminder'], 10, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize manager if module is enabled
|
||||||
|
*/
|
||||||
|
public static function maybe_init_manager()
|
||||||
|
{
|
||||||
|
if (ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
// Ensure tables exist
|
||||||
|
self::ensure_tables();
|
||||||
|
SubscriptionManager::init();
|
||||||
|
SubscriptionScheduler::init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure database tables exist
|
||||||
|
*/
|
||||||
|
private static function ensure_tables()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
|
||||||
|
// Check if table exists
|
||||||
|
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
|
||||||
|
SubscriptionManager::create_tables();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle module enable
|
||||||
|
*/
|
||||||
|
public static function on_module_enabled($module_id)
|
||||||
|
{
|
||||||
|
if ($module_id === 'subscription') {
|
||||||
|
SubscriptionManager::create_tables();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add subscription fields to product edit page
|
||||||
|
*/
|
||||||
|
public static function add_product_subscription_fields()
|
||||||
|
{
|
||||||
|
global $post;
|
||||||
|
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<div class="options_group show_if_simple show_if_variable">';
|
||||||
|
|
||||||
|
woocommerce_wp_checkbox([
|
||||||
|
'id' => '_woonoow_subscription_enabled',
|
||||||
|
'label' => __('Enable Subscription', 'woonoow'),
|
||||||
|
'description' => __('Enable recurring subscription billing for this product', 'woonoow'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo '<div class="woonoow-subscription-options" style="display:none;">';
|
||||||
|
|
||||||
|
woocommerce_wp_select([
|
||||||
|
'id' => '_woonoow_subscription_period',
|
||||||
|
'label' => __('Billing Period', 'woonoow'),
|
||||||
|
'description' => __('How often to bill the customer', 'woonoow'),
|
||||||
|
'options' => [
|
||||||
|
'day' => __('Daily', 'woonoow'),
|
||||||
|
'week' => __('Weekly', 'woonoow'),
|
||||||
|
'month' => __('Monthly', 'woonoow'),
|
||||||
|
'year' => __('Yearly', 'woonoow'),
|
||||||
|
],
|
||||||
|
'value' => get_post_meta($post->ID, '_woonoow_subscription_period', true) ?: 'month',
|
||||||
|
]);
|
||||||
|
|
||||||
|
woocommerce_wp_text_input([
|
||||||
|
'id' => '_woonoow_subscription_interval',
|
||||||
|
'label' => __('Billing Interval', 'woonoow'),
|
||||||
|
'description' => __('Bill every X periods (e.g., 2 = every 2 months)', 'woonoow'),
|
||||||
|
'type' => 'number',
|
||||||
|
'value' => get_post_meta($post->ID, '_woonoow_subscription_interval', true) ?: 1,
|
||||||
|
'custom_attributes' => [
|
||||||
|
'min' => '1',
|
||||||
|
'max' => '365',
|
||||||
|
'step' => '1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
woocommerce_wp_text_input([
|
||||||
|
'id' => '_woonoow_subscription_trial_days',
|
||||||
|
'label' => __('Free Trial Days', 'woonoow'),
|
||||||
|
'description' => __('Number of free trial days before first billing (0 = no trial)', 'woonoow'),
|
||||||
|
'type' => 'number',
|
||||||
|
'value' => get_post_meta($post->ID, '_woonoow_subscription_trial_days', true) ?: 0,
|
||||||
|
'custom_attributes' => [
|
||||||
|
'min' => '0',
|
||||||
|
'step' => '1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
woocommerce_wp_text_input([
|
||||||
|
'id' => '_woonoow_subscription_signup_fee',
|
||||||
|
'label' => __('Sign-up Fee', 'woonoow') . ' (' . get_woocommerce_currency_symbol() . ')',
|
||||||
|
'description' => __('One-time fee charged on first subscription order', 'woonoow'),
|
||||||
|
'type' => 'text',
|
||||||
|
'value' => get_post_meta($post->ID, '_woonoow_subscription_signup_fee', true) ?: '',
|
||||||
|
'data_type' => 'price',
|
||||||
|
]);
|
||||||
|
|
||||||
|
woocommerce_wp_text_input([
|
||||||
|
'id' => '_woonoow_subscription_length',
|
||||||
|
'label' => __('Subscription Length', 'woonoow'),
|
||||||
|
'description' => __('Number of billing periods (0 = unlimited/until cancelled)', 'woonoow'),
|
||||||
|
'type' => 'number',
|
||||||
|
'value' => get_post_meta($post->ID, '_woonoow_subscription_length', true) ?: 0,
|
||||||
|
'custom_attributes' => [
|
||||||
|
'min' => '0',
|
||||||
|
'step' => '1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo '</div>'; // .woonoow-subscription-options
|
||||||
|
|
||||||
|
// Add inline script to show/hide options based on checkbox
|
||||||
|
?>
|
||||||
|
<script type="text/javascript">
|
||||||
|
jQuery(function($) {
|
||||||
|
function toggleSubscriptionOptions() {
|
||||||
|
if ($('#_woonoow_subscription_enabled').is(':checked')) {
|
||||||
|
$('.woonoow-subscription-options').show();
|
||||||
|
} else {
|
||||||
|
$('.woonoow-subscription-options').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toggleSubscriptionOptions();
|
||||||
|
$('#_woonoow_subscription_enabled').on('change', toggleSubscriptionOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
|
||||||
|
echo '</div>'; // .options_group
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save subscription fields
|
||||||
|
*/
|
||||||
|
public static function save_product_subscription_fields($post_id)
|
||||||
|
{
|
||||||
|
$subscription_enabled = isset($_POST['_woonoow_subscription_enabled']) ? 'yes' : 'no';
|
||||||
|
update_post_meta($post_id, '_woonoow_subscription_enabled', $subscription_enabled);
|
||||||
|
|
||||||
|
if (isset($_POST['_woonoow_subscription_period'])) {
|
||||||
|
update_post_meta($post_id, '_woonoow_subscription_period', sanitize_text_field($_POST['_woonoow_subscription_period']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_POST['_woonoow_subscription_interval'])) {
|
||||||
|
update_post_meta($post_id, '_woonoow_subscription_interval', absint($_POST['_woonoow_subscription_interval']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_POST['_woonoow_subscription_trial_days'])) {
|
||||||
|
update_post_meta($post_id, '_woonoow_subscription_trial_days', absint($_POST['_woonoow_subscription_trial_days']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_POST['_woonoow_subscription_signup_fee'])) {
|
||||||
|
update_post_meta($post_id, '_woonoow_subscription_signup_fee', wc_format_decimal($_POST['_woonoow_subscription_signup_fee']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_POST['_woonoow_subscription_length'])) {
|
||||||
|
update_post_meta($post_id, '_woonoow_subscription_length', absint($_POST['_woonoow_subscription_length']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maybe create subscription from completed order
|
||||||
|
*/
|
||||||
|
public static function maybe_create_subscription($order_id)
|
||||||
|
{
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = wc_get_order($order_id);
|
||||||
|
if (!$order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if subscription already created for this order
|
||||||
|
if ($order->get_meta('_woonoow_subscription_created')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($order->get_items() as $item) {
|
||||||
|
$product_id = $item->get_product_id();
|
||||||
|
$variation_id = $item->get_variation_id();
|
||||||
|
|
||||||
|
// Check if product has subscription enabled
|
||||||
|
if (get_post_meta($product_id, '_woonoow_subscription_enabled', true) !== 'yes') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subscription for this product
|
||||||
|
SubscriptionManager::create_from_order($order, $item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark order as processed
|
||||||
|
$order->update_meta_data('_woonoow_subscription_created', 'yes');
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify add to cart button text for subscription products
|
||||||
|
*/
|
||||||
|
public static function subscription_add_to_cart_text($text, $product)
|
||||||
|
{
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product_id = $product->get_id();
|
||||||
|
if (get_post_meta($product_id, '_woonoow_subscription_enabled', true) === 'yes') {
|
||||||
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
|
return $settings['button_text_subscribe'] ?? __('Subscribe Now', 'woonoow');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register subscription notification events
|
||||||
|
*
|
||||||
|
* @param array $events Existing events
|
||||||
|
* @return array Updated events
|
||||||
|
*/
|
||||||
|
public static function register_notification_events($events)
|
||||||
|
{
|
||||||
|
// Customer notifications
|
||||||
|
$events['subscription_pending_cancel'] = [
|
||||||
|
'id' => 'subscription_pending_cancel',
|
||||||
|
'label' => __('Subscription Pending Cancellation', 'woonoow'),
|
||||||
|
'description' => __('When a subscription is scheduled for cancellation at period end', 'woonoow'),
|
||||||
|
'category' => 'subscriptions',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => self::get_subscription_variables(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$events['subscription_cancelled'] = [
|
||||||
|
'id' => 'subscription_cancelled',
|
||||||
|
'label' => __('Subscription Cancelled', 'woonoow'),
|
||||||
|
'description' => __('When a subscription is cancelled and access ends', 'woonoow'),
|
||||||
|
'category' => 'subscriptions',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => self::get_subscription_variables(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$events['subscription_expired'] = [
|
||||||
|
'id' => 'subscription_expired',
|
||||||
|
'label' => __('Subscription Expired', 'woonoow'),
|
||||||
|
'description' => __('When a subscription expires due to end date or failed payments', 'woonoow'),
|
||||||
|
'category' => 'subscriptions',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => self::get_subscription_variables(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$events['subscription_paused'] = [
|
||||||
|
'id' => 'subscription_paused',
|
||||||
|
'label' => __('Subscription Paused', 'woonoow'),
|
||||||
|
'description' => __('When a subscription is put on hold', 'woonoow'),
|
||||||
|
'category' => 'subscriptions',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => self::get_subscription_variables(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$events['subscription_resumed'] = [
|
||||||
|
'id' => 'subscription_resumed',
|
||||||
|
'label' => __('Subscription Resumed', 'woonoow'),
|
||||||
|
'description' => __('When a subscription is resumed from pause', 'woonoow'),
|
||||||
|
'category' => 'subscriptions',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => self::get_subscription_variables(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$events['subscription_renewal_failed'] = [
|
||||||
|
'id' => 'subscription_renewal_failed',
|
||||||
|
'label' => __('Subscription Renewal Failed', 'woonoow'),
|
||||||
|
'description' => __('When a renewal payment fails', 'woonoow'),
|
||||||
|
'category' => 'subscriptions',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => self::get_subscription_variables(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$events['subscription_renewal_payment_due'] = [
|
||||||
|
'id' => 'subscription_renewal_payment_due',
|
||||||
|
'label' => __('Subscription Renewal Payment Due', 'woonoow'),
|
||||||
|
'description' => __('When a manual payment is required for subscription renewal', 'woonoow'),
|
||||||
|
'category' => 'subscriptions',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => array_merge(self::get_subscription_variables(), [
|
||||||
|
'{payment_link}' => __('Link to payment page', 'woonoow'),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
$events['subscription_renewal_reminder'] = [
|
||||||
|
'id' => 'subscription_renewal_reminder',
|
||||||
|
'label' => __('Subscription Renewal Reminder', 'woonoow'),
|
||||||
|
'description' => __('Reminder before subscription renewal', 'woonoow'),
|
||||||
|
'category' => 'subscriptions',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => self::get_subscription_variables(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Staff notifications
|
||||||
|
$events['subscription_cancelled_admin'] = [
|
||||||
|
'id' => 'subscription_cancelled',
|
||||||
|
'label' => __('Subscription Cancelled', 'woonoow'),
|
||||||
|
'description' => __('When a customer cancels their subscription', 'woonoow'),
|
||||||
|
'category' => 'subscriptions',
|
||||||
|
'recipient_type' => 'staff',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => self::get_subscription_variables(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$events['subscription_renewal_failed_admin'] = [
|
||||||
|
'id' => 'subscription_renewal_failed',
|
||||||
|
'label' => __('Subscription Renewal Failed', 'woonoow'),
|
||||||
|
'description' => __('When a subscription renewal payment fails', 'woonoow'),
|
||||||
|
'category' => 'subscriptions',
|
||||||
|
'recipient_type' => 'staff',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => self::get_subscription_variables(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscription-specific template variables
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private static function get_subscription_variables()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'{subscription_id}' => __('Subscription ID', 'woonoow'),
|
||||||
|
'{subscription_status}' => __('Subscription status', 'woonoow'),
|
||||||
|
'{product_name}' => __('Product name', 'woonoow'),
|
||||||
|
'{billing_period}' => __('Billing period (e.g., monthly)', 'woonoow'),
|
||||||
|
'{recurring_amount}' => __('Recurring payment amount', 'woonoow'),
|
||||||
|
'{next_payment_date}' => __('Next payment date', 'woonoow'),
|
||||||
|
'{end_date}' => __('Subscription end date', 'woonoow'),
|
||||||
|
'{cancel_reason}' => __('Cancellation reason', 'woonoow'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle pending cancellation notification
|
||||||
|
*/
|
||||||
|
public static function on_pending_cancel($subscription_id, $reason = '')
|
||||||
|
{
|
||||||
|
self::send_subscription_notification('subscription_pending_cancel', $subscription_id, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancellation notification
|
||||||
|
*/
|
||||||
|
public static function on_cancelled($subscription_id, $reason = '')
|
||||||
|
{
|
||||||
|
self::send_subscription_notification('subscription_cancelled', $subscription_id, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle expiration notification
|
||||||
|
*/
|
||||||
|
public static function on_expired($subscription_id, $reason = '')
|
||||||
|
{
|
||||||
|
self::send_subscription_notification('subscription_expired', $subscription_id, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle pause notification
|
||||||
|
*/
|
||||||
|
public static function on_paused($subscription_id)
|
||||||
|
{
|
||||||
|
self::send_subscription_notification('subscription_paused', $subscription_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle resume notification
|
||||||
|
*/
|
||||||
|
public static function on_resumed($subscription_id)
|
||||||
|
{
|
||||||
|
self::send_subscription_notification('subscription_resumed', $subscription_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle renewal failed notification
|
||||||
|
*/
|
||||||
|
public static function on_renewal_failed($subscription_id, $failed_count)
|
||||||
|
{
|
||||||
|
self::send_subscription_notification('subscription_renewal_failed', $subscription_id, '', $failed_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle renewal payment due notification
|
||||||
|
*/
|
||||||
|
public static function on_renewal_payment_due($subscription_id, $order = null)
|
||||||
|
{
|
||||||
|
$payment_link = '';
|
||||||
|
if ($order && is_a($order, 'WC_Order')) {
|
||||||
|
$payment_link = $order->get_checkout_payment_url();
|
||||||
|
}
|
||||||
|
self::send_subscription_notification('subscription_renewal_payment_due', $subscription_id, '', 0, ['payment_link' => $payment_link]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle renewal reminder notification
|
||||||
|
*/
|
||||||
|
public static function on_renewal_reminder($subscription)
|
||||||
|
{
|
||||||
|
if (!$subscription || !isset($subscription->id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::send_subscription_notification('subscription_renewal_reminder', $subscription->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send subscription notification
|
||||||
|
*
|
||||||
|
* @param string $event_id Event ID
|
||||||
|
* @param int $subscription_id Subscription ID
|
||||||
|
* @param string $reason Optional reason
|
||||||
|
* @param int $failed_count Optional failed payment count
|
||||||
|
* @param array $extra_data Optional extra data variables
|
||||||
|
*/
|
||||||
|
private static function send_subscription_notification($event_id, $subscription_id, $reason = '', $failed_count = 0, $extra_data = [])
|
||||||
|
{
|
||||||
|
$subscription = SubscriptionManager::get($subscription_id);
|
||||||
|
if (!$subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = get_user_by('id', $subscription->user_id);
|
||||||
|
$product = wc_get_product($subscription->product_id);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'subscription' => $subscription,
|
||||||
|
'customer' => $user,
|
||||||
|
'product' => $product,
|
||||||
|
'reason' => $reason,
|
||||||
|
'failed_count' => $failed_count,
|
||||||
|
'payment_link' => $extra_data['payment_link'] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Send via NotificationManager
|
||||||
|
if (class_exists('\\WooNooW\\Core\\Notifications\\NotificationManager')) {
|
||||||
|
\WooNooW\Core\Notifications\NotificationManager::send($event_id, 'email', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle manual renewal payment completion
|
||||||
|
*/
|
||||||
|
public static function on_order_status_changed($order_id, $old_status, $new_status)
|
||||||
|
{
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($new_status, ['processing', 'completed'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a subscription renewal order
|
||||||
|
global $wpdb;
|
||||||
|
$table_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||||
|
|
||||||
|
$link = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT subscription_id, order_type FROM $table_orders WHERE order_id = %d",
|
||||||
|
$order_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($link && $link->order_type === 'renewal') {
|
||||||
|
$order = wc_get_order($order_id);
|
||||||
|
if ($order) {
|
||||||
|
SubscriptionManager::handle_renewal_success($link->subscription_id, $order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
263
includes/Modules/Subscription/SubscriptionScheduler.php
Normal file
263
includes/Modules/Subscription/SubscriptionScheduler.php
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription Scheduler
|
||||||
|
*
|
||||||
|
* Handles cron jobs for subscription renewals and expirations
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules\Subscription
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules\Subscription;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
use WooNooW\Core\ModuleRegistry;
|
||||||
|
|
||||||
|
class SubscriptionScheduler
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron hook for processing renewals
|
||||||
|
*/
|
||||||
|
const RENEWAL_HOOK = 'woonoow_process_subscription_renewals';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron hook for checking expired subscriptions
|
||||||
|
*/
|
||||||
|
const EXPIRY_HOOK = 'woonoow_check_expired_subscriptions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron hook for sending renewal reminders
|
||||||
|
*/
|
||||||
|
const REMINDER_HOOK = 'woonoow_send_renewal_reminders';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the scheduler
|
||||||
|
*/
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
|
// Register cron handlers
|
||||||
|
add_action(self::RENEWAL_HOOK, [__CLASS__, 'process_renewals']);
|
||||||
|
add_action(self::EXPIRY_HOOK, [__CLASS__, 'check_expirations']);
|
||||||
|
add_action(self::REMINDER_HOOK, [__CLASS__, 'send_reminders']);
|
||||||
|
|
||||||
|
// Schedule cron events if not already scheduled
|
||||||
|
self::schedule_events();
|
||||||
|
|
||||||
|
// Cleanup on plugin deactivation
|
||||||
|
register_deactivation_hook(WOONOOW_PLUGIN_FILE, [__CLASS__, 'unschedule_events']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule cron events
|
||||||
|
*/
|
||||||
|
public static function schedule_events()
|
||||||
|
{
|
||||||
|
if (!wp_next_scheduled(self::RENEWAL_HOOK)) {
|
||||||
|
wp_schedule_event(time(), 'hourly', self::RENEWAL_HOOK);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wp_next_scheduled(self::EXPIRY_HOOK)) {
|
||||||
|
wp_schedule_event(time(), 'daily', self::EXPIRY_HOOK);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wp_next_scheduled(self::REMINDER_HOOK)) {
|
||||||
|
wp_schedule_event(time(), 'daily', self::REMINDER_HOOK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unschedule cron events
|
||||||
|
*/
|
||||||
|
public static function unschedule_events()
|
||||||
|
{
|
||||||
|
wp_clear_scheduled_hook(self::RENEWAL_HOOK);
|
||||||
|
wp_clear_scheduled_hook(self::EXPIRY_HOOK);
|
||||||
|
wp_clear_scheduled_hook(self::REMINDER_HOOK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process due subscription renewals
|
||||||
|
*/
|
||||||
|
public static function process_renewals()
|
||||||
|
{
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$due_subscriptions = SubscriptionManager::get_due_renewals();
|
||||||
|
|
||||||
|
foreach ($due_subscriptions as $subscription) {
|
||||||
|
// Log renewal attempt
|
||||||
|
do_action('woonoow/subscription/renewal_processing', $subscription->id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$success = SubscriptionManager::renew($subscription->id);
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
do_action('woonoow/subscription/renewal_completed', $subscription->id);
|
||||||
|
} else {
|
||||||
|
// Auto-debit failed (returns false), so schedule retry
|
||||||
|
// Note: 'manual' falls into a separate bucket in SubscriptionManager and returns true (handled)
|
||||||
|
self::schedule_retry($subscription->id);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Log error
|
||||||
|
error_log('[WooNooW Subscription] Renewal failed for subscription #' . $subscription->id . ': ' . $e->getMessage());
|
||||||
|
do_action('woonoow/subscription/renewal_error', $subscription->id, $e);
|
||||||
|
|
||||||
|
// Also schedule retry on exception
|
||||||
|
self::schedule_retry($subscription->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for expired subscriptions
|
||||||
|
*/
|
||||||
|
public static function check_expirations()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
$now = current_time('mysql');
|
||||||
|
|
||||||
|
// Find subscriptions that have passed their end date
|
||||||
|
$expired = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT id FROM $table
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND end_date IS NOT NULL
|
||||||
|
AND end_date <= %s",
|
||||||
|
$now
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($expired as $subscription) {
|
||||||
|
SubscriptionManager::update_status($subscription->id, 'expired');
|
||||||
|
do_action('woonoow/subscription/expired', $subscription->id, 'end_date_reached');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check pending-cancel subscriptions that need to be finalized
|
||||||
|
$pending_cancel = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT id FROM $table
|
||||||
|
WHERE status = 'pending-cancel'
|
||||||
|
AND next_payment_date IS NOT NULL
|
||||||
|
AND next_payment_date <= %s",
|
||||||
|
$now
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($pending_cancel as $subscription) {
|
||||||
|
SubscriptionManager::update_status($subscription->id, 'cancelled');
|
||||||
|
do_action('woonoow/subscription/cancelled', $subscription->id, 'pending_cancel_completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send renewal reminder emails
|
||||||
|
*/
|
||||||
|
public static function send_reminders()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if reminders are enabled
|
||||||
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
|
if (empty($settings['send_renewal_reminder'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$days_before = $settings['reminder_days_before'] ?? 3;
|
||||||
|
$reminder_date = date('Y-m-d H:i:s', strtotime("+$days_before days"));
|
||||||
|
$tomorrow = date('Y-m-d H:i:s', strtotime('+' . ($days_before + 1) . ' days'));
|
||||||
|
|
||||||
|
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
|
||||||
|
// Find subscriptions due for reminder (that haven't had reminder sent for this billing cycle)
|
||||||
|
$due_reminders = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT * FROM $table
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND next_payment_date IS NOT NULL
|
||||||
|
AND next_payment_date >= %s
|
||||||
|
AND next_payment_date < %s
|
||||||
|
AND (reminder_sent_at IS NULL OR reminder_sent_at < last_payment_date OR (last_payment_date IS NULL AND reminder_sent_at < start_date))",
|
||||||
|
$reminder_date,
|
||||||
|
$tomorrow
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($due_reminders as $subscription) {
|
||||||
|
// Trigger reminder email
|
||||||
|
do_action('woonoow/subscription/renewal_reminder', $subscription);
|
||||||
|
|
||||||
|
// Mark reminder as sent in database
|
||||||
|
$wpdb->update(
|
||||||
|
$table,
|
||||||
|
['reminder_sent_at' => current_time('mysql')],
|
||||||
|
['id' => $subscription->id],
|
||||||
|
['%s'],
|
||||||
|
['%d']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get retry schedule for failed payments
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
* @return string|null Next retry datetime or null if no more retries
|
||||||
|
*/
|
||||||
|
public static function get_next_retry_date($subscription_id)
|
||||||
|
{
|
||||||
|
$subscription = SubscriptionManager::get($subscription_id);
|
||||||
|
if (!$subscription) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
|
|
||||||
|
if (empty($settings['renewal_retry_enabled'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$retry_days_str = $settings['renewal_retry_days'] ?? '1,3,5';
|
||||||
|
$retry_days = array_map('intval', array_filter(explode(',', $retry_days_str)));
|
||||||
|
|
||||||
|
$failed_count = $subscription->failed_payment_count;
|
||||||
|
|
||||||
|
if ($failed_count >= count($retry_days)) {
|
||||||
|
return null; // No more retries
|
||||||
|
}
|
||||||
|
|
||||||
|
$days_to_add = $retry_days[$failed_count] ?? 1;
|
||||||
|
return date('Y-m-d H:i:s', strtotime("+$days_to_add days"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a retry for failed payment
|
||||||
|
*
|
||||||
|
* @param int $subscription_id
|
||||||
|
*/
|
||||||
|
public static function schedule_retry($subscription_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$next_retry = self::get_next_retry_date($subscription_id);
|
||||||
|
|
||||||
|
if ($next_retry) {
|
||||||
|
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
$wpdb->update(
|
||||||
|
$table,
|
||||||
|
['next_payment_date' => $next_retry],
|
||||||
|
['id' => $subscription_id],
|
||||||
|
['%s'],
|
||||||
|
['%d']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
includes/Modules/SubscriptionSettings.php
Normal file
109
includes/Modules/SubscriptionSettings.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Subscription Module Settings
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
class SubscriptionSettings {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the settings
|
||||||
|
*/
|
||||||
|
public static function init() {
|
||||||
|
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register subscription settings schema
|
||||||
|
*/
|
||||||
|
public static function register_schema($schemas) {
|
||||||
|
$schemas['subscription'] = [
|
||||||
|
'default_status' => [
|
||||||
|
'type' => 'select',
|
||||||
|
'label' => __('Default Subscription Status', 'woonoow'),
|
||||||
|
'description' => __('Status for new subscriptions after successful payment', 'woonoow'),
|
||||||
|
'options' => [
|
||||||
|
'active' => __('Active', 'woonoow'),
|
||||||
|
'pending' => __('Pending', 'woonoow'),
|
||||||
|
],
|
||||||
|
'default' => 'active',
|
||||||
|
],
|
||||||
|
'button_text_subscribe' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'label' => __('Subscribe Button Text', 'woonoow'),
|
||||||
|
'description' => __('Text for the subscribe button on subscription products', 'woonoow'),
|
||||||
|
'placeholder' => 'Subscribe Now',
|
||||||
|
'default' => 'Subscribe Now',
|
||||||
|
],
|
||||||
|
'button_text_renew' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'label' => __('Renew Button Text', 'woonoow'),
|
||||||
|
'description' => __('Text for the renewal button', 'woonoow'),
|
||||||
|
'placeholder' => 'Renew Subscription',
|
||||||
|
'default' => 'Renew Subscription',
|
||||||
|
],
|
||||||
|
'allow_customer_cancel' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Allow Customer Cancellation', 'woonoow'),
|
||||||
|
'description' => __('Allow customers to cancel their own subscriptions', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'allow_customer_pause' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Allow Customer Pause', 'woonoow'),
|
||||||
|
'description' => __('Allow customers to pause and resume their subscriptions', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'max_pause_count' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'label' => __('Maximum Pause Count', 'woonoow'),
|
||||||
|
'description' => __('Maximum number of times a subscription can be paused (0 = unlimited)', 'woonoow'),
|
||||||
|
'default' => 3,
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 10,
|
||||||
|
],
|
||||||
|
'renewal_retry_enabled' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Retry Failed Renewals', 'woonoow'),
|
||||||
|
'description' => __('Automatically retry failed renewal payments', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'renewal_retry_days' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'label' => __('Retry Days', 'woonoow'),
|
||||||
|
'description' => __('Days after failure to retry payment (comma-separated, e.g., 1,3,5)', 'woonoow'),
|
||||||
|
'placeholder' => '1,3,5',
|
||||||
|
'default' => '1,3,5',
|
||||||
|
],
|
||||||
|
'expire_after_failed_attempts' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'label' => __('Max Failed Attempts', 'woonoow'),
|
||||||
|
'description' => __('Number of failed payment attempts before subscription expires', 'woonoow'),
|
||||||
|
'default' => 3,
|
||||||
|
'min' => 1,
|
||||||
|
'max' => 10,
|
||||||
|
],
|
||||||
|
'send_renewal_reminder' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Send Renewal Reminders', 'woonoow'),
|
||||||
|
'description' => __('Send email reminder before subscription renewal', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'reminder_days_before' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'label' => __('Reminder Days Before', 'woonoow'),
|
||||||
|
'description' => __('Days before renewal to send reminder email', 'woonoow'),
|
||||||
|
'default' => 3,
|
||||||
|
'min' => 1,
|
||||||
|
'max' => 14,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $schemas;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ if (!defined('ABSPATH')) exit;
|
|||||||
define('WOONOOW_PATH', plugin_dir_path(__FILE__));
|
define('WOONOOW_PATH', plugin_dir_path(__FILE__));
|
||||||
define('WOONOOW_URL', plugin_dir_url(__FILE__));
|
define('WOONOOW_URL', plugin_dir_url(__FILE__));
|
||||||
define('WOONOOW_VERSION', '0.1.0');
|
define('WOONOOW_VERSION', '0.1.0');
|
||||||
|
define('WOONOOW_PLUGIN_FILE', __FILE__);
|
||||||
|
|
||||||
spl_autoload_register(function ($class) {
|
spl_autoload_register(function ($class) {
|
||||||
$prefix = 'WooNooW\\';
|
$prefix = 'WooNooW\\';
|
||||||
@@ -41,6 +42,7 @@ add_action('plugins_loaded', function () {
|
|||||||
WooNooW\Modules\NewsletterSettings::init();
|
WooNooW\Modules\NewsletterSettings::init();
|
||||||
WooNooW\Modules\WishlistSettings::init();
|
WooNooW\Modules\WishlistSettings::init();
|
||||||
WooNooW\Modules\Licensing\LicensingModule::init();
|
WooNooW\Modules\Licensing\LicensingModule::init();
|
||||||
|
WooNooW\Modules\Subscription\SubscriptionModule::init();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activation/Deactivation hooks
|
// Activation/Deactivation hooks
|
||||||
|
|||||||
Reference in New Issue
Block a user