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:
2026-04-16 17:40:27 +07:00
parent 5053ccdba2
commit b26c8199a5
562 changed files with 59030 additions and 37600 deletions

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
requests>=2.28.0
python-dotenv>=1.0.0
jinja2>=3.1.0

View 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);
*/

View 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"]

View 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:

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>&copy; {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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
});

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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"
}
}

View 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": "นโยบายการคืนสินค้า"
}
}

View File

@@ -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>

View 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;
}
}

View 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[];
}

View 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);
}

View 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');
}

View 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;
}

View 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' };
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' }
});
}
};

View File

@@ -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' }
});
}
};

View File

@@ -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' }
});
}
};

View File

@@ -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' }
});
}
};

View File

@@ -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' }
});
}
};

View File

@@ -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' }
});
}
};

View File

@@ -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' }
});
}
};

View File

@@ -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' }
});
};

View File

@@ -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' }
});
}
};

View File

@@ -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' }
});
}
};

View File

@@ -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' }
});
}
};

View File

@@ -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' }
});
}
};

View 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' }
});
}
};

View 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' }
});
}
};

View 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' }
});
}
};

View 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