feat: Create customer-spa core foundation (Sprint 1)

Sprint 1 - Foundation Complete! 

Created Core Files:
 src/main.tsx - Entry point
 src/App.tsx - Main app with routing
 src/index.css - Global styles (TailwindCSS)
 index.html - Development HTML

Pages Created (Placeholders):
 pages/Shop/index.tsx - Product listing
 pages/Product/index.tsx - Product detail
 pages/Cart/index.tsx - Shopping cart
 pages/Checkout/index.tsx - Checkout process
 pages/Account/index.tsx - My Account with sub-routes

Library Setup:
 lib/api/client.ts - API client with endpoints
 lib/cart/store.ts - Cart state management (Zustand)
 types/index.ts - TypeScript definitions

Configuration:
 .gitignore - Ignore node_modules, dist, logs
 README.md - Documentation

Features Implemented:

1. Routing (React Router v7)
   - /shop - Product listing
   - /shop/product/:id - Product detail
   - /shop/cart - Shopping cart
   - /shop/checkout - Checkout
   - /shop/account/* - My Account (dashboard, orders, profile)

2. API Client
   - Fetch wrapper with error handling
   - WordPress nonce authentication
   - Endpoints for shop, cart, checkout, account
   - TypeScript typed responses

3. Cart State (Zustand)
   - Add/update/remove items
   - Cart drawer (open/close)
   - LocalStorage persistence
   - Quantity management
   - Coupon support

4. Type Definitions
   - Product, Order, Customer types
   - Address, ShippingMethod, PaymentMethod
   - Cart, CartItem types
   - Window interface for WordPress globals

5. React Query Setup
   - QueryClient configured
   - 5-minute stale time
   - Retry on error
   - No refetch on window focus

6. Toast Notifications
   - Sonner toast library
   - Top-right position
   - Rich colors

Tech Stack:
- React 18 + TypeScript
- Vite (port 5174)
- React Router v7
- TanStack Query
- Zustand (state)
- TailwindCSS
- shadcn/ui
- React Hook Form + Zod

Dependencies Installed:
 437 packages installed
 All peer dependencies resolved
 Ready for development

Next Steps (Sprint 2):
- Implement Shop page with product grid
- Create ProductCard component
- Add filters and search
- Implement pagination
- Connect to WordPress API

Ready to run:
```bash
cd customer-spa
npm run dev
# Opens https://woonoow.local:5174
```
This commit is contained in:
dwindown
2025-11-21 13:53:38 +07:00
parent 342104eeab
commit 909bddb23d
15 changed files with 8398 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
/**
* API Client for WooNooW Customer SPA
* Handles all HTTP requests to WordPress REST API
*/
// Get API base URL from WordPress
const getApiBase = (): string => {
// @ts-ignore - WordPress global
return window.woonoowCustomer?.apiUrl || '/wp-json/woonoow/v1';
};
// Get nonce for authentication
const getNonce = (): string => {
// @ts-ignore - WordPress global
return window.woonoowCustomer?.nonce || '';
};
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: any;
headers?: Record<string, string>;
}
class ApiClient {
private baseUrl: string;
constructor() {
this.baseUrl = getApiBase();
}
private async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {} } = options;
const url = `${this.baseUrl}${endpoint}`;
const config: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': getNonce(),
...headers,
},
credentials: 'same-origin',
};
if (body) {
config.body = JSON.stringify(body);
}
try {
const response = await fetch(url, config);
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('[API Error]', error);
throw error;
}
}
// GET request
async get<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
let url = endpoint;
if (params) {
const query = new URLSearchParams(params).toString();
url += `?${query}`;
}
return this.request<T>(url, { method: 'GET' });
}
// POST request
async post<T>(endpoint: string, body?: any): Promise<T> {
return this.request<T>(endpoint, { method: 'POST', body });
}
// PUT request
async put<T>(endpoint: string, body?: any): Promise<T> {
return this.request<T>(endpoint, { method: 'PUT', body });
}
// DELETE request
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
}
// 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',
};

View File

@@ -0,0 +1,114 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface CartItem {
key: string;
product_id: number;
variation_id?: number;
quantity: number;
name: string;
price: number;
image?: string;
attributes?: Record<string, string>;
}
export interface Cart {
items: CartItem[];
subtotal: number;
tax: number;
shipping: number;
total: number;
coupon?: {
code: string;
discount: number;
};
}
interface CartStore {
cart: Cart;
isOpen: boolean;
// Actions
setCart: (cart: Cart) => void;
addItem: (item: CartItem) => void;
updateQuantity: (key: string, quantity: number) => void;
removeItem: (key: string) => void;
clearCart: () => void;
openCart: () => void;
closeCart: () => void;
toggleCart: () => void;
}
const initialCart: Cart = {
items: [],
subtotal: 0,
tax: 0,
shipping: 0,
total: 0,
};
export const useCartStore = create<CartStore>()(
persist(
(set) => ({
cart: initialCart,
isOpen: false,
setCart: (cart) => set({ cart }),
addItem: (item) =>
set((state) => {
const existingItem = state.cart.items.find((i) => i.key === item.key);
if (existingItem) {
// Update quantity if item exists
return {
cart: {
...state.cart,
items: state.cart.items.map((i) =>
i.key === item.key
? { ...i, quantity: i.quantity + item.quantity }
: i
),
},
};
}
// Add new item
return {
cart: {
...state.cart,
items: [...state.cart.items, item],
},
};
}),
updateQuantity: (key, quantity) =>
set((state) => ({
cart: {
...state.cart,
items: state.cart.items.map((item) =>
item.key === key ? { ...item, quantity } : item
),
},
})),
removeItem: (key) =>
set((state) => ({
cart: {
...state.cart,
items: state.cart.items.filter((item) => item.key !== key),
},
})),
clearCart: () => set({ cart: initialCart }),
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
}),
{
name: 'woonoow-cart',
partialize: (state) => ({ cart: state.cart }),
}
)
);