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:
@@ -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 };
|
||||
|
||||
190
customer-spa/src/lib/currency.ts
Normal file
190
customer-spa/src/lib/currency.ts
Normal 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})`;
|
||||
}
|
||||
54
customer-spa/src/lib/utils.ts
Normal file
54
customer-spa/src/lib/utils.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user