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
This commit is contained in:
Dwindi Ramadhana
2026-01-04 20:03:33 +07:00
parent befacf9d29
commit 0f542ad452
13 changed files with 420 additions and 32 deletions

View File

@@ -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() {
<input
type="text"
placeholder="Enter coupon code"
value={couponCode}
onChange={(e) => 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}
/>
<Button type="button" variant="outline" size="sm">Apply</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleApplyCoupon}
disabled={isApplyingCoupon || !couponCode.trim()}
>
{isApplyingCoupon ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
</Button>
</div>
{/* Applied Coupons */}
{appliedCoupons.length > 0 && (
<div className="mt-3 space-y-2">
{appliedCoupons.map((coupon) => (
<div key={coupon.code} className="flex items-center justify-between p-2 bg-green-50 border border-green-200 rounded-md">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800">{coupon.code}</span>
<span className="text-sm text-green-600">-{formatPrice(coupon.discount)}</span>
</div>
<button
type="button"
onClick={() => handleRemoveCoupon(coupon.code)}
className="text-green-600 hover:text-green-800 p-1"
disabled={isApplyingCoupon}
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
)}
@@ -702,6 +793,13 @@ export default function Checkout() {
<span>Subtotal</span>
<span>{formatPrice(subtotal)}</span>
</div>
{/* Show discount if coupons applied */}
{discountTotal > 0 && (
<div className="flex justify-between text-sm text-green-600">
<span>Discount</span>
<span>-{formatPrice(discountTotal)}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span>Shipping</span>
<span>{shipping === 0 ? 'Free' : formatPrice(shipping)}</span>
@@ -714,7 +812,7 @@ export default function Checkout() {
)}
<div className="border-t pt-2 flex justify-between font-bold text-lg">
<span>Total</span>
<span>{formatPrice(total)}</span>
<span>{formatPrice(total - discountTotal)}</span>
</div>
</div>