Files
opencode-skill/skills/ecommerce-astro/scripts/create_ecommerce.py
Kunthawat Greethong b26c8199a5 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
2026-04-16 17:40:27 +07:00

2035 lines
65 KiB
Python
Executable File

#!/usr/bin/env python3
import os
import sys
import argparse
import shutil
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
def pkg_json(name):
return (
'''{
"name": "'''
+ name
+ """",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^5.17.1",
"@astrojs/react": "^4.2.0",
"@astrojs/node": "^9.1.0",
"@astrojs/sitemap": "^3.2.0",
"@supabase/supabase-js": "^2.47.0",
"@supabase/ssr": "^0.6.1",
"@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1",
"zustand": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"jose": "^6.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0"
}
}"""
)
ASTRO_CONFIG = """import {{ defineConfig }} from 'astro/config';
import react from '@astrojs/react';
import node from '@astrojs/node';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({{
site: '{site_url}',
output: 'hybrid',
adapter: node({{ mode: 'standalone' }}),
i18n: {{
locales: [{locales}],
defaultLocale: '{default_locale}',
routing: {{
prefixDefaultLocale: false,
fallbackType: 'rewrite',
}},
}},
integrations: [
react(),
sitemap({{
i18n: {{ defaultLocale: '{default_locale}' }},
}}),
],
vite: {{
plugins: [tailwindcss()],
}},
}});
"""
TSCONFIG = """{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}"""
SUPABASE_TS = """import {{ createClient }} from '@supabase/supabase-js';
import type {{ Database }} from './types';
const supabaseUrl = import.meta.env.SUPABASE_URL;
const supabaseAnonKey = import.meta.env.SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
export type {{ Database }};
"""
AUTH_TS = r"""import { supabase } from './supabase';
import { SignJWT, jwtVerify } from 'jose';
import type { User, VendorProfile } from './types';
const JWT_SECRET = new TextEncoder().encode(
import.meta.env.JWT_SECRET || 'default-secret-change-me'
);
export interface AuthUser {
id: string;
email: string;
name: string | null;
role: 'customer' | 'vendor' | 'admin';
vendor_id?: string;
}
export async function createSessionToken(user: AuthUser): Promise<string> {
return new SignJWT({
sub: user.id,
email: user.email,
name: user.name,
role: user.role,
vendor_id: user.vendor_id,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(JWT_SECRET);
}
export async function verifySessionToken(token: string): Promise<AuthUser | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return {
id: payload.sub as string,
email: payload.email as string,
name: payload.name as string | null,
role: payload.role as 'customer' | 'vendor' | 'admin',
vendor_id: payload.vendor_id as string | undefined,
};
} catch {
return null;
}
}
export async function registerUser(
email: string,
password: string,
name: string
): Promise<{ user: User | null; error: string | null }> {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { name },
},
});
if (error) return { user: null, error: error.message };
const user = data.user;
if (!user) return { user: null, error: 'Registration failed' };
return {
user: {
id: user.id,
email: user.email || email,
name,
role: 'customer',
},
error: null,
};
}
export async function loginUser(
email: string,
password: string
): Promise<{ user: AuthUser | null; token: string | null; error: string | null }> {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) return { user: null, token: null, error: error.message };
const user = data.user;
if (!user) return { user: null, token: null, error: 'Login failed' };
const { data: profileData } = await supabase
.from('vendor_profiles')
.select('id')
.eq('user_id', user.id)
.single();
const authUser: AuthUser = {
id: user.id,
email: user.email || email,
name: user.user_metadata?.name || null,
role: profileData ? 'vendor' : 'customer',
vendor_id: profileData?.id,
};
const token = await createSessionToken(authUser);
return { user: authUser, token, error: null };
}
export async function logoutUser(): Promise<void> {
await supabase.auth.signOut();
}
"""
PAYSOS_TS = r"""export interface PaySoPayment {
merchant_id: string;
order_id: string;
amount: number;
currency: string;
description: string;
callback_url: string;
return_url: string;
customer_name?: string;
customer_email?: string;
customer_phone?: string;
}
export interface PaySoResponse {
code: string;
message: string;
data: {
payment_url: string;
qr_code?: string;
qr_image?: string;
transaction_id: string;
};
}
export interface PaySoWebhookPayload {
transaction_id: string;
order_id: string;
amount: number;
status: 'pending' | 'success' | 'failed';
timestamp: string;
signature: string;
}
export async function createPaySoPayment(payment: PaySoPayment): Promise<PaySoResponse> {
const response = await fetch('https://api.paysogateway.com/v1/payment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${import.meta.env.PAYSOLO_API_KEY}`,
},
body: JSON.stringify({
merchant_id: import.meta.env.PAYSOLO_MERCHANT_ID,
order_id: payment.order_id,
amount: payment.amount,
currency: payment.currency || 'THB',
description: payment.description,
callback_url: payment.callback_url,
return_url: payment.return_url,
customer: {
name: payment.customer_name,
email: payment.customer_email,
phone: payment.customer_phone,
},
}),
});
if (!response.ok) {
throw new Error(`PaySo API error: ${response.status}`);
}
return response.json();
}
export async function verifyPaySoSignature(
payload: PaySoWebhookPayload,
signature: string
): Promise<boolean> {
const crypto = await import('crypto');
const secret = import.meta.env.PAYSOLO_SECRET_KEY;
const data = JSON.stringify({
transaction_id: payload.transaction_id,
order_id: payload.order_id,
amount: payload.amount,
status: payload.status,
});
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(data)
.digest('hex');
return signature === expectedSignature;
}
"""
TYPES_TS = r"""export interface User {
id: string;
email: string;
name: string | null;
phone: string | null;
role: 'customer' | 'vendor' | 'admin';
avatar_url: string | null;
created_at: string;
updated_at: string;
}
export interface VendorProfile {
id: string;
user_id: string;
store_name: string;
store_slug: string;
store_description: string | null;
store_logo: string | null;
bank_account: string | null;
bank_name: string | null;
payout_status: 'pending' | 'approved' | 'rejected';
total_earnings: number;
approved_at: string | null;
created_at: string;
}
export interface Category {
id: string;
name: string;
slug: string;
description: string | null;
image_url: string | null;
parent_id: string | null;
sort_order: number;
}
export interface Product {
id: string;
vendor_id: string;
category_id: string | null;
name: string;
slug: string;
description: string | null;
price: number;
compare_at_price: number | null;
cost_price: number | null;
sku: string | null;
barcode: string | null;
inventory: number;
low_stock_threshold: number;
track_inventory: boolean;
allow_backorder: boolean;
weight: number | null;
images: string[];
metadata: Record<string, unknown>;
status: 'draft' | 'active' | 'archived';
featured: boolean;
created_at: string;
updated_at: string;
}
export interface ProductVariant {
id: string;
product_id: string;
name: string;
sku: string | null;
price: number | null;
inventory: number;
attributes: Record<string, string>;
image_url: string | null;
}
export interface Review {
id: string;
product_id: string;
user_id: string;
order_id: string | null;
rating: number;
title: string | null;
comment: string | null;
images: string[];
verified_purchase: boolean;
status: 'pending' | 'approved' | 'rejected';
created_at: string;
}
export interface Order {
id: string;
order_number: string;
user_id: string;
vendor_id: string | null;
status: 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
payment_status: 'unpaid' | 'paid' | 'failed' | 'refunded';
subtotal: number;
tax: number;
shipping_cost: number;
total: number;
currency: string;
payment_method: string | null;
payment_provider: string | null;
payment_ref: string | null;
shipping_name: string | null;
shipping_phone: string | null;
shipping_address: string | null;
shipping_city: string | null;
shipping_postal: string | null;
shipping_country: string;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface OrderItem {
id: string;
order_id: string;
product_id: string;
variant_id: string | null;
vendor_id: string | null;
quantity: number;
unit_price: number;
total_price: number;
}
export interface CartItem {
id: string;
product: Product;
variant: ProductVariant | null;
quantity: number;
}
"""
UTILS_TS = r"""export function formatPrice(amount: number, currency = 'THB'): string {
return new Intl.NumberFormat('th-TH', {
style: 'currency',
currency,
}).format(amount);
}
export function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function generateOrderNumber(): string {
const date = new Date();
const dateStr = date.toISOString().slice(0, 10).replace(/-/g, '');
const random = Math.random().toString(36).substring(2, 10).toUpperCase();
return `ORD-${dateStr}-${random}`;
}
export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ');
}
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
"""
CART_STORE = r"""import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { CartItem, Product, ProductVariant } from '../lib/types';
interface CartStore {
items: CartItem[];
addItem: (product: Product, variant?: ProductVariant, quantity?: number) => void;
removeItem: (productId: string, variantId?: string) => void;
updateQuantity: (productId: string, variantId: string | undefined, quantity: number) => void;
clearCart: () => void;
getTotal: () => number;
getItemCount: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (product, variant, quantity = 1) => {
set((state) => {
const existingIndex = state.items.findIndex(
(item) =>
item.product.id === product.id &&
item.variant?.id === variant?.id
);
if (existingIndex >= 0) {
const newItems = [...state.items];
newItems[existingIndex].quantity += quantity;
return { items: newItems };
}
return {
items: [
...state.items,
{ id: crypto.randomUUID(), product, variant: variant || null, quantity },
],
};
});
},
removeItem: (productId, variantId) => {
set((state) => ({
items: state.items.filter(
(item) =>
!(item.product.id === productId && item.variant?.id === variantId)
),
}));
},
updateQuantity: (productId, variantId, quantity) => {
if (quantity <= 0) {
get().removeItem(productId, variantId);
return;
}
set((state) => ({
items: state.items.map((item) =>
item.product.id === productId && item.variant?.id === variantId
? { ...item, quantity }
: item
),
}));
},
clearCart: () => set({ items: [] }),
getTotal: () => {
return get().items.reduce(
(total, item) => total + (item.variant?.price || item.product.price) * item.quantity,
0
);
},
getItemCount: () => {
return get().items.reduce((count, item) => count + item.quantity, 0);
},
}),
{
name: 'ecommerce-cart',
}
)
);
"""
AUTH_STORE = r"""import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { AuthUser } from '../lib/auth';
interface AuthStore {
user: AuthUser | null;
token: string | null;
isLoading: boolean;
setAuth: (user: AuthUser, token: string) => void;
logout: () => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
token: null,
isLoading: false,
setAuth: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
setLoading: (isLoading) => set({ isLoading }),
}),
{
name: 'ecommerce-auth',
}
)
);
"""
VENDOR_STORE = r"""import { create } from 'zustand';
import { supabase } from '../lib/supabase';
import type { Product, Order, VendorProfile } from '../lib/types';
interface VendorStore {
profile: VendorProfile | null;
products: Product[];
orders: Order[];
isLoading: boolean;
fetchProfile: (userId: string) => Promise<void>;
fetchProducts: (vendorId: string) => Promise<void>;
fetchOrders: (vendorId: string) => Promise<void>;
createProduct: (product: Partial<Product>) => Promise<Product | null>;
updateProduct: (id: string, updates: Partial<Product>) => Promise<void>;
updateOrderStatus: (orderId: string, status: string) => Promise<void>;
}
export const useVendorStore = create<VendorStore>((set, get) => ({
profile: null,
products: [],
orders: [],
isLoading: false,
fetchProfile: async (userId) => {
set({ isLoading: true });
const { data } = await supabase
.from('vendor_profiles')
.select('*')
.eq('user_id', userId)
.single();
set({ profile: data, isLoading: false });
},
fetchProducts: async (vendorId) => {
set({ isLoading: true });
const { data } = await supabase
.from('products')
.select('*')
.eq('vendor_id', vendorId)
.order('created_at', { ascending: false });
set({ products: data || [], isLoading: false });
},
fetchOrders: async (vendorId) => {
set({ isLoading: true });
const { data } = await supabase
.from('orders')
.select('*')
.eq('vendor_id', vendorId)
.order('created_at', { ascending: false });
set({ orders: data || [], isLoading: false });
},
createProduct: async (product) => {
const { data, error } = await supabase
.from('products')
.insert(product)
.select()
.single();
if (error) return null;
set((state) => ({ products: [data, ...state.products] }));
return data;
},
updateProduct: async (id, updates) => {
await supabase.from('products').update(updates).eq('id', id);
set((state) => ({
products: state.products.map((p) =>
p.id === id ? { ...p, ...updates } : p
),
}));
},
updateOrderStatus: async (orderId, status) => {
await supabase.from('orders').update({ status }).eq('id', orderId);
set((state) => ({
orders: state.orders.map((o) =>
o.id === orderId ? { ...o, status: status as Order['status'] } : o
),
}));
},
}));
"""
BASE_LAYOUT = """---
interface Props {{
title: string;
description?: string;
}}
const {{ title, description = '{site_name}' }} = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={{description}} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{{title}} | {site_name}</title>
</head>
<body class="bg-gray-50 text-gray-900">
<slot />
</body>
</html>
<style is:global>
@import "tailwindcss";
</style>
"""
TH_JSON = """{{
"common": {{
"home": "หน้าแรก",
"products": "สินค้า",
"cart": "ตะกร้า",
"checkout": "ชำระเงิน",
"login": "เข้าสู่ระบบ",
"register": "ลงทะเบียน",
"logout": "ออกจากระบบ"
}},
"product": {{
"addToCart": "เพิ่มลงตะกร้า",
"outOfStock": "สินค้าหมด",
"inStock": "มีสินค้า"
}},
"cart": {{
"empty": "ตะกร้าว่าง",
"total": "รวม"
}}
}}"""
EN_JSON = """{{
"common": {{
"home": "Home",
"products": "Products",
"cart": "Cart",
"checkout": "Checkout",
"login": "Login",
"register": "Register",
"logout": "Logout"
}},
"product": {{
"addToCart": "Add to Cart",
"outOfStock": "Out of Stock",
"inStock": "In Stock"
}},
"cart": {{
"empty": "Your cart is empty",
"total": "Total"
}}
}}"""
GLOBAL_CSS = """@import "tailwindcss";
@theme {
--font-sans: "Inter", system-ui, sans-serif;
--color-primary: #2563eb;
--color-secondary: #64748b;
}"""
CART_BUTTON = r"""import { useCartStore } from '../../stores/cart';
export function CartButton() {
const getItemCount = useCartStore((state) => state.getItemCount);
const count = getItemCount();
return (
<a
href="/cart"
className="relative p-2 text-gray-600 hover:text-primary transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{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>
)}
</a>
);
}"""
CART_DRAWER = r"""import { useState } from 'react';
import { useCartStore } from '../../stores/cart';
import { formatPrice } from '../../lib/utils';
import { CartItem } from './CartItem';
export function CartDrawer() {
const [isOpen, setIsOpen] = useState(false);
const items = useCartStore((state) => state.items);
const getTotal = useCartStore((state) => state.getTotal);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="p-2 text-gray-600 hover:text-primary transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</button>
{isOpen && (
<div className="fixed inset-0 z-50 overflow-hidden">
<div className="absolute inset-0 bg-black/50" onClick={() => setIsOpen(false)} />
<div className="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl">
<div className="flex h-full flex-col">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold">ตะกร้าสินค้า</h2>
<button onClick={() => setIsOpen(false)} className="p-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{items.length === 0 ? (
<p className="text-center text-gray-500 py-8">ตะกร้าว่างเปล่า</p>
) : (
<div className="space-y-4">
{items.map((item) => (
<CartItem key={item.id} item={item} />
))}
</div>
)}
</div>
{items.length > 0 && (
<div className="border-t p-4 space-y-4">
<div className="flex justify-between text-lg font-semibold">
<span>รวม</span>
<span>{formatPrice(getTotal())}</span>
</div>
<a
href="/checkout"
className="block w-full bg-primary text-white text-center py-3 rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
ชำระเงิน
</a>
</div>
)}
</div>
</div>
</div>
)}
</>
);
}"""
CART_ITEM_COMPONENT = r"""import { useCartStore } from '../../stores/cart';
import { formatPrice } from '../../lib/utils';
import type { CartItem as CartItemType } from '../../lib/types';
interface Props {
item: CartItemType;
}
export function CartItem({ item }: Props) {
const updateQuantity = useCartStore((state) => state.updateQuantity);
const removeItem = useCartStore((state) => state.removeItem);
const price = item.variant?.price || item.product.price;
return (
<div className="flex gap-4 p-4 bg-gray-50 rounded-lg">
<img
src={item.product.images[0] || '/placeholder.jpg'}
alt={item.product.name}
className="w-20 h-20 object-cover rounded"
/>
<div className="flex-1">
<h3 className="font-medium">{item.product.name}</h3>
{item.variant && (
<p className="text-sm text-gray-500">{item.variant.name}</p>
)}
<p className="font-semibold text-primary mt-1">
{formatPrice(price)}
</p>
</div>
<div className="flex flex-col items-end justify-between">
<button
onClick={() => removeItem(item.product.id, item.variant?.id)}
className="text-gray-400 hover:text-red-500"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<div className="flex items-center gap-2">
<button
onClick={() => updateQuantity(item.product.id, item.variant?.id, item.quantity - 1)}
className="w-8 h-8 rounded bg-gray-200 hover:bg-gray-300"
>
-
</button>
<span className="w-8 text-center">{item.quantity}</span>
<button
onClick={() => updateQuantity(item.product.id, item.variant?.id, item.quantity + 1)}
className="w-8 h-8 rounded bg-gray-200 hover:bg-gray-300"
>
+
</button>
</div>
</div>
</div>
);
}"""
PRODUCT_CARD = r"""import { Link } from '@astrojs/react/components';
import { formatPrice } from '../../lib/utils';
import type { Product } from '../../lib/types';
interface Props {
product: Product;
}
export function ProductCard({ product }: Props) {
const isOutOfStock = product.inventory <= 0 && !product.allow_backorder;
const isLowStock = product.inventory > 0 && product.inventory <= product.low_stock_threshold;
return (
<Link href={`/products/${product.slug}`} className="group block">
<div className="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-shadow">
<div className="aspect-square bg-gray-100 relative">
<img
src={product.images[0] || '/placeholder.jpg'}
alt={product.name}
className="w-full h-full object-cover"
/>
{isOutOfStock && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="bg-white px-3 py-1 rounded-full text-sm font-medium">
สินค้าหมด
</span>
</div>
)}
{product.featured && !isOutOfStock && (
<span className="absolute top-2 left-2 bg-primary text-white px-2 py-1 rounded text-xs font-medium">
แนะนำ
</span>
)}
</div>
<div className="p-4">
<h3 className="font-medium text-gray-900 group-hover:text-primary transition-colors line-clamp-2">
{product.name}
</h3>
<div className="mt-2 flex items-center gap-2">
<span className="text-lg font-bold text-primary">
{formatPrice(product.price)}
</span>
{product.compare_at_price && (
<span className="text-sm text-gray-400 line-through">
{formatPrice(product.compare_at_price)}
</span>
)}
</div>
{isLowStock && (
<p className="text-xs text-orange-500 mt-1">สินค้าใกล้หมด ({product.inventory} ชิ้น)</p>
)}
</div>
</div>
</Link>
);
}"""
CHECKOUT_FORM = r"""import { useState } from 'react';
import { useCartStore } from '../../stores/cart';
import { useAuthStore } from '../../stores/auth';
import { formatPrice, generateOrderNumber } from '../../lib/utils';
export function CheckoutForm() {
const items = useCartStore((state) => state.items);
const getTotal = useCartStore((state) => state.getTotal);
const clearCart = useCartStore((state) => state.clearCart);
const user = useAuthStore((state) => state.user);
const [formData, setFormData] = useState({
name: user?.name || '',
phone: '',
address: '',
city: '',
postal: '',
paymentMethod: 'qr',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const response = await fetch('/api/checkout/create-order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
items: items.map((item) => ({
product_id: item.product.id,
variant_id: item.variant?.id,
quantity: item.quantity,
unit_price: item.variant?.price || item.product.price,
})),
order_number: generateOrderNumber(),
}),
});
const data = await response.json();
if (data.payment_url) {
window.location.href = data.payment_url;
} else {
clearCart();
window.location.href = `/orders/${data.order_id}`;
}
} catch (error) {
console.error('Checkout error:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-white rounded-lg shadow-sm p-6">
<h2 className="text-lg font-semibold mb-4">ข้อมูลจัดส่ง</h2>
<div className="grid gap-4">
<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 })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">เบอร์โทรศัพท์</label>
<input
type="tel"
required
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">ที่อยู่</label>
<textarea
required
rows={3}
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">จังหวัด</label>
<input
type="text"
required
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">รหัสไปรษณีย์</label>
<input
type="text"
required
value={formData.postal}
onChange={(e) => setFormData({ ...formData, postal: e.target.value })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
/>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6">
<h2 className="text-lg font-semibold mb-4">วิธีการชำระเงิน</h2>
<div className="space-y-3">
<label className="flex items-center gap-3 p-4 border rounded-lg cursor-pointer hover:border-primary">
<input
type="radio"
name="payment"
value="qr"
checked={formData.paymentMethod === 'qr'}
onChange={(e) => setFormData({ ...formData, paymentMethod: e.target.value })}
className="text-primary"
/>
<span className="font-medium">QR Code</span>
<span className="text-sm text-gray-500">ชำระผ่าน QR ที่ธนาคาร</span>
</label>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6">
<h2 className="text-lg font-semibold mb-4">สรุปคำสั่งซื้อ</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>ราคารวม ({items.length} รายการ)</span>
<span>{formatPrice(getTotal())}</span>
</div>
<div className="flex justify-between">
<span>ค่าจัดส่ง</span>
<span>฿0</span>
</div>
<div className="border-t pt-2 mt-2 flex justify-between font-semibold text-lg">
<span>รวมทั้งสิ้น</span>
<span className="text-primary">{formatPrice(getTotal())}</span>
</div>
</div>
</div>
<button
type="submit"
disabled={isSubmitting || items.length === 0}
className="w-full bg-primary text-white py-4 rounded-lg font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{isSubmitting ? 'กำลังประมวลผล...' : 'ยืนยันคำสั่งซื้อ'}
</button>
</form>
);
}"""
VENDOR_DASHBOARD = r"""import { useVendorStore } from '../../stores/vendor';
import { formatPrice } from '../../lib/utils';
import { Link } from '@astrojs/react/components';
export function VendorDashboard() {
const { profile, products, orders, isLoading } = useVendorStore();
if (isLoading) {
return <div className="text-center py-8">กำลังโหลด...</div>;
}
const pendingOrders = orders.filter((o) => o.status === 'pending' || o.status === 'confirmed');
const totalRevenue = orders
.filter((o) => o.payment_status === 'paid')
.reduce((sum, o) => sum + o.total, 0);
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-6 rounded-lg shadow-sm">
<h3 className="text-gray-500 text-sm">คำสั่งซื้อรอดำเนินการ</h3>
<p className="text-2xl font-bold text-primary">{pendingOrders.length}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm">
<h3 className="text-gray-500 text-sm">สินค้าทั้งหมด</h3>
<p className="text-2xl font-bold text-primary">{products.length}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm">
<h3 className="text-gray-500 text-sm">รายได้ทั้งหมด</h3>
<p className="text-2xl font-bold text-primary">{formatPrice(totalRevenue)}</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">คำสั่งซื้อล่าสุด</h2>
<Link href="/vendor/orders" className="text-primary text-sm hover:underline">
ดูทั้งหมด
</Link>
</div>
{orders.length === 0 ? (
<p className="text-gray-500 text-center py-4">ยังไม่มีคำสั่งซื้อ</p>
) : (
<div className="space-y-3">
{orders.slice(0, 5).map((order) => (
<div key={order.id} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<div>
<p className="font-medium">{order.order_number}</p>
<p className="text-sm text-gray-500">{order.created_at}</p>
</div>
<div className="text-right">
<p className="font-semibold">{formatPrice(order.total)}</p>
<span className={`inline-block px-2 py-0.5 rounded text-xs ${
order.status === 'pending' ? 'bg-yellow-100 text-yellow-700' :
order.status === 'confirmed' ? 'bg-blue-100 text-blue-700' :
order.status === 'shipped' ? 'bg-purple-100 text-purple-700' :
'bg-green-100 text-green-700'
}`}>
{order.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}"""
PRODUCT_FORM = r"""import { useState } from 'react';
import { useVendorStore } from '../../stores/vendor';
import { generateSlug } from '../../lib/utils';
interface Props {
product?: Partial<import('../../lib/types').Product>;
onSuccess?: () => void;
}
export function ProductForm({ product, onSuccess }: Props) {
const createProduct = useVendorStore((state) => state.createProduct);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: product?.name || '',
slug: product?.slug || '',
description: product?.description || '',
price: product?.price || 0,
compare_at_price: product?.compare_at_price || 0,
sku: product?.sku || '',
inventory: product?.inventory || 0,
low_stock_threshold: product?.low_stock_threshold || 5,
images: product?.images || [],
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const slug = formData.slug || generateSlug(formData.name);
await createProduct({
...formData,
slug,
status: 'draft',
});
onSuccess?.();
} catch (error) {
console.error('Error creating product:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<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 })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">รายละเอียด</label>
<textarea
rows={4}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">ราคา (บาท)</label>
<input
type="number"
required
min="0"
step="0.01"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary"
/>
</div>
<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 })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">จำนวนในสต็อก</label>
<input
type="number"
min="0"
value={formData.inventory}
onChange={(e) => setFormData({ ...formData, inventory: parseInt(e.target.value) })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">แจ้งเตือนสต็อกต่ำ</label>
<input
type="number"
min="0"
value={formData.low_stock_threshold}
onChange={(e) => setFormData({ ...formData, low_stock_threshold: parseInt(e.target.value) })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-primary text-white py-3 rounded-lg font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{isSubmitting ? 'กำลังบันทึก...' : 'บันทึกสินค้า'}
</button>
</form>
);
}"""
INDEX_PAGE = """---
import BaseLayout from '../layouts/BaseLayout.astro';
import { ProductCard } from '../components/product/ProductCard';
import { supabase } from '../lib/supabase';
const {{ data: products }} = await supabase
.from('products')
.select('*')
.eq('status', 'active')
.order('created_at', {{ ascending: false }})
.limit(12);
---
<BaseLayout title="หน้าแรก">
<section class="bg-gradient-to-r from-primary to-primary/80 text-white py-20">
<div class="container mx-auto px-4 text-center">
<h1 class="text-4xl md:text-5xl font-bold mb-4">{site_name}</h1>
<p class="text-xl text-white/80 mb-8">สินค้าคุณภาพ ราคาดีที่สุด</p>
<a href="/products" class="inline-block bg-white text-primary px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 transition-colors">
ดูสินค้าทั้งหมด
</a>
</div>
</section>
<section class="py-12 container mx-auto px-4">
<h2 class="text-2xl font-bold mb-8">สินค้าแนะนำ</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products?.map((product) => (
<ProductCard client:load product={product} />
))}
</div>
</section>
</BaseLayout>
"""
CHECKOUT_PAGE = """---
import BaseLayout from '../layouts/BaseLayout.astro';
import { CheckoutForm } from '../components/checkout/CheckoutForm';
---
<BaseLayout title="ชำระเงิน">
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-8">ชำระเงิน</h1>
<CheckoutForm client:load />
</div>
</BaseLayout>
"""
API_AUTH_LOGIN = """import type {{ APIRoute }} from 'astro';
import {{ loginUser }} from '../../../lib/auth';
export const POST: APIRoute = async ({{ request }}) => {{
try {{
const {{ email, password }} = await request.json();
if (!email || !password) {{
return new Response(JSON.stringify({{ error: 'Email and password required' }}), {{
status: 400,
headers: {{ 'Content-Type': 'application/json' }},
}});
}}
const {{ user, token, error }} = await loginUser(email, password);
if (error) {{
return new Response(JSON.stringify({{ error }}), {{
status: 401,
headers: {{ 'Content-Type': 'application/json' }},
}});
}}
return new Response(JSON.stringify({{ user, token }}), {{
status: 200,
headers: {{ 'Content-Type': 'application/json' }},
}});
}} catch (error) {{
return new Response(JSON.stringify({{ error: 'Internal server error' }}), {{
status: 500,
headers: {{ 'Content-Type': 'application/json' }},
}});
}}
}};
"""
API_CHECKOUT = """import type {{ APIRoute }} from 'astro';
import {{ supabase }} from '../../../lib/supabase';
import {{ createPaySoPayment }} from '../../../lib/payso';
export const POST: APIRoute = async ({{ request }}) => {{
try {{
const body = await request.json();
const {{ items, order_number, name, phone, address, city, postal, paymentMethod }} = body;
const subtotal = items.reduce(
(sum: number, item: {{ unit_price: number; quantity: number }}) => sum + item.unit_price * item.quantity,
0
);
const {{ data: userData }} = await supabase.auth.getUser();
const userId = userData?.user?.id;
const {{ data: order, error: orderError }} = await supabase
.from('orders')
.insert({{
order_number,
user_id: userId,
subtotal,
tax: 0,
shipping_cost: 0,
total: subtotal,
shipping_name: name,
shipping_phone: phone,
shipping_address: address,
shipping_city: city,
shipping_postal: postal,
status: 'pending',
payment_status: 'unpaid',
payment_method: paymentMethod,
}})
.select()
.single();
if (orderError) {{
return new Response(JSON.stringify({{ error: orderError.message }}), {{
status: 500,
headers: {{ 'Content-Type': 'application/json' }},
}});
}}
for (const item of items) {{
await supabase.from('order_items').insert({{
order_id: order.id,
product_id: item.product_id,
variant_id: item.variant_id || null,
quantity: item.quantity,
unit_price: item.unit_price,
total_price: item.unit_price * item.quantity,
}});
}}
if (paymentMethod === 'qr') {{
const payment = await createPaySoPayment({{
merchant_id: import.meta.env.PAYSOLO_MERCHANT_ID,
order_id: order.id,
amount: subtotal,
currency: 'THB',
description: `Order ${{order_number}}`,
callback_url: `${{import.meta.env.SITE_URL}}/api/webhooks/payso`,
return_url: `${{import.meta.env.SITE_URL}}/orders/${{order.id}}`,
customer_name: name,
customer_phone: phone,
}});
if (payment.data?.payment_url) {{
return new Response(JSON.stringify({{
order_id: order.id,
payment_url: payment.data.payment_url,
}}), {{
status: 200,
headers: {{ 'Content-Type': 'application/json' }},
}});
}}
}}
return new Response(JSON.stringify({{ order_id: order.id }}), {{
status: 200,
headers: {{ 'Content-Type': 'application/json' }},
}});
}} catch (error) {{
return new Response(JSON.stringify({{ error: 'Internal server error' }}), {{
status: 500,
headers: {{ 'Content-Type': 'application/json' }},
}});
}}
}};
"""
API_PAYSO_WEBHOOK = """import type {{ APIRoute }} from 'astro';
import {{ supabase }} from '../../../../lib/supabase';
import {{ verifyPaySoSignature }} from '../../../../lib/payso';
export const POST: APIRoute = async ({{ request }}) => {{
try {{
const payload = await request.json();
const signature = request.headers.get('x-payso-signature') || '';
const isValid = await verifyPaySoSignature(payload, signature);
if (!isValid) {{
return new Response(JSON.stringify({{ error: 'Invalid signature' }}), {{
status: 401,
headers: {{ 'Content-Type': 'application/json' }},
}});
}}
const {{ transaction_id, order_id, amount, status }} = payload;
if (status === 'success') {{
await supabase
.from('orders')
.update({{ payment_status: 'paid', payment_ref: transaction_id }})
.eq('id', order_id);
await supabase.from('payments').insert({{
order_id,
provider: 'payso',
provider_ref: transaction_id,
amount,
status: 'completed',
paid_at: new Date().toISOString(),
}});
}} else if (status === 'failed') {{
await supabase
.from('orders')
.update({{ payment_status: 'failed' }})
.eq('id', order_id);
}}
return new Response(JSON.stringify({{ success: true }}), {{
status: 200,
headers: {{ 'Content-Type': 'application/json' }},
}});
}} catch (error) {{
return new Response(JSON.stringify({{ error: 'Internal server error' }}), {{
status: 500,
headers: {{ 'Content-Type': 'application/json' }},
}});
}}
}};
"""
DOCKERFILE = """FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 4321
CMD ["npm", "run", "preview"]
"""
DOCKER_COMPOSE = """services:
web:
build: .
ports:
- "4321:4321"
environment:
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
- PAYSOLO_MERCHANT_ID=${PAYSOLO_MERCHANT_ID}
- PAYSOLO_API_KEY=${PAYSOLO_API_KEY}
- SITE_URL=${SITE_URL}
env_file:
- .env
"""
GITIGNORE = """node_modules/
dist/
.env
.env.*
!.env.example
.DS_Store
"""
HEADER_COMPONENT = """---
import {{ CartButton }} from '../components/cart/CartButton';
---
<header class="bg-white shadow-sm sticky top-0 z-40">
<nav class="container mx-auto px-4">
<div class="flex items-center justify-between h-16">
<a href="/" class="text-xl font-bold text-primary">LOGO</a>
<div class="hidden md:flex items-center gap-6">
<a href="/" class="text-gray-600 hover:text-primary">หน้าแรก</a>
<a href="/products" class="text-gray-600 hover:text-primary">สินค้า</a>
<a href="/cart" class="text-gray-600 hover:text-primary">ตะกร้า</a>
</div>
<div class="flex items-center gap-4">
<CartButton client:load />
<a href="/auth/login" class="text-gray-600 hover:text-primary">เข้าสู่ระบบ</a>
</div>
</div>
</nav>
</header>
"""
FOOTER_COMPONENT = """<footer class="bg-gray-900 text-white py-12 mt-auto">
<div class="container mx-auto px-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<div>
<h3 class="font-bold text-lg mb-4">{site_name}</h3>
<p class="text-gray-400">สินค้าคุณภาพ ราคาดีที่สุด</p>
</div>
<div>
<h4 class="font-semibold mb-4">บริการ</h4>
<ul class="space-y-2 text-gray-400">
<li><a href="/products" class="hover:text-white">สินค้าทั้งหมด</a></li>
<li><a href="/cart" class="hover:text-white">ตะกร้า</a></li>
<li><a href="/checkout" class="hover:text-white">ชำระเงิน</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4">บัญชี</h4>
<ul class="space-y-2 text-gray-400">
<li><a href="/auth/login" class="hover:text-white">เข้าสู่ระบบ</a></li>
<li><a href="/auth/register" class="hover:text-white">ลงทะเบียน</a></li>
<li><a href="/orders" class="hover:text-white">คำสั่งซื้อ</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4">ติดต่อ</h4>
<p class="text-gray-400">อีเมล: info@example.com</p>
<p class="text-gray-400">โทร: 02-xxx-xxxx</p>
</div>
</div>
<div class="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<p>&copy; 2024 {site_name}. All rights reserved.</p>
</div>
</div>
</footer>
"""
LOGIN_PAGE = """---
import BaseLayout from '../../layouts/BaseLayout.astro';
---
<BaseLayout title="เข้าสู่ระบบ">
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div class="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<h1 class="text-2xl font-bold text-center mb-8">เข้าสู่ระบบ</h1>
<form id="login-form" class="space-y-6">
<div>
<label class="block text-sm font-medium mb-2">อีเมล</label>
<input
type="email"
id="email"
required
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">รหัสผ่าน</label>
<input
type="password"
id="password"
required
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
/>
</div>
<button
type="submit"
id="submit-btn"
class="w-full bg-primary text-white py-3 rounded-lg font-semibold hover:bg-primary/90 transition-colors"
>
เข้าสู่ระบบ
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-600">
ยังไม่มีบัญชี? <a href="/auth/register" class="text-primary hover:underline">ลงทะเบียน</a>
</p>
</div>
</div>
</BaseLayout>
<script>
const form = document.getElementById('login-form') as HTMLFormElement;
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement;
form.addEventListener('submit', async (e) => {{
e.preventDefault();
submitBtn.disabled = true;
submitBtn.textContent = 'กำลังเข้าสู่ระบบ...';
const email = (document.getElementById('email') as HTMLInputElement).value;
const password = (document.getElementById('password') as HTMLInputElement).value;
try {{
const response = await fetch('/api/auth/login', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ email, password }}),
}});
const data = await response.json();
if (response.ok && data.token) {{
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(data.user));
window.location.href = '/';
}} else {{
alert(data.error || 'Login failed');
submitBtn.disabled = false;
submitBtn.textContent = 'เข้าสู่ระบบ';
}}
}} catch (error) {{
alert('An error occurred');
submitBtn.disabled = false;
submitBtn.textContent = 'เข้าสู่ระบบ';
}}
}});
</script>
"""
CART_PAGE = """---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="ตะกร้าสินค้า">
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-8">ตะกร้าสินค้า</h1>
<div id="cart-container">
<p class="text-center text-gray-500 py-8">กำลังโหลด...</p>
</div>
</div>
</BaseLayout>
<script>
import {{ create }} from 'zustand';
import {{ persist }} from 'zustand/middleware';
import {{ formatPrice }} from '../lib/utils';
interface CartItem {{
id: string;
product: any;
variant: any;
quantity: number;
}}
interface CartStore {{
items: CartItem[];
addItem: (product: any, variant: any, quantity?: number) => void;
removeItem: (productId: string, variantId?: string) => void;
updateQuantity: (productId: string, variantId: string | undefined, quantity: number) => void;
getTotal: () => number;
}}
const useCartStore = create<CartStore>()(
persist(
(set, get) => ({{
items: [],
addItem: (product, variant, quantity = 1) => {{
set((state) => {{
const existingIndex = state.items.findIndex(
(item) => item.product.id === product.id && item.variant?.id === variant?.id
);
if (existingIndex >= 0) {{
const newItems = [...state.items];
newItems[existingIndex].quantity += quantity;
return {{ items: newItems }};
}}
return {{ items: [...state.items, {{ id: crypto.randomUUID(), product, variant: variant || null, quantity }}] }};
}});
}},
removeItem: (productId, variantId) => {{
set((state) => ({{
items: state.items.filter((item) => !(item.product.id === productId && item.variant?.id === variantId)),
}}));
}},
updateQuantity: (productId, variantId, quantity) => {{
if (quantity <= 0) {{
get().removeItem(productId, variantId);
return;
}}
set((state) => ({{
items: state.items.map((item) =>
item.product.id === productId && item.variant?.id === variantId
? {{ ...item, quantity }}
: item
),
}}));
}},
getTotal: () => get().items.reduce((total: number, item: CartItem) => total + (item.variant?.price || item.product.price) * item.quantity, 0),
}}),
{{ name: 'ecommerce-cart' }}
)
);
const cart = useCartStore.getState();
const container = document.getElementById('cart-container');
if (cart.items.length === 0) {{
container.innerHTML = '<p class="text-center text-gray-500 py-8">ตะกร้าว่างเปล่า</p>';
}} else {{
container.innerHTML = `
<div class="space-y-4">
${{cart.items.map((item: CartItem) => `
<div class="flex gap-4 p-4 bg-white rounded-lg shadow-sm" data-item-id="${{item.id}}">
<img src="${{item.product.images[0] || '/placeholder.jpg'}}" alt="${{item.product.name}}" class="w-24 h-24 object-cover rounded" />
<div class="flex-1">
<h3 class="font-medium">${{item.product.name}}</h3>
${{item.variant ? `<p class="text-sm text-gray-500">${{item.variant.name}}</p>` : ''}}
<p class="font-semibold text-primary mt-1">${{formatPrice(item.variant?.price || item.product.price)}}</p>
</div>
<div class="flex flex-col items-end justify-between">
<button onclick="removeItem('${{item.product.id}}', '${{item.variant?.id}}')" class="text-gray-400 hover:text-red-500">
ลบ
</button>
<div class="flex items-center gap-2">
<button onclick="updateQty('${{item.product.id}}', '${{item.variant?.id}}', -1)" class="w-8 h-8 rounded bg-gray-200">-</button>
<span class="w-8 text-center">${{item.quantity}}</span>
<button onclick="updateQty('${{item.product.id}}', '${{item.variant?.id}}', 1)" class="w-8 h-8 rounded bg-gray-200">+</button>
</div>
</div>
</div>
`).join('')}}
</div>
<div class="mt-8 flex justify-between items-center">
<h3 class="text-xl font-semibold">รวม: ${{formatPrice(cart.getTotal())}}</h3>
<a href="/checkout" class="bg-primary text-white px-8 py-3 rounded-lg font-semibold hover:bg-primary/90">
ชำระเงิน
</a>
</div>
`;
}}
(window as any).removeItem = (productId: string, variantId: string) => {{
useCartStore.getState().removeItem(productId, variantId || undefined);
location.reload();
}};
(window as any).updateQty = (productId: string, variantId: string, delta: number) => {{
const item = cart.items.find((i: CartItem) => i.product.id === productId && i.variant?.id === variantId);
if (item) {{
useCartStore.getState().updateQuantity(productId, variantId || undefined, item.quantity + delta);
location.reload();
}}
}};
</script>
"""
def create_project(args):
output_path = Path(args.output)
project_name = args.name.lower().replace(" ", "-")
site_url = f"https://{project_name}.moreminimore.com"
languages = [lang.strip() for lang in args.languages.split(",")]
default_locale = "en" if "en" in languages else languages[0]
print(f"Creating e-commerce project: {args.name}")
print(f"Output: {args.output}")
dirs = [
output_path / "supabase" / "migrations",
output_path / "src" / "components" / "cart",
output_path / "src" / "components" / "checkout",
output_path / "src" / "components" / "product",
output_path / "src" / "components" / "vendor",
output_path / "src" / "components" / "ui",
output_path / "src" / "layouts",
output_path / "src" / "lib",
output_path / "src" / "stores",
output_path / "src" / "pages" / "products",
output_path / "src" / "pages" / "cart",
output_path / "src" / "pages" / "checkout",
output_path / "src" / "pages" / "orders",
output_path / "src" / "pages" / "auth",
output_path / "src" / "pages" / "vendor" / "products",
output_path / "src" / "pages" / "vendor" / "orders",
output_path / "src" / "pages" / "api" / "auth",
output_path / "src" / "pages" / "api" / "checkout",
output_path / "src" / "pages" / "api" / "webhooks",
output_path / "src" / "i18n",
output_path / "src" / "styles",
output_path / "public" / "images",
]
for d in dirs:
d.mkdir(parents=True, exist_ok=True)
print(" ✓ Directory structure created")
for lang in languages:
(output_path / "src" / "pages" / lang).mkdir(exist_ok=True)
locales_str = ", ".join([f"'{lang}'" for lang in languages])
config = ASTRO_CONFIG.format(
site_url=site_url,
locales=locales_str,
default_locale=default_locale,
)
(output_path / "astro.config.mjs").write_text(config)
package = pkg_json(project_name)
(output_path / "package.json").write_text(package)
(output_path / "tsconfig.json").write_text(TSCONFIG)
migration_src = SCRIPT_DIR / "supabase_migration.sql"
if migration_src.exists():
shutil.copy(
migration_src,
output_path / "supabase" / "migrations" / "001_initial_schema.sql",
)
lib_files = {
"supabase.ts": SUPABASE_TS,
"auth.ts": AUTH_TS,
"payso.ts": PAYSOS_TS,
"types.ts": TYPES_TS,
"utils.ts": UTILS_TS,
}
for filename, content in lib_files.items():
(output_path / "src" / "lib" / filename).write_text(content)
stores_files = {
"cart.ts": CART_STORE,
"auth.ts": AUTH_STORE,
"vendor.ts": VENDOR_STORE,
}
for filename, content in stores_files.items():
(output_path / "src" / "stores" / filename).write_text(content)
components = {
"cart/CartButton.tsx": CART_BUTTON,
"cart/CartDrawer.tsx": CART_DRAWER,
"cart/CartItem.tsx": CART_ITEM_COMPONENT,
"product/ProductCard.tsx": PRODUCT_CARD,
"checkout/CheckoutForm.tsx": CHECKOUT_FORM,
"vendor/VendorDashboard.tsx": VENDOR_DASHBOARD,
"vendor/ProductForm.tsx": PRODUCT_FORM,
}
for path, content in components.items():
(output_path / "src" / "components" / path).write_text(content)
(output_path / "src" / "styles" / "global.css").write_text(GLOBAL_CSS)
base_layout = BASE_LAYOUT.format(site_name=args.name)
(output_path / "src" / "layouts" / "BaseLayout.astro").write_text(base_layout)
header = HEADER_COMPONENT.format(site_name=args.name)
(output_path / "src" / "components" / "Header.astro").write_text(header)
footer = FOOTER_COMPONENT.format(site_name=args.name)
(output_path / "src" / "components" / "Footer.astro").write_text(footer)
(output_path / "src" / "i18n" / "th.json").write_text(TH_JSON)
if len(languages) > 1:
(output_path / "src" / "i18n" / "en.json").write_text(EN_JSON)
pages = {
"index.astro": INDEX_PAGE,
"cart.astro": CART_PAGE,
"checkout.astro": CHECKOUT_PAGE,
"auth/login.astro": LOGIN_PAGE,
"api/auth/login.ts": API_AUTH_LOGIN,
"api/checkout/create-order.ts": API_CHECKOUT,
"api/webhooks/payso.ts": API_PAYSO_WEBHOOK,
}
for path, content in pages.items():
(output_path / "src" / "pages" / path).write_text(content)
(output_path / "Dockerfile").write_text(DOCKERFILE)
(output_path / "docker-compose.yml").write_text(DOCKER_COMPOSE)
(output_path / ".gitignore").write_text(GITIGNORE)
env_content = f"""# Supabase
SUPABASE_URL={args.supabase_url or "https://xxx.supabase.co"}
SUPABASE_ANON_KEY={args.supabase_key or "your-anon-key"}
SUPABASE_SERVICE_ROLE_KEY={args.supabase_service_key or "your-service-key"}
# PaySo Payment
PAYSOLO_MERCHANT_ID={args.paysolo_merchant_id or "your-merchant-id"}
PAYSOLO_API_KEY={args.paysolo_api_key or "your-api-key"}
PAYSOLO_SECRET_KEY={args.paysolo_secret_key or "your-secret-key"}
PAYSOLO_CALLBACK_URL={args.callback_url or f"{site_url}/api/webhooks/payso"}
# JWT
JWT_SECRET={args.jwt_secret or "change-this-to-a-secure-secret-key"}
# Site
SITE_URL={site_url}
SITE_NAME={args.name}
"""
(output_path / ".env.example").write_text(env_content)
(output_path / ".env").write_text(env_content)
(output_path / "public" / "favicon.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🛒</text></svg>'
)
print(" ✓ Configuration files created")
print(" ✓ Supabase client and types created")
print(" ✓ Auth and PaySo integration created")
print(" ✓ Zustand stores created")
print(" ✓ React components created")
print(" ✓ Pages created")
print(" ✓ i18n translations created")
print(" ✓ Dockerfile created")
return output_path
def main():
parser = argparse.ArgumentParser(description="Create e-commerce Astro site")
parser.add_argument("--name", required=True, help="Store name")
parser.add_argument("--slug", default="", help="Store slug (URL friendly)")
parser.add_argument(
"--languages", default="th,en", help="Languages (comma-separated)"
)
parser.add_argument("--output", "-o", required=True, help="Output directory")
parser.add_argument("--supabase-url", default="", help="Supabase project URL")
parser.add_argument("--supabase-key", default="", help="Supabase anon key")
parser.add_argument(
"--supabase-service-key", default="", help="Supabase service role key"
)
parser.add_argument("--paysolo-merchant-id", default="", help="PaySo merchant ID")
parser.add_argument("--paysolo-api-key", default="", help="PaySo API key")
parser.add_argument("--paysolo-secret-key", default="", help="PaySo secret key")
parser.add_argument("--callback-url", default="", help="PaySo callback URL")
parser.add_argument("--jwt-secret", default="", help="JWT secret key")
args = parser.parse_args()
if not args.slug:
args.slug = args.name.lower().replace(" ", "-")
output_path = create_project(args)
print(f"\n✅ E-commerce site created at: {output_path}")
print("\nNext steps:")
print(f" 1. cd {output_path}")
print(" 2. npm install")
print(" 3. Run the Supabase migration in your dashboard:")
print(f" supabase/migrations/001_initial_schema.sql")
print(" 4. Update .env with your credentials")
print(" 5. npm run dev")
if __name__ == "__main__":
main()