feat: Newsletter system improvements and validation framework

- Fix: Marketing events now display in Staff notifications tab
- Reorganize: Move Coupons to Marketing/Coupons for better organization
- Add: Comprehensive email/phone validation with extensible filter hooks
  - Email validation with regex pattern (xxxx@xxxx.xx)
  - Phone validation with WhatsApp verification support
  - Filter hooks for external API integration (QuickEmailVerification, etc.)
- Fix: Newsletter template routes now use centralized notification email builder
- Add: Validation.php class for reusable validation logic
- Add: VALIDATION_HOOKS.md documentation with integration examples
- Add: NEWSLETTER_CAMPAIGN_PLAN.md architecture for future campaign system
- Fix: API delete method call in Newsletter.tsx (delete -> del)
- Remove: Duplicate EmailTemplates.tsx (using notification system instead)
- Update: Newsletter controller to use centralized Validation class

Breaking changes:
- Coupons routes moved from /routes/Coupons to /routes/Marketing/Coupons
- Legacy /coupons routes maintained for backward compatibility
This commit is contained in:
Dwindi Ramadhana
2025-12-26 10:59:48 +07:00
parent 0b08ddefa1
commit 0b2c8a56d6
23 changed files with 1132 additions and 232 deletions

View File

@@ -21,8 +21,9 @@ export function useWishlist() {
const [isLoading, setIsLoading] = useState(false);
const [productIds, setProductIds] = useState<Set<number>>(new Set());
// Check if wishlist is enabled
const isEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
// Check if wishlist is enabled (default true if not explicitly set to false)
const settings = (window as any).woonoowCustomer?.settings;
const isEnabled = settings?.wishlist_enabled !== false;
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
// Load wishlist on mount

View File

@@ -1,6 +1,6 @@
import React, { ReactNode, useState } from 'react';
import { Link } from 'react-router-dom';
import { ShoppingCart, User, Search, Menu, X } from 'lucide-react';
import { Search, ShoppingCart, User, Menu, X, Heart } from 'lucide-react';
import { useLayout } from '../contexts/ThemeContext';
import { useCartStore } from '../lib/cart/store';
import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSettings';
@@ -130,6 +130,14 @@ function ClassicLayout({ children }: BaseLayoutProps) {
</a>
))}
{/* Wishlist */}
{headerSettings.elements.wishlist && (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false && user?.isLoggedIn && (
<Link to="/my-account/wishlist" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
<Heart className="h-5 w-5" />
<span className="hidden lg:block">Wishlist</span>
</Link>
)}
{/* Cart */}
{headerSettings.elements.cart && (
<Link to="/cart" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">

View File

@@ -50,41 +50,21 @@ export default function Addresses() {
const loadAddresses = async () => {
try {
const response: any = await api.get('/account/addresses');
console.log('API response:', response);
console.log('Type of response:', typeof response);
console.log('Is array:', Array.isArray(response));
console.log('Response keys:', response ? Object.keys(response) : 'null');
console.log('Response values:', response ? Object.values(response) : 'null');
// Handle different response structures
let data: Address[] = [];
if (Array.isArray(response)) {
// Direct array response
data = response;
console.log('Using direct array');
} else if (response && typeof response === 'object') {
// Log all properties to debug
console.log('Checking object properties...');
// Check common wrapper properties
if (Array.isArray(response.data)) {
data = response.data;
console.log('Using response.data');
} else if (Array.isArray(response.addresses)) {
data = response.addresses;
console.log('Using response.addresses');
} else if (response.length !== undefined && typeof response === 'object') {
// Might be array-like object, convert to array
data = Object.values(response).filter((item: any) => item && typeof item === 'object' && item.id) as Address[];
console.log('Converted object to array:', data);
} else {
console.error('API returned unexpected structure:', response);
console.error('Available keys:', Object.keys(response));
}
}
console.log('Final addresses array:', data);
setAddresses(data);
} catch (error) {
console.error('Load addresses error:', error);
@@ -124,16 +104,11 @@ export default function Addresses() {
const handleSave = async () => {
try {
console.log('Saving address:', formData);
if (editingAddress) {
console.log('Updating address ID:', editingAddress.id);
const response = await api.put(`/account/addresses/${editingAddress.id}`, formData);
console.log('Update response:', response);
await api.put(`/account/addresses/${editingAddress.id}`, formData);
toast.success('Address updated successfully');
} else {
console.log('Creating new address');
const response = await api.post('/account/addresses', formData);
console.log('Create response:', response);
await api.post('/account/addresses', formData);
toast.success('Address added successfully');
}
setShowModal(false);

View File

@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import { useCartStore } from '@/lib/cart/store';
import { useProductSettings } from '@/hooks/useAppearanceSettings';
import { useWishlist } from '@/hooks/useWishlist';
import { Button } from '@/components/ui/button';
import Container from '@/components/Layout/Container';
import { ProductCard } from '@/components/ProductCard';
@@ -23,6 +24,7 @@ export default function Product() {
const [selectedAttributes, setSelectedAttributes] = useState<Record<string, string>>({});
const thumbnailsRef = useRef<HTMLDivElement>(null);
const { addItem } = useCartStore();
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist, isLoggedIn } = useWishlist();
// Fetch product details by slug
const { data: product, isLoading, error } = useQuery<ProductType | null>({
@@ -40,27 +42,19 @@ export default function Product() {
queryFn: async () => {
if (!product) return [];
console.log('[Related Products] Fetching for product:', product.id);
console.log('[Related Products] Categories:', product.categories);
try {
if (product.related_ids && product.related_ids.length > 0) {
const ids = product.related_ids.slice(0, 4).join(',');
console.log('[Related Products] Using related_ids:', ids);
const response = await apiClient.get<ProductsResponse>(`/shop/products?include=${ids}`);
console.log('[Related Products] Response:', response);
return response.products || [];
}
const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id;
if (categoryId) {
console.log('[Related Products] Using category:', categoryId);
const response = await apiClient.get<ProductsResponse>(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`);
console.log('[Related Products] Response:', response.products?.length, 'products');
return response.products || [];
}
console.log('[Related Products] No category found');
return [];
} catch (error) {
console.error('Failed to fetch related products:', error);
@@ -70,15 +64,6 @@ export default function Product() {
enabled: !!product?.id && elements.related_products,
});
// Debug logging
console.log('[Related Products] Settings:', {
enabled: elements.related_products,
hasProduct: !!product?.id,
queryEnabled: !!product?.id && elements.related_products,
relatedProductsData: relatedProducts,
relatedProductsLength: relatedProducts?.length
});
// Set initial image when product loads
useEffect(() => {
if (product && !selectedImage) {
@@ -502,10 +487,21 @@ export default function Product() {
<ShoppingCart className="h-5 w-5" />
Add to Cart
</button>
<button className="w-full h-14 flex items-center justify-center gap-2 bg-white text-gray-900 rounded-xl font-semibold text-base border-2 border-gray-200 hover:border-gray-400 transition-all">
<Heart className="h-5 w-5" />
Add to Wishlist
</button>
{wishlistEnabled && (
<button
onClick={() => product && toggleWishlist(product.id)}
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${
product && isInWishlist(product.id)
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
}`}
>
<Heart className={`h-5 w-5 ${
product && isInWishlist(product.id) ? 'fill-red-500' : ''
}`} />
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
</button>
)}
</div>
)}
@@ -694,7 +690,7 @@ export default function Product() {
<div className="flex items-center gap-2">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<svg key={star} className={`w-5 h-5 ${star <= (product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
<svg key={star} className={`w-5 h-5 ${star <= Number(product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
</svg>
))}

View File

@@ -7,6 +7,7 @@ export interface ProductCategory {
name: string;
slug: string;
count?: number;
term_id?: number;
}
export interface Product {
@@ -31,6 +32,11 @@ export interface Product {
attributes?: any[];
variations?: number[];
permalink?: string;
related_ids?: number[];
virtual?: boolean;
downloadable?: boolean;
review_count?: number;
average_rating?: string;
}
export interface ProductsResponse {