From 0f542ad452c13d83ff07dcadb4b8dea2cba621bf Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sun, 4 Jan 2026 20:03:33 +0700 Subject: [PATCH] feat: Multiple fixes and features 1. Add allow_custom_avatar toggle to Customer Settings 2. Implement coupon apply/remove in Cart and Checkout pages 3. Update Cart interface with coupons array and discount_total 4. Implement Downloads page to fetch from /account/downloads API --- .../src/routes/Marketing/Newsletter/index.tsx | 2 +- admin-spa/src/routes/Settings/Customers.tsx | 10 ++ .../Settings/Notifications/Customer.tsx | 2 +- .../Notifications/EmailConfiguration.tsx | 2 +- .../Notifications/PushConfiguration.tsx | 2 +- .../routes/Settings/Notifications/Staff.tsx | 2 +- .../Settings/Notifications/TemplateEditor.tsx | 2 +- customer-spa/src/lib/cart/api.ts | 54 ++++++ customer-spa/src/lib/cart/store.ts | 13 +- customer-spa/src/pages/Account/Downloads.tsx | 155 +++++++++++++++++- customer-spa/src/pages/Cart/index.tsx | 97 +++++++++-- customer-spa/src/pages/Checkout/index.tsx | 104 +++++++++++- includes/Compat/CustomerSettingsProvider.php | 7 +- 13 files changed, 420 insertions(+), 32 deletions(-) diff --git a/admin-spa/src/routes/Marketing/Newsletter/index.tsx b/admin-spa/src/routes/Marketing/Newsletter/index.tsx index c4ddb55..d562212 100644 --- a/admin-spa/src/routes/Marketing/Newsletter/index.tsx +++ b/admin-spa/src/routes/Marketing/Newsletter/index.tsx @@ -56,7 +56,7 @@ export default function Newsletter() { description={__('Manage subscribers and send email campaigns')} > - + {__('Subscribers')} {__('Campaigns')} diff --git a/admin-spa/src/routes/Settings/Customers.tsx b/admin-spa/src/routes/Settings/Customers.tsx index 8a36ff4..bf7aa46 100644 --- a/admin-spa/src/routes/Settings/Customers.tsx +++ b/admin-spa/src/routes/Settings/Customers.tsx @@ -13,6 +13,7 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency'; interface CustomerSettings { auto_register_members: boolean; multiple_addresses_enabled: boolean; + allow_custom_avatar: boolean; vip_min_spent: number; vip_min_orders: number; vip_timeframe: 'all' | '30' | '90' | '365'; @@ -24,6 +25,7 @@ export default function CustomersSettings() { const [settings, setSettings] = useState({ auto_register_members: false, multiple_addresses_enabled: true, + allow_custom_avatar: false, vip_min_spent: 1000, vip_min_orders: 10, vip_timeframe: 'all', @@ -138,6 +140,14 @@ export default function CustomersSettings() { onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })} /> + setSettings({ ...settings, allow_custom_avatar: checked })} + /> + diff --git a/admin-spa/src/routes/Settings/Notifications/Customer.tsx b/admin-spa/src/routes/Settings/Notifications/Customer.tsx index 55924b5..fa862d3 100644 --- a/admin-spa/src/routes/Settings/Notifications/Customer.tsx +++ b/admin-spa/src/routes/Settings/Notifications/Customer.tsx @@ -34,7 +34,7 @@ export default function CustomerNotifications() { } > - + {__('Channels')} {__('Events')} diff --git a/admin-spa/src/routes/Settings/Notifications/EmailConfiguration.tsx b/admin-spa/src/routes/Settings/Notifications/EmailConfiguration.tsx index e459d67..e8ec545 100644 --- a/admin-spa/src/routes/Settings/Notifications/EmailConfiguration.tsx +++ b/admin-spa/src/routes/Settings/Notifications/EmailConfiguration.tsx @@ -22,7 +22,7 @@ export default function EmailConfiguration() { } > - + {__('Template Settings')} {__('Connection Settings')} diff --git a/admin-spa/src/routes/Settings/Notifications/PushConfiguration.tsx b/admin-spa/src/routes/Settings/Notifications/PushConfiguration.tsx index 4996dfb..778e075 100644 --- a/admin-spa/src/routes/Settings/Notifications/PushConfiguration.tsx +++ b/admin-spa/src/routes/Settings/Notifications/PushConfiguration.tsx @@ -25,7 +25,7 @@ export default function PushConfiguration() { } > - + {__('Template Settings')} {__('Connection Settings')} diff --git a/admin-spa/src/routes/Settings/Notifications/Staff.tsx b/admin-spa/src/routes/Settings/Notifications/Staff.tsx index af3f27e..c920b8d 100644 --- a/admin-spa/src/routes/Settings/Notifications/Staff.tsx +++ b/admin-spa/src/routes/Settings/Notifications/Staff.tsx @@ -34,7 +34,7 @@ export default function StaffNotifications() { } > - + {__('Channels')} {__('Events')} diff --git a/admin-spa/src/routes/Settings/Notifications/TemplateEditor.tsx b/admin-spa/src/routes/Settings/Notifications/TemplateEditor.tsx index 41e6d8c..2d8903e 100644 --- a/admin-spa/src/routes/Settings/Notifications/TemplateEditor.tsx +++ b/admin-spa/src/routes/Settings/Notifications/TemplateEditor.tsx @@ -206,7 +206,7 @@ export default function TemplateEditor({ {/* Body - Scrollable */}
- + {__('Editor')} diff --git a/customer-spa/src/lib/cart/api.ts b/customer-spa/src/lib/cart/api.ts index a4c94f5..745cb90 100644 --- a/customer-spa/src/lib/cart/api.ts +++ b/customer-spa/src/lib/cart/api.ts @@ -109,3 +109,57 @@ export async function fetchCart(): Promise { const data = await response.json(); return data; } + +/** + * Apply coupon to cart via API + */ +export async function applyCoupon(couponCode: string): Promise { + const { apiRoot, nonce } = getApiConfig(); + + const response = await fetch(`${apiRoot}/cart/apply-coupon`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': nonce, + }, + credentials: 'include', + body: JSON.stringify({ + coupon_code: couponCode, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Failed to apply coupon'); + } + + const data = await response.json(); + return data.cart; +} + +/** + * Remove coupon from cart via API + */ +export async function removeCoupon(couponCode: string): Promise { + const { apiRoot, nonce } = getApiConfig(); + + const response = await fetch(`${apiRoot}/cart/remove-coupon`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': nonce, + }, + credentials: 'include', + body: JSON.stringify({ + coupon_code: couponCode, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Failed to remove coupon'); + } + + const data = await response.json(); + return data.cart; +} diff --git a/customer-spa/src/lib/cart/store.ts b/customer-spa/src/lib/cart/store.ts index b330ccf..e852c26 100644 --- a/customer-spa/src/lib/cart/store.ts +++ b/customer-spa/src/lib/cart/store.ts @@ -24,12 +24,19 @@ export interface Cart { code: string; discount: number; }; + coupons?: { + code: string; + discount: number; + type?: string; + }[]; + discount_total?: number; + shipping_total?: number; } interface CartStore { cart: Cart; isOpen: boolean; - + // Actions setCart: (cart: Cart) => void; addItem: (item: CartItem) => void; @@ -60,7 +67,7 @@ export const useCartStore = create()( addItem: (item) => set((state) => { const existingItem = state.cart.items.find((i) => i.key === item.key); - + if (existingItem) { // Update quantity if item exists return { @@ -74,7 +81,7 @@ export const useCartStore = create()( }, }; } - + // Add new item return { cart: { diff --git a/customer-spa/src/pages/Account/Downloads.tsx b/customer-spa/src/pages/Account/Downloads.tsx index 83c2b34..735ea08 100644 --- a/customer-spa/src/pages/Account/Downloads.tsx +++ b/customer-spa/src/pages/Account/Downloads.tsx @@ -1,15 +1,158 @@ -import React from 'react'; -import { Download } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { Download, Loader2, FileText, ExternalLink } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { api } from '@/lib/api/client'; +import { toast } from 'sonner'; +import { formatPrice } from '@/lib/currency'; + +interface DownloadItem { + download_id: string; + download_url: string; + product_id: number; + product_name: string; + product_url: string; + download_name: string; + order_id: number; + order_key: string; + downloads_remaining: string; + access_expires: string | null; + file: { + name: string; + file: string; + }; +} export default function Downloads() { + const [downloads, setDownloads] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchDownloads = async () => { + try { + setIsLoading(true); + const data = await api.get('/account/downloads'); + setDownloads(data); + } catch (err: any) { + console.error('Failed to fetch downloads:', err); + setError(err.message || 'Failed to load downloads'); + } finally { + setIsLoading(false); + } + }; + + fetchDownloads(); + }, []); + + const handleDownload = (downloadUrl: string, fileName: string) => { + // Open download in new tab + window.open(downloadUrl, '_blank'); + toast.success(`Downloading ${fileName}`); + }; + + if (isLoading) { + return ( +
+

Downloads

+
+ +

Loading your downloads...

+
+
+ ); + } + + if (error) { + return ( +
+

Downloads

+
+

{error}

+ +
+
+ ); + } + + if (downloads.length === 0) { + return ( +
+

Downloads

+
+ +

No downloads available

+

+ Downloads will appear here after you purchase downloadable products. +

+
+
+ ); + } + return (

Downloads

- -
- -

No downloads available

+ +
+ {downloads.map((download) => ( +
+
+
+
+ +
+
+

+ {download.product_name} +

+

+ {download.download_name || download.file?.name || 'Download'} +

+
+ + Order #{download.order_id} + + {download.downloads_remaining && download.downloads_remaining !== 'unlimited' && ( + + {download.downloads_remaining} downloads left + + )} + {download.access_expires && ( + + Expires: {new Date(download.access_expires).toLocaleDateString()} + + )} +
+
+
+ + +
+
+ ))}
+ + {downloads.length > 0 && ( +

+ {downloads.length} {downloads.length === 1 ? 'download' : 'downloads'} available +

+ )}
); } diff --git a/customer-spa/src/pages/Cart/index.tsx b/customer-spa/src/pages/Cart/index.tsx index a784fe7..47052a4 100644 --- a/customer-spa/src/pages/Cart/index.tsx +++ b/customer-spa/src/pages/Cart/index.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useCartStore, type CartItem } from '@/lib/cart/store'; import { useCartSettings } from '@/hooks/useAppearanceSettings'; -import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api'; +import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart, applyCoupon, removeCoupon } from '@/lib/cart/api'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -14,7 +14,7 @@ import { } from '@/components/ui/dialog'; import Container from '@/components/Layout/Container'; import { formatPrice } from '@/lib/currency'; -import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2 } from 'lucide-react'; +import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2, X, Tag } from 'lucide-react'; import { toast } from 'sonner'; export default function Cart() { @@ -24,7 +24,9 @@ export default function Cart() { const [showClearDialog, setShowClearDialog] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const [isLoading, setIsLoading] = useState(true); - + const [couponCode, setCouponCode] = useState(''); + const [isApplyingCoupon, setIsApplyingCoupon] = useState(false); + // Fetch cart from server on mount to sync with WooCommerce useEffect(() => { const loadCart = async () => { @@ -37,10 +39,10 @@ export default function Cart() { setIsLoading(false); } }; - + loadCart(); }, [setCart]); - + // Calculate total from items const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0); @@ -49,7 +51,7 @@ export default function Cart() { handleRemoveItem(key); return; } - + setIsUpdating(true); try { const updatedCart = await updateCartItemQuantity(key, newQuantity); @@ -92,6 +94,37 @@ export default function Cart() { } }; + const handleApplyCoupon = async () => { + if (!couponCode.trim()) return; + + setIsApplyingCoupon(true); + try { + const updatedCart = await applyCoupon(couponCode.trim()); + setCart(updatedCart); + setCouponCode(''); + toast.success('Coupon applied successfully'); + } catch (error: any) { + console.error('Failed to apply coupon:', error); + toast.error(error.message || 'Failed to apply coupon'); + } finally { + setIsApplyingCoupon(false); + } + }; + + const handleRemoveCoupon = async (code: string) => { + setIsUpdating(true); + try { + const updatedCart = await removeCoupon(code); + setCart(updatedCart); + toast.success('Coupon removed'); + } catch (error: any) { + console.error('Failed to remove coupon:', error); + toast.error(error.message || 'Failed to remove coupon'); + } finally { + setIsUpdating(false); + } + }; + // Show loading state while fetching cart if (isLoading) { return ( @@ -162,7 +195,7 @@ export default function Cart() {

{item.name}

- + {/* Variation Attributes */} {item.attributes && Object.keys(item.attributes).length > 0 && (
@@ -177,7 +210,7 @@ export default function Cart() { })}
)} - +

{formatPrice(item.price)}

@@ -237,10 +270,43 @@ export default function Cart() { setCouponCode(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleApplyCoupon()} className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" + disabled={isApplyingCoupon} /> - +
+ + {/* Applied Coupons */} + {(cart as any).coupons?.length > 0 && ( +
+ {(cart as any).coupons.map((coupon: { code: string; discount: number }) => ( +
+
+ + {coupon.code} + -{formatPrice(coupon.discount)} +
+ +
+ ))} +
+ )}
)} @@ -262,15 +328,22 @@ export default function Cart() {
Subtotal - {formatPrice(total)} + {formatPrice((cart as any).subtotal || total)}
+ {/* Show discount if coupons applied */} + {(cart as any).discount_total > 0 && ( +
+ Discount + -{formatPrice((cart as any).discount_total)} +
+ )}
Shipping - Calculated at checkout + {(cart as any).shipping_total > 0 ? formatPrice((cart as any).shipping_total) : 'Calculated at checkout'}
Total - {formatPrice(total)} + {formatPrice((cart as any).total || total)}
diff --git a/customer-spa/src/pages/Checkout/index.tsx b/customer-spa/src/pages/Checkout/index.tsx index f621483..1349f50 100644 --- a/customer-spa/src/pages/Checkout/index.tsx +++ b/customer-spa/src/pages/Checkout/index.tsx @@ -5,11 +5,12 @@ import { useCheckoutSettings } from '@/hooks/useAppearanceSettings'; import { Button } from '@/components/ui/button'; import Container from '@/components/Layout/Container'; import { formatPrice } from '@/lib/currency'; -import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2 } from 'lucide-react'; +import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2, Loader2, X, Tag } from 'lucide-react'; import { toast } from 'sonner'; import { apiClient } from '@/lib/api/client'; import { api } from '@/lib/api/client'; import { AddressSelector } from '@/components/AddressSelector'; +import { applyCoupon, removeCoupon, fetchCart } from '@/lib/cart/api'; interface SavedAddress { id: number; @@ -34,6 +35,10 @@ export default function Checkout() { const { cart } = useCartStore(); const { layout, elements } = useCheckoutSettings(); const [isProcessing, setIsProcessing] = useState(false); + const [couponCode, setCouponCode] = useState(''); + const [isApplyingCoupon, setIsApplyingCoupon] = useState(false); + const [appliedCoupons, setAppliedCoupons] = useState<{ code: string; discount: number }[]>([]); + const [discountTotal, setDiscountTotal] = useState(0); const user = (window as any).woonoowCustomer?.user; // Check if cart contains only virtual/downloadable products @@ -189,6 +194,57 @@ export default function Checkout() { } }, [user]); + const handleApplyCoupon = async () => { + if (!couponCode.trim()) return; + + setIsApplyingCoupon(true); + try { + const updatedCart = await applyCoupon(couponCode.trim()); + if (updatedCart.coupons) { + setAppliedCoupons(updatedCart.coupons); + setDiscountTotal(updatedCart.discount_total || 0); + } + setCouponCode(''); + toast.success('Coupon applied successfully'); + } catch (error: any) { + toast.error(error.message || 'Failed to apply coupon'); + } finally { + setIsApplyingCoupon(false); + } + }; + + const handleRemoveCoupon = async (code: string) => { + setIsApplyingCoupon(true); + try { + const updatedCart = await removeCoupon(code); + if (updatedCart.coupons) { + setAppliedCoupons(updatedCart.coupons); + setDiscountTotal(updatedCart.discount_total || 0); + } + toast.success('Coupon removed'); + } catch (error: any) { + toast.error(error.message || 'Failed to remove coupon'); + } finally { + setIsApplyingCoupon(false); + } + }; + + // Load cart data including coupons on mount + useEffect(() => { + const loadCartData = async () => { + try { + const cartData = await fetchCart(); + if (cartData.coupons) { + setAppliedCoupons(cartData.coupons); + setDiscountTotal(cartData.discount_total || 0); + } + } catch (error) { + console.error('Failed to load cart data:', error); + } + }; + loadCartData(); + }, []); + const handlePlaceOrder = async (e: React.FormEvent) => { e.preventDefault(); setIsProcessing(true); @@ -652,10 +708,45 @@ export default function Checkout() { setCouponCode(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleApplyCoupon())} className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" + disabled={isApplyingCoupon} /> - + + + {/* Applied Coupons */} + {appliedCoupons.length > 0 && ( +
+ {appliedCoupons.map((coupon) => ( +
+
+ + {coupon.code} + -{formatPrice(coupon.discount)} +
+ +
+ ))} +
+ )} )} @@ -702,6 +793,13 @@ export default function Checkout() { Subtotal {formatPrice(subtotal)} + {/* Show discount if coupons applied */} + {discountTotal > 0 && ( +
+ Discount + -{formatPrice(discountTotal)} +
+ )}
Shipping {shipping === 0 ? 'Free' : formatPrice(shipping)} @@ -714,7 +812,7 @@ export default function Checkout() { )}
Total - {formatPrice(total)} + {formatPrice(total - discountTotal)}
diff --git a/includes/Compat/CustomerSettingsProvider.php b/includes/Compat/CustomerSettingsProvider.php index 299025d..92dd803 100644 --- a/includes/Compat/CustomerSettingsProvider.php +++ b/includes/Compat/CustomerSettingsProvider.php @@ -21,6 +21,7 @@ class CustomerSettingsProvider { // General 'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes', 'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes', + 'allow_custom_avatar' => get_option('woonoow_allow_custom_avatar', 'no') === 'yes', // VIP Customer Qualification 'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)), @@ -49,8 +50,10 @@ class CustomerSettingsProvider { update_option('woonoow_multiple_addresses_enabled', $value); } - - + if (array_key_exists('allow_custom_avatar', $settings)) { + $value = !empty($settings['allow_custom_avatar']) ? 'yes' : 'no'; + update_option('woonoow_allow_custom_avatar', $value); + } // VIP settings if (isset($settings['vip_min_spent'])) { update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));