feat: Implement A4 invoice layout and hide Label for virtual orders

Invoice:
- Enhanced A4-ready layout with proper structure
- Store header with invoice number
- Billing/shipping address sections
- Styled items table with alternating rows
- Totals summary with conditional display
- Thank you footer

Label:
- Label button now hidden for virtual-only orders
- Uses existing isVirtualOnly detection

Print CSS:
- Added @page A4 size directive
- Print-color-adjust for background colors
- 20mm padding for proper margins

Documentation:
- Updated subscription module plan (comprehensive)
- Updated affiliate module plan (comprehensive)
- Created shipping label standardization plan
This commit is contained in:
Dwindi Ramadhana
2026-01-05 19:16:13 +07:00
parent 2efc6a7605
commit 40aee67c46
5 changed files with 733 additions and 57 deletions

View File

@@ -0,0 +1,241 @@
# Affiliate Module Plan
## Overview
Referral tracking with hybrid customer/affiliate roles, integrated as a core plugin module.
---
## Module Architecture
### Core Features
- **Affiliate registration**: Customers can become affiliates
- **Approval workflow**: Manual or auto-approval of affiliates
- **Unique referral links/codes**: Each affiliate gets unique tracking
- **Commission tracking**: Track referrals and calculate earnings
- **Tiered commission rates**: Different rates per product/category/affiliate level
- **Payout management**: Track and process affiliate payouts
- **Affiliate dashboard**: Self-service stats and link generator
### Hybrid Roles
- A customer can also be an affiliate
- No separate user type; affiliate data linked to existing user
- Affiliates can still make purchases (self-referral rules configurable)
---
## Database Schema
### Table: `woonoow_affiliates`
```sql
CREATE TABLE woonoow_affiliates (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL UNIQUE,
status ENUM('pending', 'active', 'rejected', 'suspended') DEFAULT 'pending',
referral_code VARCHAR(50) NOT NULL UNIQUE,
commission_rate DECIMAL(5,2) DEFAULT NULL, -- Override global rate
tier_id BIGINT UNSIGNED DEFAULT NULL,
payment_email VARCHAR(255) DEFAULT NULL,
payment_method VARCHAR(50) DEFAULT NULL,
total_earnings DECIMAL(15,2) DEFAULT 0,
total_unpaid DECIMAL(15,2) DEFAULT 0,
total_paid DECIMAL(15,2) DEFAULT 0,
referral_count INT UNSIGNED DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_referral_code (referral_code)
);
```
### Table: `woonoow_referrals`
```sql
CREATE TABLE woonoow_referrals (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
affiliate_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED DEFAULT NULL,
subtotal DECIMAL(15,2) NOT NULL,
commission_rate DECIMAL(5,2) NOT NULL,
commission_amount DECIMAL(15,2) NOT NULL,
status ENUM('pending', 'approved', 'rejected', 'paid') DEFAULT 'pending',
referral_type ENUM('link', 'code', 'coupon') DEFAULT 'link',
ip_address VARCHAR(45) DEFAULT NULL,
user_agent TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
approved_at DATETIME DEFAULT NULL,
paid_at DATETIME DEFAULT NULL,
INDEX idx_affiliate (affiliate_id),
INDEX idx_order (order_id),
INDEX idx_status (status)
);
```
### Table: `woonoow_payouts`
```sql
CREATE TABLE woonoow_payouts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
affiliate_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(15,2) NOT NULL,
method VARCHAR(50) DEFAULT NULL,
reference VARCHAR(255) DEFAULT NULL, -- Bank ref, PayPal transaction, etc.
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME DEFAULT NULL,
INDEX idx_affiliate (affiliate_id),
INDEX idx_status (status)
);
```
### Table: `woonoow_affiliate_tiers` (Optional)
```sql
CREATE TABLE woonoow_affiliate_tiers (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
commission_rate DECIMAL(5,2) NOT NULL,
min_referrals INT UNSIGNED DEFAULT 0, -- Auto-promote at X referrals
min_earnings DECIMAL(15,2) DEFAULT 0, -- Auto-promote at X earnings
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
## Backend Structure
```
includes/Modules/Affiliate/
├── AffiliateModule.php # Bootstrap, hooks, tracking
├── AffiliateManager.php # Core logic
├── ReferralTracker.php # Track referral cookies/links
└── AffiliateSettings.php # Settings schema
includes/Api/AffiliatesController.php # REST endpoints
```
### AffiliateManager Methods
```php
- register($user_id) # Register user as affiliate
- approve($affiliate_id) # Approve pending affiliate
- reject($affiliate_id, $reason) # Reject application
- suspend($affiliate_id) # Suspend affiliate
- track_referral($order, $affiliate) # Create referral record
- calculate_commission($order, $affiliate) # Calculate earnings
- approve_referral($referral_id) # Approve pending referral
- create_payout($affiliate_id, $amount) # Create payout request
- process_payout($payout_id) # Mark payout complete
- get_affiliate_stats($affiliate_id) # Dashboard stats
```
### Referral Tracking
1. Affiliate shares link: `yourstore.com/?ref=CODE`
2. ReferralTracker sets cookie: `wnw_ref=CODE` (30 days default)
3. On checkout, check cookie and link to affiliate
4. On order completion, create referral record
---
## REST API Endpoints
### Admin Endpoints
```
GET /affiliates # List affiliates
GET /affiliates/{id} # Affiliate details
PUT /affiliates/{id} # Update affiliate
POST /affiliates/{id}/approve # Approve affiliate
POST /affiliates/{id}/reject # Reject affiliate
GET /affiliates/referrals # All referrals
GET /affiliates/payouts # All payouts
POST /affiliates/payouts/{id}/complete # Complete payout
```
### Customer/Affiliate Endpoints
```
GET /my-affiliate # Check if affiliate
POST /my-affiliate/register # Register as affiliate
GET /my-affiliate/stats # Dashboard stats
GET /my-affiliate/referrals # My referrals
GET /my-affiliate/payouts # My payouts
POST /my-affiliate/payout-request # Request payout
GET /my-affiliate/links # Generate referral links
```
---
## Admin SPA
### Affiliates List (`/marketing/affiliates`)
- Table: Name, Email, Status, Referrals, Earnings, Actions
- Filters: Status, Date range
- Actions: Approve, Reject, View
### Affiliate Detail (`/marketing/affiliates/:id`)
- Affiliate info card
- Stats summary
- Referrals list
- Payouts history
- Action buttons
### Referrals List (`/marketing/affiliates/referrals`)
- All referrals across affiliates
- Filters: Status, Affiliate, Date
### Payouts (`/marketing/affiliates/payouts`)
- Payout requests
- Process payouts
- Payment history
---
## Customer SPA
### Become an Affiliate (`/my-account/affiliate`)
- Registration form (if not affiliate)
- Dashboard (if affiliate)
### Affiliate Dashboard
- Stats: Total Referrals, Pending, Approved, Earnings
- Referral link generator
- Recent referrals
- Payout request button
### My Referrals
- List of referrals with status
- Commission amount
### My Payouts
- Payout history
- Pending amount
- Request payout form
---
## Settings Schema
```php
return [
'enabled' => true,
'registration_type' => 'open', // open, approval, invite
'auto_approve' => false,
'default_commission_rate' => 10, // 10%
'commission_type' => 'percentage', // percentage, flat
'cookie_duration' => 30, // days
'min_payout_amount' => 50,
'payout_methods' => ['bank_transfer', 'paypal'],
'allow_self_referral' => false,
'referral_approval' => 'auto', // auto, manual
'approval_delay_days' => 14, // Wait X days before auto-approve
];
```
---
## Implementation Priority
1. Database tables and AffiliateManager
2. ReferralTracker (cookie-based tracking)
3. Order hook to create referrals
4. Admin SPA affiliates management
5. Customer SPA affiliate dashboard
6. Payout management
7. Tier system (optional)

View File

@@ -0,0 +1,84 @@
# Shipping Label Plan
## Overview
Standardized waybill data structure for shipping label generation.
## Problem
- Different shipping carrier addons (JNE, JNT, SiCepat, etc.) store data differently
- No standard structure for label generation
- Label button needs waybill data to function
## Proposed Solution
### 1. Standardized Meta Key
Order meta: `_shipping_waybill`
### 2. Data Structure
```json
{
"tracking_number": "JNE123456789",
"carrier": "jne",
"carrier_name": "JNE Express",
"service": "REG",
"estimated_days": 3,
"sender": {
"name": "Store Name",
"address": "Full address line 1",
"city": "Jakarta",
"postcode": "12345",
"phone": "08123456789"
},
"recipient": {
"name": "Customer Name",
"address": "Full address line 1",
"city": "Bandung",
"postcode": "40123",
"phone": "08987654321"
},
"package": {
"weight": "1.5",
"weight_unit": "kg",
"dimensions": "20x15x10",
"dimensions_unit": "cm"
},
"label_url": null,
"barcode": "JNE123456789",
"barcode_type": "128",
"created_at": "2026-01-05T12:00:00+07:00"
}
```
### 3. Addon Integration Contract
Shipping addons MUST:
1. Call `update_post_meta($order_id, '_shipping_waybill', $waybill_data)`
2. Use the standard structure above
3. Set `label_url` if carrier provides downloadable PDF
4. Set `barcode` for local label generation
### 4. Label Button Behavior
1. Check if `_shipping_waybill` meta exists on order
2. If `label_url` → open carrier's PDF
3. Otherwise → generate printable label from meta data
### 5. UI Behavior
- Label button hidden if order is virtual-only
- Label button shows "Generate Label" if no waybill yet
- Label button shows "Print Label" if waybill exists
## API Endpoint (Future)
```
POST /woonoow/v1/orders/{id}/generate-waybill
- Calls shipping carrier API
- Stores waybill in standardized format
- Returns waybill data
GET /woonoow/v1/orders/{id}/waybill
- Returns current waybill data
```
## Implementation Priority
1. Define standard structure (this document)
2. Implement Label UI conditional logic
3. Create waybill API endpoint
4. Document for addon developers

View File

@@ -0,0 +1,191 @@
# Subscription Module Plan
## Overview
Recurring product subscriptions with flexible billing, integrated as a core plugin module (like Newsletter/Wishlist/Licensing).
---
## Module Architecture
### Core Features
- **Recurring billing**: Weekly, monthly, yearly, custom intervals
- **Free trials**: X days free before billing starts
- **Sign-up fees**: One-time fee on first subscription
- **Automatic renewals**: Process payment on renewal date
- **Manual renewal**: Allow customers to renew manually
- **Proration**: Calculate prorated amounts on plan changes
- **Pause/Resume**: Allow customers to pause subscriptions
### Product Integration
- Checkbox under "Additional Options": **Enable subscription for this product**
- When enabled, show subscription settings:
- Billing period (weekly/monthly/yearly/custom)
- Billing interval (every X periods)
- Free trial days
- Sign-up fee
- Subscription length (0 = unlimited)
- Variable products: Variation-level subscription settings (different durations/prices per variation)
### Integration with Licensing
- Licenses can be bound to subscriptions
- When subscription is active → license is valid
- When subscription expires/cancelled → license is revoked
- Auto-renewal keeps license active
---
## Database Schema
### Table: `woonoow_subscriptions`
```sql
CREATE TABLE woonoow_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,
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,
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_status (status),
INDEX idx_next_payment (next_payment_date)
);
```
### Table: `woonoow_subscription_orders`
Links subscription to renewal orders:
```sql
CREATE TABLE woonoow_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)
);
```
---
## Backend Structure
```
includes/Modules/Subscription/
├── SubscriptionModule.php # Bootstrap, hooks, product meta
├── SubscriptionManager.php # Core logic
├── SubscriptionScheduler.php # Cron jobs for renewals
└── SubscriptionSettings.php # Settings schema
includes/Api/SubscriptionsController.php # REST endpoints
```
### SubscriptionManager Methods
```php
- create($order, $product, $user) # Create subscription from order
- renew($subscription_id) # Process renewal
- cancel($subscription_id, $reason) # Cancel subscription
- pause($subscription_id) # Pause subscription
- resume($subscription_id) # Resume paused subscription
- switch($subscription_id, $new_product) # Switch plan
- get_next_payment_date($subscription) # Calculate next date
- process_renewal_payment($subscription) # Charge payment
```
### Cron Jobs
- `woonoow_process_subscription_renewals`: Run daily, process due renewals
- `woonoow_check_expired_subscriptions`: Mark expired subscriptions
---
## REST API Endpoints
### Admin Endpoints
```
GET /subscriptions # List all subscriptions
GET /subscriptions/{id} # Get subscription details
PUT /subscriptions/{id} # Update subscription
POST /subscriptions/{id}/cancel # Cancel subscription
POST /subscriptions/{id}/renew # Force renewal
```
### Customer Endpoints
```
GET /my-subscriptions # Customer's subscriptions
GET /my-subscriptions/{id} # Subscription detail
POST /my-subscriptions/{id}/cancel # Request cancellation
POST /my-subscriptions/{id}/pause # Pause subscription
POST /my-subscriptions/{id}/resume # Resume subscription
```
---
## Admin SPA
### Subscriptions List (`/subscriptions`)
- Table: ID, Customer, Product, Status, Next Payment, Actions
- Filters: Status, Product, Date range
- Actions: View, Cancel, Renew
### Subscription Detail (`/subscriptions/:id`)
- Subscription info card
- Related orders list
- Payment history
- Action buttons: Cancel, Pause, Renew
---
## Customer SPA
### My Subscriptions (`/my-account/subscriptions`)
- List of active/past subscriptions
- Status badges
- Next payment info
- Actions: Cancel, Pause, View
### Subscription Detail
- Product info
- Billing schedule
- Payment history
- Management actions
---
## Settings Schema
```php
return [
'default_status' => 'active',
'button_text_subscribe' => 'Subscribe Now',
'button_text_renew' => 'Renew Subscription',
'allow_customer_cancel' => true,
'allow_customer_pause' => true,
'max_pause_count' => 3,
'renewal_retry_days' => [1, 3, 5], // Retry failed payments
'expire_after_failed_attempts' => 3,
'send_renewal_reminder' => true,
'reminder_days_before' => 3,
];
```
---
## Implementation Priority
1. Database tables and SubscriptionManager
2. Product meta fields for subscription settings
3. Order hook to create subscription
4. Renewal cron job
5. Admin SPA list/detail pages
6. Customer SPA pages
7. Integration with Licensing module

View File

@@ -34,6 +34,7 @@
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
@@ -63,9 +64,22 @@
}
@layer base {
* { @apply border-border; }
body { @apply bg-background text-foreground; }
h1, h2, h3, h4, h5, h6 { @apply text-foreground; }
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-foreground;
}
/* Override WordPress common.css focus/active styles */
a:focus,
@@ -126,11 +140,14 @@
/* Page defaults for print */
@page {
size: auto; /* let the browser choose */
margin: 12mm; /* comfortable default */
size: auto;
/* let the browser choose */
margin: 12mm;
/* comfortable default */
}
@media print {
/* Hide WordPress admin chrome */
#adminmenuback,
#adminmenuwrap,
@@ -139,44 +156,124 @@
#wpfooter,
#screen-meta,
.notice,
.update-nag { display: none !important; }
.update-nag {
display: none !important;
}
/* Reset layout to full-bleed for our app */
html, body, #wpwrap, #wpcontent { background: #fff !important; margin: 0 !important; padding: 0 !important; }
#woonoow-admin-app, #woonoow-admin-app > div { margin: 0 !important; padding: 0 !important; max-width: 100% !important; }
html,
body,
#wpwrap,
#wpcontent {
background: #fff !important;
margin: 0 !important;
padding: 0 !important;
}
#woonoow-admin-app,
#woonoow-admin-app>div {
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
}
/* Hide elements flagged as no-print, reveal print-only */
.no-print { display: none !important; }
.print-only { display: block !important; }
.no-print {
display: none !important;
}
.print-only {
display: block !important;
}
/* Improve table row density on paper */
.print-tight tr > * { padding-top: 6px !important; padding-bottom: 6px !important; }
.print-tight tr>* {
padding-top: 6px !important;
padding-bottom: 6px !important;
}
}
/* By default, label-only content stays hidden unless in print or label mode */
.print-only { display: none; }
.print-only {
display: none;
}
/* Label mode toggled by router (?mode=label) */
.woonoow-label-mode .print-only { display: block; }
.woonoow-label-mode .print-only {
display: block;
}
.woonoow-label-mode .no-print-label,
.woonoow-label-mode .wp-header-end,
.woonoow-label-mode .wrap { display: none !important; }
.woonoow-label-mode .wrap {
display: none !important;
}
/* Optional page presets (opt-in by adding the class to a wrapper before printing) */
.print-a4 {}
.print-letter {}
.print-4x6 {}
@media print {
.print-a4 { }
/* A4 Invoice layout */
@page {
size: A4;
margin: 0;
}
.print-a4 {
width: 210mm !important;
min-height: 297mm !important;
padding: 20mm !important;
margin: 0 auto !important;
box-sizing: border-box !important;
background: white !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.print-a4 * {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Ensure backgrounds print */
.print-a4 .bg-gray-50 {
background-color: #f9fafb !important;
}
.print-a4 .bg-gray-900 {
background-color: #111827 !important;
}
.print-a4 .text-white {
color: white !important;
}
.print-letter {}
/* Thermal label (4x6in) with minimal margins */
.print-4x6 { width: 6in; }
.print-4x6 * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.print-4x6 {
width: 6in;
}
.print-4x6 * {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
/* --- WooNooW: Popper menus & fullscreen fixes --- */
[data-radix-popper-content-wrapper] { z-index: 2147483647 !important; }
body.woonoow-fullscreen .woonoow-app { overflow: visible; }
[data-radix-popper-content-wrapper] {
z-index: 2147483647 !important;
}
body.woonoow-fullscreen .woonoow-app {
overflow: visible;
}
/* --- WooCommerce Admin Notices --- */
.woocommerce-message,

View File

@@ -205,9 +205,11 @@ export default function OrderShow() {
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printInvoice} title={__('Print invoice')}>
<FileText className="w-4 h-4" /> {__('Invoice')}
</button>
{!isVirtualOnly && (
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printLabel} title={__('Print shipping label')}>
<Ticket className="w-4 h-4" /> {__('Label')}
</button>
)}
</div>
</div>
@@ -475,54 +477,115 @@ export default function OrderShow() {
{order && (
<div className="print-only">
{mode === 'invoice' && (
<div className="max-w-[800px] mx-auto p-6 text-sm">
<div className="flex items-start justify-between mb-6">
<div className="print-a4 bg-white" style={{ minHeight: '297mm', width: '210mm', margin: '0 auto', padding: '20mm', boxSizing: 'border-box' }}>
{/* Invoice Header */}
<div className="flex items-start justify-between mb-8 pb-6 border-b-2 border-gray-200">
<div>
<div className="text-xl font-semibold">Invoice</div>
<div className="opacity-60">Order #{order.number} · {new Date((order.date_ts || 0) * 1000).toLocaleString()}</div>
<div className="text-3xl font-bold text-gray-900">{__('INVOICE')}</div>
<div className="text-sm text-gray-600 mt-1">#{order.number}</div>
</div>
<div className="text-right">
<div className="font-medium">{siteTitle}</div>
<div className="opacity-60 text-xs">{window.location.origin}</div>
<div className="text-xl font-semibold text-gray-900">{siteTitle}</div>
<div className="text-sm text-gray-500 mt-1">{window.location.origin}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Invoice Meta */}
<div className="grid grid-cols-2 gap-8 mb-8">
<div>
<div className="text-xs opacity-60 mb-1">{__('Bill To')}</div>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: order.billing?.address || order.billing?.name || '' }} />
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{__('Invoice Date')}</div>
<div className="text-sm text-gray-900">{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}</div>
{order.payment_method && (
<>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2 mt-4">{__('Payment Method')}</div>
<div className="text-sm text-gray-900">{order.payment_method}</div>
</>
)}
</div>
<div className="text-right">
<canvas ref={qrRef} className="inline-block w-24 h-24 border" />
<div className="flex justify-end">
<canvas ref={qrRef} className="w-24 h-24" style={{ border: '1px solid #e5e7eb' }} />
</div>
</div>
<table className="w-full border-collapse mb-6">
{/* Billing & Shipping */}
<div className="grid grid-cols-2 gap-8 mb-8">
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{__('Bill To')}</div>
<div className="text-sm text-gray-900 font-medium">{order.billing?.name || '—'}</div>
{order.billing?.email && <div className="text-sm text-gray-600">{order.billing.email}</div>}
{order.billing?.phone && <div className="text-sm text-gray-600">{order.billing.phone}</div>}
<div className="text-sm text-gray-600 mt-2" dangerouslySetInnerHTML={{ __html: order.billing?.address || '' }} />
</div>
{!isVirtualOnly && order.shipping?.name && (
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{__('Ship To')}</div>
<div className="text-sm text-gray-900 font-medium">{order.shipping?.name || '—'}</div>
<div className="text-sm text-gray-600 mt-2" dangerouslySetInnerHTML={{ __html: order.shipping?.address || '' }} />
</div>
)}
</div>
{/* Items Table */}
<table className="w-full mb-8" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr>
<th className="text-left border-b py-2 pr-2">Product</th>
<th className="text-right border-b py-2 px-2">Qty</th>
<th className="text-right border-b py-2 px-2">Subtotal</th>
<th className="text-right border-b py-2 pl-2">Total</th>
<tr className="bg-gray-900 text-white">
<th className="text-left py-3 px-4 text-sm font-semibold">{__('Product')}</th>
<th className="text-center py-3 px-4 text-sm font-semibold w-20">{__('Qty')}</th>
<th className="text-right py-3 px-4 text-sm font-semibold w-32">{__('Price')}</th>
<th className="text-right py-3 px-4 text-sm font-semibold w-32">{__('Total')}</th>
</tr>
</thead>
<tbody>
{(order.items || []).map((it: any) => (
<tr key={it.id}>
<td className="py-1 pr-2">{it.name}</td>
<td className="py-1 px-2 text-right">×{it.qty}</td>
<td className="py-1 px-2 text-right"><Money value={it.subtotal} currency={order.currency} symbol={order.currency_symbol} /></td>
<td className="py-1 pl-2 text-right"><Money value={it.total} currency={order.currency} symbol={order.currency_symbol} /></td>
{(order.items || []).map((it: any, idx: number) => (
<tr key={it.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="py-3 px-4 text-sm">
<div className="font-medium text-gray-900">{it.name}</div>
{it.sku && <div className="text-xs text-gray-500">SKU: {it.sku}</div>}
</td>
<td className="py-3 px-4 text-sm text-center text-gray-600">{it.qty}</td>
<td className="py-3 px-4 text-sm text-right text-gray-600"><Money value={it.subtotal / it.qty} currency={order.currency} symbol={order.currency_symbol} /></td>
<td className="py-3 px-4 text-sm text-right font-medium text-gray-900"><Money value={it.total} currency={order.currency} symbol={order.currency_symbol} /></td>
</tr>
))}
</tbody>
</table>
<div className="flex justify-end">
<div className="min-w-[260px]">
<div className="flex justify-between"><span>Subtotal</span><span><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Discount</span><span><Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Shipping</span><span><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Tax</span><span><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between font-semibold border-t mt-2 pt-2"><span>Total</span><span><Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} /></span></div>
{/* Totals */}
<div className="flex justify-end mb-8">
<div className="w-72">
<div className="flex justify-between py-2 text-sm">
<span className="text-gray-600">{__('Subtotal')}</span>
<span className="text-gray-900"><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></span>
</div>
{(order.totals?.discount || 0) > 0 && (
<div className="flex justify-between py-2 text-sm">
<span className="text-gray-600">{__('Discount')}</span>
<span className="text-green-600">-<Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></span>
</div>
)}
{(order.totals?.shipping || 0) > 0 && (
<div className="flex justify-between py-2 text-sm">
<span className="text-gray-600">{__('Shipping')}</span>
<span className="text-gray-900"><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></span>
</div>
)}
{(order.totals?.tax || 0) > 0 && (
<div className="flex justify-between py-2 text-sm">
<span className="text-gray-600">{__('Tax')}</span>
<span className="text-gray-900"><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></span>
</div>
)}
<div className="flex justify-between py-3 mt-2 border-t-2 border-gray-900">
<span className="text-lg font-bold text-gray-900">{__('Total')}</span>
<span className="text-lg font-bold text-gray-900"><Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} /></span>
</div>
</div>
</div>
{/* Footer */}
<div className="mt-auto pt-8 border-t border-gray-200 text-center text-xs text-gray-500">
<p>{__('Thank you for your business!')}</p>
<p className="mt-1">{siteTitle} {window.location.origin}</p>
</div>
</div>
)}