Update skills: add website-creator, mql-developer, ecommerce-astro
Changes: - Add FAL_KEY and GEMINI_API_KEY to .env.example - Update picture-it to use ~/.config/opencode/.env (unified creds) - Remove shodh-memory skill (no longer used) - Remove alphaear-* skills (deprecated) - Remove thai-frontend-dev skill (replaced by website-creator) - Remove theme-factory skill - Add mql-developer skill (MQL5 trading) - Add ecommerce-astro skill (Astro e-commerce) - Add website-creator skill (Next.js + Payload CMS) - Update install script for new skills
This commit is contained in:
17
skills/ecommerce-astro/scripts/.env.example
Normal file
17
skills/ecommerce-astro/scripts/.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Supabase Configuration
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
|
||||
# PaySo Payment Gateway (Thai Payment)
|
||||
PAYSOLO_MERCHANT_ID=your-merchant-id
|
||||
PAYSOLO_API_KEY=your-api-key
|
||||
PAYSOLO_SECRET_KEY=your-secret-key
|
||||
PAYSOLO_CALLBACK_URL=https://yourdomain.com/api/webhooks/payso
|
||||
|
||||
# JWT Authentication
|
||||
JWT_SECRET=your-super-secret-jwt-key-min-32-chars-here
|
||||
|
||||
# Site Configuration
|
||||
SITE_URL=https://yourdomain.com
|
||||
SITE_NAME=My Store
|
||||
2034
skills/ecommerce-astro/scripts/create_ecommerce.py
Executable file
2034
skills/ecommerce-astro/scripts/create_ecommerce.py
Executable file
File diff suppressed because it is too large
Load Diff
3
skills/ecommerce-astro/scripts/requirements.txt
Normal file
3
skills/ecommerce-astro/scripts/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
requests>=2.28.0
|
||||
python-dotenv>=1.0.0
|
||||
jinja2>=3.1.0
|
||||
421
skills/ecommerce-astro/scripts/supabase_migration.sql
Normal file
421
skills/ecommerce-astro/scripts/supabase_migration.sql
Normal file
@@ -0,0 +1,421 @@
|
||||
-- =====================================================
|
||||
-- E-commerce Database Schema for Supabase
|
||||
-- Astro 6 + React + PaySo Multi-vendor Marketplace
|
||||
-- =====================================================
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- =====================================================
|
||||
-- USERS & AUTHENTICATION
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT,
|
||||
phone TEXT,
|
||||
role TEXT DEFAULT 'customer' CHECK (role IN ('customer', 'vendor', 'admin')),
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- VENDOR PROFILES (Multi-vendor support)
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE vendor_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
|
||||
store_name TEXT NOT NULL,
|
||||
store_slug TEXT UNIQUE NOT NULL,
|
||||
store_description TEXT,
|
||||
store_logo TEXT,
|
||||
bank_account TEXT,
|
||||
bank_name TEXT,
|
||||
payout_status TEXT DEFAULT 'pending' CHECK (payout_status IN ('pending', 'approved', 'rejected')),
|
||||
total_earnings DECIMAL(12,2) DEFAULT 0,
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- CATEGORIES (Hierarchical)
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE categories (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
parent_id UUID REFERENCES categories(id),
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- PRODUCTS
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE products (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
vendor_id UUID REFERENCES vendor_profiles(id) ON DELETE CASCADE,
|
||||
category_id UUID REFERENCES categories(id),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
price DECIMAL(12,2) NOT NULL,
|
||||
compare_at_price DECIMAL(12,2),
|
||||
cost_price DECIMAL(12,2),
|
||||
sku TEXT UNIQUE,
|
||||
barcode TEXT,
|
||||
inventory INT DEFAULT 0,
|
||||
low_stock_threshold INT DEFAULT 5,
|
||||
track_inventory BOOLEAN DEFAULT TRUE,
|
||||
allow_backorder BOOLEAN DEFAULT FALSE,
|
||||
weight DECIMAL(8,2),
|
||||
images JSONB DEFAULT '[]',
|
||||
metadata JSONB DEFAULT '{}',
|
||||
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')),
|
||||
featured BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- PRODUCT VARIANTS
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE product_variants (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
product_id UUID REFERENCES products(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
sku TEXT UNIQUE,
|
||||
price DECIMAL(12,2),
|
||||
inventory INT DEFAULT 0,
|
||||
attributes JSONB DEFAULT '{}',
|
||||
image_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- REVIEWS
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE reviews (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
product_id UUID REFERENCES products(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id),
|
||||
order_id UUID,
|
||||
rating INT CHECK (rating >= 1 AND rating <= 5),
|
||||
title TEXT,
|
||||
comment TEXT,
|
||||
images JSONB DEFAULT '[]',
|
||||
verified_purchase BOOLEAN DEFAULT FALSE,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- ORDERS
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE orders (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
order_number TEXT UNIQUE NOT NULL,
|
||||
user_id UUID REFERENCES users(id),
|
||||
vendor_id UUID REFERENCES vendor_profiles(id),
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded')),
|
||||
payment_status TEXT DEFAULT 'unpaid' CHECK (payment_status IN ('unpaid', 'paid', 'failed', 'refunded')),
|
||||
subtotal DECIMAL(12,2) NOT NULL,
|
||||
tax DECIMAL(12,2) DEFAULT 0,
|
||||
shipping_cost DECIMAL(12,2) DEFAULT 0,
|
||||
total DECIMAL(12,2) NOT NULL,
|
||||
currency TEXT DEFAULT 'THB',
|
||||
payment_method TEXT,
|
||||
payment_provider TEXT,
|
||||
payment_ref TEXT,
|
||||
shipping_name TEXT,
|
||||
shipping_phone TEXT,
|
||||
shipping_address TEXT,
|
||||
shipping_city TEXT,
|
||||
shipping_postal TEXT,
|
||||
shipping_country TEXT DEFAULT 'Thailand',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- ORDER ITEMS
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE order_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
order_id UUID REFERENCES orders(id) ON DELETE CASCADE,
|
||||
product_id UUID REFERENCES products(id),
|
||||
variant_id UUID REFERENCES product_variants(id),
|
||||
vendor_id UUID REFERENCES vendor_profiles(id),
|
||||
quantity INT NOT NULL,
|
||||
unit_price DECIMAL(12,2) NOT NULL,
|
||||
total_price DECIMAL(12,2) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- PAYMENTS
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE payments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
order_id UUID REFERENCES orders(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
provider TEXT NOT NULL,
|
||||
provider_ref TEXT,
|
||||
amount DECIMAL(12,2) NOT NULL,
|
||||
currency TEXT DEFAULT 'THB',
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'failed', 'refunded')),
|
||||
payment_data JSONB,
|
||||
paid_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- VENDOR PAYOUTS
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE vendor_payouts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
vendor_id UUID REFERENCES vendor_profiles(id),
|
||||
amount DECIMAL(12,2) NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
|
||||
bank_account TEXT,
|
||||
bank_name TEXT,
|
||||
notes TEXT,
|
||||
processed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- SHOPPING CARTS
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE carts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE cart_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
cart_id UUID REFERENCES carts(id) ON DELETE CASCADE,
|
||||
product_id UUID REFERENCES products(id),
|
||||
variant_id UUID REFERENCES product_variants(id),
|
||||
quantity INT DEFAULT 1,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(cart_id, product_id, variant_id)
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- INDEXES FOR PERFORMANCE
|
||||
-- =====================================================
|
||||
|
||||
-- Products
|
||||
CREATE INDEX idx_products_vendor ON products(vendor_id);
|
||||
CREATE INDEX idx_products_category ON products(category_id);
|
||||
CREATE INDEX idx_products_slug ON products(slug);
|
||||
CREATE INDEX idx_products_status ON products(status);
|
||||
CREATE INDEX idx_products_featured ON products(featured) WHERE featured = TRUE;
|
||||
CREATE INDEX idx_products_inventory ON products(inventory);
|
||||
|
||||
-- Orders
|
||||
CREATE INDEX idx_orders_user ON orders(user_id);
|
||||
CREATE INDEX idx_orders_vendor ON orders(vendor_id);
|
||||
CREATE INDEX idx_orders_number ON orders(order_number);
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
CREATE INDEX idx_orders_payment_status ON orders(payment_status);
|
||||
CREATE INDEX idx_orders_created ON orders(created_at DESC);
|
||||
|
||||
-- Order Items
|
||||
CREATE INDEX idx_order_items_order ON order_items(order_id);
|
||||
CREATE INDEX idx_order_items_product ON order_items(product_id);
|
||||
CREATE INDEX idx_order_items_vendor ON order_items(vendor_id);
|
||||
|
||||
-- Reviews
|
||||
CREATE INDEX idx_reviews_product ON reviews(product_id);
|
||||
CREATE INDEX idx_reviews_user ON reviews(user_id);
|
||||
CREATE INDEX idx_reviews_product_rating ON reviews(product_id, rating);
|
||||
|
||||
-- Cart
|
||||
CREATE INDEX idx_cart_items_cart ON cart_items(cart_id);
|
||||
|
||||
-- Payments
|
||||
CREATE INDEX idx_payments_order ON payments(order_id);
|
||||
CREATE INDEX idx_payments_status ON payments(status);
|
||||
|
||||
-- Vendor
|
||||
CREATE INDEX idx_vendor_profiles_user ON vendor_profiles(user_id);
|
||||
CREATE INDEX idx_vendor_profiles_slug ON vendor_profiles(store_slug);
|
||||
|
||||
-- Categories
|
||||
CREATE INDEX idx_categories_parent ON categories(parent_id);
|
||||
|
||||
-- =====================================================
|
||||
-- ROW LEVEL SECURITY (RLS)
|
||||
-- =====================================================
|
||||
|
||||
-- Enable RLS on all tables
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE vendor_profiles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE categories ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE product_variants ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE vendor_payouts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE carts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE cart_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users: Users can read their own profile
|
||||
CREATE POLICY "Users can view own profile" ON users
|
||||
FOR SELECT USING (auth.uid() = id);
|
||||
|
||||
CREATE POLICY "Users can update own profile" ON users
|
||||
FOR UPDATE USING (auth.uid() = id);
|
||||
|
||||
-- Products: Anyone can view active products
|
||||
CREATE POLICY "Anyone can view active products" ON products
|
||||
FOR SELECT USING (status = 'active');
|
||||
|
||||
CREATE POLICY "Vendors can manage own products" ON products
|
||||
FOR ALL USING (
|
||||
vendor_id IN (
|
||||
SELECT id FROM vendor_profiles WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Categories: Anyone can view categories
|
||||
CREATE POLICY "Anyone can view categories" ON categories
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- Reviews: Anyone can view approved reviews
|
||||
CREATE POLICY "Anyone can view approved reviews" ON reviews
|
||||
FOR SELECT USING (status = 'approved');
|
||||
|
||||
-- Orders: Users can view their own orders
|
||||
CREATE POLICY "Users can view own orders" ON orders
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Vendors can view their orders" ON orders
|
||||
FOR SELECT USING (
|
||||
vendor_id IN (
|
||||
SELECT id FROM vendor_profiles WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Carts: Users can manage their own cart
|
||||
CREATE POLICY "Users can manage own cart" ON carts
|
||||
FOR ALL USING (user_id = auth.uid());
|
||||
|
||||
CREATE POLICY "Users can manage own cart items" ON cart_items
|
||||
FOR ALL USING (
|
||||
cart_id IN (SELECT id FROM carts WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- FUNCTIONS & TRIGGERS
|
||||
-- =====================================================
|
||||
|
||||
-- Auto-update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply to tables with updated_at
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_products_updated_at
|
||||
BEFORE UPDATE ON products
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_orders_updated_at
|
||||
BEFORE UPDATE ON orders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Function to generate order number
|
||||
CREATE OR REPLACE FUNCTION generate_order_number()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.order_number = 'ORD-' || TO_CHAR(NOW(), 'YYYYMMDD') || '-' || UPPER(SUBSTRING(NEW.id::text, 1, 8));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER generate_order_number_trigger
|
||||
BEFORE INSERT ON orders
|
||||
FOR EACH ROW EXECUTE FUNCTION generate_order_number();
|
||||
|
||||
-- Function to update product inventory on order
|
||||
CREATE OR REPLACE FUNCTION update_inventory_on_order()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE products
|
||||
SET inventory = inventory - NEW.quantity
|
||||
WHERE id = NEW.product_id AND track_inventory = TRUE;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE products
|
||||
SET inventory = inventory + OLD.quantity
|
||||
WHERE id = OLD.product_id AND track_inventory = TRUE;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_inventory_trigger
|
||||
AFTER INSERT OR DELETE ON order_items
|
||||
FOR EACH ROW EXECUTE FUNCTION update_inventory_on_order();
|
||||
|
||||
-- Function to update vendor earnings on payment
|
||||
CREATE OR REPLACE FUNCTION update_vendor_earnings()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'completed' THEN
|
||||
UPDATE vendor_profiles
|
||||
SET total_earnings = total_earnings + NEW.amount
|
||||
WHERE vendor_id IN (
|
||||
SELECT vendor_id FROM orders WHERE id = NEW.order_id
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_vendor_earnings_trigger
|
||||
AFTER UPDATE OF status ON payments
|
||||
FOR EACH ROW EXECUTE FUNCTION update_vendor_earnings();
|
||||
|
||||
-- =====================================================
|
||||
-- SEED DATA (Optional sample categories)
|
||||
-- =====================================================
|
||||
|
||||
-- Uncomment to add sample categories
|
||||
/*
|
||||
INSERT INTO categories (name, slug, description, sort_order) VALUES
|
||||
('Electronics', 'electronics', 'Electronic devices and accessories', 1),
|
||||
('Clothing', 'clothing', 'Fashion and apparel', 2),
|
||||
('Home & Garden', 'home-garden', 'Home improvement and garden', 3),
|
||||
('Sports', 'sports', 'Sports and outdoor equipment', 4),
|
||||
('Books', 'books', 'Books and media', 5);
|
||||
*/
|
||||
39
skills/ecommerce-astro/scripts/templates/Dockerfile
Normal file
39
skills/ecommerce-astro/scripts/templates/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies stage
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Build stage
|
||||
FROM deps AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 astro
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY --from=builder --chown=astro:nodejs /app/dist ./dist
|
||||
COPY --from=builder --chown=astro:nodejs /app/package*.json ./
|
||||
|
||||
USER astro
|
||||
|
||||
EXPOSE 4321
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:4321/ || exit 1
|
||||
|
||||
CMD ["node", "dist/server/entry.mjs"]
|
||||
39
skills/ecommerce-astro/scripts/templates/docker-compose.yml
Normal file
39
skills/ecommerce-astro/scripts/templates/docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "4321:4321"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
|
||||
- PAYSOLO_MERCHANT_ID=${PAYSOLO_MERCHANT_ID}
|
||||
- PAYSOLO_API_KEY=${PAYSOLO_API_KEY}
|
||||
- PAYSOLO_SECRET_KEY=${PAYSOLO_SECRET_KEY}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:4321/"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
||||
# Optional: Local Supabase (for development)
|
||||
# supabase:
|
||||
# image: supabase/postgres:15.1.0.117
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
# environment:
|
||||
# POSTGRES_PASSWORD: postgres
|
||||
# POSTGRES_DB: postgres
|
||||
# volumes:
|
||||
# - supabase-data:/var/lib/postgresql/data
|
||||
# - ./supabase/migrations:/docker-entrypoint-initdb.d
|
||||
|
||||
volumes:
|
||||
supabase-data:
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
interface Props {
|
||||
role: 'vendor' | 'admin';
|
||||
}
|
||||
|
||||
const { role } = Astro.props;
|
||||
|
||||
const vendorLinks = [
|
||||
{ href: '/vendor/dashboard', label: 'แดชบอร์ด', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||
{ href: '/vendor/products', label: 'สินค้า', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' },
|
||||
{ href: '/vendor/orders', label: 'คำสั่งซื้อ', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
|
||||
{ href: '/vendor/settings', label: 'ตั้งค่า', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
];
|
||||
|
||||
const adminLinks = [
|
||||
{ href: '/admin/dashboard', label: 'แดชบอร์ด', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||
{ href: '/admin/users', label: 'ผู้ใช้งาน', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
|
||||
{ href: '/admin/vendors', label: 'ร้านค้า', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' },
|
||||
{ href: '/admin/orders', label: 'คำสั่งซื้อ', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
|
||||
{ href: '/admin/categories', label: 'หมวดหมู่', icon: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z' },
|
||||
];
|
||||
|
||||
const links = role === 'admin' ? adminLinks : vendorLinks;
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<aside class="w-64 bg-white shadow-sm min-h-screen">
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-bold text-gray-900 mb-6">
|
||||
{role === 'admin' ? 'ผู้ดูแลระบบ' : 'จัดการร้านค้า'}
|
||||
</h2>
|
||||
|
||||
<nav class="space-y-1">
|
||||
{links.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
currentPath === link.href
|
||||
? 'bg-blue-50 text-blue-700 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={link.icon} />
|
||||
</svg>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-3 px-4 py-3 text-gray-600 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12" />
|
||||
</svg>
|
||||
กลับไปร้านค้า
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
LayoutDashboard, Package, ShoppingCart, Users, BarChart3,
|
||||
Settings, ChevronLeft, ChevronRight, LogOut, Bell
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: any;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
interface AdminSidebarProps {
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
export default function AdminSidebar({ currentPath }: AdminSidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const mainNav: NavItem[] = [
|
||||
{ name: 'แดชบอร์ด', href: '/admin', icon: LayoutDashboard },
|
||||
{ name: 'สินค้า', href: '/admin/products', icon: Package, badge: 12 },
|
||||
{ name: 'คำสั่งซื้อ', href: '/admin/orders', icon: ShoppingCart, badge: 5 },
|
||||
{ name: 'ลูกค้า', href: '/admin/customers', icon: Users },
|
||||
{ name: 'รายงาน', href: '/admin/reports', icon: BarChart3 },
|
||||
];
|
||||
|
||||
const bottomNav: NavItem[] = [
|
||||
{ name: 'ตั้งค่า', href: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`
|
||||
fixed left-0 top-0 h-screen bg-gray-900 text-white transition-all duration-300 z-40
|
||||
${isCollapsed ? 'w-16' : 'w-64'}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
{!isCollapsed && (
|
||||
<Link href="/admin" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<span className="font-bold">E</span>
|
||||
</div>
|
||||
<span className="font-semibold">E-Commerce</span>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-1.5 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Navigation */}
|
||||
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto">
|
||||
{mainNav.map(item => {
|
||||
const isActive = currentPath === item.href;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors relative
|
||||
${isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">{item.name}</span>
|
||||
{item.badge && item.badge > 0 && (
|
||||
<span className={`
|
||||
text-xs px-2 py-0.5 rounded-full
|
||||
${isActive ? 'bg-white/20' : 'bg-blue-600'}
|
||||
`}>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isCollapsed && item.badge && item.badge > 0 && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Notifications */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-4 py-3 mx-2 mb-2 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Bell className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">3 คำสั่งซื้อใหม่</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">ต้องจัดการ</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<div className="py-4 px-2 border-t border-gray-800 space-y-1">
|
||||
{bottomNav.map(item => {
|
||||
const isActive = currentPath === item.href;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors
|
||||
${isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!isCollapsed && <span>{item.name}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<LogOut className="w-5 h-5 flex-shrink-0" />
|
||||
{!isCollapsed && <span>ออกจากระบบ</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ChevronUp, ChevronDown, Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface Column {
|
||||
key: string;
|
||||
header: string;
|
||||
sortable?: boolean;
|
||||
render?: (value: any, row: any) => React.ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
columns: Column[];
|
||||
data: any[];
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
itemsPerPage?: number;
|
||||
onRowClick?: (row: any) => void;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export default function DataTable({
|
||||
columns,
|
||||
data,
|
||||
searchable = true,
|
||||
searchPlaceholder = 'ค้นหา...',
|
||||
itemsPerPage = 10,
|
||||
onRowClick,
|
||||
emptyMessage = 'ไม่พบข้อมูล'
|
||||
}: DataTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Filter data
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchQuery) return data;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return data.filter(row =>
|
||||
columns.some(col => {
|
||||
const value = row[col.key];
|
||||
return value && String(value).toLowerCase().includes(query);
|
||||
})
|
||||
);
|
||||
}, [data, searchQuery, columns]);
|
||||
|
||||
// Sort data
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortConfig) return filteredData;
|
||||
|
||||
return [...filteredData].sort((a, b) => {
|
||||
const aValue = a[sortConfig.key];
|
||||
const bValue = b[sortConfig.key];
|
||||
|
||||
if (aValue === bValue) return 0;
|
||||
|
||||
const comparison = aValue > bValue ? 1 : -1;
|
||||
return sortConfig.direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [filteredData, sortConfig]);
|
||||
|
||||
// Paginate data
|
||||
const totalPages = Math.ceil(sortedData.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedData.slice(start, start + itemsPerPage);
|
||||
}, [sortedData, currentPage, itemsPerPage]);
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
setSortConfig(prev => {
|
||||
if (prev?.key === key) {
|
||||
if (prev.direction === 'asc') return { key, direction: 'desc' };
|
||||
return null;
|
||||
}
|
||||
return { key, direction: 'asc' };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border overflow-hidden">
|
||||
{/* Header */}
|
||||
{searchable && (
|
||||
<div className="p-4 border-b">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`
|
||||
px-4 py-3 text-left text-sm font-medium text-gray-600
|
||||
${col.sortable ? 'cursor-pointer hover:bg-gray-100' : ''}
|
||||
`}
|
||||
style={{ width: col.width }}
|
||||
onClick={() => col.sortable && handleSort(col.key)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable && (
|
||||
<span className="text-gray-400">
|
||||
{sortConfig?.key === col.key ? (
|
||||
sortConfig.direction === 'asc' ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-4 h-4 opacity-30">
|
||||
<ChevronUp className="w-4 h-4 -mb-2" />
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{paginatedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-gray-500">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedData.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={`
|
||||
hover:bg-gray-50 transition-colors
|
||||
${onRowClick ? 'cursor-pointer' : ''}
|
||||
`}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map(col => (
|
||||
<td key={col.key} className="px-4 py-3 text-sm">
|
||||
{col.render ? col.render(row[col.key], row) : row[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<p className="text-sm text-gray-500">
|
||||
แสดง {(currentPage - 1) * itemsPerPage + 1} - {Math.min(currentPage * itemsPerPage, sortedData.length)}
|
||||
จาก {sortedData.length} รายการ
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
{[...Array(totalPages)].map((_, i) => {
|
||||
const page = i + 1;
|
||||
const isCurrentPage = page === currentPage;
|
||||
const isNearCurrent = Math.abs(page - currentPage) <= 1;
|
||||
const isFirst = page === 1;
|
||||
const isLast = page === totalPages;
|
||||
|
||||
if (!isNearCurrent && !isFirst && !isLast) {
|
||||
if (page === 2 || page === totalPages - 1) {
|
||||
return <span key={page} className="px-2">...</span>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`
|
||||
w-8 h-8 rounded-lg text-sm font-medium transition-colors
|
||||
${isCurrentPage
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'hover:bg-gray-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState } from 'react';
|
||||
import { Clock, Check, Truck, Package, XCircle, RotateCcw } from 'lucide-react';
|
||||
|
||||
type OrderStatus = 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
status: OrderStatus;
|
||||
customerName: string;
|
||||
total: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface OrderStatusManagerProps {
|
||||
order: Order;
|
||||
onStatusChange: (status: OrderStatus) => void;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<OrderStatus, {
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
icon: any;
|
||||
nextStatuses: OrderStatus[];
|
||||
}> = {
|
||||
pending: {
|
||||
label: 'รอตรวจสอบ',
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-50 border-yellow-200',
|
||||
icon: Clock,
|
||||
nextStatuses: ['confirmed', 'cancelled']
|
||||
},
|
||||
confirmed: {
|
||||
label: 'ยืนยันแล้ว',
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50 border-blue-200',
|
||||
icon: Check,
|
||||
nextStatuses: ['processing', 'cancelled']
|
||||
},
|
||||
processing: {
|
||||
label: 'กำลังจัดเตรียม',
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50 border-purple-200',
|
||||
icon: Package,
|
||||
nextStatuses: ['shipped']
|
||||
},
|
||||
shipped: {
|
||||
label: 'จัดส่งแล้ว',
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50 border-indigo-200',
|
||||
icon: Truck,
|
||||
nextStatuses: ['delivered']
|
||||
},
|
||||
delivered: {
|
||||
label: 'จัดส่งสำเร็จ',
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50 border-green-200',
|
||||
icon: Check,
|
||||
nextStatuses: ['refunded']
|
||||
},
|
||||
cancelled: {
|
||||
label: 'ยกเลิกแล้ว',
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50 border-red-200',
|
||||
icon: XCircle,
|
||||
nextStatuses: []
|
||||
},
|
||||
refunded: {
|
||||
label: 'คืนเงินแล้ว',
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-50 border-gray-200',
|
||||
icon: RotateCcw,
|
||||
nextStatuses: []
|
||||
}
|
||||
};
|
||||
|
||||
export default function OrderStatusManager({ order, onStatusChange }: OrderStatusManagerProps) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [selectedStatus, setSelectedStatus] = useState<OrderStatus | null>(null);
|
||||
|
||||
const currentConfig = STATUS_CONFIG[order.status];
|
||||
const Icon = currentConfig.icon;
|
||||
|
||||
const handleStatusChange = async (newStatus: OrderStatus) => {
|
||||
setSelectedStatus(newStatus);
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const confirmStatusChange = async () => {
|
||||
if (!selectedStatus) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await onStatusChange(selectedStatus);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
setShowConfirmModal(false);
|
||||
setSelectedStatus(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Status */}
|
||||
<div className={`p-4 rounded-xl border ${currentConfig.bgColor}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
order.status === 'cancelled' || order.status === 'refunded'
|
||||
? 'bg-red-100' : 'bg-white'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${currentConfig.color}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={`font-medium ${currentConfig.color}`}>
|
||||
{currentConfig.label}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
อัปเดตล่าสุด: {new Date(order.updatedAt).toLocaleDateString('th-TH', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Timeline */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-gray-200" />
|
||||
<div className="space-y-4 relative">
|
||||
{(['pending', 'confirmed', 'processing', 'shipped', 'delivered']).map((status, index) => {
|
||||
const config = STATUS_CONFIG[status as OrderStatus];
|
||||
const statusIndex = Object.keys(STATUS_CONFIG).indexOf(order.status);
|
||||
const currentIndex = Object.keys(STATUS_CONFIG).indexOf(status);
|
||||
const isCompleted = currentIndex <= statusIndex;
|
||||
const isCurrent = status === order.status;
|
||||
|
||||
return (
|
||||
<div key={status} className="flex items-center gap-4">
|
||||
<div className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center z-10
|
||||
${isCompleted ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-400'}
|
||||
${isCurrent ? 'ring-4 ring-offset-2 ' + config.bgColor.replace('50', '100').replace('200', '300') : ''}
|
||||
`}>
|
||||
<Check className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${
|
||||
isCompleted ? 'text-gray-900' : 'text-gray-400'
|
||||
}`}>
|
||||
{config.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Actions */}
|
||||
{currentConfig.nextStatuses.length > 0 && (
|
||||
<div className="border-t pt-6">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">อัปเดตสถานะ</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentConfig.nextStatuses.map(status => {
|
||||
const config = STATUS_CONFIG[status];
|
||||
const NextIcon = config.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleStatusChange(status)}
|
||||
disabled={isUpdating}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-colors
|
||||
${config.bgColor} ${config.color} border-current
|
||||
hover:opacity-80 disabled:opacity-50
|
||||
`}
|
||||
>
|
||||
<NextIcon className="w-4 h-4" />
|
||||
<span className="font-medium">{config.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<div className="border-t pt-6">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">ประวัติการเปลี่ยนสถานะ</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-gray-600">ยืนยันคำสั่งซื้อ</span>
|
||||
<span className="text-gray-400 ml-auto">10 เม.ย. 2567 14:30</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-gray-600">สร้างคำสั่งซื้อ</span>
|
||||
<span className="text-gray-400 ml-auto">10 เม.ย. 2567 14:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Modal */}
|
||||
{showConfirmModal && selectedStatus && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
/>
|
||||
<div className="relative bg-white rounded-xl p-6 max-w-sm w-full mx-4 shadow-xl">
|
||||
<h3 className="font-medium text-lg mb-2">ยืนยันการเปลี่ยนสถานะ</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
คุณต้องการเปลี่ยนสถานะจาก
|
||||
<span className="font-medium"> {currentConfig.label} </span>
|
||||
เป็น
|
||||
<span className="font-medium"> {STATUS_CONFIG[selectedStatus].label} </span>
|
||||
หรือไม่?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
className="flex-1 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmStatusChange}
|
||||
disabled={isUpdating}
|
||||
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isUpdating ? 'กำลังอัปเดต...' : 'ยืนยัน'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { useCartStore } from '../../stores/cart';
|
||||
import { ShoppingCart } from 'lucide-react';
|
||||
|
||||
interface AddToCartProps {
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
images: string[];
|
||||
variants?: { id: string; name: string; price: number }[];
|
||||
};
|
||||
variant?: { id: string; name: string; price: number };
|
||||
}
|
||||
|
||||
export default function AddToCart({ product, variant }: AddToCartProps) {
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
setIsAdding(true);
|
||||
addItem({
|
||||
id: variant?.id || product.id,
|
||||
productId: product.id,
|
||||
name: product.name,
|
||||
price: variant?.price || product.price,
|
||||
image: product.images[0],
|
||||
variant: variant ? { id: variant.id, name: variant.name } : undefined,
|
||||
quantity
|
||||
});
|
||||
setIsAdding(false);
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => setShowSuccess(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">จำนวน</label>
|
||||
<div className="flex items-center border rounded-lg">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="px-3 py-2 hover:bg-gray-100"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-16 text-center border-x py-2"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="px-3 py-2 hover:bg-gray-100"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={isAdding}
|
||||
className={`w-full py-3 px-6 rounded-lg font-medium flex items-center justify-center gap-2 ${
|
||||
showSuccess
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
{isAdding ? 'กำลังเพิ่ม...' : showSuccess ? 'เพิ่มแล้ว!' : 'เพิ่มลงตะกร้า'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useCartStore } from '../../stores/cart';
|
||||
import { ShoppingCart } from 'lucide-react';
|
||||
|
||||
export default function CartBadge() {
|
||||
const { items, toggleCart } = useCartStore();
|
||||
const count = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
return (
|
||||
<button onClick={toggleCart} className="relative p-2 hover:bg-gray-100 rounded-lg">
|
||||
<ShoppingCart className="w-6 h-6" />
|
||||
{count > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { useCartStore } from '../../stores/cart';
|
||||
import CartItems from './CartItems';
|
||||
import CartSummary from './CartSummary';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function CartDrawer() {
|
||||
const { isOpen, toggleCart, items } = useCartStore();
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={toggleCart}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-in-out duration-500"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in-out duration-500"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transform transition ease-in-out duration-500"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-500"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<Dialog.Panel className="pointer-events-auto w-screen max-w-md">
|
||||
<div className="flex h-full flex-col bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between px-4 py-6 border-b">
|
||||
<Dialog.Title className="text-lg font-medium">
|
||||
ตะกร้าสินค้า ({items.length})
|
||||
</Dialog.Title>
|
||||
<button onClick={toggleCart} className="text-gray-400 hover:text-gray-500">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-12">ตะกร้าสินค้าว่างเปล่า</p>
|
||||
) : (
|
||||
<CartItems />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="border-t px-4 py-6">
|
||||
<CartSummary />
|
||||
<a
|
||||
href="/checkout"
|
||||
className="mt-4 w-full bg-blue-600 text-white py-3 px-4 rounded-lg text-center block hover:bg-blue-700"
|
||||
>
|
||||
ชำระเงิน
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCartStore } from '../../stores/cart';
|
||||
import { Minus, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function CartItems() {
|
||||
const { items, updateQuantity, removeItem } = useCartStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex gap-4 py-4 border-b">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-20 h-20 object-cover rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{item.name}</h4>
|
||||
{item.variant && (
|
||||
<p className="text-sm text-gray-500">{item.variant.name}</p>
|
||||
)}
|
||||
<p className="font-bold mt-1">฿{item.price.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end justify-between">
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
||||
className="w-8 h-8 border rounded flex items-center justify-center"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-8 text-center">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
className="w-8 h-8 border rounded flex items-center justify-center"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCartStore } from '../../stores/cart';
|
||||
import { formatCurrency } from '../../lib/utils';
|
||||
|
||||
export default function CartSummary() {
|
||||
const { items } = useCartStore();
|
||||
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
const tax = subtotal * 0.07;
|
||||
const shipping = subtotal >= 500 ? 0 : 50;
|
||||
const total = subtotal + tax + shipping;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">ราคารวม</span>
|
||||
<span>{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">ภาษี VAT (7%)</span>
|
||||
<span>{formatCurrency(tax)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">ค่าจัดส่ง</span>
|
||||
<span>{shipping === 0 ? 'ฟรี' : formatCurrency(shipping)}</span>
|
||||
</div>
|
||||
{shipping === 0 && (
|
||||
<p className="text-xs text-green-600">ซื้อขั้นต่ำ 500 บาท จัดส่งฟรี!</p>
|
||||
)}
|
||||
<div className="border-t pt-3 flex justify-between font-bold text-lg">
|
||||
<span>รวมทั้งสิ้น</span>
|
||||
<span>{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
interface BillingInfoProps {
|
||||
data: {
|
||||
name?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
postal?: string;
|
||||
country?: string;
|
||||
};
|
||||
onChange: (field: string, value: string) => void;
|
||||
}
|
||||
|
||||
export default function BillingInfo({ data, onChange }: BillingInfoProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-lg mb-4">ที่อยู่ออกใบเสร็จ</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">ชื่อ-นามสกุล</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.name || ''}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
placeholder="ชื่อ-นามสกุล"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">ที่อยู่</label>
|
||||
<textarea
|
||||
value={data.address || ''}
|
||||
onChange={(e) => onChange('address', e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 h-24"
|
||||
placeholder="ที่อยู่"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">จังหวัด</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.city || ''}
|
||||
onChange={(e) => onChange('city', e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">รหัสไปรษณีย์</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.postal || ''}
|
||||
onChange={(e) => onChange('postal', e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">ประเทศ</label>
|
||||
<select
|
||||
value={data.country || 'Thailand'}
|
||||
onChange={(e) => onChange('country', e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="Thailand">Thailand</option>
|
||||
<option value="Cambodia">Cambodia</option>
|
||||
<option value="Laos">Laos</option>
|
||||
<option value="Myanmar">Myanmar</option>
|
||||
<option value="Malaysia">Malaysia</option>
|
||||
<option value="Singapore">Singapore</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
import { useCartStore } from '../../stores/cart';
|
||||
import ShippingForm from './ShippingForm';
|
||||
import PaymentMethod from './PaymentMethod';
|
||||
import OrderSummary from './OrderSummary';
|
||||
|
||||
type Step = 'shipping' | 'payment' | 'review';
|
||||
|
||||
export default function CheckoutForm() {
|
||||
const [step, setStep] = useState<Step>('shipping');
|
||||
const [shippingData, setShippingData] = useState<any>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<'payso' | 'stripe'>('payso');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const { items, clearCart } = useCartStore();
|
||||
|
||||
const handleShippingSubmit = (data: any) => {
|
||||
setShippingData(data);
|
||||
setStep('payment');
|
||||
};
|
||||
|
||||
const handlePaymentSubmit = async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// Create order
|
||||
const orderRes = await fetch('/api/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items, shippingAddress: shippingData })
|
||||
});
|
||||
const { order } = await orderRes.json();
|
||||
|
||||
// Create payment
|
||||
const paymentRes = await fetch('/api/payments/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orderId: order.id })
|
||||
});
|
||||
const { paymentUrl } = await paymentRes.json();
|
||||
|
||||
// Redirect to PaySo
|
||||
window.location.href = paymentUrl;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex gap-4 mb-8">
|
||||
{['shipping', 'payment', 'review'].map((s, i) => (
|
||||
<div key={s} className={`flex-1 text-center pb-2 ${step === s ? 'border-b-2 border-blue-600' : ''}`}>
|
||||
<span className="text-sm text-gray-500">ขั้นตอน {i + 1}</span>
|
||||
<p className="font-medium">{s === 'shipping' ? 'ที่อยู่จัดส่ง' : s === 'payment' ? 'ชำระเงิน' : 'สรุป'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 'shipping' && <ShippingForm onSubmit={handleShippingSubmit} />}
|
||||
{step === 'payment' && (
|
||||
<PaymentMethod
|
||||
value={paymentMethod}
|
||||
onChange={setPaymentMethod}
|
||||
onContinue={handlePaymentSubmit}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
)}
|
||||
{step === 'review' && (
|
||||
<div className="bg-gray-50 p-6 rounded-lg">
|
||||
<h3 className="font-medium mb-4">สรุปคำสั่งซื้อ</h3>
|
||||
<pre>{JSON.stringify({ shippingData, paymentMethod }, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<OrderSummary />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
interface CheckoutSingleItemProps {
|
||||
thumb_src: string;
|
||||
thumb_alt: string;
|
||||
title: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export default function CheckoutSingleItem({ thumb_src, thumb_alt, title, color, size, price }: CheckoutSingleItemProps) {
|
||||
return (
|
||||
<div className="flex gap-4 py-4 border-b">
|
||||
<img
|
||||
src={thumb_src}
|
||||
alt={thumb_alt}
|
||||
className="w-20 h-20 object-cover rounded-lg"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{title}</h4>
|
||||
{(color || size) && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{[color, size].filter(Boolean).join(' / ')}
|
||||
</p>
|
||||
)}
|
||||
<p className="font-bold mt-1">฿{price.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
interface CheckoutSingleItemDarkProps {
|
||||
thumb_src: string;
|
||||
thumb_alt: string;
|
||||
title: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export default function CheckoutSingleItemDark({ thumb_src, thumb_alt, title, color, size, price }: CheckoutSingleItemDarkProps) {
|
||||
return (
|
||||
<div className="flex gap-4 py-4 border-b border-gray-700">
|
||||
<img
|
||||
src={thumb_src}
|
||||
alt={thumb_alt}
|
||||
className="w-20 h-20 object-cover rounded-lg"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-white">{title}</h4>
|
||||
{(color || size) && (
|
||||
<p className="text-sm text-gray-400">
|
||||
{[color, size].filter(Boolean).join(' / ')}
|
||||
</p>
|
||||
)}
|
||||
<p className="font-bold mt-1 text-green-400">฿{price.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
interface OrderStatusManagerProps {
|
||||
orderId: string;
|
||||
currentStatus: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
}
|
||||
|
||||
const statuses = [
|
||||
{ value: 'pending', label: 'รอดำเนินการ', color: 'bg-yellow-100 text-yellow-700' },
|
||||
{ value: 'confirmed', label: 'ยืนยันแล้ว', color: 'bg-blue-100 text-blue-700' },
|
||||
{ value: 'processing', label: 'กำลังประมวลผล', color: 'bg-purple-100 text-purple-700' },
|
||||
{ value: 'shipped', label: 'จัดส่งแล้ว', color: 'bg-orange-100 text-orange-700' },
|
||||
{ value: 'delivered', label: 'จัดส่งสำเร็จ', color: 'bg-green-100 text-green-700' },
|
||||
{ value: 'cancelled', label: 'ยกเลิก', color: 'bg-red-100 text-red-700' },
|
||||
{ value: 'refunded', label: 'คืนเงินแล้ว', color: 'bg-gray-100 text-gray-700' },
|
||||
];
|
||||
|
||||
export default function OrderStatusManager({ orderId, currentStatus, onStatusChange }: OrderStatusManagerProps) {
|
||||
const current = statuses.find(s => s.value === currentStatus) || statuses[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium">สถานะคำสั่งซื้อ</label>
|
||||
<select
|
||||
value={currentStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
>
|
||||
{statuses.map(status => (
|
||||
<option key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className={`inline-block px-3 py-1 rounded-full text-sm ${current.color}`}>
|
||||
{current.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCartStore } from '../../stores/cart';
|
||||
import { formatCurrency } from '../../lib/utils';
|
||||
|
||||
export default function OrderSummary() {
|
||||
const { items } = useCartStore();
|
||||
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
const tax = subtotal * 0.07;
|
||||
const shipping = subtotal >= 500 ? 0 : 50;
|
||||
const total = subtotal + tax + shipping;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-6 sticky top-4">
|
||||
<h3 className="font-medium text-lg mb-4">สรุปคำสั่งซื้อ</h3>
|
||||
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex gap-3">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-14 h-14 object-cover rounded"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.name}</p>
|
||||
{item.variant && (
|
||||
<p className="text-xs text-gray-500">{item.variant.name}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600">x{item.quantity}</p>
|
||||
</div>
|
||||
<p className="text-sm font-medium">
|
||||
{formatCurrency(item.price * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t mt-4 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">ราคารวม</span>
|
||||
<span>{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">ภาษี VAT (7%)</span>
|
||||
<span>{formatCurrency(tax)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">ค่าจัดส่ง</span>
|
||||
<span>{shipping === 0 ? 'ฟรี' : formatCurrency(shipping)}</span>
|
||||
</div>
|
||||
{shipping > 0 && (
|
||||
<p className="text-xs text-blue-600">
|
||||
ซื้ออีก {formatCurrency(500 - subtotal)} จะได้จัดส่งฟรี!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t mt-4 pt-4 flex justify-between font-bold text-lg">
|
||||
<span>รวมทั้งสิ้น</span>
|
||||
<span className="text-blue-600">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { CreditCard, Smartphone } from 'lucide-react';
|
||||
|
||||
export default function PaymentDetails() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-lg mb-4">รายละเอียดการชำระเงิน</h3>
|
||||
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<CreditCard className="w-5 h-5 text-gray-600" />
|
||||
<span className="font-medium">บัตรเครดิต/เดบิต</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="หมายเลขบัตร"
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="MM/YY"
|
||||
className="border rounded-lg px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="CVV"
|
||||
className="border rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ชื่อบนบัตร"
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Smartphone className="w-5 h-5 text-gray-600" />
|
||||
<span className="font-medium">QR Code / PromptPay</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
ชำระเงินผ่าน QR Code ด้วยแอปธนาคารหรือ PromptPay
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>การชำระเงินของคุณปลอดภัยและเข้ารหัส 256-bit SSL</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CreditCard, Wallet } from 'lucide-react';
|
||||
|
||||
interface PaymentMethodProps {
|
||||
value: 'payso' | 'stripe';
|
||||
onChange: (value: 'payso' | 'stripe') => void;
|
||||
onContinue: () => void;
|
||||
isProcessing: boolean;
|
||||
}
|
||||
|
||||
export default function PaymentMethod({ value, onChange, onContinue, isProcessing }: PaymentMethodProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium">เลือกวิธีการชำระเงิน</label>
|
||||
|
||||
<div
|
||||
onClick={() => onChange('payso')}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-colors ${
|
||||
value === 'payso' ? 'border-blue-600 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
value === 'payso' ? 'border-blue-600' : 'border-gray-300'
|
||||
}`}>
|
||||
{value === 'payso' && <div className="w-3 h-3 rounded-full bg-blue-600" />}
|
||||
</div>
|
||||
<Wallet className="w-6 h-6 text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">PaySo</p>
|
||||
<p className="text-sm text-gray-500">พร้อมเพย์, บัตรเครดิต, QR Code</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => onChange('stripe')}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-colors ${
|
||||
value === 'stripe' ? 'border-blue-600 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
value === 'stripe' ? 'border-blue-600' : 'border-gray-300'
|
||||
}`}>
|
||||
{value === 'stripe' && <div className="w-3 h-3 rounded-full bg-blue-600" />}
|
||||
</div>
|
||||
<CreditCard className="w-6 h-6 text-purple-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Stripe</p>
|
||||
<p className="text-sm text-gray-500">บัตรเครดิต/เดบิต ทั่วโลก</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>หมายเหตุ:</strong> การชำระเงินทุกช่องทางมีความปลอดภัยสูง ข้อมูลบัตรของคุณจะไม่ถูกเก็บบนเซิร์ฟเวอร์ของเรา
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onContinue}
|
||||
disabled={isProcessing}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isProcessing ? 'กำลังประมวลผล...' : 'ดำเนินการต่อ'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import Input from '../ui/Input';
|
||||
|
||||
interface ShippingFormProps {
|
||||
onSubmit: (data: any) => void;
|
||||
}
|
||||
|
||||
export default function ShippingForm({ onSubmit }: ShippingFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
province: '',
|
||||
district: '',
|
||||
subdistrict: '',
|
||||
postalCode: ''
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const provinces = [
|
||||
'กรุงเทพมหานคร', 'นนทบุรี', 'ปทุมธานี', 'สมุทรปราการ', 'สมุทรสาคร',
|
||||
'เชียงใหม่', 'ภูเก็ต', 'ขอนแก่น', 'นครราชสีมา', 'สงขลา'
|
||||
];
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.firstName) newErrors.firstName = 'กรุณากรอกชื่อ';
|
||||
if (!formData.lastName) newErrors.lastName = 'กรุณากรอกนามสกุล';
|
||||
if (!formData.phone) newErrors.phone = 'กรุณากรอกเบอร์โทรศัพท์';
|
||||
if (!formData.address) newErrors.address = 'กรุณากรอกที่อยู่';
|
||||
if (!formData.province) newErrors.province = 'กรุณาเลือกจังหวัด';
|
||||
if (!formData.postalCode) newErrors.postalCode = 'กรุณากรอกรหัสไปรษณีย์';
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (validate()) {
|
||||
onSubmit(formData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="ชื่อ *"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
||||
error={errors.firstName}
|
||||
placeholder="สมชาย"
|
||||
/>
|
||||
<Input
|
||||
label="นามสกุล *"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||
error={errors.lastName}
|
||||
placeholder="ใจดี"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="เบอร์โทรศัพท์ *"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
error={errors.phone}
|
||||
placeholder="0812345678"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">ที่อยู่ *</label>
|
||||
<textarea
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||
className={`w-full border rounded-lg px-3 py-2 h-24 ${errors.address ? 'border-red-500' : ''}`}
|
||||
placeholder="123/45 ถนนสุขุมวิท แขวงคลองเตย เขตคลองเตย"
|
||||
/>
|
||||
{errors.address && <p className="text-red-500 text-sm mt-1">{errors.address}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">จังหวัด *</label>
|
||||
<select
|
||||
value={formData.province}
|
||||
onChange={(e) => setFormData({ ...formData, province: e.target.value })}
|
||||
className={`w-full border rounded-lg px-3 py-2 ${errors.province ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="">เลือกจังหวัด</option>
|
||||
{provinces.map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.province && <p className="text-red-500 text-sm mt-1">{errors.province}</p>}
|
||||
</div>
|
||||
<Input
|
||||
label="รหัสไปรษณีย์ *"
|
||||
value={formData.postalCode}
|
||||
onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
|
||||
error={errors.postalCode}
|
||||
placeholder="10110"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
ถัดไป
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Truck, RotateCcw, BadgeCheck, CustomerSupport } from 'lucide-react';
|
||||
|
||||
const incentives = [
|
||||
{ icon: Truck, text: 'จัดส่งฟรี สั่งซื้อ 500฿ ขึ้นไป' },
|
||||
{ icon: RotateCcw, text: 'คืนสินค้าฟรี ภายใน 7 วัน' },
|
||||
{ icon: BadgeCheck, text: 'สินค้าคุณภาพ รับประกัน 1 ปี' },
|
||||
{ icon: CustomerSupport, text: 'ติดต่อ 086-xxx-xxxx' }
|
||||
];
|
||||
|
||||
export default function IncentiveCols() {
|
||||
return (
|
||||
<section className="py-4 border-b">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-wrap justify-center gap-6 md:gap-12">
|
||||
{incentives.map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<item.icon className="w-4 h-4 text-blue-600" />
|
||||
<span>{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Truck, RefreshCw, Shield, HeadphonesIcon, CreditCard, Gift } from 'lucide-react';
|
||||
|
||||
const incentives = [
|
||||
{
|
||||
icon: Truck,
|
||||
title: 'จัดส่งฟรี',
|
||||
description: 'สั่งซื้อ 500 บาทขึ้นไป จัดส่งฟรีทั่วประเทศ'
|
||||
},
|
||||
{
|
||||
icon: RefreshCw,
|
||||
title: 'คืนสินค้าได้',
|
||||
description: 'ยกเลิกหรือเปลี่ยนสินค้าภายใน 7 วัน'
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'สินค้าคุณภาพ',
|
||||
description: 'ตรวจสอบคุณภาพก่อนส่งทุกชิ้น'
|
||||
},
|
||||
{
|
||||
icon: HeadphonesIcon,
|
||||
title: 'ติดต่อง่าย',
|
||||
description: 'พร้อมตอบคำถาม 24 ชั่วโมง'
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: 'ชำระเงินปลอดภัย',
|
||||
description: 'รองรับบัตรเครดิต, QR Code, โอนเงิน'
|
||||
},
|
||||
{
|
||||
icon: Gift,
|
||||
title: 'ส่วนลดพิเศษ',
|
||||
description: 'สมัครสมาชิกรับส่วนลด exclusive'
|
||||
}
|
||||
];
|
||||
|
||||
export default function IncentiveLarge() {
|
||||
return (
|
||||
<section className="py-12 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
{incentives.map((item, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-3 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<item.icon className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-medium text-sm mb-1">{item.title}</h3>
|
||||
<p className="text-xs text-gray-500">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
const footerLinks = {
|
||||
บริการ: [
|
||||
{ href: '/products', label: 'สินค้าทั้งหมด' },
|
||||
{ href: '/vendors', label: 'ร้านค้า' },
|
||||
{ href: '/vendor/register', label: 'สมัครเป็นร้านค้า' },
|
||||
],
|
||||
ช่วยเหลือ: [
|
||||
{ href: '/faq', label: 'คำถามที่พบบ่อย' },
|
||||
{ href: '/contact', label: 'ติดต่อเรา' },
|
||||
{ href: '/shipping', label: 'วิธีการจัดส่ง' },
|
||||
],
|
||||
เกี่ยวกับเรา: [
|
||||
{ href: '/about', label: 'เกี่ยวกับเรา' },
|
||||
{ href: '/terms', label: 'เงื่อนไขการใช้งาน' },
|
||||
{ href: '/privacy', label: 'นโยบายความเป็นส่วนตัว' },
|
||||
]
|
||||
};
|
||||
---
|
||||
|
||||
<footer class="bg-gray-900 text-gray-300 mt-auto">
|
||||
<div class="container mx-auto px-4 py-12">
|
||||
<div class="grid md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div>
|
||||
<a href="/" class="text-2xl font-bold text-white">ร้านของเรา</a>
|
||||
<p class="mt-4 text-gray-400">
|
||||
ร้านค้าออนไลน์คุณภาพดี ราคาถูกใจ รวบรวมสินค้าจากร้านค้าทั่วประเทศไทย
|
||||
</p>
|
||||
<div class="flex gap-4 mt-6">
|
||||
<a href="#" class="w-10 h-10 bg-gray-800 rounded-full flex items-center justify-center hover:bg-blue-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" class="w-10 h-10 bg-gray-800 rounded-full flex items-center justify-center hover:bg-pink-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" class="w-10 h-10 bg-gray-800 rounded-full flex items-center justify-center hover:bg-green-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M.057 24l1.687-6.163c-1.041-1.804-1.588-3.849-1.587-5.946.003-6.556 5.338-11.891 11.893-11.891 3.181.001 6.167 1.24 8.413 3.488 2.245 2.248 3.481 5.236 3.48 8.414-.003 6.557-5.338 11.892-11.893 11.892-1.99-.001-3.951-.5-5.688-1.448l-6.305 1.654zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
{Object.entries(footerLinks).map(([title, links]) => (
|
||||
<div>
|
||||
<h3 class="text-white font-bold mb-4">{title}</h3>
|
||||
<ul class="space-y-2">
|
||||
{links.map(link => (
|
||||
<li>
|
||||
<a href={link.href} class="hover:text-white transition-colors">
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<!-- Contact -->
|
||||
<div>
|
||||
<h3 class="text-white font-bold mb-4">ติดต่อเรา</h3>
|
||||
<ul class="space-y-2 text-gray-400">
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<span>02-xxx-xxxx</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>contact@example.com</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>123 ถนนตัวอย่าง<br/>แขวงตัวอย่าง เขตตัวอย่าง<br/>กรุงเทพฯ 10000</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-800 my-8" />
|
||||
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-400">
|
||||
<p>© {new Date().getFullYear()} ร้านของเรา. สงวนลิขสิทธิ์.</p>
|
||||
<div class="flex gap-6">
|
||||
<a href="/terms" class="hover:text-white transition-colors">เงื่อนไขการใช้งาน</a>
|
||||
<a href="/privacy" class="hover:text-white transition-colors">นโยบายความเป็นส่วนตัว</a>
|
||||
<a href="/cookies" class="hover:text-white transition-colors">นโยบายคุกกี้</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
const navLinks = [
|
||||
{ href: '/products', label: 'สินค้า' },
|
||||
{ href: '/vendors', label: 'ร้านค้า' },
|
||||
];
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<header class="bg-white shadow-sm sticky top-0 z-40">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="text-2xl font-bold text-blue-600">ร้านของเรา</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Nav -->
|
||||
<nav class="hidden md:flex items-center gap-6">
|
||||
{navLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class={`text-gray-600 hover:text-blue-600 transition-colors ${
|
||||
currentPath.startsWith(link.href) ? 'text-blue-600 font-medium' : ''
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="hidden md:flex flex-1 max-w-md mx-8">
|
||||
<form action="/products" method="GET" class="w-full">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="ค้นหาสินค้า..."
|
||||
class="w-full border border-gray-200 rounded-lg pl-10 pr-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<svg class="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Wishlist -->
|
||||
<a href="/wishlist" class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Account -->
|
||||
<a href="/account" class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Cart (handled by CartBadge) -->
|
||||
<div id="cart-button"></div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button class="md:hidden p-2 hover:bg-gray-100 rounded-lg" id="mobile-menu-btn">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div class="md:hidden hidden" id="mobile-menu">
|
||||
<div class="py-4 space-y-2">
|
||||
<form action="/products" method="GET" class="px-2 pb-4">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="ค้นหาสินค้า..."
|
||||
class="w-full border border-gray-200 rounded-lg px-4 py-2"
|
||||
/>
|
||||
</form>
|
||||
{navLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class={`block px-4 py-2 ${
|
||||
currentPath.startsWith(link.href) ? 'text-blue-600 bg-blue-50' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
<hr class="my-2" />
|
||||
<a href="/wishlist" class="block px-4 py-2 text-gray-600">สินค้าที่ชอบ</a>
|
||||
<a href="/account" class="block px-4 py-2 text-gray-600">บัญชีของฉัน</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
const menuBtn = document.getElementById('mobile-menu-btn');
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
|
||||
menuBtn?.addEventListener('click', () => {
|
||||
menu?.classList.toggle('hidden');
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
interface OrderCardProductProps {
|
||||
product: {
|
||||
name: string;
|
||||
image?: string;
|
||||
price?: number;
|
||||
};
|
||||
status: string;
|
||||
quantity: number;
|
||||
address?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export default function OrderCardProduct({ product, status, quantity, address, email, phone }: OrderCardProductProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b flex justify-between items-center">
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${
|
||||
status === 'delivered' ? 'bg-green-100 text-green-700' :
|
||||
status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">x{quantity}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex gap-4">
|
||||
<img
|
||||
src={product.image || '/placeholder.jpg'}
|
||||
alt={product.name}
|
||||
className="w-20 h-20 object-cover rounded-lg"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{product.name}</h4>
|
||||
{product.price && (
|
||||
<p className="text-sm text-gray-500">฿{product.price.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(address || email || phone) && (
|
||||
<div className="p-4 bg-gray-50 border-t">
|
||||
<p className="text-sm font-medium mb-2">ที่อยู่จัดส่ง</p>
|
||||
{address && <p className="text-sm text-gray-600">{address}</p>}
|
||||
{phone && <p className="text-sm text-gray-600">{phone}</p>}
|
||||
{email && <p className="text-sm text-gray-600">{email}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { formatCurrency } from '../../lib/utils';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
order_number: string;
|
||||
status: string;
|
||||
total: number;
|
||||
created_at: string;
|
||||
items?: { length: number }[];
|
||||
}
|
||||
|
||||
interface OrderHistoryProps {
|
||||
orders: Order[];
|
||||
onViewDetails: (id: string) => void;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
confirmed: 'bg-blue-100 text-blue-700',
|
||||
processing: 'bg-purple-100 text-purple-700',
|
||||
shipped: 'bg-orange-100 text-orange-700',
|
||||
delivered: 'bg-green-100 text-green-700',
|
||||
cancelled: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
export default function OrderHistory({ orders, onViewDetails }: OrderHistoryProps) {
|
||||
if (!orders?.length) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">ยังไม่มีคำสั่งซื้อ</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{orders.map(order => (
|
||||
<div key={order.id} className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<p className="font-bold">{order.order_number}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString('th-TH')}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${statusColors[order.status] || 'bg-gray-100'}`}>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
{order.items?.length || 0} รายการ
|
||||
</p>
|
||||
<p className="font-bold text-lg">{formatCurrency(order.total)}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onViewDetails(order.id)}
|
||||
className="mt-4 w-full py-2 border rounded-lg text-blue-600 hover:bg-blue-50"
|
||||
>
|
||||
ดูรายละเอียด
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { formatCurrency } from '../../lib/utils';
|
||||
|
||||
interface OrderHistoryCardProps {
|
||||
order: {
|
||||
order_number: string;
|
||||
status: string;
|
||||
total: number;
|
||||
created_at: string;
|
||||
};
|
||||
products?: { name: string; image?: string }[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function OrderHistoryCard({ order, products, onClick }: OrderHistoryCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-sm p-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{products?.slice(0, 3).map((p, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={p.image || '/placeholder.jpg'}
|
||||
alt={p.name}
|
||||
className="w-16 h-16 object-cover rounded-lg"
|
||||
/>
|
||||
))}
|
||||
{(products?.length || 0) > 3 && (
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-sm text-gray-500">+{(products?.length || 0) - 3}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium">{order.order_number}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString('th-TH')}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
order.status === 'delivered' ? 'bg-green-100 text-green-700' :
|
||||
order.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">รวม</span>
|
||||
<span className="font-bold">{formatCurrency(order.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
interface OrderProductRowProps {
|
||||
name: string;
|
||||
image?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export default function OrderProductRow({ name, image, quantity, unitPrice }: OrderProductRowProps) {
|
||||
return (
|
||||
<tr className="border-b">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={image || '/placeholder.jpg'}
|
||||
alt=""
|
||||
className="w-12 h-12 object-cover rounded"
|
||||
/>
|
||||
<span className="font-medium">{name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">{quantity}</td>
|
||||
<td className="py-3 px-4 text-right">฿{unitPrice.toLocaleString()}</td>
|
||||
<td className="py-3 px-4 text-right font-medium">฿{(unitPrice * quantity).toLocaleString()}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { formatCurrency } from '../../lib/utils';
|
||||
|
||||
interface OrderItem {
|
||||
product?: { name: string; images?: string[] };
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
}
|
||||
|
||||
interface OrderSummariesProps {
|
||||
order: {
|
||||
id: string;
|
||||
order_number: string;
|
||||
status: string;
|
||||
payment_status: string;
|
||||
total: number;
|
||||
created_at: string;
|
||||
};
|
||||
products: OrderItem[];
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'รอดำเนินการ',
|
||||
confirmed: 'ยืนยันแล้ว',
|
||||
processing: 'กำลังประมวลผล',
|
||||
shipped: 'จัดส่งแล้ว',
|
||||
delivered: 'จัดส่งสำเร็จ',
|
||||
cancelled: 'ยกเลิก',
|
||||
refunded: 'คืนเงินแล้ว'
|
||||
};
|
||||
|
||||
export default function OrderSummaries({ order, products }: OrderSummariesProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">หมายเลขคำสั่งซื้อ</p>
|
||||
<p className="font-bold">{order.order_number}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${
|
||||
order.status === 'delivered' ? 'bg-green-100 text-green-700' :
|
||||
order.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{statusLabels[order.status] || order.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{products.map((item, idx) => (
|
||||
<div key={idx} className="flex gap-4">
|
||||
<img
|
||||
src={item.product?.images?.[0] || '/placeholder.jpg'}
|
||||
alt=""
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{item.product?.name}</p>
|
||||
<p className="text-sm text-gray-500">x{item.quantity}</p>
|
||||
</div>
|
||||
<p className="font-medium">{formatCurrency(item.unit_price * item.quantity)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-gray-600">ราคารวม</span>
|
||||
<span>{formatCurrency(order.total * 0.93)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-gray-600">ภาษี (7%)</span>
|
||||
<span>{formatCurrency(order.total * 0.07)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-lg mt-4">
|
||||
<span>รวมทั้งสิ้น</span>
|
||||
<span>{formatCurrency(order.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
interface CardCategoryProps {
|
||||
thumb_src: string;
|
||||
title: string;
|
||||
collection: string;
|
||||
cta?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CardCategory({ thumb_src, title, collection, cta = 'ดูสินค้า', className = '' }: CardCategoryProps) {
|
||||
return (
|
||||
<div className={`relative rounded-xl overflow-hidden group ${className}`}>
|
||||
<img
|
||||
src={thumb_src}
|
||||
alt={title}
|
||||
className="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
|
||||
<div className="absolute inset-0 flex flex-col justify-end p-6">
|
||||
<p className="text-white/80 text-sm mb-1">{collection}</p>
|
||||
<h3 className="text-white text-xl font-bold mb-3">{title}</h3>
|
||||
<a
|
||||
href={`/products?category=${title.toLowerCase()}`}
|
||||
className="inline-block bg-white text-gray-900 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{cta}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface AccordionItem {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ProductAccordionProps {
|
||||
items: AccordionItem[];
|
||||
defaultOpen?: number;
|
||||
}
|
||||
|
||||
export default function ProductAccordion({ items, defaultOpen }: ProductAccordionProps) {
|
||||
const [openIndex, setOpenIndex] = useState(defaultOpen ?? null);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="border-b last:border-b-0">
|
||||
<button
|
||||
onClick={() => setOpenIndex(openIndex === index ? null : index)}
|
||||
className="w-full px-4 py-3 flex justify-between items-center bg-gray-50 hover:bg-gray-100"
|
||||
>
|
||||
<span className="font-medium">{item.title}</span>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 transition-transform ${openIndex === index ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{openIndex === index && (
|
||||
<div className="px-4 py-3 text-gray-600">
|
||||
{item.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
interface ProductBadgeProps {
|
||||
colors?: { name: string; hex: string }[];
|
||||
selected?: string;
|
||||
onSelect: (color: string) => void;
|
||||
}
|
||||
|
||||
export default function ProductBadge({ colors, selected, onSelect }: ProductBadgeProps) {
|
||||
if (!colors?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="block text-sm font-medium">สี</label>
|
||||
{selected && <span className="text-sm text-gray-500">{selected}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{colors.map(color => (
|
||||
<button
|
||||
key={color.name}
|
||||
onClick={() => onSelect(color.name)}
|
||||
className={`w-10 h-10 rounded-full border-2 transition-all ${
|
||||
selected === color.name
|
||||
? 'border-blue-600 scale-110'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
style={{ backgroundColor: color.hex }}
|
||||
title={color.name}
|
||||
>
|
||||
{selected === color.name && (
|
||||
<span className="sr-only">{color.name}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
interface Props {
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
price: number;
|
||||
compare_at_price?: number;
|
||||
images?: string[];
|
||||
inventory?: number;
|
||||
track_inventory?: boolean;
|
||||
category?: { name: string; slug: string };
|
||||
vendor?: { store_name: string };
|
||||
avgRating?: number;
|
||||
reviewCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const { product } = Astro.props;
|
||||
const discount = product.compare_at_price && product.compare_at_price > product.price
|
||||
? Math.round((1 - product.price / product.compare_at_price) * 100)
|
||||
: 0;
|
||||
const image = product.images?.[0] || 'https://via.placeholder.com/300x300?text=No+Image';
|
||||
---
|
||||
|
||||
<article class="bg-white rounded-xl shadow-sm overflow-hidden hover:shadow-lg transition-shadow group">
|
||||
<a href={`/products/${product.slug}`} class="block">
|
||||
<div class="aspect-square overflow-hidden bg-gray-100 relative">
|
||||
<img
|
||||
src={image}
|
||||
alt={product.name}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{discount > 0 && (
|
||||
<span class="absolute top-3 left-3 bg-red-500 text-white px-2 py-1 rounded text-xs font-medium">
|
||||
ลด {discount}%
|
||||
</span>
|
||||
)}
|
||||
{(!product.track_inventory || (product.inventory ?? 0) > 5) && (
|
||||
<span class="absolute top-3 right-3 bg-green-500 text-white px-2 py-1 rounded text-xs">
|
||||
มีสินค้า
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
{product.category && (
|
||||
<p class="text-xs text-gray-500 mb-1">{product.category.name}</p>
|
||||
)}
|
||||
<h3 class="font-medium text-gray-900 line-clamp-2 mb-2">
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{product.avgRating && (
|
||||
<div class="flex items-center gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<svg
|
||||
class={`w-3 h-3 ${i < Math.round(product.avgRating) ? 'text-yellow-400' : 'text-gray-300'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
))}
|
||||
<span class="text-xs text-gray-500">({product.reviewCount || 0})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-lg font-bold text-blue-600">
|
||||
฿{product.price.toLocaleString()}
|
||||
</span>
|
||||
{product.compare_at_price && product.compare_at_price > product.price && (
|
||||
<span class="text-sm text-gray-400 line-through ml-2">
|
||||
฿{product.compare_at_price.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.vendor && (
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
โดย {product.vendor.store_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
interface ProductFeatureProps {
|
||||
title: string;
|
||||
images?: string[];
|
||||
fullDescription?: string;
|
||||
featuresDetails?: { title: string; items: string[] }[];
|
||||
}
|
||||
|
||||
export default function ProductFeature({ title, images, fullDescription, featuresDetails }: ProductFeatureProps) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<h2 className="text-2xl font-bold text-center mb-8">{title}</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-6">
|
||||
{fullDescription && (
|
||||
<p className="text-gray-600">{fullDescription}</p>
|
||||
)}
|
||||
|
||||
{featuresDetails?.map((feature, i) => (
|
||||
<div key={i}>
|
||||
<h3 className="font-bold mb-2">{feature.title}</h3>
|
||||
<ul className="space-y-2">
|
||||
{feature.items.map((item, j) => (
|
||||
<li key={j} className="flex items-start gap-2">
|
||||
<span className="w-5 h-5 bg-green-100 text-green-600 rounded-full flex items-center justify-center text-sm flex-shrink-0 mt-0.5">
|
||||
✓
|
||||
</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{images?.map((img, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface ProductFeature2Props {
|
||||
title: string;
|
||||
fullDescription?: string;
|
||||
images?: string[];
|
||||
data?: { title: string; content: string }[];
|
||||
}
|
||||
|
||||
export default function ProductFeature2({ title, fullDescription, images, data }: ProductFeature2Props) {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="py-12">
|
||||
<h2 className="text-2xl font-bold text-center mb-8">{title}</h2>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-12">
|
||||
<div>
|
||||
{images?.[activeTab] && (
|
||||
<img
|
||||
src={images[activeTab]}
|
||||
alt=""
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex gap-4 mb-6 overflow-x-auto">
|
||||
{data?.map((tab, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveTab(i)}
|
||||
className={`px-4 py-2 rounded-lg whitespace-nowrap ${
|
||||
activeTab === i ? 'bg-blue-600 text-white' : 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data?.[activeTab] && (
|
||||
<div className="prose">
|
||||
<p>{data[activeTab].content}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fullDescription && (
|
||||
<p className="mt-6 text-gray-600">{fullDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
search?: string;
|
||||
categories?: string[];
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
vendors?: string[];
|
||||
}
|
||||
|
||||
interface ProductFiltersProps {
|
||||
categories: Category[];
|
||||
onFilterChange: (filters: Filters) => void;
|
||||
}
|
||||
|
||||
export default function ProductFilters({ categories, onFilterChange }: ProductFiltersProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [priceRange, setPriceRange] = useState<[number, number]>([0, 100000]);
|
||||
|
||||
const applyFilters = () => {
|
||||
onFilterChange({ search, categories: selectedCategories, priceMin: priceRange[0], priceMax: priceRange[1] });
|
||||
};
|
||||
|
||||
const toggleCategory = (id: string) => {
|
||||
const newCategories = selectedCategories.includes(id)
|
||||
? selectedCategories.filter(c => c !== id)
|
||||
: [...selectedCategories, id];
|
||||
setSelectedCategories(newCategories);
|
||||
onFilterChange({ search, categories: newCategories, priceMin: priceRange[0], priceMax: priceRange[1] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ค้นหาสินค้า..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">หมวดหมู่</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.map(category => (
|
||||
<label key={category.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.includes(category.id)}
|
||||
onChange={() => toggleCategory(category.id)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="flex-1">{category.name}</span>
|
||||
{category.count && (
|
||||
<span className="text-sm text-gray-500">({category.count})</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">ช่วงราคา</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="ต่ำสุด"
|
||||
value={priceRange[0] || ''}
|
||||
onChange={(e) => setPriceRange([Number(e.target.value), priceRange[1]])}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
<span className="text-gray-400">-</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="สูงสุด"
|
||||
value={priceRange[1] || ''}
|
||||
onChange={(e) => setPriceRange([priceRange[0], Number(e.target.value)])}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setSelectedCategories([]);
|
||||
setPriceRange([0, 100000]);
|
||||
onFilterChange({});
|
||||
}}
|
||||
className="w-full py-2 border rounded-lg text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
ล้างตัวกรอง
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, ZoomIn } from 'lucide-react';
|
||||
|
||||
interface ProductGalleryProps {
|
||||
images: string[];
|
||||
productName: string;
|
||||
}
|
||||
|
||||
export default function ProductGallery({ images, productName }: ProductGalleryProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Main Image */}
|
||||
<div className="relative aspect-square overflow-hidden rounded-xl bg-gray-100 group">
|
||||
<img
|
||||
src={images[currentIndex]}
|
||||
alt={`${productName} - รูปที่ ${currentIndex + 1}`}
|
||||
className="w-full h-full object-cover cursor-zoom-in"
|
||||
onClick={() => setIsZoomed(true)}
|
||||
/>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/80 backdrop-blur rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/80 backdrop-blur rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setIsZoomed(true)}
|
||||
className="absolute bottom-4 right-4 w-10 h-10 bg-white/80 backdrop-blur rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-4 left-4 text-sm text-white bg-black/50 px-2 py-1 rounded">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
className={`flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all ${
|
||||
index === currentIndex
|
||||
? 'border-blue-600 ring-2 ring-blue-100'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={`ภาพย่อย ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom Modal */}
|
||||
{isZoomed && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
||||
onClick={() => setIsZoomed(false)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsZoomed(false)}
|
||||
className="absolute top-4 right-4 text-white hover:text-gray-300"
|
||||
>
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<img
|
||||
src={images[currentIndex]}
|
||||
alt={productName}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-8 flex gap-4">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goToPrevious(); }}
|
||||
className="w-12 h-12 bg-white/20 backdrop-blur rounded-full flex items-center justify-center text-white hover:bg-white/30"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goToNext(); }}
|
||||
className="w-12 h-12 bg-white/20 backdrop-blur rounded-full flex items-center justify-center text-white hover:bg-white/30"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import StarRating from '../review/StarRating';
|
||||
|
||||
interface ProductOverviewGalleryProps {
|
||||
product: {
|
||||
name: string;
|
||||
price: number;
|
||||
images?: string[];
|
||||
colors?: { name: string; hex: string }[];
|
||||
rating?: number;
|
||||
reviews?: number;
|
||||
fullDescription?: string;
|
||||
data?: { title: string; content: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProductOverviewGallery({ product }: ProductOverviewGalleryProps) {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
|
||||
const tabs = ['รูปภาพ', 'รายละเอียด', ...(product.data?.map(d => d.title) || [])];
|
||||
const images = product.images || ['/placeholder.jpg'];
|
||||
|
||||
const nextImage = () => setCurrentImageIndex((prev) => (prev + 1) % images.length);
|
||||
const prevImage = () => setCurrentImageIndex((prev) => (prev - 1 + images.length) % images.length);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-4 border-b mb-6">
|
||||
{tabs.map((tab, i) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(i)}
|
||||
className={`pb-3 px-2 font-medium border-b-2 ${
|
||||
activeTab === i ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 0 && (
|
||||
<div className="relative">
|
||||
<div className="aspect-square rounded-xl overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={images[currentImageIndex]}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevImage}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextImage}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
{images.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentImageIndex(i)}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
currentImageIndex === i ? 'bg-blue-600' : 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<div className="prose">
|
||||
<p>{product.fullDescription || 'ไม่มีรายละเอียด'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab >= 2 && product.data?.[activeTab - 2] && (
|
||||
<div>
|
||||
<h3 className="font-bold mb-2">{product.data[activeTab - 2].title}</h3>
|
||||
<p className="text-gray-600">{product.data[activeTab - 2].content}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 p-6 bg-gray-50 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold">{product.name}</h3>
|
||||
{product.rating && (
|
||||
<StarRating rating={product.rating} showCount count={product.reviews || 0} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
฿{product.price.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{product.colors?.length && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
{product.colors.map(c => (
|
||||
<div
|
||||
key={c.name}
|
||||
className="w-8 h-8 rounded-full border-2 border-gray-200"
|
||||
style={{ backgroundColor: c.hex }}
|
||||
title={c.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import StarRating from '../review/StarRating';
|
||||
import AddToCart from '../cart/AddToCart';
|
||||
|
||||
interface ProductOverviewGridProps {
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
images?: string[];
|
||||
colors?: { name: string; hex: string }[];
|
||||
sizes?: { name: string; available: boolean }[];
|
||||
rating?: number;
|
||||
reviews?: number;
|
||||
fullDescription?: string;
|
||||
highlights?: string[];
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProductOverviewGrid({ product }: ProductOverviewGridProps) {
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const [selectedColor, setSelectedColor] = useState<string>();
|
||||
const [selectedSize, setSelectedSize] = useState<string>();
|
||||
|
||||
return (
|
||||
<div className="grid lg:grid-cols-2 gap-12">
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-square rounded-xl overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={product.images?.[selectedImage] || '/placeholder.jpg'}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{product.images?.map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(i)}
|
||||
className={`flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 ${
|
||||
selectedImage === i ? 'border-blue-600' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{product.name}</h1>
|
||||
{product.rating && (
|
||||
<StarRating rating={product.rating} showCount count={product.reviews || 0} size="lg" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
฿{product.price.toLocaleString()}
|
||||
</div>
|
||||
|
||||
{product.fullDescription && (
|
||||
<p className="text-gray-600">{product.fullDescription}</p>
|
||||
)}
|
||||
|
||||
{product.colors?.length && (
|
||||
<div>
|
||||
<p className="font-medium mb-2">สี: {selectedColor}</p>
|
||||
<div className="flex gap-2">
|
||||
{product.colors.map(c => (
|
||||
<button
|
||||
key={c.name}
|
||||
onClick={() => setSelectedColor(c.name)}
|
||||
className={`w-12 h-12 rounded-full border-2 ${
|
||||
selectedColor === c.name ? 'border-blue-600' : 'border-gray-200'
|
||||
}`}
|
||||
style={{ backgroundColor: c.hex }}
|
||||
title={c.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product.sizes?.length && (
|
||||
<div>
|
||||
<p className="font-medium mb-2">ไซส์: {selectedSize || 'เลือกไซส์'}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.sizes.map(s => (
|
||||
<button
|
||||
key={s.name}
|
||||
onClick={() => s.available && setSelectedSize(s.name)}
|
||||
disabled={!s.available}
|
||||
className={`px-4 py-2 border rounded-lg ${
|
||||
selectedSize === s.name
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: s.available
|
||||
? 'border-gray-200 hover:border-gray-300'
|
||||
: 'border-gray-200 text-gray-300 line-through'
|
||||
}`}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddToCart client:idle product={{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
images: product.images
|
||||
}} />
|
||||
|
||||
{product.highlights?.length && (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-bold mb-3">จุดเด่น</h3>
|
||||
<ul className="space-y-2">
|
||||
{product.highlights.map((h, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{h}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import StarRating from '../review/StarRating';
|
||||
|
||||
interface ProductQuickview2Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
product: {
|
||||
name: string;
|
||||
price: number;
|
||||
images?: string[];
|
||||
colors?: { name: string; hex: string }[];
|
||||
rating?: number;
|
||||
reviews?: number;
|
||||
sizes?: { name: string; available: boolean }[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProductQuickview2({ isOpen, onClose, product }: ProductQuickview2Props) {
|
||||
const [selectedColor, setSelectedColor] = useState(product.colors?.[0]?.name);
|
||||
const [selectedSize, setSelectedSize] = useState<string>();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 p-6">
|
||||
<div>
|
||||
<img
|
||||
src={product.images?.[0] || '/placeholder.jpg'}
|
||||
alt={product.name}
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-2">{product.name}</h3>
|
||||
{product.rating && (
|
||||
<StarRating rating={product.rating} showCount count={product.reviews || 0} />
|
||||
)}
|
||||
<p className="text-2xl font-bold text-blue-600 mt-4">
|
||||
฿{product.price.toLocaleString()}
|
||||
</p>
|
||||
|
||||
{product.colors?.length && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium">สี: {selectedColor}</p>
|
||||
<div className="flex gap-2">
|
||||
{product.colors.map(c => (
|
||||
<button
|
||||
key={c.name}
|
||||
onClick={() => setSelectedColor(c.name)}
|
||||
className={`w-8 h-8 rounded-full border-2 ${
|
||||
selectedColor === c.name ? 'border-blue-600' : 'border-gray-200'
|
||||
}`}
|
||||
style={{ backgroundColor: c.hex }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product.sizes?.length && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium">ไซส์: {selectedSize || 'เลือกไซส์'}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.sizes.map(s => (
|
||||
<button
|
||||
key={s.name}
|
||||
onClick={() => s.available && setSelectedSize(s.name)}
|
||||
disabled={!s.available}
|
||||
className={`px-3 py-1 border rounded ${
|
||||
selectedSize === s.name
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: s.available
|
||||
? 'border-gray-200 hover:border-gray-300'
|
||||
: 'border-gray-200 text-gray-300 line-through'
|
||||
}`}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="w-full mt-6 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700">
|
||||
เพิ่มลงตะกร้า
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
interface ProductRatingProps {
|
||||
rating: number;
|
||||
max?: number;
|
||||
showCount?: boolean;
|
||||
count?: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5'
|
||||
};
|
||||
|
||||
export default function ProductRating({ rating, max = 5, showCount = false, count = 0, size = 'md' }: ProductRatingProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(max)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`${sizeClasses[size]} ${
|
||||
i < Math.round(rating)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
{showCount && (
|
||||
<span className="ml-1 text-sm text-gray-500">({count})</span>
|
||||
)}
|
||||
<span className="ml-1 text-sm font-medium">{rating.toFixed(1)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
interface SizeOption {
|
||||
name: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
interface ProductSizesProps {
|
||||
sizes: SizeOption[];
|
||||
selected?: string;
|
||||
onSelect: (size: string) => void;
|
||||
}
|
||||
|
||||
export default function ProductSizes({ sizes, selected, onSelect }: ProductSizesProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium">ไซส์</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sizes.map(size => (
|
||||
<button
|
||||
key={size.name}
|
||||
onClick={() => size.available && onSelect(size.name)}
|
||||
disabled={!size.available}
|
||||
className={`px-4 py-2 border rounded-lg transition-colors ${
|
||||
selected === size.name
|
||||
? 'border-blue-600 bg-blue-50 text-blue-600'
|
||||
: size.available
|
||||
? 'border-gray-200 hover:border-gray-300'
|
||||
: 'border-gray-200 text-gray-300 cursor-not-allowed line-through'
|
||||
}`}
|
||||
>
|
||||
{size.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface VariantAttribute {
|
||||
name: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
interface ProductVariantsProps {
|
||||
variants: { id: string; name: string; price: number; attributes: Record<string, string> }[];
|
||||
onSelect: (variant: any) => void;
|
||||
selectedId?: string;
|
||||
}
|
||||
|
||||
export default function ProductVariants({ variants, onSelect, selectedId }: ProductVariantsProps) {
|
||||
if (!variants?.length) return null;
|
||||
|
||||
// Extract unique attribute types (size, color, etc.)
|
||||
const attributeTypes = ['size', 'color', 'style', 'material'].filter(attr =>
|
||||
variants.some(v => v.attributes?.[attr])
|
||||
);
|
||||
|
||||
const handleAttributeSelect = (attrName: string, value: string) => {
|
||||
// Find a variant that matches all currently selected attributes plus this new one
|
||||
// For simplicity, just find any variant with this attribute value
|
||||
const variant = variants.find(v => v.attributes?.[attrName] === value);
|
||||
if (variant) {
|
||||
onSelect(variant);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{attributeTypes.map(attrName => (
|
||||
<div key={attrName}>
|
||||
<label className="block text-sm font-medium text-gray-900 mb-3">
|
||||
{attrName === 'size' ? 'ขนาด' :
|
||||
attrName === 'color' ? 'สี' :
|
||||
attrName === 'style' ? 'สไตล์' :
|
||||
attrName === 'material' ? 'วัสดุ' : attrName}
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({variants.find(v => v.id === selectedId)?.attributes?.[attrName] || 'เลือก'})
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{attrName === 'color' ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[...new Set(variants.map(v => v.attributes?.[attrName]))].map(value => {
|
||||
const isSelected = variants.find(v => v.id === selectedId)?.attributes?.[attrName] === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => handleAttributeSelect(attrName, value)}
|
||||
className={`px-4 py-2 border-2 rounded-lg flex items-center gap-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-5 h-5 rounded-full border border-gray-300"
|
||||
style={{ backgroundColor: value.toLowerCase() }}
|
||||
/>
|
||||
<span className="text-sm">{value}</span>
|
||||
{isSelected && <Check className="w-4 h-4 text-blue-600" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[...new Set(variants.map(v => v.attributes?.[attrName]))].map(value => {
|
||||
const isSelected = variants.find(v => v.id === selectedId)?.attributes?.[attrName] === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => handleAttributeSelect(attrName, value)}
|
||||
className={`px-4 py-2 border-2 rounded-lg transition-all ${
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50 text-blue-600'
|
||||
: 'border-gray-200 hover:border-gray-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Price display for selected variant */}
|
||||
{selectedId && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600">ราคาสำหรับตัวเลือกนี้</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
฿{variants.find(v => v.id === selectedId)?.price.toLocaleString() || 0}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Package, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface StockBadgeProps {
|
||||
inventory: number;
|
||||
lowStockThreshold?: number;
|
||||
trackInventory?: boolean;
|
||||
showIcon?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export default function StockBadge({
|
||||
inventory,
|
||||
lowStockThreshold = 5,
|
||||
trackInventory = true,
|
||||
showIcon = true,
|
||||
size = 'md'
|
||||
}: StockBadgeProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5'
|
||||
};
|
||||
|
||||
if (!trackInventory) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 text-green-600 ${sizeClasses[size]}`}>
|
||||
{showIcon && <CheckCircle className={iconSizes[size]} />}
|
||||
<span>พร้อมส่ง</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inventory === 0) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 text-red-600 ${sizeClasses[size]}`}>
|
||||
{showIcon && <XCircle className={iconSizes[size]} />}
|
||||
<span>หมดสต็อก</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inventory <= lowStockThreshold) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 text-orange-600 ${sizeClasses[size]}`}>
|
||||
{showIcon && <AlertTriangle className={iconSizes[size]} />}
|
||||
<span>เหลือเพียง {inventory} ชิ้น</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 text-green-600 ${sizeClasses[size]}`}>
|
||||
{showIcon && <Package className={iconSizes[size]} />}
|
||||
<span>มีสินค้า ({inventory})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
|
||||
interface WishlistButtonProps {
|
||||
productId: string;
|
||||
productName: string;
|
||||
productImage: string;
|
||||
productPrice: number;
|
||||
isInWishlist?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export default function WishlistButton({
|
||||
productId,
|
||||
productName,
|
||||
productImage,
|
||||
productPrice,
|
||||
isInWishlist: initialState = false,
|
||||
size = 'md',
|
||||
showLabel = false
|
||||
}: WishlistButtonProps) {
|
||||
const [isInWishlist, setIsInWishlist] = useState(initialState);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
const sizeConfig = {
|
||||
sm: { button: 'w-8 h-8', icon: 'w-4 h-4', text: 'text-xs' },
|
||||
md: { button: 'w-10 h-10', icon: 'w-5 h-5', text: 'text-sm' },
|
||||
lg: { button: 'w-12 h-12', icon: 'w-6 h-6', text: 'text-base' }
|
||||
};
|
||||
|
||||
const toggleWishlist = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsAnimating(true);
|
||||
|
||||
try {
|
||||
if (isInWishlist) {
|
||||
// Remove from wishlist
|
||||
await fetch(`/api/wishlist/${productId}`, { method: 'DELETE' });
|
||||
setIsInWishlist(false);
|
||||
} else {
|
||||
// Add to wishlist
|
||||
await fetch('/api/wishlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
productName,
|
||||
productImage,
|
||||
productPrice
|
||||
})
|
||||
});
|
||||
setIsInWishlist(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update wishlist:', error);
|
||||
}
|
||||
|
||||
setTimeout(() => setIsAnimating(false), 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleWishlist}
|
||||
className={`
|
||||
${sizeConfig[size].button}
|
||||
rounded-full flex items-center justify-center transition-all
|
||||
${isInWishlist
|
||||
? 'bg-red-50 text-red-500 hover:bg-red-100'
|
||||
: 'bg-white/80 backdrop-blur text-gray-400 hover:text-red-500 hover:bg-white'
|
||||
}
|
||||
${isAnimating ? 'scale-110' : 'scale-100'}
|
||||
shadow-sm hover:shadow-md
|
||||
`}
|
||||
aria-label={isInWishlist ? 'ลบออกจากรายการโปรด' : 'เพิ่มไปยังรายการโปรด'}
|
||||
>
|
||||
<Heart
|
||||
className={`${sizeConfig[size].icon} transition-transform ${isAnimating ? 'animate-pulse' : ''}`}
|
||||
fill={isInWishlist ? 'currentColor' : 'none'}
|
||||
/>
|
||||
{showLabel && (
|
||||
<span className={`ml-2 ${sizeConfig[size].text} ${isInWishlist ? 'text-red-500' : 'text-gray-600'}`}>
|
||||
{isInWishlist ? 'ในรายการโปรด' : 'เพิ่มไปรายการโปรด'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
interface PromoSectionLargeProps {
|
||||
title: string;
|
||||
fullDescription?: string;
|
||||
pageHeaderBgImg?: string;
|
||||
pageHeaderMinVh?: string;
|
||||
pageHeaderRadius?: string;
|
||||
ctaText?: string;
|
||||
ctaHref?: string;
|
||||
}
|
||||
|
||||
export default function PromoSectionLarge({
|
||||
title,
|
||||
fullDescription,
|
||||
pageHeaderBgImg,
|
||||
pageHeaderMinVh = '60vh',
|
||||
pageHeaderRadius,
|
||||
ctaText = 'ซื้อเลย',
|
||||
ctaHref = '/products'
|
||||
}: PromoSectionLargeProps) {
|
||||
return (
|
||||
<section
|
||||
className="relative flex items-center justify-center overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: pageHeaderBgImg ? `url(${pageHeaderBgImg})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
minHeight: pageHeaderMinVh,
|
||||
borderRadius: pageHeaderRadius
|
||||
}}
|
||||
>
|
||||
{pageHeaderBgImg && <div className="absolute inset-0 bg-black/40" />}
|
||||
|
||||
<div className="relative z-10 text-center text-white px-4">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">{title}</h1>
|
||||
{fullDescription && (
|
||||
<p className="text-xl mb-8 max-w-2xl mx-auto">{fullDescription}</p>
|
||||
)}
|
||||
<a
|
||||
href={ctaHref}
|
||||
className="inline-block bg-white text-gray-900 px-8 py-3 rounded-lg font-medium hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{ctaText}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Quote } from 'lucide-react';
|
||||
|
||||
interface Testimonial {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
comment: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
interface TestimonialsFadeProps {
|
||||
pageHeaderBgImg?: string;
|
||||
pageHeaderMinVh?: string;
|
||||
testimonials?: Testimonial[];
|
||||
}
|
||||
|
||||
export default function TestimonialsFade({
|
||||
pageHeaderBgImg,
|
||||
pageHeaderMinVh = '40vh',
|
||||
testimonials = []
|
||||
}: TestimonialsFadeProps) {
|
||||
const [current, setCurrent] = useState(0);
|
||||
|
||||
const next = () => setCurrent((prev) => (prev + 1) % testimonials.length);
|
||||
const prev = () => setCurrent((prev) => (prev - 1 + testimonials.length) % testimonials.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1) return;
|
||||
const interval = setInterval(next, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [testimonials.length]);
|
||||
|
||||
if (!testimonials.length) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div
|
||||
className="relative py-20 bg-gray-900 text-white"
|
||||
style={{
|
||||
backgroundImage: pageHeaderBgImg ? `linear-gradient(rgba(0,0,0,0.7), url(${pageHeaderBgImg}))` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
minHeight: pageHeaderMinVh
|
||||
}}
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<Quote className="w-12 h-12 mx-auto mb-6 text-blue-400" />
|
||||
|
||||
<div className="relative min-h-[200px]">
|
||||
{testimonials.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute inset-0 transition-opacity duration-500 ${
|
||||
i === current ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xl md:text-2xl mb-6">{t.comment}</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{t.avatar ? (
|
||||
<img src={t.avatar} alt={t.name} className="w-12 h-12 rounded-full" />
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
{t.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="font-medium">{t.name}</p>
|
||||
{t.role && <p className="text-sm text-gray-400">{t.role}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{testimonials.length > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<button onClick={prev} className="p-2 bg-white/10 rounded-full hover:bg-white/20">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={next} className="p-2 bg-white/10 rounded-full hover:bg-white/20">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import StarRating from './StarRating';
|
||||
|
||||
interface ReviewCommentProps {
|
||||
review: {
|
||||
id: string;
|
||||
rating: number;
|
||||
title?: string;
|
||||
comment: string;
|
||||
user: { name: string; avatar_url?: string };
|
||||
created_at: string;
|
||||
images?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function ReviewComment({ review }: ReviewCommentProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center overflow-hidden">
|
||||
{review.user.avatar_url ? (
|
||||
<img src={review.user.avatar_url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-gray-500 font-medium">{review.user.name[0]}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{review.user.name}</p>
|
||||
<StarRating rating={review.rating} size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(review.created_at).toLocaleDateString('th-TH', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{review.title && (
|
||||
<h4 className="font-medium mb-2">{review.title}</h4>
|
||||
)}
|
||||
|
||||
<p className="text-gray-600 mb-4">{review.comment}</p>
|
||||
|
||||
{review.images?.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{review.images.map((img, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-20 h-20 object-cover rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from 'react';
|
||||
import StarRating from './StarRating';
|
||||
|
||||
interface ReviewFormProps {
|
||||
productId: string;
|
||||
onSubmit: (review: { rating: number; title: string; comment: string; images: File[] }) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export default function ReviewForm({ productId, onSubmit, isSubmitting = false }: ReviewFormProps) {
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoverRating, setHoverRating] = useState(0);
|
||||
const [title, setTitle] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length + images.length > 5) {
|
||||
setErrors(prev => ({ ...prev, images: 'สูงสุด 5 รูปเท่านั้น' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setImages(prev => [...prev, ...files]);
|
||||
|
||||
const newPreviews = files.map(file => URL.createObjectURL(file));
|
||||
setImagePreviews(prev => [...prev, ...newPreviews]);
|
||||
setErrors(prev => {
|
||||
const { images: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImages(prev => prev.filter((_, i) => i !== index));
|
||||
setImagePreviews(prev => {
|
||||
URL.revokeObjectURL(prev[index]);
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (rating === 0) newErrors.rating = 'กรุณาให้คะแนน';
|
||||
if (!comment.trim()) newErrors.comment = 'กรุณาเขียนรีวิว';
|
||||
if (comment.length < 10) newErrors.comment = 'รีวิวต้องมีอย่างน้อย 10 ตัวอักษร';
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
await onSubmit({ rating, title, comment, images });
|
||||
|
||||
// Reset form
|
||||
setRating(0);
|
||||
setHoverRating(0);
|
||||
setTitle('');
|
||||
setComment('');
|
||||
setImages([]);
|
||||
setImagePreviews([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl border p-6 space-y-6">
|
||||
<h3 className="font-medium text-lg">เขียนรีวิว</h3>
|
||||
|
||||
{/* Rating */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">คะแนน *</label>
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
className="p-1 hover:scale-110 transition-transform"
|
||||
>
|
||||
<StarRating
|
||||
rating={hoverRating || rating}
|
||||
size={28}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{rating > 0 && (
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{rating === 1 ? 'ไม่พอใจมาก' :
|
||||
rating === 2 ? 'ไม่พอใจ' :
|
||||
rating === 3 ? 'พอใช้' :
|
||||
rating === 4 ? 'พอใจ' : 'พอใจมาก'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{errors.rating && <p className="text-red-500 text-sm mt-1">{errors.rating}</p>}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">หัวข้อ (ไม่บังคับ)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="สรุปประสบการณ์ของคุณ"
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">รีวิว *</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="แชร์ประสบการณ์ที่ใช้สินค้าของคุณ..."
|
||||
className={`w-full border rounded-lg px-3 py-2 h-32 resize-none ${errors.comment ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
<div className="flex justify-between mt-1">
|
||||
{errors.comment && <p className="text-red-500 text-sm">{errors.comment}</p>}
|
||||
<span className="text-xs text-gray-400 ml-auto">{comment.length}/1000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">รูปภาพ (ไม่บังคับ)</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{imagePreviews.map((preview, i) => (
|
||||
<div key={i} className="relative w-20 h-20">
|
||||
<img
|
||||
src={preview}
|
||||
alt={`รูปที่ ${i + 1}`}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-xs"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{images.length < 5 && (
|
||||
<label className="w-20 h-20 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-colors">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span className="text-xs text-gray-400 mt-1">{images.length}/5</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{errors.images && <p className="text-red-500 text-sm mt-1">{errors.images}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'กำลังส่งรีวิว...' : 'ส่งรีวิว'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import StarRating from './StarRating';
|
||||
|
||||
interface Review {
|
||||
id: string;
|
||||
rating: number;
|
||||
title?: string;
|
||||
comment: string;
|
||||
user: { name: string; avatar_url?: string };
|
||||
created_at: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
interface ReviewListProps {
|
||||
reviews: Review[];
|
||||
productId?: string;
|
||||
onLoadMore?: () => void;
|
||||
hasMore?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function ReviewList({
|
||||
reviews,
|
||||
productId,
|
||||
onLoadMore,
|
||||
hasMore = false,
|
||||
isLoading = false
|
||||
}: ReviewListProps) {
|
||||
if (!reviews?.length) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900 mb-1">ยังไม่มีรีวิว</h3>
|
||||
<p className="text-gray-500 text-sm">เป็นคนแรกที่รีวิวสินค้านี้</p>
|
||||
<a
|
||||
href={`#write-review${productId ? `?productId=${productId}` : ''}`}
|
||||
className="mt-4 inline-block text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
เขียนรีวิว
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{reviews.map(review => (
|
||||
<div key={review.id} className="border-b pb-6 last:border-b-0">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center overflow-hidden">
|
||||
{review.user.avatar_url ? (
|
||||
<img
|
||||
src={review.user.avatar_url}
|
||||
alt={review.user.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-500 font-medium">
|
||||
{review.user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{review.user.name}</p>
|
||||
<StarRating rating={review.rating} size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(review.created_at).toLocaleDateString('th-TH', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{review.title && (
|
||||
<h4 className="font-medium text-gray-900 mb-1">{review.title}</h4>
|
||||
)}
|
||||
|
||||
<p className="text-gray-600 leading-relaxed">{review.comment}</p>
|
||||
|
||||
{review.images && review.images.length > 0 && (
|
||||
<div className="flex gap-2 mt-4 overflow-x-auto pb-2">
|
||||
{review.images.map((img, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={img}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt={`รูปรีวิว ${i + 1}`}
|
||||
className="w-20 h-20 object-cover rounded-lg hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<div className="text-center pt-4">
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoading}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'กำลังโหลด...' : 'ดูรีวิวเพิ่มเติม'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
interface ReviewProgressProps {
|
||||
reviews: { rating: number }[];
|
||||
}
|
||||
|
||||
export default function ReviewProgress({ reviews }: ReviewProgressProps) {
|
||||
const total = reviews.length;
|
||||
|
||||
const breakdown = [5, 4, 3, 2, 1].map(rating => {
|
||||
const count = reviews.filter(r => Math.round(r.rating) === rating).length;
|
||||
const percentage = total > 0 ? (count / total) * 100 : 0;
|
||||
return { rating, count, percentage };
|
||||
});
|
||||
|
||||
const average = total > 0
|
||||
? reviews.reduce((sum, r) => sum + r.rating, 0) / total
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<div className="flex items-center gap-6 mb-6">
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold">{average.toFixed(1)}</p>
|
||||
<div className="flex justify-center mt-1">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<span key={i} className={`text-lg ${i <= Math.round(average) ? 'text-yellow-400' : 'text-gray-300'}`}>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">{total} รีวิว</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
{breakdown.map(({ rating, count, percentage }) => (
|
||||
<div key={rating} className="flex items-center gap-2">
|
||||
<span className="text-sm w-6">{rating} ★</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-yellow-400 rounded-full"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 w-8">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
interface StarRatingProps {
|
||||
rating: number;
|
||||
max?: number;
|
||||
size?: number;
|
||||
showValue?: boolean;
|
||||
interactive?: boolean;
|
||||
onChange?: (rating: number) => void;
|
||||
}
|
||||
|
||||
export default function StarRating({
|
||||
rating,
|
||||
max = 5,
|
||||
size = 16,
|
||||
showValue = false,
|
||||
interactive = false,
|
||||
onChange
|
||||
}: StarRatingProps) {
|
||||
const handleClick = (index: number) => {
|
||||
if (interactive && onChange) {
|
||||
onChange(index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{[...Array(max)].map((_, i) => {
|
||||
const starValue = i + 1;
|
||||
const isFilled = starValue <= rating;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleClick(starValue)}
|
||||
disabled={!interactive}
|
||||
className={`${interactive ? 'cursor-pointer hover:scale-110 transition-transform' : 'cursor-default'}`}
|
||||
>
|
||||
<Star
|
||||
size={size}
|
||||
className={`
|
||||
${isFilled ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'}
|
||||
${interactive ? 'hover:fill-yellow-300' : ''}
|
||||
transition-colors
|
||||
`}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{showValue && (
|
||||
<span className="ml-1 text-sm text-gray-600 font-medium">
|
||||
{rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
interface LinkGroup {
|
||||
title: string;
|
||||
links: { label: string; href: string }[];
|
||||
}
|
||||
|
||||
interface StoreDoubleColumnProps {
|
||||
left: { title: string; links: LinkGroup[] };
|
||||
right: { title: string; links: LinkGroup[] };
|
||||
}
|
||||
|
||||
export default function StoreDoubleColumn({ left, right }: StoreDoubleColumnProps) {
|
||||
const renderColumn = (column: { title: string; links: LinkGroup[] }) => (
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-lg mb-4">{column.title}</h3>
|
||||
{column.links.map((group, i) => (
|
||||
<div key={i} className="mb-4">
|
||||
<p className="font-medium text-gray-900 mb-2">{group.title}</p>
|
||||
<ul className="space-y-2">
|
||||
{group.links.map((link, j) => (
|
||||
<li key={j}>
|
||||
<a
|
||||
href={link.href}
|
||||
className="text-gray-600 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex gap-12">
|
||||
{renderColumn(left)}
|
||||
{renderColumn(right)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Search, ShoppingCart, User, Menu } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface StoreNavbarProps {
|
||||
logo?: string;
|
||||
storeName?: string;
|
||||
cartCount?: number;
|
||||
isLoggedIn?: boolean;
|
||||
}
|
||||
|
||||
export default function StoreNavbar({ logo, storeName = 'ร้านของฉัน', cartCount = 0, isLoggedIn }: StoreNavbarProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm sticky top-0 z-40">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg lg:hidden"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<a href="/" className="flex items-center gap-2">
|
||||
{logo ? (
|
||||
<img src={logo} alt={storeName} className="h-8 w-auto" />
|
||||
) : (
|
||||
<span className="text-xl font-bold">{storeName}</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-6">
|
||||
<a href="/products" className="text-gray-700 hover:text-blue-600">สินค้า</a>
|
||||
<a href="/vendors" className="text-gray-700 hover:text-blue-600">ร้านค้า</a>
|
||||
<a href="/about" className="text-gray-700 hover:text-blue-600">เกี่ยวกับเรา</a>
|
||||
<a href="/contact" className="text-gray-700 hover:text-blue-600">ติดต่อ</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<a href="/cart" className="p-2 hover:bg-gray-100 rounded-lg relative">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
|
||||
{cartCount > 9 ? '9+' : cartCount}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
<a href={isLoggedIn ? '/account' : '/login'} className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
<User className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div className="lg:hidden border-t bg-white">
|
||||
<nav className="container mx-auto px-4 py-4 space-y-2">
|
||||
<a href="/products" className="block py-2 text-gray-700">สินค้า</a>
|
||||
<a href="/vendors" className="block py-2 text-gray-700">ร้านค้า</a>
|
||||
<a href="/about" className="block py-2 text-gray-700">เกี่ยวกับเรา</a>
|
||||
<a href="/contact" className="block py-2 text-gray-700">ติดต่อ</a>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Store, Package, Users, Settings } from 'lucide-react';
|
||||
|
||||
interface StoreNavigationProps {
|
||||
isLoggedIn?: boolean;
|
||||
isVendor?: boolean;
|
||||
}
|
||||
|
||||
export default function StoreNavigation({ isLoggedIn, isVendor }: StoreNavigationProps) {
|
||||
const mainLinks = [
|
||||
{ href: '/', label: 'หน้าแรก', icon: Store },
|
||||
{ href: '/products', label: 'สินค้าทั้งหมด', icon: Package },
|
||||
];
|
||||
|
||||
const userLinks = isLoggedIn ? [
|
||||
{ href: '/account', label: 'บัญชีของฉัน', icon: Users },
|
||||
{ href: '/account/orders', label: 'คำสั่งซื้อ', icon: Package },
|
||||
] : [];
|
||||
|
||||
const vendorLinks = isVendor ? [
|
||||
{ href: '/vendor/dashboard', label: 'จัดการร้าน', icon: Settings },
|
||||
{ href: '/vendor/products', label: 'สินค้าของฉัน', icon: Package },
|
||||
{ href: '/vendor/orders', label: 'คำสั่งซื้อ', icon: Package },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center gap-8">
|
||||
{mainLinks.map(link => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="flex items-center gap-2 py-4 text-gray-700 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<link.icon className="w-4 h-4" />
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{userLinks.length > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300">|</span>
|
||||
{userLinks.map(link => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="flex items-center gap-2 py-4 text-gray-700 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<link.icon className="w-4 h-4" />
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{vendorLinks.length > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300">|</span>
|
||||
{vendorLinks.map(link => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="flex items-center gap-2 py-4 text-blue-600 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
<link.icon className="w-4 h-4" />
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Phone, Mail, MapPin } from 'lucide-react';
|
||||
|
||||
interface UpperNavbarProps {
|
||||
contact?: {
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
};
|
||||
socialLinks?: { label: string; href: string }[];
|
||||
}
|
||||
|
||||
export default function UpperNavbar({ contact, socialLinks }: UpperNavbarProps) {
|
||||
return (
|
||||
<div className="bg-gray-900 text-white text-sm">
|
||||
<div className="container mx-auto px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
{contact?.phone && (
|
||||
<a href={`tel:${contact.phone}`} className="flex items-center gap-1 hover:text-blue-300">
|
||||
<Phone className="w-4 h-4" />
|
||||
{contact.phone}
|
||||
</a>
|
||||
)}
|
||||
{contact?.email && (
|
||||
<a href={`mailto:${contact.email}`} className="flex items-center gap-1 hover:text-blue-300">
|
||||
<Mail className="w-4 h-4" />
|
||||
{contact.email}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{socialLinks?.map(link => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className="hover:text-blue-300"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success';
|
||||
size?: 'sm' | 'md' | 'lg' | 'icon';
|
||||
isLoading?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
|
||||
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
|
||||
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
success: 'bg-green-600 text-white hover:bg-green-700 focus:ring-green-500'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
||||
md: 'px-4 py-2 text-sm gap-2',
|
||||
lg: 'px-6 py-3 text-base gap-2',
|
||||
icon: 'p-2'
|
||||
};
|
||||
|
||||
export default forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className = '',
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`
|
||||
inline-flex items-center justify-center rounded-lg font-medium
|
||||
transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variants[variant]} ${sizes[size]} ${className}
|
||||
`}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : leftIcon ? (
|
||||
<span className="flex-shrink-0">{leftIcon}</span>
|
||||
) : null}
|
||||
{children}
|
||||
{!isLoading && rightIcon && (
|
||||
<span className="flex-shrink-0">{rightIcon}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { InputHTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className = '',
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
{label}
|
||||
{props.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`
|
||||
w-full border rounded-lg px-3 py-2
|
||||
transition-colors placeholder:text-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-0
|
||||
${error
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-200'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-200'
|
||||
}
|
||||
${leftIcon ? 'pl-10' : ''}
|
||||
${rightIcon ? 'pr-10' : ''}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-red-500 text-sm mt-1 flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="text-gray-500 text-sm mt-1">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'Input';
|
||||
export default Input;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Fragment, ReactNode } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
showCloseButton?: boolean;
|
||||
closeOnOverlayClick?: boolean;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
full: 'max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)]'
|
||||
};
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
closeOnOverlayClick = true,
|
||||
footer
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={closeOnOverlayClick ? onClose : () => {}}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className={`w-full ${sizes[size]} transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all`}>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-start justify-between px-6 py-4 border-b">
|
||||
<div>
|
||||
{title && (
|
||||
<Dialog.Title className="text-lg font-semibold text-gray-900">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
{description && (
|
||||
<Dialog.Description className="text-sm text-gray-500 mt-1">
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
)}
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="px-6 py-4 border-t bg-gray-50 flex items-center justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { SelectHTMLAttributes, forwardRef } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const Select = forwardRef<HTMLSelectElement, SelectProps>(({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
options,
|
||||
placeholder = 'เลือก...',
|
||||
className = '',
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
{label}
|
||||
{props.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
className={`
|
||||
w-full border rounded-lg px-3 py-2 pr-10
|
||||
appearance-none bg-white
|
||||
transition-colors cursor-pointer
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-0
|
||||
${error
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-200'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-200'
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map(option => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-red-500 text-sm mt-1 flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="text-gray-500 text-sm mt-1">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Select.displayName = 'Select';
|
||||
export default Select;
|
||||
175
skills/ecommerce-astro/scripts/templates/src/components/vendor/EarningsChart.tsx
vendored
Normal file
175
skills/ecommerce-astro/scripts/templates/src/components/vendor/EarningsChart.tsx
vendored
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TrendingUp, TrendingDown, Calendar } from 'lucide-react';
|
||||
|
||||
interface EarningsData {
|
||||
date: string;
|
||||
amount: number;
|
||||
orders: number;
|
||||
}
|
||||
|
||||
interface EarningsChartProps {
|
||||
data: EarningsData[];
|
||||
period?: '7d' | '30d' | '90d';
|
||||
onPeriodChange?: (period: '7d' | '30d' | '90d') => void;
|
||||
}
|
||||
|
||||
export default function EarningsChart({
|
||||
data,
|
||||
period = '30d',
|
||||
onPeriodChange
|
||||
}: EarningsChartProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const totalEarnings = data.reduce((sum, d) => sum + d.amount, 0);
|
||||
const totalOrders = data.reduce((sum, d) => sum + d.orders, 0);
|
||||
const avgOrderValue = totalOrders > 0 ? totalEarnings / totalOrders : 0;
|
||||
|
||||
// Calculate previous period for comparison
|
||||
const previousPeriodEarnings = totalEarnings * 0.85; // Mock previous period
|
||||
const earningsChange = ((totalEarnings - previousPeriodEarnings) / previousPeriodEarnings) * 100;
|
||||
|
||||
const maxAmount = Math.max(...data.map(d => d.amount));
|
||||
const chartHeight = 200;
|
||||
const chartWidth = 100;
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
if (amount >= 1000000) {
|
||||
return `฿${(amount / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (amount >= 1000) {
|
||||
return `฿${(amount / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return `฿${amount.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-lg">รายได้</h3>
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{(['7d', '30d', '90d'] as const).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPeriodChange?.(p)}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
period === p
|
||||
? 'bg-white shadow-sm text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{p === '7d' ? '7 วัน' : p === '30d' ? '30 วัน' : '90 วัน'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-gray-500">รายได้รวม</p>
|
||||
<p className="text-xl font-bold text-gray-900 mt-1">
|
||||
{formatCurrency(totalEarnings)}
|
||||
</p>
|
||||
<div className={`flex items-center gap-1 mt-1 text-sm ${
|
||||
earningsChange >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{earningsChange >= 0 ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
<span>{Math.abs(earningsChange).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<p className="text-sm text-gray-500">คำสั่งซื้อ</p>
|
||||
<p className="text-xl font-bold text-gray-900 mt-1">
|
||||
{totalOrders.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">คำสั่งซื้อทั้งหมด</p>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-50 rounded-lg">
|
||||
<p className="text-sm text-gray-500">เฉลี่ย/คำสั่งซื้อ</p>
|
||||
<p className="text-xl font-bold text-gray-900 mt-1">
|
||||
{formatCurrency(avgOrderValue)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">AOV</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="relative">
|
||||
<div className="flex items-end justify-between h-[200px] gap-1">
|
||||
{data.map((d, i) => {
|
||||
const heightPercent = (d.amount / maxAmount) * 100;
|
||||
const isHovered = hoveredIndex === i;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 relative group"
|
||||
onMouseEnter={() => setHoveredIndex(i)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
bg-gradient-to-t from-blue-600 to-blue-400 rounded-t-md transition-all duration-200
|
||||
${isHovered ? 'bg-blue-600' : ''}
|
||||
`}
|
||||
style={{
|
||||
height: `${heightPercent}%`,
|
||||
minHeight: heightPercent > 0 ? '4px' : '0'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
{isHovered && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-10">
|
||||
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 whitespace-nowrap">
|
||||
<p className="font-medium">{formatCurrency(d.amount)}</p>
|
||||
<p className="text-gray-400">{d.orders} คำสั่ง</p>
|
||||
<p className="text-gray-500 text-[10px]">{formatDate(d.date)}</p>
|
||||
</div>
|
||||
<div className="w-2 h-2 bg-gray-900 rotate-45 absolute left-1/2 -translate-x-1/2 -bottom-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* X-axis labels */}
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-400">
|
||||
{data.length > 0 && (
|
||||
<>
|
||||
<span>{formatDate(data[0].date)}</span>
|
||||
{data.length > 1 && (
|
||||
<span>{formatDate(data[data.length - 1].date)}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 pt-4 border-t">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded" />
|
||||
<span>รายได้</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>อัปเดตเมื่อ {new Date().toLocaleDateString('th-TH')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
363
skills/ecommerce-astro/scripts/templates/src/components/vendor/ProductForm.tsx
vendored
Normal file
363
skills/ecommerce-astro/scripts/templates/src/components/vendor/ProductForm.tsx
vendored
Normal file
@@ -0,0 +1,363 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Image from 'next/image';
|
||||
import { Upload, X, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
interface ProductFormProps {
|
||||
product?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
compareAtPrice?: number;
|
||||
sku?: string;
|
||||
inventory?: number;
|
||||
categoryId?: string;
|
||||
images?: string[];
|
||||
variants?: any[];
|
||||
};
|
||||
onSubmit: (data: any) => void;
|
||||
categories?: { id: string; name: string }[];
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export default function ProductForm({
|
||||
product,
|
||||
onSubmit,
|
||||
categories = [],
|
||||
isSubmitting = false
|
||||
}: ProductFormProps) {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: product?.name || '',
|
||||
description: product?.description || '',
|
||||
price: product?.price || '',
|
||||
compareAtPrice: product?.compareAtPrice || '',
|
||||
sku: product?.sku || '',
|
||||
inventory: product?.inventory || 0,
|
||||
categoryId: product?.categoryId || '',
|
||||
images: product?.images || [],
|
||||
});
|
||||
const [uploadedImages, setUploadedImages] = useState<File[]>([]);
|
||||
const [variants, setVariants] = useState(product?.variants || []);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length + formData.images.length + uploadedImages.length > 10) {
|
||||
setErrors(prev => ({ ...prev, images: 'สูงสุด 10 รูปเท่านั้น' }));
|
||||
return;
|
||||
}
|
||||
setUploadedImages(prev => [...prev, ...files]);
|
||||
setErrors(prev => {
|
||||
const { images: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (index: number, isUploaded: boolean) => {
|
||||
if (isUploaded) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: prev.images.filter((_, i) => i !== index)
|
||||
}));
|
||||
} else {
|
||||
setUploadedImages(prev => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const addVariant = () => {
|
||||
setVariants(prev => [...prev, {
|
||||
id: `variant-${Date.now()}`,
|
||||
name: '',
|
||||
price: '',
|
||||
inventory: 0,
|
||||
attributes: {}
|
||||
}]);
|
||||
};
|
||||
|
||||
const updateVariant = (index: number, field: string, value: any) => {
|
||||
setVariants(prev => prev.map((v, i) =>
|
||||
i === index ? { ...v, [field]: value } : v
|
||||
));
|
||||
};
|
||||
|
||||
const removeVariant = (index: number) => {
|
||||
setVariants(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.name.trim()) newErrors.name = 'กรุณากรอกชื่อสินค้า';
|
||||
if (!formData.price) newErrors.price = 'กรุณากรอกราคา';
|
||||
if (formData.price && parseFloat(formData.price) <= 0) newErrors.price = 'ราคาต้องมากกว่า 0';
|
||||
if (formData.compareAtPrice && parseFloat(formData.compareAtPrice) <= parseFloat(formData.price)) {
|
||||
newErrors.compareAtPrice = 'ราคาเดิมต้องมากกว่าราคาปัจจุบัน';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
const submitData = {
|
||||
...formData,
|
||||
price: parseFloat(formData.price),
|
||||
compareAtPrice: formData.compareAtPrice ? parseFloat(formData.compareAtPrice) : null,
|
||||
inventory: parseInt(formData.inventory.toString()) || 0,
|
||||
variants: variants.map(v => ({
|
||||
...v,
|
||||
price: v.price ? parseFloat(v.price) : 0
|
||||
})),
|
||||
uploadedImages
|
||||
};
|
||||
|
||||
await onSubmit(submitData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Basic Info */}
|
||||
<div className="bg-white rounded-xl border p-6 space-y-6">
|
||||
<h3 className="font-medium">ข้อมูลพื้นฐาน</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">ชื่อสินค้า *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="เช่น เสื้อยืดคอกลม Cotton 100%"
|
||||
className={`w-full border rounded-lg px-3 py-2 ${errors.name ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">รายละเอียด</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="อธิบายรายละเอียดสินค้า เช่น วัสดุ, ขนาด, วิธีใช้งาน..."
|
||||
className="w-full border rounded-lg px-3 py-2 h-32 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">หมวดหมู่</label>
|
||||
<select
|
||||
value={formData.categoryId}
|
||||
onChange={e => setFormData({ ...formData, categoryId: e.target.value })}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="">เลือกหมวดหมู่</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="bg-white rounded-xl border p-6 space-y-6">
|
||||
<h3 className="font-medium">ราคาและสต็อก</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">ราคา *</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">฿</span>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.price}
|
||||
onChange={e => setFormData({ ...formData, price: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className={`w-full border rounded-lg pl-8 pr-3 py-2 ${errors.price ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.price && <p className="text-red-500 text-sm mt-1">{errors.price}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">ราคาเดิม (ถ้าลดราคา)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">฿</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.compareAtPrice}
|
||||
onChange={e => setFormData({ ...formData, compareAtPrice: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className={`w-full border rounded-lg pl-8 pr-3 py-2 ${errors.compareAtPrice ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.compareAtPrice && <p className="text-red-500 text-sm mt-1">{errors.compareAtPrice}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">SKU (รหัสสินค้า)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.sku}
|
||||
onChange={e => setFormData({ ...formData, sku: e.target.value })}
|
||||
placeholder="SKU-001"
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">สต็อก *</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="0"
|
||||
value={formData.inventory}
|
||||
onChange={e => setFormData({ ...formData, inventory: parseInt(e.target.value) || 0 })}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div className="bg-white rounded-xl border p-6 space-y-6">
|
||||
<h3 className="font-medium">รูปภาพสินค้า</h3>
|
||||
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{formData.images.map((url, i) => (
|
||||
<div key={`existing-${i}`} className="relative aspect-square rounded-lg overflow-hidden bg-gray-100 group">
|
||||
<img src={url} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i, true)}
|
||||
className="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{i === 0 && (
|
||||
<span className="absolute bottom-1 left-1 bg-blue-500 text-white text-xs px-1 rounded">
|
||||
หลัก
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{uploadedImages.map((file, i) => (
|
||||
<div key={`new-${i}`} className="relative aspect-square rounded-lg overflow-hidden bg-gray-100 group">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i, false)}
|
||||
className="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{(formData.images.length + uploadedImages.length) < 10 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="aspect-square border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center text-gray-400 hover:border-blue-400 hover:text-blue-500 transition-colors"
|
||||
>
|
||||
<Plus className="w-8 h-8" />
|
||||
<span className="text-xs mt-1">เพิ่มรูป</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
รูปแรกจะเป็นรูปหลัก สูงสุด 10 รูป ขนาดแนะนำ 800x800px
|
||||
</p>
|
||||
{errors.images && <p className="text-red-500 text-sm">{errors.images}</p>}
|
||||
</div>
|
||||
|
||||
{/* Variants */}
|
||||
<div className="bg-white rounded-xl border p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">ตัวเลือกสินค้า</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addVariant}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
เพิ่มตัวเลือก
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{variants.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{variants.map((variant, index) => (
|
||||
<div key={variant.id} className="grid grid-cols-4 gap-3 p-4 bg-gray-50 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={variant.name}
|
||||
onChange={e => updateVariant(index, 'name', e.target.value)}
|
||||
placeholder="ชื่อตัวเลือก (เช่น S, M, L)"
|
||||
className="border rounded-lg px-3 py-2"
|
||||
/>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 text-sm">฿</span>
|
||||
<input
|
||||
type="number"
|
||||
value={variant.price}
|
||||
onChange={e => updateVariant(index, 'price', e.target.value)}
|
||||
placeholder="ราคา"
|
||||
className="w-full border rounded-lg pl-7 pr-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={variant.inventory}
|
||||
onChange={e => updateVariant(index, 'inventory', parseInt(e.target.value) || 0)}
|
||||
placeholder="สต็อก"
|
||||
className="border rounded-lg px-3 py-2"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeVariant(index)}
|
||||
className="text-red-500 hover:text-red-700 flex items-center justify-center"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
ไม่มีตัวเลือกสินค้า
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'กำลังบันทึก...' : product?.id ? 'บันทึกการเปลี่ยนแปลง' : 'สร้างสินค้า'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
104
skills/ecommerce-astro/scripts/templates/src/components/vendor/VendorCard.tsx
vendored
Normal file
104
skills/ecommerce-astro/scripts/templates/src/components/vendor/VendorCard.tsx
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
import { MapPin, Star, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface Vendor {
|
||||
id: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
coverImage?: string;
|
||||
description: string;
|
||||
rating: number;
|
||||
reviewCount: number;
|
||||
productCount: number;
|
||||
joinedDate: string;
|
||||
location?: string;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
interface VendorCardProps {
|
||||
vendor: Vendor;
|
||||
variant?: 'default' | 'compact' | 'featured';
|
||||
}
|
||||
|
||||
export default function VendorCard({ vendor, variant = 'default' }: VendorCardProps) {
|
||||
const isCompact = variant === 'compact';
|
||||
const isFeatured = variant === 'featured';
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
bg-white rounded-xl border overflow-hidden
|
||||
${isFeatured ? 'ring-2 ring-blue-500' : ''}
|
||||
${isCompact ? '' : 'shadow-sm hover:shadow-md transition-shadow'}
|
||||
`}>
|
||||
{/* Cover Image */}
|
||||
<div className="relative h-24 bg-gradient-to-r from-blue-500 to-purple-600">
|
||||
{vendor.coverImage && (
|
||||
<img
|
||||
src={vendor.coverImage}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute -bottom-8 left-4">
|
||||
<div className="w-16 h-16 rounded-xl border-4 border-white bg-gray-100 overflow-hidden shadow-md">
|
||||
{vendor.logo ? (
|
||||
<img src={vendor.logo} alt={vendor.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xl font-bold text-gray-400">
|
||||
{vendor.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{vendor.verified && (
|
||||
<div className="absolute bottom-2 right-2 bg-blue-500 text-white text-xs px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
รับรอง
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`pt-10 ${isCompact ? 'px-4 pb-4' : 'p-4'}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">{vendor.name}</h3>
|
||||
{vendor.location && (
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1 mt-0.5">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{vendor.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCompact && (
|
||||
<p className="text-sm text-gray-600 mt-2 line-clamp-2">
|
||||
{vendor.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mt-3 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="font-medium">{vendor.rating.toFixed(1)}</span>
|
||||
<span className="text-gray-500">({vendor.reviewCount})</span>
|
||||
</div>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="text-gray-600">{vendor.productCount} สินค้า</span>
|
||||
</div>
|
||||
|
||||
{!isCompact && (
|
||||
<a
|
||||
href={`/vendor/${vendor.id}`}
|
||||
className="mt-4 w-full flex items-center justify-center gap-2 py-2 border border-gray-200 rounded-lg text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors"
|
||||
>
|
||||
<span>ดูร้านค้า</span>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
skills/ecommerce-astro/scripts/templates/src/i18n/en.json
Normal file
116
skills/ecommerce-astro/scripts/templates/src/i18n/en.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"common": {
|
||||
"home": "Home",
|
||||
"products": "Products",
|
||||
"cart": "Cart",
|
||||
"checkout": "Checkout",
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"logout": "Logout",
|
||||
"search": "Search",
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
"success": "Success",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"viewAll": "View All"
|
||||
},
|
||||
"product": {
|
||||
"addToCart": "Add to Cart",
|
||||
"addedToCart": "Added!",
|
||||
"outOfStock": "Out of Stock",
|
||||
"inStock": "In Stock",
|
||||
"lowStock": "Only {count} left",
|
||||
"price": "Price",
|
||||
"originalPrice": "Original Price",
|
||||
"discount": "Save {percent}%",
|
||||
"description": "Description",
|
||||
"reviews": "Reviews",
|
||||
"noReviews": "No reviews yet",
|
||||
"verifiedPurchase": "Verified Purchase"
|
||||
},
|
||||
"cart": {
|
||||
"title": "Shopping Cart",
|
||||
"empty": "Your cart is empty",
|
||||
"items": "{count} items",
|
||||
"subtotal": "Subtotal",
|
||||
"tax": "VAT (7%)",
|
||||
"shipping": "Shipping",
|
||||
"freeShipping": "Free",
|
||||
"freeShippingThreshold": "Buy 500+ THB for free shipping!",
|
||||
"total": "Total",
|
||||
"proceedToCheckout": "Proceed to Checkout",
|
||||
"continueShopping": "Continue Shopping"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Checkout",
|
||||
"shippingAddress": "Shipping Address",
|
||||
"paymentMethod": "Payment Method",
|
||||
"orderSummary": "Order Summary",
|
||||
"placeOrder": "Place Order",
|
||||
"processing": "Processing...",
|
||||
"success": "Order Successful!",
|
||||
"orderNumber": "Order Number",
|
||||
"thankYou": "Thank you for your order"
|
||||
},
|
||||
"auth": {
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"name": "Full Name",
|
||||
"phone": "Phone Number",
|
||||
"forgotPassword": "Forgot Password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"registerSuccess": "Registration successful",
|
||||
"loginSuccess": "Login successful",
|
||||
"logoutSuccess": "Logout successful"
|
||||
},
|
||||
"vendor": {
|
||||
"dashboard": "Vendor Dashboard",
|
||||
"myProducts": "My Products",
|
||||
"myOrders": "My Orders",
|
||||
"earnings": "Earnings",
|
||||
"settings": "Store Settings",
|
||||
"applyVendor": "Apply for Vendor",
|
||||
"storeName": "Store Name",
|
||||
"storeDescription": "Store Description",
|
||||
"bankAccount": "Bank Account",
|
||||
"bankName": "Bank Name",
|
||||
"pendingApproval": "Pending Approval",
|
||||
"approved": "Approved"
|
||||
},
|
||||
"order": {
|
||||
"history": "Order History",
|
||||
"details": "Order Details",
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"confirmed": "Confirmed",
|
||||
"processing": "Processing",
|
||||
"shipped": "Shipped",
|
||||
"delivered": "Delivered",
|
||||
"cancelled": "Cancelled",
|
||||
"refunded": "Refunded"
|
||||
},
|
||||
"paymentStatus": {
|
||||
"unpaid": "Unpaid",
|
||||
"paid": "Paid",
|
||||
"failed": "Failed",
|
||||
"refunded": "Refunded"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"contact": "Contact Us",
|
||||
"about": "About Us",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Service",
|
||||
"shipping": "Shipping Policy",
|
||||
"returns": "Return Policy"
|
||||
}
|
||||
}
|
||||
116
skills/ecommerce-astro/scripts/templates/src/i18n/th.json
Normal file
116
skills/ecommerce-astro/scripts/templates/src/i18n/th.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"common": {
|
||||
"home": "หน้าแรก",
|
||||
"products": "สินค้า",
|
||||
"cart": "ตะกร้า",
|
||||
"checkout": "ชำระเงิน",
|
||||
"login": "เข้าสู่ระบบ",
|
||||
"register": "สมัครสมาชิก",
|
||||
"logout": "ออกจากระบบ",
|
||||
"search": "ค้นหา",
|
||||
"loading": "กำลังโหลด...",
|
||||
"error": "เกิดข้อผิดพลาด",
|
||||
"success": "สำเร็จ",
|
||||
"cancel": "ยกเลิก",
|
||||
"save": "บันทึก",
|
||||
"delete": "ลบ",
|
||||
"edit": "แก้ไข",
|
||||
"add": "เพิ่ม",
|
||||
"back": "กลับ",
|
||||
"next": "ถัดไป",
|
||||
"previous": "ก่อนหน้า",
|
||||
"viewAll": "ดูทั้งหมด"
|
||||
},
|
||||
"product": {
|
||||
"addToCart": "เพิ่มลงตะกร้า",
|
||||
"addedToCart": "เพิ่มแล้ว!",
|
||||
"outOfStock": "หมดสินค้า",
|
||||
"inStock": "มีสินค้า",
|
||||
"lowStock": "เหลือเพียง {count} ชิ้น",
|
||||
"price": "ราคา",
|
||||
"originalPrice": "ราคาเดิม",
|
||||
"discount": "ลด {percent}%",
|
||||
"description": "รายละเอียดสินค้า",
|
||||
"reviews": "รีวิว",
|
||||
"noReviews": "ยังไม่มีรีวิว",
|
||||
"verifiedPurchase": "ซื้อแล้ว"
|
||||
},
|
||||
"cart": {
|
||||
"title": "ตะกร้าสินค้า",
|
||||
"empty": "ตะกร้าว่างเปล่า",
|
||||
"items": "{count} รายการ",
|
||||
"subtotal": "ราคารวม",
|
||||
"tax": "ภาษี VAT (7%)",
|
||||
"shipping": "ค่าจัดส่ง",
|
||||
"freeShipping": "ฟรี",
|
||||
"freeShippingThreshold": "ซื้อขั้นต่ำ 500 บาท จัดส่งฟรี!",
|
||||
"total": "รวมทั้งสิ้น",
|
||||
"proceedToCheckout": "ดำเนินการชำระเงิน",
|
||||
"continueShopping": "เลือกซื้อสินค้าต่อ"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "ชำระเงิน",
|
||||
"shippingAddress": "ที่อยู่จัดส่ง",
|
||||
"paymentMethod": "วิธีการชำระเงิน",
|
||||
"orderSummary": "สรุปคำสั่งซื้อ",
|
||||
"placeOrder": "สั่งซื้อ",
|
||||
"processing": "กำลังดำเนินการ...",
|
||||
"success": "สั่งซื้อสำเร็จ!",
|
||||
"orderNumber": "หมายเลขคำสั่งซื้อ",
|
||||
"thankYou": "ขอบคุณที่สั่งซื้อ"
|
||||
},
|
||||
"auth": {
|
||||
"email": "อีเมล",
|
||||
"password": "รหัสผ่าน",
|
||||
"confirmPassword": "ยืนยันรหัสผ่าน",
|
||||
"name": "ชื่อ-นามสกุล",
|
||||
"phone": "เบอร์โทรศัพท์",
|
||||
"forgotPassword": "ลืมรหัสผ่าน?",
|
||||
"noAccount": "ยังไม่มีบัญชี?",
|
||||
"hasAccount": "มีบัญชีอยู่แล้ว?",
|
||||
"registerSuccess": "สมัครสมาชิกสำเร็จ",
|
||||
"loginSuccess": "เข้าสู่ระบบสำเร็จ",
|
||||
"logoutSuccess": "ออกจากระบบสำเร็จ"
|
||||
},
|
||||
"vendor": {
|
||||
"dashboard": "แดชบอร์ดร้านค้า",
|
||||
"myProducts": "สินค้าของฉัน",
|
||||
"myOrders": "คำสั่งซื้อของฉัน",
|
||||
"earnings": "รายได้",
|
||||
"settings": "ตั้งค่าร้าน",
|
||||
"applyVendor": "สมัครเปิดร้าน",
|
||||
"storeName": "ชื่อร้าน",
|
||||
"storeDescription": "รายละเอียดร้าน",
|
||||
"bankAccount": "เลขบัญชี",
|
||||
"bankName": "ธนาคาร",
|
||||
"pendingApproval": "รอการอนุมัติ",
|
||||
"approved": "อนุมัติแล้ว"
|
||||
},
|
||||
"order": {
|
||||
"history": "ประวัติคำสั่งซื้อ",
|
||||
"details": "รายละเอียดคำสั่งซื้อ",
|
||||
"status": {
|
||||
"pending": "รอดำเนินการ",
|
||||
"confirmed": "ยืนยันแล้ว",
|
||||
"processing": "กำลังประมวลผล",
|
||||
"shipped": "จัดส่งแล้ว",
|
||||
"delivered": "จัดส่งสำเร็จ",
|
||||
"cancelled": "ยกเลิก",
|
||||
"refunded": "คืนเงินแล้ว"
|
||||
},
|
||||
"paymentStatus": {
|
||||
"unpaid": "ยังไม่ชำระ",
|
||||
"paid": "ชำระแล้ว",
|
||||
"failed": "ชำระไม่สำเร็จ",
|
||||
"refunded": "คืนเงินแล้ว"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"contact": "ติดต่อเรา",
|
||||
"about": "เกี่ยวกับเรา",
|
||||
"privacy": "นโยบายความเป็นส่วนตัว",
|
||||
"terms": "เงื่อนไขการใช้งาน",
|
||||
"shipping": "นโยบายการจัดส่ง",
|
||||
"returns": "นโยบายการคืนสินค้า"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = 'ร้านค้าออนไลน์คุณภาพดี ราคาถูกใจ' } = Astro.props;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="th">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content={description}>
|
||||
<title>{title} | ร้านของเรา</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Thai:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
font-family: 'Noto Sans Thai', sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-50 text-gray-900">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
172
skills/ecommerce-astro/scripts/templates/src/lib/auth.ts
Normal file
172
skills/ecommerce-astro/scripts/templates/src/lib/auth.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { hash, compare } from 'bcryptjs';
|
||||
import type { AstroCookies } from 'astro';
|
||||
|
||||
const JWT_SECRET = import.meta.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
const JWT_EXPIRES_IN = import.meta.env.JWT_EXPIRES_IN || '7d';
|
||||
|
||||
export interface JWTPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: 'customer' | 'vendor' | 'admin';
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: 'customer' | 'vendor' | 'admin';
|
||||
}
|
||||
|
||||
// Password hashing
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, 12);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||
return compare(password, hashedPassword);
|
||||
}
|
||||
|
||||
// JWT Token management
|
||||
export function generateToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JWTPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET) as JWTPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeToken(token: string): JWTPayload | null {
|
||||
try {
|
||||
return jwt.decode(token) as JWTPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract token from Authorization header
|
||||
export function getTokenFromHeader(authHeader: string | null): string | null {
|
||||
if (!authHeader?.startsWith('Bearer ')) return null;
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
|
||||
// Extract token from cookie
|
||||
export function getTokenFromCookie(cookies: AstroCookies): string | null {
|
||||
const token = cookies.get('auth_token')?.value;
|
||||
return token || null;
|
||||
}
|
||||
|
||||
// Get current user from request
|
||||
export function getCurrentUser(request: Request, cookies: AstroCookies): AuthUser | null {
|
||||
// Try header first
|
||||
const authHeader = request.headers.get('authorization');
|
||||
let token = getTokenFromHeader(authHeader);
|
||||
|
||||
// Fallback to cookie
|
||||
if (!token) {
|
||||
token = getTokenFromCookie(cookies);
|
||||
}
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.userId,
|
||||
email: payload.email,
|
||||
name: null,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
|
||||
// Cookie helpers
|
||||
export const AUTH_COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
};
|
||||
|
||||
export function setAuthCookie(cookies: AstroCookies, token: string): void {
|
||||
cookies.set('auth_token', token, AUTH_COOKIE_OPTIONS);
|
||||
}
|
||||
|
||||
export function clearAuthCookie(cookies: AstroCookies): void {
|
||||
cookies.delete('auth_token', { path: '/' });
|
||||
}
|
||||
|
||||
// Role-based access control
|
||||
export function isAdmin(user: AuthUser | null): boolean {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
|
||||
export function isVendor(user: AuthUser | null): boolean {
|
||||
return user?.role === 'vendor' || user?.role === 'admin';
|
||||
}
|
||||
|
||||
export function isCustomer(user: AuthUser | null): boolean {
|
||||
return user?.role === 'customer';
|
||||
}
|
||||
|
||||
// Require authentication middleware helper
|
||||
export function requireAuth(user: AuthUser | null): AuthUser {
|
||||
if (!user) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// Require role middleware helper
|
||||
export function requireRole(user: AuthUser | null, ...roles: AuthUser['role'][]): AuthUser {
|
||||
const authenticatedUser = requireAuth(user);
|
||||
if (!roles.includes(authenticatedUser.role)) {
|
||||
throw new Error('Forbidden');
|
||||
}
|
||||
return authenticatedUser;
|
||||
}
|
||||
|
||||
// Generate reset token (for password reset flow)
|
||||
export function generateResetToken(userId: string, email: string): string {
|
||||
return jwt.sign(
|
||||
{ userId, email, type: 'reset' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyResetToken(token: string): { userId: string; email: string } | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string; email: string; type: string };
|
||||
if (decoded.type !== 'reset') return null;
|
||||
return { userId: decoded.userId, email: decoded.email };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate email verification token
|
||||
export function generateVerificationToken(userId: string, email: string): string {
|
||||
return jwt.sign(
|
||||
{ userId, email, type: 'verification' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyVerificationToken(token: string): { userId: string; email: string } | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string; email: string; type: string };
|
||||
if (decoded.type !== 'verification') return null;
|
||||
return { userId: decoded.userId, email: decoded.email };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
437
skills/ecommerce-astro/scripts/templates/src/lib/db.ts
Normal file
437
skills/ecommerce-astro/scripts/templates/src/lib/db.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Local SQLite Database Wrapper
|
||||
*
|
||||
* Uses better-sqlite3 for local development and testing.
|
||||
* In production, use Supabase (supabase.ts) instead.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { join, dirname } from 'path';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
|
||||
// Ensure data directory exists
|
||||
const dataDir = join(process.cwd(), 'data');
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = join(dataDir, 'ecommerce.db');
|
||||
|
||||
// Create database connection
|
||||
export const db = new Database(dbPath);
|
||||
|
||||
// Enable WAL mode for better performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// ============================================
|
||||
// Initialize Tables
|
||||
// ============================================
|
||||
|
||||
db.exec(`
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT,
|
||||
role TEXT DEFAULT 'customer' CHECK(role IN ('customer', 'vendor', 'admin')),
|
||||
avatar_url TEXT,
|
||||
phone TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Profiles table (additional user info)
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
phone TEXT,
|
||||
address TEXT,
|
||||
city TEXT,
|
||||
province TEXT,
|
||||
postal_code TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Categories table
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
parent_id TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Products table
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id TEXT PRIMARY KEY,
|
||||
vendor_id TEXT,
|
||||
category_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
price REAL NOT NULL,
|
||||
compare_at_price REAL,
|
||||
sku TEXT UNIQUE,
|
||||
barcode TEXT,
|
||||
inventory INTEGER DEFAULT 0,
|
||||
images TEXT DEFAULT '[]',
|
||||
status TEXT DEFAULT 'active' CHECK(status IN ('draft', 'active', 'archived')),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (vendor_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Orders table
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
order_number TEXT UNIQUE NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_email TEXT,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled')),
|
||||
payment_status TEXT DEFAULT 'pending' CHECK(payment_status IN ('pending', 'paid', 'failed', 'refunded')),
|
||||
subtotal REAL NOT NULL,
|
||||
tax REAL DEFAULT 0,
|
||||
shipping REAL DEFAULT 0,
|
||||
discount REAL DEFAULT 0,
|
||||
total REAL NOT NULL,
|
||||
shipping_name TEXT,
|
||||
shipping_phone TEXT,
|
||||
shipping_address TEXT,
|
||||
shipping_city TEXT,
|
||||
shipping_province TEXT,
|
||||
shipping_postal_code TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Order items table
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
order_id TEXT NOT NULL,
|
||||
product_id TEXT,
|
||||
product_name TEXT NOT NULL,
|
||||
product_image TEXT,
|
||||
price REAL NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
total REAL NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Cart items table
|
||||
CREATE TABLE IF NOT EXISTS cart_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
product_id TEXT NOT NULL,
|
||||
quantity INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, product_id)
|
||||
);
|
||||
|
||||
-- Payments table
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
order_id TEXT NOT NULL,
|
||||
method TEXT NOT NULL CHECK(method IN ('promptpay', 'credit_card', 'bank_transfer', 'cash_on_delivery')),
|
||||
amount REAL NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'completed', 'failed', 'refunded')),
|
||||
reference TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Sessions table (for auth)
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Refresh tokens table
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_products_slug ON products(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_category ON products(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_vendor ON products(vendor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_status ON products(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_number ON orders(order_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cart_items_user ON cart_items(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
|
||||
`);
|
||||
|
||||
// ============================================
|
||||
// Type Definitions
|
||||
// ============================================
|
||||
|
||||
export interface DbUser {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
name: string | null;
|
||||
role: 'customer' | 'vendor' | 'admin';
|
||||
avatar_url: string | null;
|
||||
phone: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DbProduct {
|
||||
id: string;
|
||||
vendor_id: string | null;
|
||||
category_id: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
compare_at_price: number | null;
|
||||
sku: string | null;
|
||||
barcode: string | null;
|
||||
inventory: number;
|
||||
images: string;
|
||||
status: 'draft' | 'active' | 'archived';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DbOrder {
|
||||
id: string;
|
||||
order_number: string;
|
||||
user_id: string | null;
|
||||
guest_email: string | null;
|
||||
status: 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
||||
payment_status: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
shipping: number;
|
||||
discount: number;
|
||||
total: number;
|
||||
shipping_name: string | null;
|
||||
shipping_phone: string | null;
|
||||
shipping_address: string | null;
|
||||
shipping_city: string | null;
|
||||
shipping_province: string | null;
|
||||
shipping_postal_code: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DbCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
image_url: string | null;
|
||||
parent_id: string | null;
|
||||
sort_order: number;
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get user by email
|
||||
*/
|
||||
export function getUserByEmail(email: string): DbUser | undefined {
|
||||
return db.prepare('SELECT * FROM users WHERE email = ?').get(email) as DbUser | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
export function getUserById(id: string): DbUser | undefined {
|
||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user
|
||||
*/
|
||||
export function createUser(user: Omit<DbUser, 'created_at' | 'updated_at'>): DbUser {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (id, email, password_hash, name, role, avatar_url, phone)
|
||||
VALUES (@id, @email, @password_hash, @name, @role, @avatar_url, @phone)
|
||||
`);
|
||||
stmt.run(user);
|
||||
return getUserById(user.id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product by slug
|
||||
*/
|
||||
export function getProductBySlug(slug: string): DbProduct | undefined {
|
||||
return db.prepare('SELECT * FROM products WHERE slug = ? AND status = ?')
|
||||
.get(slug, 'active') as DbProduct | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products by category
|
||||
*/
|
||||
export function getProductsByCategory(categorySlug: string, limit = 20, offset = 0): DbProduct[] {
|
||||
return db.prepare(`
|
||||
SELECT p.* FROM products p
|
||||
JOIN categories c ON p.category_id = c.id
|
||||
WHERE c.slug = ? AND p.status = 'active'
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(categorySlug, limit, offset) as DbProduct[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured products
|
||||
*/
|
||||
export function getFeaturedProducts(limit = 10): DbProduct[] {
|
||||
return db.prepare(`
|
||||
SELECT * FROM products
|
||||
WHERE status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`).all(limit) as DbProduct[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order by number
|
||||
*/
|
||||
export function getOrderByNumber(orderNumber: string): DbOrder | undefined {
|
||||
return db.prepare('SELECT * FROM orders WHERE order_number = ?').get(orderNumber) as DbOrder | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create order
|
||||
*/
|
||||
export function createOrder(order: Omit<DbOrder, 'created_at' | 'updated_at'>): DbOrder {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO orders (
|
||||
id, order_number, user_id, guest_email, status, payment_status,
|
||||
subtotal, tax, shipping, discount, total,
|
||||
shipping_name, shipping_phone, shipping_address, shipping_city,
|
||||
shipping_province, shipping_postal_code, notes
|
||||
) VALUES (
|
||||
@id, @order_number, @user_id, @guest_email, @status, @payment_status,
|
||||
@subtotal, @tax, @shipping, @discount, @total,
|
||||
@shipping_name, @shipping_phone, @shipping_address, @shipping_city,
|
||||
@shipping_province, @shipping_postal_code, @notes
|
||||
)
|
||||
`);
|
||||
stmt.run(order);
|
||||
return getOrderByNumber(order.order_number)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order
|
||||
*/
|
||||
export function updateOrder(id: string, updates: Partial<DbOrder>): void {
|
||||
const fields = Object.keys(updates)
|
||||
.filter(k => k !== 'id')
|
||||
.map(k => `${k} = @${k}`)
|
||||
.join(', ');
|
||||
|
||||
if (fields) {
|
||||
const stmt = db.prepare(`UPDATE orders SET ${fields}, updated_at = datetime('now') WHERE id = @id`);
|
||||
stmt.run({ ...updates, id });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cart items for user
|
||||
*/
|
||||
export function getCartItems(userId: string) {
|
||||
return db.prepare(`
|
||||
SELECT ci.*, p.name, p.slug, p.price, p.images, p.inventory
|
||||
FROM cart_items ci
|
||||
JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.user_id = ?
|
||||
`).all(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to cart
|
||||
*/
|
||||
export function addToCart(userId: string, productId: string, quantity = 1): void {
|
||||
const existing = db.prepare(
|
||||
'SELECT * FROM cart_items WHERE user_id = ? AND product_id = ?'
|
||||
).get(userId, productId);
|
||||
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
'UPDATE cart_items SET quantity = quantity + ? WHERE user_id = ? AND product_id = ?'
|
||||
).run(quantity, userId, productId);
|
||||
} else {
|
||||
const id = crypto.randomUUID();
|
||||
db.prepare(
|
||||
'INSERT INTO cart_items (id, user_id, product_id, quantity) VALUES (?, ?, ?, ?)'
|
||||
).run(id, userId, productId, quantity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cart item quantity
|
||||
*/
|
||||
export function updateCartQuantity(userId: string, productId: string, quantity: number): void {
|
||||
if (quantity <= 0) {
|
||||
db.prepare('DELETE FROM cart_items WHERE user_id = ? AND product_id = ?').run(userId, productId);
|
||||
} else {
|
||||
db.prepare(
|
||||
'UPDATE cart_items SET quantity = ?, updated_at = datetime(\'now\') WHERE user_id = ? AND product_id = ?'
|
||||
).run(quantity, userId, productId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cart
|
||||
*/
|
||||
export function clearCart(userId: string): void {
|
||||
db.prepare('DELETE FROM cart_items WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update product inventory
|
||||
*/
|
||||
export function updateInventory(productId: string, quantity: number): void {
|
||||
db.prepare(
|
||||
'UPDATE products SET inventory = inventory - ?, updated_at = datetime(\'now\') WHERE id = ?'
|
||||
).run(quantity, productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories
|
||||
*/
|
||||
export function getCategories(): DbCategory[] {
|
||||
return db.prepare('SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name').all() as DbCategory[];
|
||||
}
|
||||
287
skills/ecommerce-astro/scripts/templates/src/lib/payso.ts
Normal file
287
skills/ecommerce-astro/scripts/templates/src/lib/payso.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* PaySo (Thai e-Payment) Integration
|
||||
*
|
||||
* Thai payment gateway supporting:
|
||||
* - QR Code (PromptPay)
|
||||
* - Credit Card
|
||||
* - Bank Transfer
|
||||
*
|
||||
* Documentation: https://www.paysolutions.asia/
|
||||
*/
|
||||
|
||||
export interface PaySoPaymentRequest {
|
||||
merchantid: string;
|
||||
refno: string;
|
||||
customeremail: string;
|
||||
productdetail: string;
|
||||
total: number;
|
||||
cc?: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export interface PaySoInquiryRequest {
|
||||
merchantId: string;
|
||||
orderNo: string;
|
||||
refNo: string;
|
||||
productDetail: string;
|
||||
}
|
||||
|
||||
export interface PaySoPaymentData {
|
||||
referenceNo: string;
|
||||
orderNo: string;
|
||||
amount: number;
|
||||
qrCode?: string;
|
||||
paymentUrl?: string;
|
||||
paymentDate?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PaySoPaymentResponse {
|
||||
status: number;
|
||||
message: string;
|
||||
data?: PaySoPaymentData;
|
||||
}
|
||||
|
||||
export interface PaySoConfig {
|
||||
merchantId: string;
|
||||
apiKey: string;
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
// API endpoints
|
||||
const PAYSO_API_URL = import.meta.env.PAYSO_API_URL || 'https://www.thaiepay.com/epaylink/payment.aspx';
|
||||
const PAYSO_INQUIRY_URL = import.meta.env.PAYSO_INQUIRY_URL || 'https://apis.paysolutions.asia/order/orderdetailpost';
|
||||
const PAYSO_REFUND_URL = import.meta.env.PAYSO_REFUND_URL || 'https://apis.paysolutions.asia/refund/refundpost';
|
||||
|
||||
export class PaySoClient {
|
||||
private merchantId: string;
|
||||
private apiKey: string;
|
||||
private secretKey: string;
|
||||
|
||||
constructor(merchantId: string, apiKey: string, secretKey: string) {
|
||||
this.merchantId = merchantId;
|
||||
this.apiKey = apiKey;
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create PaySo payment URL (redirect user to payment page)
|
||||
*/
|
||||
createPaymentUrl(params: {
|
||||
refno: string;
|
||||
customeremail: string;
|
||||
productdetail: string;
|
||||
total: number;
|
||||
cc?: string;
|
||||
lang?: string;
|
||||
returnUrl?: string;
|
||||
callbackUrl?: string;
|
||||
}): string {
|
||||
const url = new URL(PAYSO_API_URL);
|
||||
|
||||
// Required parameters
|
||||
url.searchParams.append('merchantid', this.merchantId);
|
||||
url.searchParams.append('refno', params.refno);
|
||||
url.searchParams.append('customeremail', params.customeremail);
|
||||
url.searchParams.append('productdetail', params.productdetail);
|
||||
url.searchParams.append('total', params.total.toFixed(2));
|
||||
|
||||
// Optional parameters
|
||||
if (params.cc) {
|
||||
url.searchParams.append('cc', params.cc);
|
||||
} else {
|
||||
// Enable all payment methods by default
|
||||
url.searchParams.append('cc', 'y');
|
||||
}
|
||||
|
||||
if (params.lang) {
|
||||
url.searchParams.append('lang', params.lang);
|
||||
} else {
|
||||
url.searchParams.append('lang', 'TH');
|
||||
}
|
||||
|
||||
if (params.returnUrl) {
|
||||
url.searchParams.append('returnUrl', params.returnUrl);
|
||||
}
|
||||
|
||||
if (params.callbackUrl) {
|
||||
url.searchParams.append('callbackUrl', params.callbackUrl);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create QR payment URL (PromptPay)
|
||||
*/
|
||||
createQRPaymentUrl(params: {
|
||||
refno: string;
|
||||
customeremail: string;
|
||||
productdetail: string;
|
||||
total: number;
|
||||
returnUrl?: string;
|
||||
}): string {
|
||||
const url = new URL(PAYSO_API_URL);
|
||||
|
||||
url.searchParams.append('merchantid', this.merchantId);
|
||||
url.searchParams.append('refno', params.refno);
|
||||
url.searchParams.append('customeremail', params.customeremail);
|
||||
url.searchParams.append('productdetail', params.productdetail);
|
||||
url.searchParams.append('total', params.total.toFixed(2));
|
||||
url.searchParams.append('cc', 'y'); // Enable credit card too
|
||||
url.searchParams.append('lang', 'TH');
|
||||
|
||||
if (params.returnUrl) {
|
||||
url.searchParams.append('returnUrl', params.returnUrl);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inquiry order status from PaySo API
|
||||
*/
|
||||
async inquiryOrder(refNo: string): Promise<PaySoPaymentResponse> {
|
||||
try {
|
||||
const response = await fetch(PAYSO_INQUIRY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': this.apiKey,
|
||||
'secretkey': this.secretKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
merchantId: this.merchantId,
|
||||
orderNo: '',
|
||||
refNo: refNo,
|
||||
productDetail: ''
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: -1,
|
||||
message: `API Error: ${response.status}`
|
||||
};
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: -1,
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund payment
|
||||
*/
|
||||
async refund(params: {
|
||||
refNo: string;
|
||||
amount: number;
|
||||
reason?: string;
|
||||
}): Promise<PaySoPaymentResponse> {
|
||||
try {
|
||||
const response = await fetch(PAYSO_REFUND_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': this.apiKey,
|
||||
'secretkey': this.secretKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
merchantId: this.merchantId,
|
||||
refNo: params.refNo,
|
||||
amount: params.amount.toFixed(2),
|
||||
reason: params.reason || 'Customer request'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: -1,
|
||||
message: `API Error: ${response.status}`
|
||||
};
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: -1,
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PaySo callback/return response
|
||||
*/
|
||||
static parseResponse(params: URLSearchParams): {
|
||||
refNo: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
approveCode?: string;
|
||||
invoiceNo?: string;
|
||||
paymentDate?: string;
|
||||
} {
|
||||
return {
|
||||
refNo: params.get('refno') || '',
|
||||
status: params.get('status') || '',
|
||||
amount: parseFloat(params.get('amt') || '0'),
|
||||
approveCode: params.get('apprvcode') || undefined,
|
||||
invoiceNo: params.get('invoiceNo') || undefined,
|
||||
paymentDate: params.get('paydt') || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create PaySo client from environment variables
|
||||
*/
|
||||
export function createPaySoClient(): PaySoClient {
|
||||
const merchantId = import.meta.env.PAYSO_MERCHANT_ID;
|
||||
const apiKey = import.meta.env.PAYSO_API_KEY;
|
||||
const secretKey = import.meta.env.PAYSO_SECRET_KEY;
|
||||
|
||||
if (!merchantId || !apiKey || !secretKey) {
|
||||
console.warn('PaySo credentials not configured. Set PAYSO_MERCHANT_ID, PAYSO_API_KEY, and PAYSO_SECRET_KEY in .env');
|
||||
}
|
||||
|
||||
return new PaySoClient(
|
||||
merchantId || 'demo-merchant',
|
||||
apiKey || 'demo-api-key',
|
||||
secretKey || 'demo-secret-key'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique reference number for PaySo
|
||||
*/
|
||||
export function generatePaySoRefNo(): string {
|
||||
const timestamp = Date.now().toString().slice(-10);
|
||||
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
return `PS${timestamp}${random}`;
|
||||
}
|
||||
|
||||
// Payment status mapping
|
||||
export const PAYSO_STATUS = {
|
||||
SUCCESS: '0',
|
||||
PENDING: '1',
|
||||
FAIL: '2',
|
||||
CANCEL: '3',
|
||||
} as const;
|
||||
|
||||
export type PaySoStatus = typeof PAYSO_STATUS[keyof typeof PAYSO_STATUS];
|
||||
|
||||
export function isPaymentSuccess(status: string): boolean {
|
||||
return status === PAYSO_STATUS.SUCCESS;
|
||||
}
|
||||
|
||||
export function isPaymentPending(status: string): boolean {
|
||||
return status === PAYSO_STATUS.PENDING;
|
||||
}
|
||||
|
||||
export function isPaymentFailed(status: string): boolean {
|
||||
return [PAYSO_STATUS.FAIL, PAYSO_STATUS.CANCEL].includes(status as PaySoStatus);
|
||||
}
|
||||
294
skills/ecommerce-astro/scripts/templates/src/lib/stripe.ts
Normal file
294
skills/ecommerce-astro/scripts/templates/src/lib/stripe.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Stripe Payment Integration
|
||||
*
|
||||
* Supports:
|
||||
* - Payment Intents (Cards, etc.)
|
||||
* - Webhook handling
|
||||
* - Refunds
|
||||
*/
|
||||
|
||||
import Stripe from 'stripe';
|
||||
|
||||
// Initialize Stripe client
|
||||
const stripeSecretKey = import.meta.env.STRIPE_SECRET_KEY;
|
||||
|
||||
export const stripe = new Stripe(stripeSecretKey || 'sk_test_placeholder', {
|
||||
apiVersion: '2025-01-27.acacia' as any,
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
|
||||
export interface CreatePaymentIntentParams {
|
||||
amount: number; // in satang (THB = 100 satang)
|
||||
currency?: string;
|
||||
metadata?: Record<string, string>;
|
||||
customerId?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PaymentIntentResult {
|
||||
clientSecret: string;
|
||||
paymentIntentId: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Payment Intents
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create a payment intent for checkout
|
||||
* Amount should be in satang (baht * 100)
|
||||
*/
|
||||
export async function createPaymentIntent(
|
||||
params: CreatePaymentIntentParams
|
||||
): Promise<PaymentIntentResult> {
|
||||
const {
|
||||
amount,
|
||||
currency = 'thb',
|
||||
metadata = {},
|
||||
customerId,
|
||||
description,
|
||||
} = params;
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount,
|
||||
currency,
|
||||
automatic_payment_methods: { enabled: true },
|
||||
metadata,
|
||||
...(customerId && { customer: customerId }),
|
||||
...(description && { description }),
|
||||
});
|
||||
|
||||
if (!paymentIntent.client_secret) {
|
||||
throw new Error('Failed to create payment intent');
|
||||
}
|
||||
|
||||
return {
|
||||
clientSecret: paymentIntent.client_secret,
|
||||
paymentIntentId: paymentIntent.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a payment intent
|
||||
*/
|
||||
export async function getPaymentIntent(paymentIntentId: string) {
|
||||
return stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a payment intent
|
||||
*/
|
||||
export async function confirmPaymentIntent(
|
||||
paymentIntentId: string,
|
||||
paymentMethodId: string
|
||||
) {
|
||||
return stripe.paymentIntents.confirm(paymentIntentId, {
|
||||
payment_method: paymentMethodId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a payment intent
|
||||
*/
|
||||
export async function cancelPaymentIntent(paymentIntentId: string) {
|
||||
return stripe.paymentIntents.cancel(paymentIntentId);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Webhooks
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Construct and verify webhook event
|
||||
*/
|
||||
export async function constructWebhookEvent(
|
||||
payload: string | Buffer,
|
||||
signature: string
|
||||
) {
|
||||
const webhookSecret = import.meta.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
if (!webhookSecret) {
|
||||
throw new Error('STRIPE_WEBHOOK_SECRET not configured');
|
||||
}
|
||||
|
||||
return stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse webhook event safely
|
||||
*/
|
||||
export async function handleWebhook(
|
||||
payload: string | Buffer,
|
||||
signature: string,
|
||||
handlers: {
|
||||
onPaymentIntentSucceeded?: (intent: Stripe.PaymentIntent) => Promise<void>;
|
||||
onPaymentIntentFailed?: (intent: Stripe.PaymentIntent) => Promise<void>;
|
||||
onPaymentIntentProcessing?: (intent: Stripe.PaymentIntent) => Promise<void>;
|
||||
}
|
||||
) {
|
||||
const event = await constructWebhookEvent(payload, signature);
|
||||
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
if (handlers.onPaymentIntentSucceeded) {
|
||||
await handlers.onPaymentIntentSucceeded(event.data.object as Stripe.PaymentIntent);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
if (handlers.onPaymentIntentFailed) {
|
||||
await handlers.onPaymentIntentFailed(event.data.object as Stripe.PaymentIntent);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'payment_intent.processing':
|
||||
if (handlers.onPaymentIntentProcessing) {
|
||||
await handlers.onPaymentIntentProcessing(event.data.object as Stripe.PaymentIntent);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Unhandled webhook event type: ${event.type}`);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Customers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create a Stripe customer
|
||||
*/
|
||||
export async function createCustomer(params: {
|
||||
email: string;
|
||||
name?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}) {
|
||||
return stripe.customers.create({
|
||||
email: params.email,
|
||||
name: params.name,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer by ID
|
||||
*/
|
||||
export async function getCustomer(customerId: string) {
|
||||
return stripe.customers.retrieve(customerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update customer
|
||||
*/
|
||||
export async function updateCustomer(
|
||||
customerId: string,
|
||||
params: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
) {
|
||||
return stripe.customers.update(customerId, params);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Refunds
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create a refund
|
||||
*/
|
||||
export async function createRefund(params: {
|
||||
paymentIntentId: string;
|
||||
amount?: number; // partial refund amount in satang
|
||||
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
|
||||
}) {
|
||||
return stripe.refunds.create({
|
||||
payment_intent: params.paymentIntentId,
|
||||
amount: params.amount,
|
||||
reason: params.reason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refund status
|
||||
*/
|
||||
export async function getRefund(refundId: string) {
|
||||
return stripe.refunds.retrieve(refundId);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Checkout Sessions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create a checkout session for hosted checkout
|
||||
*/
|
||||
export async function createCheckoutSession(params: {
|
||||
lineItems: Array<{
|
||||
price_data?: {
|
||||
currency: string;
|
||||
product_data: { name: string; description?: string; images?: string[] };
|
||||
unit_amount: number;
|
||||
};
|
||||
price?: string;
|
||||
quantity: number;
|
||||
}>;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
customerEmail?: string;
|
||||
metadata?: Record<string, string>;
|
||||
mode?: 'payment' | 'subscription';
|
||||
}) {
|
||||
return stripe.checkout.sessions.create({
|
||||
mode: params.mode || 'payment',
|
||||
line_items: params.lineItems,
|
||||
success_url: params.successUrl,
|
||||
cancel_url: params.cancelUrl,
|
||||
customer_email: params.customerEmail,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Convert baht to satang (THB * 100)
|
||||
*/
|
||||
export function bahtToSatang(amount: number): number {
|
||||
return Math.round(amount * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert satang to baht
|
||||
*/
|
||||
export function satangToBaht(amount: number): number {
|
||||
return amount / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format amount for Stripe (in smallest currency unit)
|
||||
*/
|
||||
export function formatAmount(amount: number, currency = 'thb'): number {
|
||||
// For THB, no decimal places
|
||||
if (currency.toLowerCase() === 'thb') {
|
||||
return Math.round(amount);
|
||||
}
|
||||
// For others, convert to cents
|
||||
return Math.round(amount * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Stripe is configured
|
||||
*/
|
||||
export function isStripeConfigured(): boolean {
|
||||
return !!stripeSecretKey && !stripeSecretKey.includes('placeholder');
|
||||
}
|
||||
402
skills/ecommerce-astro/scripts/templates/src/lib/supabase.ts
Normal file
402
skills/ecommerce-astro/scripts/templates/src/lib/supabase.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './types';
|
||||
|
||||
const supabaseUrl = import.meta.env.SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.warn('Supabase credentials not configured. Set SUPABASE_URL and SUPABASE_ANON_KEY in .env');
|
||||
}
|
||||
|
||||
export const supabase: SupabaseClient<Database> = createClient(
|
||||
supabaseUrl || 'http://localhost:54321',
|
||||
supabaseAnonKey || 'placeholder-anon-key',
|
||||
{
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Admin client with service role key - bypasses RLS
|
||||
export const supabaseAdmin: SupabaseClient<Database> = createClient(
|
||||
supabaseUrl || 'http://localhost:54321',
|
||||
supabaseAnonKey || 'placeholder-anon-key',
|
||||
{
|
||||
auth: {
|
||||
persistSession: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Database types for Supabase
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
users: {
|
||||
Row: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: 'customer' | 'vendor' | 'admin';
|
||||
avatar_url: string | null;
|
||||
phone: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
role?: 'customer' | 'vendor' | 'admin';
|
||||
avatar_url?: string | null;
|
||||
phone?: string | null;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
name?: string | null;
|
||||
role?: 'customer' | 'vendor' | 'admin';
|
||||
avatar_url?: string | null;
|
||||
phone?: string | null;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
profiles: {
|
||||
Row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
province: string | null;
|
||||
postal_code: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
user_id: string;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
phone?: string | null;
|
||||
address?: string | null;
|
||||
city?: string | null;
|
||||
province?: string | null;
|
||||
postal_code?: string | null;
|
||||
};
|
||||
Update: {
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
phone?: string | null;
|
||||
address?: string | null;
|
||||
city?: string | null;
|
||||
province?: string | null;
|
||||
postal_code?: string | null;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
categories: {
|
||||
Row: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
image_url: string | null;
|
||||
parent_id: string | null;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
image_url?: string | null;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
};
|
||||
Update: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string | null;
|
||||
image_url?: string | null;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
};
|
||||
};
|
||||
products: {
|
||||
Row: {
|
||||
id: string;
|
||||
vendor_id: string | null;
|
||||
category_id: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
compare_at_price: number | null;
|
||||
sku: string | null;
|
||||
barcode: string | null;
|
||||
inventory: number;
|
||||
images: string[];
|
||||
status: 'draft' | 'active' | 'archived';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
vendor_id?: string | null;
|
||||
category_id?: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
price: number;
|
||||
compare_at_price?: number | null;
|
||||
sku?: string | null;
|
||||
barcode?: string | null;
|
||||
inventory?: number;
|
||||
images?: string[];
|
||||
status?: 'draft' | 'active' | 'archived';
|
||||
};
|
||||
Update: {
|
||||
vendor_id?: string | null;
|
||||
category_id?: string | null;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string | null;
|
||||
price?: number;
|
||||
compare_at_price?: number | null;
|
||||
sku?: string | null;
|
||||
barcode?: string | null;
|
||||
inventory?: number;
|
||||
images?: string[];
|
||||
status?: 'draft' | 'active' | 'archived';
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
orders: {
|
||||
Row: {
|
||||
id: string;
|
||||
order_number: string;
|
||||
user_id: string | null;
|
||||
guest_email: string | null;
|
||||
status: 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
||||
payment_status: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
shipping: number;
|
||||
discount: number;
|
||||
total: number;
|
||||
shipping_name: string | null;
|
||||
shipping_phone: string | null;
|
||||
shipping_address: string | null;
|
||||
shipping_city: string | null;
|
||||
shipping_province: string | null;
|
||||
shipping_postal_code: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
order_number: string;
|
||||
user_id?: string | null;
|
||||
guest_email?: string | null;
|
||||
status?: 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
||||
payment_status?: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
shipping: number;
|
||||
discount?: number;
|
||||
total: number;
|
||||
shipping_name?: string | null;
|
||||
shipping_phone?: string | null;
|
||||
shipping_address?: string | null;
|
||||
shipping_city?: string | null;
|
||||
shipping_province?: string | null;
|
||||
shipping_postal_code?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
Update: {
|
||||
status?: 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
||||
payment_status?: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
subtotal?: number;
|
||||
tax?: number;
|
||||
shipping?: number;
|
||||
discount?: number;
|
||||
total?: number;
|
||||
shipping_name?: string | null;
|
||||
shipping_phone?: string | null;
|
||||
shipping_address?: string | null;
|
||||
shipping_city?: string | null;
|
||||
shipping_province?: string | null;
|
||||
shipping_postal_code?: string | null;
|
||||
notes?: string | null;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
order_items: {
|
||||
Row: {
|
||||
id: string;
|
||||
order_id: string;
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
product_image: string | null;
|
||||
price: number;
|
||||
quantity: number;
|
||||
total: number;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
order_id: string;
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
product_image?: string | null;
|
||||
price: number;
|
||||
quantity: number;
|
||||
total: number;
|
||||
};
|
||||
Update: {
|
||||
product_name?: string;
|
||||
product_image?: string | null;
|
||||
price?: number;
|
||||
quantity?: number;
|
||||
total?: number;
|
||||
};
|
||||
};
|
||||
cart_items: {
|
||||
Row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
user_id: string;
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
};
|
||||
Update: {
|
||||
quantity?: number;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
payments: {
|
||||
Row: {
|
||||
id: string;
|
||||
order_id: string;
|
||||
method: 'promptpay' | 'credit_card' | 'bank_transfer' | 'cash_on_delivery';
|
||||
amount: number;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | 'refunded';
|
||||
reference: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
order_id: string;
|
||||
method: 'promptpay' | 'credit_card' | 'bank_transfer' | 'cash_on_delivery';
|
||||
amount: number;
|
||||
status?: 'pending' | 'processing' | 'completed' | 'failed' | 'refunded';
|
||||
reference?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
Update: {
|
||||
status?: 'pending' | 'processing' | 'completed' | 'failed' | 'refunded';
|
||||
reference?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Enums: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions for common queries
|
||||
export async function getProductBySlug(slug: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('*, categories(*)')
|
||||
.eq('slug', slug)
|
||||
.eq('status', 'active')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getProductsByCategory(categorySlug: string, limit = 20, offset = 0) {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('*, categories(*)')
|
||||
.eq('categories.slug', categorySlug)
|
||||
.eq('status', 'active')
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getFeaturedProducts(limit = 10) {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('*, categories(*)')
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserOrders(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('orders')
|
||||
.select('*, order_items(*, products(*))')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getOrderByNumber(orderNumber: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('orders')
|
||||
.select('*, order_items(*, products(*))')
|
||||
.eq('order_number', orderNumber)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateProductInventory(productId: string, quantity: number) {
|
||||
const { data, error } = await supabase.rpc('decrement_inventory', {
|
||||
product_id: productId,
|
||||
quantity: quantity,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
419
skills/ecommerce-astro/scripts/templates/src/lib/utils.ts
Normal file
419
skills/ecommerce-astro/scripts/templates/src/lib/utils.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* E-commerce Utility Functions
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// ID & Reference Generation
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Generate unique order number
|
||||
* Format: ORD-{timestamp36}-{random6}
|
||||
*/
|
||||
export function generateOrderNumber(): string {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
return `ORD-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique product slug
|
||||
*/
|
||||
export function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID (UUID v4 style)
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate short random string
|
||||
*/
|
||||
export function generateShortId(length = 8): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Currency & Number Formatting
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Format currency (THB default)
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency = 'THB'): string {
|
||||
return new Intl.NumberFormat('th-TH', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with thousand separators
|
||||
*/
|
||||
export function formatNumber(num: number, decimals = 0): string {
|
||||
return new Intl.NumberFormat('th-TH', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse currency string to number
|
||||
*/
|
||||
export function parseCurrency(value: string): number {
|
||||
return parseFloat(value.replace(/[^\d.-]/g, ''));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Date & Time Formatting
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Format date in Thai locale
|
||||
*/
|
||||
export function formatThaiDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat('th-TH', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time in Thai locale
|
||||
*/
|
||||
export function formatThaiDateTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat('th-TH', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 ชั่วโมงที่แล้ว")
|
||||
*/
|
||||
export function formatRelativeTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days} วันที่แล้ว`;
|
||||
if (hours > 0) return `${hours} ชั่วโมงที่แล้ว`;
|
||||
if (minutes > 0) return `${minutes} นาทีที่แล้ว`;
|
||||
return 'เมื่อสักครู่';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Validation
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Thai phone number
|
||||
*/
|
||||
export function isValidThaiPhone(phone: string): boolean {
|
||||
// Accept formats: 0812345678, +66812345678, 08-123-4567
|
||||
const cleaned = phone.replace(/[\s-]/g, '');
|
||||
return /^(\+66|0)[6-9]\d{8}$/.test(cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Thai ID card number (13 digits)
|
||||
*/
|
||||
export function isValidThaiId(id: string): boolean {
|
||||
if (!/^\d{13}$/.test(id)) return false;
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
sum += parseInt(id[i]) * (13 - i);
|
||||
}
|
||||
const checkDigit = (11 - (sum % 11)) % 10;
|
||||
return checkDigit === parseInt(id[12]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
*/
|
||||
export function isValidPassword(password: string): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('รหัสผ่านต้องมีความยาวอย่างน้อย 8 ตัวอักษร');
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('ต้องมีตัวพิมพ์ใหญ่อย่างน้อย 1 ตัว');
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('ต้องมีตัวพิมพ์เล็กอย่างน้อย 1 ตัว');
|
||||
}
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.push('ต้องมีตัวเลขอย่างน้อย 1 ตัว');
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Order Calculations
|
||||
// ============================================
|
||||
|
||||
export interface CartItem {
|
||||
price: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface OrderTotals {
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
shipping: number;
|
||||
discount: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate order totals
|
||||
*/
|
||||
export function calculateOrderTotals(
|
||||
items: CartItem[],
|
||||
options: {
|
||||
taxRate?: number;
|
||||
freeShippingThreshold?: number;
|
||||
shippingCost?: number;
|
||||
discount?: number;
|
||||
} = {}
|
||||
): OrderTotals {
|
||||
const {
|
||||
taxRate = 0.07, // 7% VAT
|
||||
freeShippingThreshold = 500,
|
||||
shippingCost = 50,
|
||||
discount = 0,
|
||||
} = options;
|
||||
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
const tax = subtotal * taxRate;
|
||||
const shipping = subtotal >= freeShippingThreshold ? 0 : shippingCost;
|
||||
const total = subtotal + tax + shipping - discount;
|
||||
|
||||
return {
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
tax: Math.round(tax * 100) / 100,
|
||||
shipping: Math.round(shipping * 100) / 100,
|
||||
discount: Math.round(discount * 100) / 100,
|
||||
total: Math.round(total * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cart item count
|
||||
*/
|
||||
export function calculateCartCount(items: CartItem[]): number {
|
||||
return items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cart subtotal
|
||||
*/
|
||||
export function calculateCartSubtotal(items: CartItem[]): number {
|
||||
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Image & URL Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get image URL with fallback
|
||||
*/
|
||||
export function getImageUrl(url: string | null | undefined, fallback = '/placeholder.png'): string {
|
||||
if (!url) return fallback;
|
||||
if (url.startsWith('http')) return url;
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate product image URLs (multiple sizes)
|
||||
*/
|
||||
export function getProductImageUrls(baseUrl: string): {
|
||||
thumbnail: string;
|
||||
small: string;
|
||||
medium: string;
|
||||
large: string;
|
||||
original: string;
|
||||
} {
|
||||
// If using Supabase Storage, append resize parameters
|
||||
if (baseUrl.includes('supabase')) {
|
||||
return {
|
||||
thumbnail: `${baseUrl}?width=150&height=150&resize=cover`,
|
||||
small: `${baseUrl}?width=300&height=300&resize=cover`,
|
||||
medium: `${baseUrl}?width=600&height=600&resize=cover`,
|
||||
large: `${baseUrl}?width=1200&height=1200&resize=cover`,
|
||||
original: baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// For other storage, return same URL
|
||||
return {
|
||||
thumbnail: baseUrl,
|
||||
small: baseUrl,
|
||||
medium: baseUrl,
|
||||
large: baseUrl,
|
||||
original: baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Thai Province Data
|
||||
// ============================================
|
||||
|
||||
export const THAI_PROVINCES = [
|
||||
'กรุงเทพมหานคร',
|
||||
'กระบี่',
|
||||
'กาญจนบุรี',
|
||||
'กาฬสินธุ์',
|
||||
'กำแพงเพชร',
|
||||
'ขอนแก่น',
|
||||
'จันทบุรี',
|
||||
'ฉะเชิงเทรา',
|
||||
'ชลบุรี',
|
||||
'ชัยนาท',
|
||||
'ชัยภูมิ',
|
||||
'ชุมพร',
|
||||
'เชียงราย',
|
||||
'เชียงใหม่',
|
||||
'ตรัง',
|
||||
'ตราด',
|
||||
'ตาก',
|
||||
'นครนายก',
|
||||
'นครปฐม',
|
||||
'นครพนม',
|
||||
'นครราชสีมา',
|
||||
'นครศรีธรรมราช',
|
||||
'นครสวรรค์',
|
||||
'นนทบุรี',
|
||||
'นราธิวาส',
|
||||
'หนองคาย',
|
||||
'หนองบัวลำภู',
|
||||
'บึงกาฬ',
|
||||
'บุรีรัมย์',
|
||||
'ประจวบคีรีขันธ์',
|
||||
'ประจวบคีรีขันธ์',
|
||||
'ปราจีนบุรี',
|
||||
'ปัตตานี',
|
||||
'พะเยา',
|
||||
'พังงา',
|
||||
'พัทลุง',
|
||||
'พิจิตร',
|
||||
'พิษณุโลก',
|
||||
'เพชรบุรี',
|
||||
'เพชรบูรณ์',
|
||||
'แพร่',
|
||||
'พะเยา',
|
||||
'ภูเก็ต',
|
||||
'มหาสารคาม',
|
||||
'มุกดาหาร',
|
||||
'แม่ฮ่องสอน',
|
||||
'ยะลา',
|
||||
'ยโสธร',
|
||||
'ร้อยเอ็ด',
|
||||
'ระนอง',
|
||||
'ระยอง',
|
||||
'ราชบุรี',
|
||||
'ลพบุรี',
|
||||
'ลำปาง',
|
||||
'ลำพูน',
|
||||
'เลย',
|
||||
'ศรีสะเกษ',
|
||||
'สกลนคร',
|
||||
'สงขลา',
|
||||
'สตูล',
|
||||
'สมุทรปราการ',
|
||||
'สมุทรสงคราม',
|
||||
'สมุทรสาคร',
|
||||
'สระแก้ว',
|
||||
'สระบุรี',
|
||||
'สิงห์บุรี',
|
||||
'สุโขทัย',
|
||||
'สุพรรณบุรี',
|
||||
'สุราษฎร์ธานี',
|
||||
'สุรินทร์',
|
||||
'หนองคาย',
|
||||
'อ่างทอง',
|
||||
'อำนาจเจริญ',
|
||||
'อุดรธานี',
|
||||
'อุตรดิตถ์',
|
||||
'อุทัยธานี',
|
||||
'อุบลราชธานี',
|
||||
] as const;
|
||||
|
||||
// ============================================
|
||||
// Order Status Helpers
|
||||
// ============================================
|
||||
|
||||
export const ORDER_STATUS = {
|
||||
pending: { label: 'รอดำเนินการ', color: 'yellow' },
|
||||
confirmed: { label: 'ยืนยันแล้ว', color: 'blue' },
|
||||
processing: { label: 'กำลังจัดเตรียม', color: 'blue' },
|
||||
shipped: { label: 'จัดส่งแล้ว', color: 'purple' },
|
||||
delivered: { label: 'ส่งมอบแล้ว', color: 'green' },
|
||||
cancelled: { label: 'ยกเลิก', color: 'red' },
|
||||
} as const;
|
||||
|
||||
export const PAYMENT_STATUS = {
|
||||
pending: { label: 'รอชำระ', color: 'yellow' },
|
||||
paid: { label: 'ชำระแล้ว', color: 'green' },
|
||||
failed: { label: 'ล้มเหลว', color: 'red' },
|
||||
refunded: { label: 'คืนเงิน', color: 'gray' },
|
||||
} as const;
|
||||
|
||||
export function getOrderStatusInfo(status: keyof typeof ORDER_STATUS) {
|
||||
return ORDER_STATUS[status] || { label: status, color: 'gray' };
|
||||
}
|
||||
|
||||
export function getPaymentStatusInfo(status: keyof typeof PAYMENT_STATUS) {
|
||||
return PAYMENT_STATUS[status] || { label: status, color: 'gray' };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Header from '../../components/layout/Header';
|
||||
import Footer from '../../components/layout/Footer';
|
||||
|
||||
const token = Astro.cookies.get('session')?.value;
|
||||
if (!token) return Astro.redirect('/login');
|
||||
|
||||
// Fetch user data
|
||||
const userRes = await fetch(new URL('/api/auth/me', Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { user } = await userRes.json();
|
||||
---
|
||||
|
||||
<Layout title="บัญชีของฉัน">
|
||||
<Header />
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold mb-8">บัญชีของฉัน</h1>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<!-- Main Content -->
|
||||
<div class="md:col-span-2 space-y-6">
|
||||
<!-- Profile Info -->
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="font-bold text-lg">ข้อมูลส่วนตัว</h2>
|
||||
<button class="text-blue-600 hover:underline text-sm">แก้ไข</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center text-2xl font-bold text-blue-600">
|
||||
{user?.name?.charAt(0) || 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-lg">{user?.name || 'ผู้ใช้งาน'}</p>
|
||||
<p class="text-gray-500">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="space-y-3">
|
||||
<div class="flex border-b pb-3">
|
||||
<dt class="w-32 text-gray-500">ชื่อ-นามสกุล</dt>
|
||||
<dd class="font-medium">{user?.name || '-'}</dd>
|
||||
</div>
|
||||
<div class="flex border-b pb-3">
|
||||
<dt class="w-32 text-gray-500">อีเมล</dt>
|
||||
<dd class="font-medium">{user?.email || '-'}</dd>
|
||||
</div>
|
||||
<div class="flex border-b pb-3">
|
||||
<dt class="w-32 text-gray-500">เบอร์โทร</dt>
|
||||
<dd class="font-medium">{user?.phone || '-'}</dd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<dt class="w-32 text-gray-500">สถานะ</dt>
|
||||
<dd>
|
||||
<span class={`px-3 py-1 rounded-full text-sm ${
|
||||
user?.role === 'admin' ? 'bg-purple-100 text-purple-700' :
|
||||
user?.role === 'vendor' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{user?.role === 'admin' ? 'ผู้ดูแล' : user?.role === 'vendor' ? 'ร้านค้า' : 'ลูกค้า'}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Vendor Access -->
|
||||
{user?.role === 'vendor' && (
|
||||
<div class="bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl p-6 border border-blue-100">
|
||||
<h2 class="font-bold text-lg mb-2">ร้านค้าของฉัน</h2>
|
||||
<p class="text-gray-600 mb-4">เข้าถึงหน้าจัดการร้านค้าเพื่อเพิ่มสินค้าและติดตามคำสั่งซื้อ</p>
|
||||
<a href="/vendor/dashboard" class="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
เข้าสู่หน้าจัดการร้านค้า
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Admin Access -->
|
||||
{user?.role === 'admin' && (
|
||||
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl p-6 border border-purple-100">
|
||||
<h2 class="font-bold text-lg mb-2">ผู้ดูแลระบบ</h2>
|
||||
<p class="text-gray-600 mb-4">จัดการร้านค้า ผู้ใช้งาน และคำสั่งซื้อทั้งหมด</p>
|
||||
<a href="/admin/dashboard" class="inline-flex items-center gap-2 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
เข้าสู่หน้าผู้ดูแล
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h3 class="font-bold mb-4">เมนูบัญชี</h3>
|
||||
<nav class="space-y-1">
|
||||
<a href="/account" class="flex items-center gap-3 py-2 px-3 rounded-lg bg-blue-50 text-blue-700 font-medium">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
ข้อมูลส่วนตัว
|
||||
</a>
|
||||
<a href="/account/orders" class="flex items-center gap-3 py-2 px-3 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
คำสั่งซื้อของฉัน
|
||||
</a>
|
||||
<a href="/wishlist" class="flex items-center gap-3 py-2 px-3 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
สินค้าที่ชอบ
|
||||
</a>
|
||||
<a href="/account/addresses" class="flex items-center gap-3 py-2 px-3 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
ที่อยู่จัดส่ง
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<a href="/api/auth/logout" class="flex items-center gap-3 py-2 px-3 rounded-lg text-red-600 hover:bg-red-50 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
ออกจากระบบ
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Header from '../../components/layout/Header';
|
||||
import Footer from '../../components/layout/Footer';
|
||||
|
||||
const { id } = Astro.params;
|
||||
const token = Astro.cookies.get('session')?.value;
|
||||
if (!token) return Astro.redirect('/login');
|
||||
|
||||
// Fetch order details
|
||||
const orderRes = await fetch(new URL(`/api/orders/${id}`, Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { order } = await orderRes.json();
|
||||
|
||||
if (!order) {
|
||||
return Astro.redirect('/account/orders');
|
||||
}
|
||||
|
||||
// Status colors
|
||||
const statusColors = {
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
paid: 'bg-blue-100 text-blue-700',
|
||||
shipped: 'bg-purple-100 text-purple-700',
|
||||
delivered: 'bg-green-100 text-green-700',
|
||||
cancelled: 'bg-red-100 text-red-700'
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
pending: 'รอชำระเงิน',
|
||||
paid: 'ชำระแล้ว',
|
||||
shipped: 'จัดส่งแล้ว',
|
||||
delivered: 'รับสินค้าแล้ว',
|
||||
cancelled: 'ยกเลิก'
|
||||
};
|
||||
|
||||
const paymentStatusLabels = {
|
||||
pending: 'รอชำระเงิน',
|
||||
paid: 'ชำระแล้ว',
|
||||
failed: 'ชำระไม่สำเร็จ',
|
||||
refunded: 'คืนเงินแล้ว'
|
||||
};
|
||||
|
||||
const paymentStatusColors = {
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
paid: 'bg-green-100 text-green-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
refunded: 'bg-gray-100 text-gray-700'
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={`คำสั่งซื้อ ${order.ref_no}`}>
|
||||
<Header />
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="text-sm text-gray-500 mb-6">
|
||||
<a href="/" class="hover:text-blue-600">หน้าแรก</a>
|
||||
<span class="mx-2">/</span>
|
||||
<a href="/account" class="hover:text-blue-600">บัญชีของฉัน</a>
|
||||
<span class="mx-2">/</span>
|
||||
<a href="/account/orders" class="hover:text-blue-600">คำสั่งซื้อ</a>
|
||||
<span class="mx-2">/</span>
|
||||
<span class="text-gray-900">{order.ref_no}</span>
|
||||
</nav>
|
||||
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">คำสั่งซื้อ {order.ref_no}</h1>
|
||||
<p class="text-gray-500">วันที่ {new Date(order.created_at).toLocaleDateString('th-TH', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span class={`px-4 py-2 rounded-full text-sm font-medium ${statusColors[order.status]}`}>
|
||||
{statusLabels[order.status]}
|
||||
</span>
|
||||
<span class={`px-4 py-2 rounded-full text-sm font-medium ${paymentStatusColors[order.payment_status]}`}>
|
||||
{paymentStatusLabels[order.payment_status]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-8">
|
||||
<!-- Order Items -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-white rounded-xl shadow-sm overflow-hidden mb-6">
|
||||
<div class="p-6 border-b">
|
||||
<h2 class="font-bold">รายการสินค้า</h2>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
{order.items?.map(item => (
|
||||
<div class="p-6 flex gap-4">
|
||||
<div class="w-20 h-20 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0">
|
||||
<img src={item.image} alt={item.name} class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium">{item.name}</h3>
|
||||
{item.variant && (
|
||||
<p class="text-sm text-gray-500">{item.variant}</p>
|
||||
)}
|
||||
<div class="flex justify-between mt-2">
|
||||
<span class="text-gray-500">x{item.quantity}</span>
|
||||
<span class="font-medium">฿{(item.price * item.quantity).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Info -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 class="font-bold mb-4">ที่อยู่จัดส่ง</h2>
|
||||
<div class="text-gray-600">
|
||||
<p class="font-medium text-gray-900">{order.shipping_name}</p>
|
||||
<p>{order.shipping_phone}</p>
|
||||
<p>{order.shipping_address}</p>
|
||||
<p>{order.shipping_district}, {order.shipping_province} {order.shipping_postal}</p>
|
||||
</div>
|
||||
{order.tracking_number && (
|
||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">หมายเลขติดตามพัสดุ</p>
|
||||
<p class="font-mono font-medium">{order.tracking_number}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div>
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 sticky top-24">
|
||||
<h2 class="font-bold mb-4">สรุปคำสั่งซื้อ</h2>
|
||||
<dl class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">ยอดสินค้า</dt>
|
||||
<dd>฿{order.subtotal?.toLocaleString() || 0}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">ค่าจัดส่ง</dt>
|
||||
<dd>{order.shipping_fee === 0 ? 'ฟรี' : `฿${order.shipping_fee?.toLocaleString()}`}</dd>
|
||||
</div>
|
||||
{order.discount > 0 && (
|
||||
<div class="flex justify-between text-green-600">
|
||||
<dt>ส่วนลด</dt>
|
||||
<dd>-฿{order.discount?.toLocaleString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
<hr />
|
||||
<div class="flex justify-between font-bold text-lg">
|
||||
<dt>ยอดรวม</dt>
|
||||
<dd class="text-blue-600">฿{order.total?.toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{order.payment_status === 'pending' && (
|
||||
<div class="mt-6 space-y-3">
|
||||
<a
|
||||
href={`/checkout/payment/${order.id}`}
|
||||
class="block w-full bg-blue-600 text-white py-3 px-4 rounded-lg text-center hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
ชำระเงิน
|
||||
</a>
|
||||
<button
|
||||
class="block w-full border border-red-300 text-red-600 py-3 px-4 rounded-lg text-center hover:bg-red-50 transition-colors"
|
||||
onclick="if(confirm('ยกเลิกคำสั่งซื้อนี้?')) { fetch('/api/orders/{order.id}/cancel', {method: 'POST', headers: {'Authorization': 'Bearer {token}'}}).then(() => location.reload()); }"
|
||||
>
|
||||
ยกเลิกคำสั่งซื้อ
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Header from '../../components/layout/Header';
|
||||
import Footer from '../../components/layout/Footer';
|
||||
|
||||
const token = Astro.cookies.get('session')?.value;
|
||||
if (!token) return Astro.redirect('/login');
|
||||
|
||||
// Fetch orders
|
||||
const ordersRes = await fetch(new URL('/api/orders', Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { orders } = await ordersRes.json();
|
||||
|
||||
// Status colors
|
||||
const statusColors = {
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
paid: 'bg-blue-100 text-blue-700',
|
||||
shipped: 'bg-purple-100 text-purple-700',
|
||||
delivered: 'bg-green-100 text-green-700',
|
||||
cancelled: 'bg-red-100 text-red-700'
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
pending: 'รอชำระเงิน',
|
||||
paid: 'ชำระแล้ว',
|
||||
shipped: 'จัดส่งแล้ว',
|
||||
delivered: 'รับสินค้าแล้ว',
|
||||
cancelled: 'ยกเลิก'
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title="ประวัติคำสั่งซื้อ">
|
||||
<Header />
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold mb-8">คำสั่งซื้อของฉัน</h1>
|
||||
|
||||
{orders && orders.length > 0 ? (
|
||||
<div class="space-y-4">
|
||||
{orders.map(order => (
|
||||
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">หมายเลขคำสั่งซื้อ</p>
|
||||
<p class="font-bold text-lg">{order.ref_no}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-500">วันที่สั่งซื้อ</p>
|
||||
<p class="font-medium">{new Date(order.created_at).toLocaleDateString('th-TH')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 pt-4 border-t">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class={`px-3 py-1 rounded-full text-sm font-medium ${statusColors[order.status] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{statusLabels[order.status] || order.status}
|
||||
</span>
|
||||
<span class="text-gray-500">{order.items?.length || 0} รายการ</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xl font-bold text-blue-600">
|
||||
฿{order.total.toLocaleString()}
|
||||
</span>
|
||||
<a
|
||||
href={`/account/orders/${order.id}`}
|
||||
class="px-4 py-2 border border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
ดูรายละเอียด
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="text-center py-20 bg-white rounded-xl">
|
||||
<div class="w-20 h-20 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-500 text-lg mb-4">ยังไม่มีคำสั่งซื้อ</p>
|
||||
<a href="/products" class="text-blue-600 hover:underline">เริ่มช้อปเลย</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Header from '../../components/layout/Header';
|
||||
import AdminSidebar from '../../components/admin/AdminSidebar';
|
||||
import CategoryForm from '../../components/admin/CategoryForm';
|
||||
|
||||
const token = Astro.cookies.get('session')?.value;
|
||||
if (!token) return Astro.redirect('/login');
|
||||
|
||||
// Check admin role
|
||||
const meRes = await fetch(new URL('/api/auth/me', Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { user } = await meRes.json();
|
||||
if (user?.role !== 'admin') return Astro.redirect('/');
|
||||
|
||||
// Fetch categories
|
||||
const categoriesRes = await fetch(`${import.meta.env.PUBLIC_SUPABASE_URL}/rest/v1/categories?select=*,parent:categories(name)&order=name.asc`, {
|
||||
headers: { 'apikey': import.meta.env.PUBLIC_SUPABASE_ANON_KEY }
|
||||
});
|
||||
const categories = await categoriesRes.json();
|
||||
---
|
||||
|
||||
<Layout title="จัดการหมวดหมู่">
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<Header />
|
||||
<div class="flex">
|
||||
<AdminSidebar client:load role="admin" />
|
||||
<main class="flex-1 p-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-2xl font-bold">หมวดหมู่สินค้า</h1>
|
||||
<button class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
+ เพิ่มหมวดหมู่
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
{categories?.map(category => (
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
{category.icon || '📦'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold">{category.name}</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
{category.parent ? `หมวดหมู่ย่อยของ ${category.parent.name}` : 'หมวดหมู่หลัก'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="text-blue-600 hover:text-blue-800">แก้ไข</button>
|
||||
<button class="text-red-600 hover:text-red-800">ลบ</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(!categories || categories.length === 0) && (
|
||||
<div class="text-center py-20 bg-white rounded-xl">
|
||||
<p class="text-gray-500">ยังไม่มีหมวดหมู่</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Header from '../../components/layout/Header';
|
||||
import AdminSidebar from '../../components/admin/AdminSidebar';
|
||||
|
||||
const token = Astro.cookies.get('session')?.value;
|
||||
if (!token) return Astro.redirect('/login');
|
||||
|
||||
// Fetch admin stats
|
||||
const statsRes = await fetch(new URL('/api/admin/stats', Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { stats } = await statsRes.json();
|
||||
---
|
||||
|
||||
<Layout title="แดชบอร์ดผู้ดูแล">
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<Header />
|
||||
<div class="flex">
|
||||
<AdminSidebar client:load role="admin" />
|
||||
<main class="flex-1 p-8">
|
||||
<h1 class="text-2xl font-bold mb-8">แดชบอร์ดผู้ดูแล</h1>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">ผู้ใช้งานทั้งหมด</p>
|
||||
<p class="text-3xl font-bold mt-1">{stats?.totalUsers || 0}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">ร้านค้าทั้งหมด</p>
|
||||
<p class="text-3xl font-bold mt-1">{stats?.totalVendors || 0}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">สินค้าทั้งหมด</p>
|
||||
<p class="text-3xl font-bold mt-1">{stats?.totalProducts || 0}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">คำสั่งซื้อทั้งหมด</p>
|
||||
<p class="text-3xl font-bold mt-1">{stats?.totalOrders || 0}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a href="/admin/users" class="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
|
||||
<h3 class="font-bold mb-2">จัดการผู้ใช้งาน</h3>
|
||||
<p class="text-gray-500 text-sm">ดูและแก้ไขข้อมูลผู้ใช้งานทั้งหมด</p>
|
||||
</a>
|
||||
<a href="/admin/vendors" class="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
|
||||
<h3 class="font-bold mb-2">จัดการร้านค้า</h3>
|
||||
<p class="text-gray-500 text-sm">อนุมัติและจัดการร้านค้า</p>
|
||||
</a>
|
||||
<a href="/admin/orders" class="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
|
||||
<h3 class="font-bold mb-2">จัดการคำสั่งซื้อ</h3>
|
||||
<p class="text-gray-500 text-sm">ติดตามคำสั่งซื้อทั้งหมด</p>
|
||||
</a>
|
||||
<a href="/admin/categories" class="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
|
||||
<h3 class="font-bold mb-2">จัดการหมวดหมู่</h3>
|
||||
<p class="text-gray-500 text-sm">เพิ่มและแก้ไขหมวดหมู่สินค้า</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Header from '../../components/layout/Header';
|
||||
import AdminSidebar from '../../components/admin/AdminSidebar';
|
||||
|
||||
const token = Astro.cookies.get('session')?.value;
|
||||
if (!token) return Astro.redirect('/login');
|
||||
|
||||
// Check admin role
|
||||
const meRes = await fetch(new URL('/api/auth/me', Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { user } = await meRes.json();
|
||||
if (user?.role !== 'admin') return Astro.redirect('/');
|
||||
|
||||
// Fetch all orders
|
||||
const ordersRes = await fetch(new URL('/api/admin/orders', Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { orders } = await ordersRes.json();
|
||||
|
||||
// Status config
|
||||
const statusConfig = {
|
||||
pending: { label: 'รอชำระเงิน', color: 'bg-yellow-100 text-yellow-700' },
|
||||
paid: { label: 'ชำระแล้ว', color: 'bg-blue-100 text-blue-700' },
|
||||
shipped: { label: 'จัดส่งแล้ว', color: 'bg-purple-100 text-purple-700' },
|
||||
delivered: { label: 'รับสินค้าแล้ว', color: 'bg-green-100 text-green-700' },
|
||||
cancelled: { label: 'ยกเลิก', color: 'bg-red-100 text-red-700' }
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title="จัดการคำสั่งซื้อ">
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<Header />
|
||||
<div class="flex">
|
||||
<AdminSidebar client:load role="admin" />
|
||||
<main class="flex-1 p-8">
|
||||
<h1 class="text-2xl font-bold mb-8">คำสั่งซื้อทั้งหมด</h1>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">หมายเลข</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ร้านค้า</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ลูกค้า</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ยอดรวม</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">สถานะ</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">วันที่</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{orders?.map(order => (
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 font-medium text-blue-600">{order.ref_no}</td>
|
||||
<td class="px-6 py-4">
|
||||
<p>{order.vendor?.store_name || '-'}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<p>{order.user?.name || order.shipping_name}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 font-medium">฿{order.total.toLocaleString()}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class={`px-2 py-1 rounded-full text-xs font-medium ${statusConfig[order.status]?.color || 'bg-gray-100 text-gray-700'}`}>
|
||||
{statusConfig[order.status]?.label || order.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString('th-TH')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Header from '../../components/layout/Header';
|
||||
import AdminSidebar from '../../components/admin/AdminSidebar';
|
||||
|
||||
const token = Astro.cookies.get('session')?.value;
|
||||
if (!token) return Astro.redirect('/login');
|
||||
|
||||
// Check admin role
|
||||
const meRes = await fetch(new URL('/api/auth/me', Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { user } = await meRes.json();
|
||||
if (user?.role !== 'admin') return Astro.redirect('/');
|
||||
|
||||
// Fetch users
|
||||
const usersRes = await fetch(new URL('/api/admin/users', Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { users } = await usersRes.json();
|
||||
|
||||
const roleColors = {
|
||||
admin: 'bg-purple-100 text-purple-700',
|
||||
vendor: 'bg-blue-100 text-blue-700',
|
||||
customer: 'bg-gray-100 text-gray-700'
|
||||
};
|
||||
|
||||
const roleLabels = {
|
||||
admin: 'ผู้ดูแล',
|
||||
vendor: 'ร้านค้า',
|
||||
customer: 'ลูกค้า'
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title="จัดการผู้ใช้งาน">
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<Header />
|
||||
<div class="flex">
|
||||
<AdminSidebar client:load role="admin" />
|
||||
<main class="flex-1 p-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-2xl font-bold">ผู้ใช้งานทั้งหมด</h1>
|
||||
<button class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
+ เพิ่มผู้ดูแล
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ผู้ใช้งาน</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">อีเมล</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">บทบาท</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">สมัครเมื่อ</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">จัดการ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{users?.map(u => (
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold">
|
||||
{u.name?.charAt(0) || 'U'}
|
||||
</div>
|
||||
<span class="font-medium">{u.name || 'ไม่ระบุ'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-500">{u.email}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class={`px-2 py-1 rounded-full text-xs font-medium ${roleColors[u.role]}`}>
|
||||
{roleLabels[u.role] || u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-500">
|
||||
{new Date(u.created_at).toLocaleDateString('th-TH')}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<button class="text-blue-600 hover:text-blue-800 font-medium mr-3">แก้ไข</button>
|
||||
{u.role !== 'admin' && (
|
||||
<button class="text-red-600 hover:text-red-800 font-medium">ลบ</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Header from '../../components/layout/Header';
|
||||
import AdminSidebar from '../../components/admin/AdminSidebar';
|
||||
|
||||
const token = Astro.cookies.get('session')?.value;
|
||||
if (!token) return Astro.redirect('/login');
|
||||
|
||||
// Check admin role
|
||||
const meRes = await fetch(new URL('/api/auth/me', Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { user } = await meRes.json();
|
||||
if (user?.role !== 'admin') return Astro.redirect('/');
|
||||
|
||||
// Fetch vendors
|
||||
const vendorsRes = await fetch(new URL('/api/admin/vendors', Astro.url).toString(), {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const { vendors } = await vendorsRes.json();
|
||||
|
||||
const statusColors = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
suspended: 'bg-red-100 text-red-700'
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
active: 'เปิดใช้งาน',
|
||||
pending: 'รออนุมัติ',
|
||||
suspended: 'ระงับ'
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title="จัดการร้านค้า">
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<Header />
|
||||
<div class="flex">
|
||||
<AdminSidebar client:load role="admin" />
|
||||
<main class="flex-1 p-8">
|
||||
<h1 class="text-2xl font-bold mb-8">ร้านค้าทั้งหมด</h1>
|
||||
|
||||
<!-- Pending Approval Alert -->
|
||||
{vendors?.filter(v => v.status === 'pending').length > 0 && (
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4 mb-6">
|
||||
<p class="text-yellow-800">
|
||||
มีร้านค้ารอการอนุมัติ {vendors.filter(v => v.status === 'pending').length} ร้าน
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ร้านค้า</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">เจ้าของ</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">สถานะ</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">สินค้า</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">สมัครเมื่อ</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">จัดการ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{vendors?.map(vendor => (
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<img src={vendor.logo || '/store-placeholder.jpg'} alt="" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">{vendor.store_name}</p>
|
||||
<p class="text-sm text-gray-500">/{vendor.store_slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<p>{vendor.user?.name}</p>
|
||||
<p class="text-sm text-gray-500">{vendor.user?.email}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[vendor.status]}`}>
|
||||
{statusLabels[vendor.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">{vendor.product_count || 0}</td>
|
||||
<td class="px-6 py-4 text-gray-500">
|
||||
{new Date(vendor.created_at).toLocaleDateString('th-TH')}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<a href={`/vendors/${vendor.store_slug}`} target="_blank" class="text-blue-600 hover:text-blue-800 font-medium mr-3">
|
||||
ดูร้าน
|
||||
</a>
|
||||
{vendor.status === 'pending' && (
|
||||
<button class="text-green-600 hover:text-green-800 font-medium approve-vendor" data-id={vendor.id}>
|
||||
อนุมัติ
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabaseAdmin } from '../../../../lib/supabase';
|
||||
import { verifyPassword, generateToken, setAuthCookie } from '../../../../lib/auth';
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
try {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
// Validate input
|
||||
if (!email || !password) {
|
||||
return new Response(JSON.stringify({ error: 'Email and password are required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const { data: user, error: userError } = await supabaseAdmin
|
||||
.from('users')
|
||||
.select('id, email, name, role, password_hash, avatar_url')
|
||||
.eq('email', email.toLowerCase())
|
||||
.single();
|
||||
|
||||
if (userError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid email or password' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await verifyPassword(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid email or password' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
});
|
||||
|
||||
// Set cookie for browser clients
|
||||
setAuthCookie(cookies, token);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
avatar_url: user.avatar_url
|
||||
},
|
||||
token
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { clearAuthCookie } from '../../../../lib/auth';
|
||||
|
||||
export const POST: APIRoute = async ({ cookies }) => {
|
||||
try {
|
||||
// Clear the auth cookie
|
||||
clearAuthCookie(cookies);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabaseAdmin } from '../../../../lib/supabase';
|
||||
import { getTokenFromHeader, verifyToken } from '../../../../lib/auth';
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Extract token from Authorization header
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = getTokenFromHeader(authHeader);
|
||||
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: 'No authentication token provided' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired token' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const { data: user, error: userError } = await supabaseAdmin
|
||||
.from('users')
|
||||
.select('id, email, name, role, avatar_url, phone, created_at')
|
||||
.eq('id', payload.userId)
|
||||
.single();
|
||||
|
||||
if (userError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'User not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
avatar_url: user.avatar_url,
|
||||
phone: user.phone,
|
||||
created_at: user.created_at
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Me error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabaseAdmin } from '../../../../lib/supabase';
|
||||
import { hashPassword, generateToken } from '../../../../lib/auth';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { email, password, name } = await request.json();
|
||||
|
||||
// Validate input
|
||||
if (!email || !password) {
|
||||
return new Response(JSON.stringify({ error: 'Email and password are required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid email format' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 8) {
|
||||
return new Response(JSON.stringify({ error: 'Password must be at least 8 characters' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const { data: existingUsers } = await supabaseAdmin
|
||||
.from('users')
|
||||
.select('id, email')
|
||||
.eq('email', email.toLowerCase())
|
||||
.limit(1);
|
||||
|
||||
if (existingUsers && existingUsers.length > 0) {
|
||||
return new Response(JSON.stringify({ error: 'Email already registered' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const { data: user, error } = await supabaseAdmin
|
||||
.from('users')
|
||||
.insert({
|
||||
email: email.toLowerCase(),
|
||||
password_hash: passwordHash,
|
||||
name: name || null,
|
||||
role: 'customer'
|
||||
})
|
||||
.select('id, email, name, role, created_at')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('User creation error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Failed to create user' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role
|
||||
},
|
||||
token
|
||||
}), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
try {
|
||||
const parentOnly = url.searchParams.get('parent') !== 'false';
|
||||
|
||||
let query = supabase
|
||||
.from('categories')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
if (parentOnly) {
|
||||
query = query.is('parent_id', null);
|
||||
}
|
||||
|
||||
const { data: categories, error } = await query;
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
categories: categories || []
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Categories list error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase, supabaseAdmin } from '../../../../lib/supabase';
|
||||
import { getTokenFromHeader, verifyToken } from '../../../../lib/auth';
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Order ID is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = getTokenFromHeader(authHeader);
|
||||
const payload = verifyToken(token || '');
|
||||
|
||||
const { data: order, error } = await supabase
|
||||
.from('orders')
|
||||
.select(`
|
||||
*,
|
||||
items:order_items(*, product:products(id, name, slug, images)),
|
||||
user:users(id, name, email)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error || !order) {
|
||||
return new Response(JSON.stringify({ error: 'Order not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
const isOwner = order.user_id === payload.userId;
|
||||
const isAdmin = payload.role === 'admin';
|
||||
|
||||
if (!isOwner && !isAdmin) {
|
||||
return new Response(JSON.stringify({ error: 'Access denied' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
order
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get order error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const PATCH: APIRoute = async ({ params, request }) => {
|
||||
try {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Order ID is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = getTokenFromHeader(authHeader);
|
||||
const payload = verifyToken(token || '');
|
||||
|
||||
if (!payload) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { status, notes } = body;
|
||||
|
||||
const { data: order } = await supabase
|
||||
.from('orders')
|
||||
.select('id, user_id, status')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (!order) {
|
||||
return new Response(JSON.stringify({ error: 'Order not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const updates: any = {};
|
||||
|
||||
if (status && ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled'].includes(status)) {
|
||||
if (payload.role !== 'vendor' && payload.role !== 'admin') {
|
||||
if (status === 'cancelled' && order.user_id !== payload.userId) {
|
||||
return new Response(JSON.stringify({ error: 'Only order owner or admin can cancel' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
if (status !== 'cancelled') {
|
||||
return new Response(JSON.stringify({ error: 'Only vendors can update order status' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
updates.status = status;
|
||||
}
|
||||
|
||||
if (notes !== undefined) {
|
||||
updates.notes = notes;
|
||||
}
|
||||
|
||||
const { data: updatedOrder, error } = await supabaseAdmin
|
||||
.from('orders')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
order: updatedOrder
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Update order error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase, supabaseAdmin } from '../../../../lib/supabase';
|
||||
import { getTokenFromHeader, verifyToken } from '../../../../lib/auth';
|
||||
|
||||
function generateOrderNumber(): string {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `ORD-${timestamp}${random}`;
|
||||
}
|
||||
|
||||
function calculateTotals(items: Array<{ price: number; quantity: number }>) {
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
const taxRate = 0.07;
|
||||
const tax = Math.round(subtotal * taxRate * 100) / 100;
|
||||
const shipping = subtotal >= 500 ? 0 : 50;
|
||||
const total = Math.round((subtotal + tax + shipping) * 100) / 100;
|
||||
|
||||
return { subtotal, tax, shipping, total };
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = getTokenFromHeader(authHeader);
|
||||
const payload = verifyToken(token || '');
|
||||
|
||||
if (!payload) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
|
||||
const status = url.searchParams.get('status');
|
||||
const from = (page - 1) * limit;
|
||||
const to = page * limit - 1;
|
||||
|
||||
let query = supabase
|
||||
.from('orders')
|
||||
.select(`
|
||||
*,
|
||||
items:order_items(*, product:products(id, name, slug, images)),
|
||||
user:users(id, name, email)
|
||||
`, { count: 'exact' })
|
||||
.range(from, to)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (payload.role === 'customer') {
|
||||
query = query.eq('user_id', payload.userId);
|
||||
} else if (payload.role === 'vendor') {
|
||||
const { data: vendor } = await supabase
|
||||
.from('vendor_profiles')
|
||||
.select('id')
|
||||
.eq('user_id', payload.userId)
|
||||
.single();
|
||||
|
||||
if (vendor) {
|
||||
query = query.eq('vendor_id', vendor.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status);
|
||||
}
|
||||
|
||||
const { data: orders, error, count } = await query;
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
orders: orders || [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count || 0,
|
||||
pages: Math.ceil((count || 0) / limit)
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Orders list error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = getTokenFromHeader(authHeader);
|
||||
const payload = verifyToken(token || '');
|
||||
|
||||
if (!payload) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { items, shippingAddress } = body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'Order items are required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (!shippingAddress || !shippingAddress.name || !shippingAddress.phone || !shippingAddress.address) {
|
||||
return new Response(JSON.stringify({ error: 'Complete shipping address is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const amounts = items.map((item: any) => ({
|
||||
price: item.price,
|
||||
quantity: item.quantity
|
||||
}));
|
||||
|
||||
const { subtotal, tax, shipping, total } = calculateTotals(amounts);
|
||||
|
||||
const orderNumber = generateOrderNumber();
|
||||
|
||||
const { data: order, error: orderError } = await supabaseAdmin
|
||||
.from('orders')
|
||||
.insert({
|
||||
order_number: orderNumber,
|
||||
user_id: payload.userId,
|
||||
subtotal,
|
||||
tax,
|
||||
shipping_cost: shipping,
|
||||
total,
|
||||
shipping_name: shippingAddress.name,
|
||||
shipping_phone: shippingAddress.phone,
|
||||
shipping_address: shippingAddress.address,
|
||||
shipping_city: shippingAddress.city || null,
|
||||
shipping_province: shippingAddress.province || null,
|
||||
shipping_postal_code: shippingAddress.postal_code || null,
|
||||
status: 'pending',
|
||||
payment_status: 'pending'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orderError) {
|
||||
throw orderError;
|
||||
}
|
||||
|
||||
const orderItems = items.map((item: any) => ({
|
||||
order_id: order.id,
|
||||
product_id: item.productId,
|
||||
product_name: item.name || 'Product',
|
||||
product_image: item.image || null,
|
||||
price: item.price,
|
||||
quantity: item.quantity,
|
||||
total: item.price * item.quantity
|
||||
}));
|
||||
|
||||
const { error: itemsError } = await supabaseAdmin
|
||||
.from('order_items')
|
||||
.insert(orderItems);
|
||||
|
||||
if (itemsError) {
|
||||
throw itemsError;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
order,
|
||||
totals: { subtotal, tax, shipping, total }
|
||||
}), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Create order error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createPaySoClient, isPaymentSuccess, isPaymentPending } from '../../../../lib/payso';
|
||||
|
||||
export const POST: APIRoute = async ({ request, redirect }) => {
|
||||
try {
|
||||
const contentType = request.headers.get('content-type');
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
const body = await request.json();
|
||||
return handleCallback(body);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const refNo = url.searchParams.get('refno');
|
||||
const status = url.searchParams.get('status');
|
||||
const amt = url.searchParams.get('amt');
|
||||
|
||||
if (status && isPaymentSuccess(status)) {
|
||||
return redirect(`/orders/success?refno=${refNo}&amt=${amt}`, 302);
|
||||
} else if (isPaymentPending(status || '')) {
|
||||
return redirect(`/orders/pending?refno=${refNo}`, 302);
|
||||
} else {
|
||||
return redirect(`/orders/failed?refno=${refNo}`, 302);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payment callback error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async function handleCallback(data: any) {
|
||||
const { refno, status, amt, apprvcode } = data;
|
||||
|
||||
if (!refno) {
|
||||
return new Response(JSON.stringify({ error: 'Missing refno' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
refno,
|
||||
status,
|
||||
message: isPaymentSuccess(status) ? 'Payment successful' : 'Payment pending'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const refNo = url.searchParams.get('refno');
|
||||
|
||||
if (!refNo) {
|
||||
return new Response(JSON.stringify({ error: 'Missing refno' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const payso = createPaySoClient();
|
||||
const result = await payso.inquiryOrder(refNo);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
result
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabaseAdmin } from '../../../../lib/supabase';
|
||||
import { getTokenFromHeader, verifyToken } from '../../../../lib/auth';
|
||||
import { createPaySoClient } from '../../../../lib/payso';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = getTokenFromHeader(authHeader);
|
||||
const payload = verifyToken(token || '');
|
||||
|
||||
if (!payload) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { orderId, method = 'promptpay' } = await request.json();
|
||||
|
||||
if (!orderId) {
|
||||
return new Response(JSON.stringify({ error: 'Order ID is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: order } = await supabaseAdmin
|
||||
.from('orders')
|
||||
.select('*, items:order_items(*, product:products(name))')
|
||||
.eq('id', orderId)
|
||||
.single();
|
||||
|
||||
if (!order) {
|
||||
return new Response(JSON.stringify({ error: 'Order not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (order.user_id !== payload.userId && payload.role !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Access denied' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (order.payment_status === 'paid') {
|
||||
return new Response(JSON.stringify({ error: 'Order already paid' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: user } = await supabaseAdmin
|
||||
.from('users')
|
||||
.select('email')
|
||||
.eq('id', order.user_id)
|
||||
.single();
|
||||
|
||||
const payso = createPaySoClient();
|
||||
|
||||
const productDetail = order.items
|
||||
.map((item: any) => item.product?.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
.substring(0, 200) || 'Order Payment';
|
||||
|
||||
const returnUrl = `${import.meta.env.SITE_URL || 'http://localhost:4321'}/orders/${order.id}/success`;
|
||||
const callbackUrl = `${import.meta.env.SITE_URL || 'http://localhost:4321'}/api/webhooks/payso`;
|
||||
|
||||
const paymentUrl = payso.createPaymentUrl({
|
||||
refno: order.order_number,
|
||||
customeremail: user?.email || 'customer@example.com',
|
||||
productdetail: productDetail,
|
||||
total: order.total,
|
||||
returnUrl,
|
||||
callbackUrl
|
||||
});
|
||||
|
||||
await supabaseAdmin
|
||||
.from('orders')
|
||||
.update({
|
||||
payment_provider: 'payso',
|
||||
payment_method: method
|
||||
})
|
||||
.eq('id', orderId);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
paymentUrl,
|
||||
orderNumber: order.order_number,
|
||||
paymentMethod: method
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Create payment error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabaseAdmin } from '../../../../lib/supabase';
|
||||
import { getTokenFromHeader, verifyToken } from '../../../../lib/auth';
|
||||
import { createPaySoClient } from '../../../../lib/payso';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = getTokenFromHeader(authHeader);
|
||||
const payload = verifyToken(token || '');
|
||||
|
||||
if (!payload) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { orderId } = await request.json();
|
||||
|
||||
if (!orderId) {
|
||||
return new Response(JSON.stringify({ error: 'Order ID is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: order } = await supabaseAdmin
|
||||
.from('orders')
|
||||
.select('order_number, payment_status')
|
||||
.eq('id', orderId)
|
||||
.single();
|
||||
|
||||
if (!order) {
|
||||
return new Response(JSON.stringify({ error: 'Order not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (order.user_id !== payload.userId && payload.role !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Access denied' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const payso = createPaySoClient();
|
||||
const result = await payso.inquiryOrder(order.order_number);
|
||||
|
||||
let paymentStatus = order.payment_status;
|
||||
let orderStatus = null;
|
||||
|
||||
if (result.status === 0 && result.data) {
|
||||
paymentStatus = 'paid';
|
||||
orderStatus = 'confirmed';
|
||||
}
|
||||
|
||||
if (paymentStatus !== order.payment_status) {
|
||||
await supabaseAdmin
|
||||
.from('orders')
|
||||
.update({
|
||||
payment_status: paymentStatus,
|
||||
status: orderStatus || undefined,
|
||||
paid_at: paymentStatus === 'paid' ? new Date().toISOString() : undefined
|
||||
})
|
||||
.eq('id', orderId);
|
||||
|
||||
if (paymentStatus === 'paid') {
|
||||
const { data: paymentOrder } = await supabaseAdmin
|
||||
.from('orders')
|
||||
.select('id, user_id, total')
|
||||
.eq('id', orderId)
|
||||
.single();
|
||||
|
||||
if (paymentOrder) {
|
||||
await supabaseAdmin.from('payments').insert({
|
||||
order_id: paymentOrder.id,
|
||||
user_id: paymentOrder.user_id,
|
||||
method: 'promptpay',
|
||||
amount: paymentOrder.total,
|
||||
status: 'completed',
|
||||
reference: order.order_number,
|
||||
paid_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
const { data: orderItems } = await supabaseAdmin
|
||||
.from('order_items')
|
||||
.select('product_id, quantity')
|
||||
.eq('order_id', orderId);
|
||||
|
||||
for (const item of orderItems || []) {
|
||||
await supabaseAdmin.rpc('decrement_inventory', {
|
||||
product_id: item.product_id,
|
||||
quantity: item.quantity
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
orderNumber: order.order_number,
|
||||
paymentStatus,
|
||||
inquiryResult: result
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payment inquiry error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
try {
|
||||
const { slug } = params;
|
||||
|
||||
if (!slug) {
|
||||
return new Response(JSON.stringify({ error: 'Product slug is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: product, error } = await supabase
|
||||
.from('products')
|
||||
.select(`
|
||||
*,
|
||||
vendor:vendor_profiles(id, store_name, store_slug, store_logo, store_description),
|
||||
category:categories(id, name, slug, description),
|
||||
variants:product_variants(id, name, sku, price, inventory, options),
|
||||
reviews:reviews(id, rating, comment, user_id, created_at, user:users(id, name, avatar_url))
|
||||
`)
|
||||
.eq('slug', slug)
|
||||
.eq('status', 'active')
|
||||
.single();
|
||||
|
||||
if (error || !product) {
|
||||
return new Response(JSON.stringify({ error: 'Product not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
let avgRating = 0;
|
||||
let reviewCount = 0;
|
||||
|
||||
if (product.reviews && product.reviews.length > 0) {
|
||||
const totalRating = product.reviews.reduce((sum: number, r: any) => sum + r.rating, 0);
|
||||
avgRating = totalRating / product.reviews.length;
|
||||
reviewCount = product.reviews.length;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
product: {
|
||||
...product,
|
||||
avgRating: Math.round(avgRating * 10) / 10,
|
||||
reviewCount
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get product error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase, supabaseAdmin } from '../../../../lib/supabase';
|
||||
import { getTokenFromHeader, verifyToken } from '../../../../lib/auth';
|
||||
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
try {
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
|
||||
const categorySlug = url.searchParams.get('category');
|
||||
const vendorSlug = url.searchParams.get('vendor');
|
||||
const search = url.searchParams.get('search');
|
||||
const featured = url.searchParams.get('featured') === 'true';
|
||||
const sort = url.searchParams.get('sort') || 'created_at';
|
||||
const order = url.searchParams.get('order') || 'desc';
|
||||
|
||||
const from = (page - 1) * limit;
|
||||
const to = page * limit - 1;
|
||||
|
||||
let query = supabase
|
||||
.from('products')
|
||||
.select(`
|
||||
*,
|
||||
vendor:vendor_profiles(id, store_name, store_slug, store_logo),
|
||||
category:categories(id, name, slug)
|
||||
`, { count: 'exact' })
|
||||
.eq('status', 'active')
|
||||
.range(from, to);
|
||||
|
||||
if (categorySlug) {
|
||||
query = query.eq('categories.slug', categorySlug);
|
||||
}
|
||||
|
||||
if (vendorSlug) {
|
||||
query = query.eq('vendor_profiles.store_slug', vendorSlug);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query = query.or(`name.ilike.%${search}%,description.ilike.%${search}%`);
|
||||
}
|
||||
|
||||
if (featured) {
|
||||
query = query.eq('featured', true);
|
||||
}
|
||||
|
||||
const sortColumn = sort === 'price' ? 'price' : sort === 'name' ? 'name' : 'created_at';
|
||||
query = query.order(sortColumn, { ascending: order === 'asc' });
|
||||
|
||||
const { data: products, error, count } = await query;
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
products: products || [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count || 0,
|
||||
pages: Math.ceil((count || 0) / limit)
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Products list error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = getTokenFromHeader(authHeader);
|
||||
const payload = verifyToken(token || '');
|
||||
|
||||
if (!payload) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.role !== 'vendor' && payload.role !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Only vendors can create products' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, slug, description, price, compare_at_price, category_id, images, inventory, sku } = body;
|
||||
|
||||
if (!name || !slug || price === undefined) {
|
||||
return new Response(JSON.stringify({ error: 'Name, slug, and price are required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: vendor } = await supabaseAdmin
|
||||
.from('vendor_profiles')
|
||||
.select('id')
|
||||
.eq('user_id', payload.userId)
|
||||
.single();
|
||||
|
||||
if (!vendor && payload.role !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Vendor profile not found' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: product, error } = await supabaseAdmin
|
||||
.from('products')
|
||||
.insert({
|
||||
vendor_id: vendor?.id || null,
|
||||
category_id: category_id || null,
|
||||
name,
|
||||
slug,
|
||||
description: description || null,
|
||||
price,
|
||||
compare_at_price: compare_at_price || null,
|
||||
images: images || [],
|
||||
inventory: inventory || 0,
|
||||
sku: sku || null,
|
||||
status: 'active'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
product
|
||||
}), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Create product error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
61
skills/ecommerce-astro/scripts/templates/src/pages/api/vendors/[slug].ts
vendored
Normal file
61
skills/ecommerce-astro/scripts/templates/src/pages/api/vendors/[slug].ts
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
try {
|
||||
const { slug } = params;
|
||||
|
||||
if (!slug) {
|
||||
return new Response(JSON.stringify({ error: 'Vendor slug is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: vendor, error } = await supabase
|
||||
.from('vendor_profiles')
|
||||
.select(`
|
||||
*,
|
||||
user:users(id, name, avatar_url)
|
||||
`)
|
||||
.eq('store_slug', slug)
|
||||
.eq('payout_status', 'approved')
|
||||
.single();
|
||||
|
||||
if (error || !vendor) {
|
||||
return new Response(JSON.stringify({ error: 'Vendor not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: products, count } = await supabase
|
||||
.from('products')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('vendor_id', vendor.id)
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
vendor: {
|
||||
...vendor,
|
||||
products_count: count || 0
|
||||
},
|
||||
products: products || []
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get vendor error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
98
skills/ecommerce-astro/scripts/templates/src/pages/api/vendors/apply.ts
vendored
Normal file
98
skills/ecommerce-astro/scripts/templates/src/pages/api/vendors/apply.ts
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabaseAdmin } from '../../../../lib/supabase';
|
||||
import { getTokenFromHeader, verifyToken } from '../../../../lib/auth';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = getTokenFromHeader(authHeader);
|
||||
const payload = verifyToken(token || '');
|
||||
|
||||
if (!payload) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { storeName, storeDescription, storePhone, bankAccount, bankName } = body;
|
||||
|
||||
if (!storeName) {
|
||||
return new Response(JSON.stringify({ error: 'Store name is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: existingVendor } = await supabaseAdmin
|
||||
.from('vendor_profiles')
|
||||
.select('id')
|
||||
.eq('user_id', payload.userId)
|
||||
.single();
|
||||
|
||||
if (existingVendor) {
|
||||
return new Response(JSON.stringify({ error: 'You already have a vendor profile' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
let storeSlug = storeName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
|
||||
const { data: existingSlug } = await supabaseAdmin
|
||||
.from('vendor_profiles')
|
||||
.select('store_slug')
|
||||
.like('store_slug', `${storeSlug}%`);
|
||||
|
||||
if (existingSlug && existingSlug.length > 0) {
|
||||
storeSlug = `${storeSlug}-${existingSlug.length + 1}`;
|
||||
}
|
||||
|
||||
const { data: vendor, error } = await supabaseAdmin
|
||||
.from('vendor_profiles')
|
||||
.insert({
|
||||
user_id: payload.userId,
|
||||
store_name: storeName,
|
||||
store_slug: storeSlug,
|
||||
store_description: storeDescription || null,
|
||||
store_phone: storePhone || null,
|
||||
bank_account: bankAccount || null,
|
||||
bank_name: bankName || null,
|
||||
payout_status: 'pending'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await supabaseAdmin
|
||||
.from('users')
|
||||
.update({ role: 'vendor' })
|
||||
.eq('id', payload.userId);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
vendor,
|
||||
message: 'Vendor application submitted successfully'
|
||||
}), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Vendor apply error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
114
skills/ecommerce-astro/scripts/templates/src/pages/api/vendors/dashboard.ts
vendored
Normal file
114
skills/ecommerce-astro/scripts/templates/src/pages/api/vendors/dashboard.ts
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabaseAdmin } from '../../../../lib/supabase';
|
||||
import { getTokenFromHeader, verifyToken } from '../../../../lib/auth';
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = getTokenFromHeader(authHeader);
|
||||
const payload = verifyToken(token || '');
|
||||
|
||||
if (!payload) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.role !== 'vendor' && payload.role !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Only vendors can access dashboard' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { data: vendor } = await supabaseAdmin
|
||||
.from('vendor_profiles')
|
||||
.select('*')
|
||||
.eq('user_id', payload.userId)
|
||||
.single();
|
||||
|
||||
if (!vendor) {
|
||||
return new Response(JSON.stringify({ error: 'Vendor profile not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { count: productsCount } = await supabaseAdmin
|
||||
.from('products')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('vendor_id', vendor.id);
|
||||
|
||||
const { data: ordersData } = await supabaseAdmin
|
||||
.from('orders')
|
||||
.select('total, status, payment_status')
|
||||
.eq('vendor_id', vendor.id);
|
||||
|
||||
const orders = ordersData || [];
|
||||
const ordersCount = orders.length;
|
||||
|
||||
let totalSales = 0;
|
||||
let pendingOrders = 0;
|
||||
let paidOrders = 0;
|
||||
let unpaidOrders = 0;
|
||||
|
||||
for (const order of orders) {
|
||||
if (order.payment_status === 'paid') {
|
||||
totalSales += order.total;
|
||||
paidOrders++;
|
||||
} else if (order.payment_status !== 'paid') {
|
||||
unpaidOrders++;
|
||||
}
|
||||
|
||||
if (order.status === 'pending' || order.status === 'confirmed' || order.status === 'processing') {
|
||||
pendingOrders++;
|
||||
}
|
||||
}
|
||||
|
||||
const { data: recentOrders } = await supabaseAdmin
|
||||
.from('orders')
|
||||
.select(`
|
||||
id, order_number, total, status, payment_status, created_at,
|
||||
user:users(name, email),
|
||||
items:order_items(product_name, quantity, price)
|
||||
`)
|
||||
.eq('vendor_id', vendor.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
const { data: recentProducts } = await supabaseAdmin
|
||||
.from('products')
|
||||
.select('id, name, slug, images, price, inventory, status')
|
||||
.eq('vendor_id', vendor.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
vendor,
|
||||
stats: {
|
||||
products: productsCount || 0,
|
||||
orders: ordersCount,
|
||||
totalSales: Math.round(totalSales * 100) / 100,
|
||||
pendingOrders,
|
||||
paidOrders,
|
||||
unpaidOrders
|
||||
},
|
||||
recentOrders: recentOrders || [],
|
||||
recentProducts: recentProducts || []
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Vendor dashboard error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
57
skills/ecommerce-astro/scripts/templates/src/pages/api/vendors/index.ts
vendored
Normal file
57
skills/ecommerce-astro/scripts/templates/src/pages/api/vendors/index.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { supabase } from '../../../../lib/supabase';
|
||||
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
try {
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
|
||||
const search = url.searchParams.get('search');
|
||||
|
||||
const from = (page - 1) * limit;
|
||||
const to = page * limit - 1;
|
||||
|
||||
let query = supabase
|
||||
.from('vendor_profiles')
|
||||
.select(`
|
||||
*,
|
||||
user:users(id, name, avatar_url, email)
|
||||
`, { count: 'exact' })
|
||||
.eq('payout_status', 'approved')
|
||||
.range(from, to);
|
||||
|
||||
if (search) {
|
||||
query = query.or(`store_name.ilike.%${search}%,store_description.ilike.%${search}%`);
|
||||
}
|
||||
|
||||
query = query.order('created_at', { ascending: false });
|
||||
|
||||
const { data: vendors, error, count } = await query;
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
vendors: vendors || [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count || 0,
|
||||
pages: Math.ceil((count || 0) / limit)
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Vendors list error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user