Misc fixes: cleanup templates and supporting updates

This commit is contained in:
Dwindi Ramadhana
2026-06-02 00:39:27 +07:00
parent df969b442d
commit dcdd6d8cac
6 changed files with 102 additions and 417 deletions

View File

@@ -1,6 +1,6 @@
# WooNooW Feature Roadmap - 2025 # WooNooW Feature Roadmap - 2025
**Last Updated**: December 31, 2025 **Last Updated**: June 1, 2026
**Status**: Active Development **Status**: Active Development
This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure. This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure.
@@ -301,66 +301,59 @@ class AffiliateTracker {
### Overview ### Overview
Recurring product subscriptions with flexible billing cycles. Recurring product subscriptions with flexible billing cycles.
### Status: **Planning** 🔵 ### Status: **Shipped**
### What's Already Built ### What's Already Built
- ✅ Product management - ✅ Product management
- ✅ Order system - ✅ Order system
- ✅ Payment gateways - ✅ Payment gateways
- ✅ Notification system - ✅ Notification system
- ✅ Database tables (`wp_woonoow_subscriptions`, `wp_woonoow_subscription_orders`) — schema below reflects actual shipped columns
- ✅ Per-gateway auto-renew capability table (kill-switchable)
- ✅ Pause/resume/cancel/early-renew customer UI
- ✅ Admin list with bulk actions, search, and per-status filter
- ✅ Renewal cron (`process_renewals`, `check_expirations`, `send_reminders`, `retry_unpaid_renewals`)
### What's Needed ### Schema (as shipped)
#### 1. Database Tables
```sql ```sql
wp_woonoow_subscriptions (id, customer_id, product_id, status, billing_period, billing_interval, price, next_payment_date, start_date, end_date, trial_end_date) wp_woonoow_subscriptions (
wp_woonoow_subscription_orders (id, subscription_id, order_id, payment_status, created_at) id, user_id, order_id, product_id, variation_id, status,
billing_period, billing_interval, recurring_amount,
start_date, trial_end_date, next_payment_date, end_date, last_payment_date,
payment_method, payment_meta, cancel_reason,
pause_count, failed_payment_count, reminder_sent_at
)
wp_woonoow_subscription_orders (
id, subscription_id, order_id, order_type ENUM 'parent'|'renewal'|'switch'|'resubscribe'
)
``` ```
#### 2. Product Meta Note: the column is `user_id`, not `customer_id` — the original spec used the
Add subscription options to product: WC-style "customer" naming, but WP schema reserves `customer` for the legacy
- Is subscription product (checkbox) WP customer user role and the column was renamed before the first migration
- Billing period (daily, weekly, monthly, yearly) shipped.
- Billing interval (e.g., 2 for every 2 months)
- Trial period (days)
#### 3. Renewal System ### Customer Dashboard
```php
class SubscriptionRenewal {
// WP-Cron daily job
public function process_renewals() {
$due_subscriptions = $this->get_due_subscriptions();
foreach ($due_subscriptions as $subscription) {
// Create renewal order
// Process payment
// Update next payment date
// Send notification
}
}
}
```
#### 4. Customer Dashboard
**Route**: `/account/subscriptions` **Route**: `/account/subscriptions`
- Active subscriptions list - Active subscriptions list
- Pause/resume subscription - Pause/resume subscription (capped at `max_pause_count` setting, default 3)
- Cancel subscription - Cancel subscription
- Update payment method - Update payment method
- View billing history - View billing history
- Change billing cycle - Change billing cycle
#### 5. Admin UI ### Admin UI
**Route**: `/products/subscriptions` **Route**: `/subscriptions`
- All subscriptions list - All subscriptions list with checkbox + bulk actions (cancel, CSV export)
- Filter by status - Free-text search by id / email / display name
- View subscription details - Per-status filter
- Manual renewal - View subscription details (per-gateway auto-renew badge, pause count)
- Renew Now (creates manual order) or Charge Now (forces auto-debit, M2)
- Cancel/refund - Cancel/refund
### Priority: **Low** 🟢 ### Priority: ~~Low~~ Shipped ✅
### Effort: 4-5 weeks ### Effort: ~~4-5 weeks~~ Shipped
--- ---

View File

@@ -18,6 +18,7 @@ import {
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { toast } from 'sonner';
import { import {
Table, Table,
TableBody, TableBody,

View File

@@ -15,7 +15,7 @@
"types": [], "types": [],
"baseUrl": ".", "baseUrl": ".",
"paths": { "@/*": ["./src/*"] }, "paths": { "@/*": ["./src/*"] },
"ignoreDeprecations": "6.0" "ignoreDeprecations": "5.0"
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -352,6 +352,21 @@ class EmailRenderer
'payment_link' => $data['payment_link'] ?? '', 'payment_link' => $data['payment_link'] ?? '',
]; ];
// O1 — Derive `billing_schedule` (e.g. "Every 3 Months") and
// `payment_method_title` (e.g. "Stripe" rather than the raw
// gateway id "stripe"). The data is on the subscription row but
// isn't pre-formatted. We rebuild both so email templates can
// show the merchant-friendly string without duplicating the
// pluralization + lookup logic.
$sub_variables['billing_schedule'] = self::format_billing_schedule(
isset($sub->billing_period) ? (string) $sub->billing_period : '',
isset($sub->billing_interval) ? (int) $sub->billing_interval : 1
);
$sub_variables['payment_method_title'] = self::resolve_payment_method_title(
isset($sub->payment_method) ? (string) $sub->payment_method : '',
$data['order'] ?? null
);
// Get product name if not already set // Get product name if not already set
if (!isset($variables['product_name']) && isset($data['product']) && $data['product'] instanceof \WC_Product) { if (!isset($variables['product_name']) && isset($data['product']) && $data['product'] instanceof \WC_Product) {
$sub_variables['product_name'] = $data['product']->get_name(); $sub_variables['product_name'] = $data['product']->get_name();
@@ -381,6 +396,57 @@ class EmailRenderer
return apply_filters('woonoow_email_variables', $variables, $event_id, $data); return apply_filters('woonoow_email_variables', $variables, $event_id, $data);
} }
/**
* O1 — Format a billing schedule string like "Every 3 Months" from raw
* period and interval columns. Mirrors the controller's
* `enrich_subscription()` math so email templates show the same string
* the customer sees in the SPA. Falls back to the period string itself
* if the period is unknown.
*/
public static function format_billing_schedule($period, $interval)
{
$period_labels = [
'day' => __('day', 'woonoow'),
'week' => __('week', 'woonoow'),
'month' => __('month', 'woonoow'),
'year' => __('year', 'woonoow'),
];
$interval = max(1, (int) $interval);
$period_label = $period_labels[$period] ?? $period;
if ($interval > 1) {
$period_label .= 's';
}
return sprintf(__('Every %s%s', 'woonoow'), $interval, $period_label);
}
/**
* O1 — Resolve a human-friendly payment method title from a stored gateway
* id. Order of preference:
* 1. The order's `payment_method_title` (most accurate; set by gateway
* at checkout — e.g. "PayPal — Visa ending in 1234")
* 2. The registered WC gateway's `get_title()` (e.g. "Stripe")
* 3. The raw id
*/
public static function resolve_payment_method_title($gateway_id, $order = null)
{
if ($order instanceof \WC_Order) {
$title = $order->get_payment_method_title();
if (!empty($title)) {
return $title;
}
}
if ($gateway_id !== '' && function_exists('WC') && WC()->payment_gateways()) {
$gateways = WC()->payment_gateways()->payment_gateways();
if (isset($gateways[$gateway_id]) && method_exists($gateways[$gateway_id], 'get_title')) {
$title = $gateways[$gateway_id]->get_title();
if (!empty($title)) {
return $title;
}
}
}
return $gateway_id;
}
/** /**
* Parse [card] tags and convert to HTML * Parse [card] tags and convert to HTML
* *

View File

@@ -1,375 +0,0 @@
<?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;
}
}

View File

@@ -338,7 +338,7 @@ class TemplateOverride
if (is_wc_endpoint_url('order-pay')) { if (is_wc_endpoint_url('order-pay')) {
global $wp; global $wp;
$order_id = $wp->query_vars['order-pay']; $order_id = $wp->query_vars['order-pay'];
wp_redirect($build_route('order-pay/' . $order_id), 302); wp_redirect($build_route('checkout/pay/' . $order_id), 302);
exit; exit;
} }