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
2035 lines
65 KiB
Python
Executable File
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>© 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()
|