feat: Add product images support with WP Media Library integration

- Add WP Media Library integration for product and variation images
- Support images array (URLs) conversion to attachment IDs
- Add images array to API responses (Admin & Customer SPA)
- Implement drag-and-drop sortable images in Admin product form
- Add image gallery thumbnails in Customer SPA product page
- Initialize WooCommerce session for guest cart operations
- Fix product variations and attributes display in Customer SPA
- Add variation image field in Admin SPA

Changes:
- includes/Api/ProductsController.php: Handle images array, add to responses
- includes/Frontend/ShopController.php: Add images array for customer SPA
- includes/Frontend/CartController.php: Initialize WC session for guests
- admin-spa/src/lib/wp-media.ts: Add openWPMediaGallery function
- admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx: WP Media + sortable images
- admin-spa/src/routes/Products/partials/tabs/VariationsTab.tsx: Add variation image field
- customer-spa/src/pages/Product/index.tsx: Add gallery thumbnails display
This commit is contained in:
Dwindi Ramadhana
2025-11-26 16:18:43 +07:00
parent 909bddb23d
commit f397ef850f
69 changed files with 12481 additions and 156 deletions

View File

@@ -88,35 +88,44 @@ class ApiClient {
}
}
// Export singleton instance
export const api = new ApiClient();
// Export API endpoints
export const endpoints = {
// Shop
products: '/shop/products',
product: (id: number) => `/shop/products/${id}`,
categories: '/shop/categories',
search: '/shop/search',
// Cart
cart: '/cart',
cartAdd: '/cart/add',
cartUpdate: '/cart/update',
cartRemove: '/cart/remove',
cartCoupon: '/cart/apply-coupon',
// Checkout
checkoutCalculate: '/checkout/calculate',
checkoutCreate: '/checkout/create-order',
paymentMethods: '/checkout/payment-methods',
shippingMethods: '/checkout/shipping-methods',
// Account
orders: '/account/orders',
order: (id: number) => `/account/orders/${id}`,
downloads: '/account/downloads',
profile: '/account/profile',
password: '/account/password',
addresses: '/account/addresses',
// API endpoints
const endpoints = {
shop: {
products: '/shop/products',
product: (id: number) => `/shop/products/${id}`,
categories: '/shop/categories',
search: '/shop/search',
},
cart: {
get: '/cart',
add: '/cart/add',
update: '/cart/update',
remove: '/cart/remove',
applyCoupon: '/cart/apply-coupon',
removeCoupon: '/cart/remove-coupon',
},
checkout: {
calculate: '/checkout/calculate',
create: '/checkout/create-order',
paymentMethods: '/checkout/payment-methods',
shippingMethods: '/checkout/shipping-methods',
},
account: {
orders: '/account/orders',
order: (id: number) => `/account/orders/${id}`,
downloads: '/account/downloads',
profile: '/account/profile',
password: '/account/password',
addresses: '/account/addresses',
},
};
// Create singleton instance with endpoints
const client = new ApiClient();
// Export as apiClient with endpoints attached
export const apiClient = Object.assign(client, { endpoints });
// Also export individual pieces for convenience
export const api = client;
export { endpoints };

View File

@@ -0,0 +1,190 @@
/**
* Currency Formatting Utilities
*
* Uses WooCommerce currency settings from window.woonoowCustomer.currency
*/
interface CurrencySettings {
code: string;
symbol: string;
position: 'left' | 'right' | 'left_space' | 'right_space';
thousandSeparator: string;
decimalSeparator: string;
decimals: number;
}
/**
* Get currency settings from window
*/
function getCurrencySettings(): CurrencySettings {
const settings = (window as any).woonoowCustomer?.currency;
// Default to USD if not available
return settings || {
code: 'USD',
symbol: '$',
position: 'left',
thousandSeparator: ',',
decimalSeparator: '.',
decimals: 2,
};
}
/**
* Format a number with thousand and decimal separators
*/
function formatNumber(
value: number,
decimals: number,
decimalSeparator: string,
thousandSeparator: string
): string {
// Round to specified decimals
const rounded = value.toFixed(decimals);
// Split into integer and decimal parts
const [integerPart, decimalPart] = rounded.split('.');
// Add thousand separators to integer part
const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator);
// Combine with decimal part if decimals > 0
if (decimals > 0 && decimalPart) {
return `${formattedInteger}${decimalSeparator}${decimalPart}`;
}
return formattedInteger;
}
/**
* Format a price using WooCommerce currency settings
*
* @param price - The price value (number or string)
* @param options - Optional overrides for currency settings
* @returns Formatted price string with currency symbol
*
* @example
* formatPrice(1234.56) // "$1,234.56"
* formatPrice(1234.56, { symbol: '€', position: 'right_space' }) // "1.234,56 €"
*/
export function formatPrice(
price: number | string,
options?: Partial<CurrencySettings>
): string {
const settings = { ...getCurrencySettings(), ...options };
const numericPrice = typeof price === 'string' ? parseFloat(price) : price;
// Handle invalid prices
if (isNaN(numericPrice)) {
return settings.symbol + '0' + settings.decimalSeparator + '00';
}
// Format the number
const formattedNumber = formatNumber(
numericPrice,
settings.decimals,
settings.decimalSeparator,
settings.thousandSeparator
);
// Apply currency symbol based on position
switch (settings.position) {
case 'left':
return `${settings.symbol}${formattedNumber}`;
case 'right':
return `${formattedNumber}${settings.symbol}`;
case 'left_space':
return `${settings.symbol} ${formattedNumber}`;
case 'right_space':
return `${formattedNumber} ${settings.symbol}`;
default:
return `${settings.symbol}${formattedNumber}`;
}
}
/**
* Format price without currency symbol
*/
export function formatPriceValue(
price: number | string,
decimals?: number
): string {
const settings = getCurrencySettings();
const numericPrice = typeof price === 'string' ? parseFloat(price) : price;
if (isNaN(numericPrice)) {
return '0' + settings.decimalSeparator + '00';
}
return formatNumber(
numericPrice,
decimals ?? settings.decimals,
settings.decimalSeparator,
settings.thousandSeparator
);
}
/**
* Get currency symbol
*/
export function getCurrencySymbol(): string {
return getCurrencySettings().symbol;
}
/**
* Get currency code (e.g., 'USD', 'EUR')
*/
export function getCurrencyCode(): string {
return getCurrencySettings().code;
}
/**
* Parse a formatted price string back to a number
*/
export function parsePrice(formattedPrice: string): number {
const settings = getCurrencySettings();
// Remove currency symbol and spaces
let cleaned = formattedPrice.replace(settings.symbol, '').trim();
// Remove thousand separators
cleaned = cleaned.replace(new RegExp(`\\${settings.thousandSeparator}`, 'g'), '');
// Replace decimal separator with dot
cleaned = cleaned.replace(settings.decimalSeparator, '.');
return parseFloat(cleaned) || 0;
}
/**
* Format a price range
*/
export function formatPriceRange(minPrice: number, maxPrice: number): string {
if (minPrice === maxPrice) {
return formatPrice(minPrice);
}
return `${formatPrice(minPrice)} - ${formatPrice(maxPrice)}`;
}
/**
* Calculate and format a discount percentage
*/
export function formatDiscount(regularPrice: number, salePrice: number): string {
if (regularPrice <= salePrice) {
return '';
}
const discount = ((regularPrice - salePrice) / regularPrice) * 100;
return `-${Math.round(discount)}%`;
}
/**
* Format a price with tax label
*/
export function formatPriceWithTax(
price: number,
taxLabel: string = 'incl. tax'
): string {
return `${formatPrice(price)} (${taxLabel})`;
}

View File

@@ -0,0 +1,54 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
/**
* Merge Tailwind classes with clsx
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Format price
*/
export function formatPrice(price: number | string, currency: string = 'USD'): string {
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(numPrice);
}
/**
* Format date
*/
export function formatDate(date: string | Date): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(dateObj);
}
/**
* Debounce function
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}