import { useState, useEffect, useRef } from "react"; // ─── Design Tokens ──────────────────────────────────────────────────────────── const T = { bg: "#0A0C10", surface: "#111418", card: "#161B22", border: "#21262D", accent: "#F97316", accentDim: "#7C3910", green: "#22C55E", red: "#EF4444", blue: "#3B82F6", yellow: "#EAB308", text: "#E6EDF3", muted: "#7D8590", subtle: "#30363D", }; const css = ` @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Sans:wght@300;400;500&display=swap'); * { box-sizing: border-box; margin: 0; padding: 0; } body { background: ${T.bg}; color: ${T.text}; font-family: 'DM Sans', sans-serif; } ::-webkit-scrollbar { width: 4px; } ::-webkit-scrollbar-track { background: ${T.surface}; } ::-webkit-scrollbar-thumb { background: ${T.subtle}; border-radius: 2px; } .app { min-height: 100vh; display: flex; flex-direction: column; } .topbar { background: ${T.surface}; border-bottom: 1px solid ${T.border}; padding: 0 20px; display: flex; align-items: center; justify-content: space-between; height: 56px; position: sticky; top: 0; z-index: 100; } .logo { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 20px; color: ${T.accent}; display: flex; align-items: center; gap: 8px; } .logo span { color: ${T.text}; } .tabs { display: flex; gap: 4px; } .tab { padding: 6px 16px; border-radius: 8px; border: none; cursor: pointer; font-family: 'DM Sans'; font-size: 14px; font-weight: 500; transition: all .2s; background: transparent; color: ${T.muted}; } .tab.active { background: ${T.accent}; color: #fff; } .tab:hover:not(.active) { background: ${T.subtle}; color: ${T.text}; } /* Auth */ .auth-wrap { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; } .auth-card { background: ${T.card}; border: 1px solid ${T.border}; border-radius: 20px; padding: 40px; width: 100%; max-width: 440px; } .auth-title { font-family: 'Syne', sans-serif; font-size: 28px; font-weight: 800; margin-bottom: 6px; } .auth-sub { color: ${T.muted}; font-size: 14px; margin-bottom: 32px; } .field { margin-bottom: 16px; } .field label { display: block; font-size: 12px; font-weight: 500; color: ${T.muted}; margin-bottom: 6px; letter-spacing: .5px; text-transform: uppercase; } .field input, .field select, .field textarea { width: 100%; background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 10px; padding: 10px 14px; color: ${T.text}; font-family: 'DM Sans'; font-size: 14px; outline: none; transition: border .2s; } .field input:focus, .field select:focus, .field textarea:focus { border-color: ${T.accent}; } .field select option { background: ${T.surface}; } .btn { width: 100%; padding: 12px; background: ${T.accent}; color: #fff; border: none; border-radius: 10px; font-family: 'Syne', sans-serif; font-size: 15px; font-weight: 700; cursor: pointer; transition: opacity .2s; margin-top: 8px; } .btn:hover { opacity: .88; } .btn.secondary { background: ${T.subtle}; color: ${T.text}; } .btn.green { background: ${T.green}; } .btn.red { background: ${T.red}; } .btn.sm { width: auto; padding: 7px 16px; font-size: 13px; margin-top: 0; border-radius: 8px; } .auth-toggle { text-align: center; margin-top: 20px; font-size: 13px; color: ${T.muted}; } .auth-toggle button { background: none; border: none; color: ${T.accent}; cursor: pointer; font-family: 'DM Sans'; font-size: 13px; text-decoration: underline; } .role-pick { display: flex; gap: 12px; margin-bottom: 24px; } .role-btn { flex: 1; padding: 14px; background: ${T.surface}; border: 2px solid ${T.border}; border-radius: 12px; cursor: pointer; text-align: center; transition: all .2s; color: ${T.text}; } .role-btn.active { border-color: ${T.accent}; background: rgba(249,115,22,.08); } .role-btn .icon { font-size: 28px; margin-bottom: 6px; } .role-btn .label { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 14px; } /* Layout */ .main { flex: 1; display: grid; grid-template-columns: 260px 1fr; min-height: calc(100vh - 56px); } .sidebar { background: ${T.surface}; border-right: 1px solid ${T.border}; padding: 20px 0; display: flex; flex-direction: column; gap: 2px; } .nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; cursor: pointer; font-size: 14px; color: ${T.muted}; border-radius: 0; transition: all .2s; border-left: 3px solid transparent; } .nav-item.active { color: ${T.accent}; border-left-color: ${T.accent}; background: rgba(249,115,22,.06); } .nav-item:hover:not(.active) { color: ${T.text}; background: rgba(255,255,255,.03); } .nav-icon { font-size: 17px; width: 20px; text-align: center; } .content { overflow-y: auto; padding: 28px; } /* Cards */ .page-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 26px; margin-bottom: 4px; } .page-sub { color: ${T.muted}; font-size: 14px; margin-bottom: 28px; } .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } .stat-card { background: ${T.card}; border: 1px solid ${T.border}; border-radius: 16px; padding: 20px; } .stat-label { font-size: 12px; color: ${T.muted}; letter-spacing: .5px; text-transform: uppercase; margin-bottom: 8px; } .stat-value { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 28px; } .stat-value.accent { color: ${T.accent}; } .stat-value.green { color: ${T.green}; } .stat-value.blue { color: ${T.blue}; } .card { background: ${T.card}; border: 1px solid ${T.border}; border-radius: 16px; padding: 20px; margin-bottom: 16px; } .card-title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } /* Products */ .product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; } .product-card { background: ${T.card}; border: 1px solid ${T.border}; border-radius: 14px; overflow: hidden; cursor: pointer; transition: all .2s; } .product-card:hover { border-color: ${T.accent}; transform: translateY(-2px); } .product-img { width: 100%; height: 120px; object-fit: cover; background: ${T.subtle}; display: flex; align-items: center; justify-content: center; font-size: 40px; } .product-info { padding: 12px; } .product-name { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 14px; margin-bottom: 4px; } .product-price { color: ${T.accent}; font-weight: 700; font-size: 15px; } .product-desc { color: ${T.muted}; font-size: 12px; margin-top: 4px; } /* Cart */ .cart-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid ${T.border}; } .qty-ctrl { display: flex; align-items: center; gap: 8px; } .qty-btn { width: 28px; height: 28px; border-radius: 8px; border: 1px solid ${T.border}; background: ${T.surface}; color: ${T.text}; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; transition: all .2s; } .qty-btn:hover { border-color: ${T.accent}; color: ${T.accent}; } /* Orders */ .order-card { background: ${T.card}; border: 1px solid ${T.border}; border-radius: 14px; padding: 16px; margin-bottom: 12px; } .order-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .order-id { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 14px; } .badge { padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; } .badge.pending { background: rgba(234,179,8,.15); color: ${T.yellow}; } .badge.active { background: rgba(249,115,22,.15); color: ${T.accent}; } .badge.delivered { background: rgba(34,197,94,.15); color: ${T.green}; } .badge.cancelled { background: rgba(239,68,68,.15); color: ${T.red}; } .order-meta { display: flex; gap: 16px; font-size: 13px; color: ${T.muted}; } /* Map */ .map-mock { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 14px; height: 300px; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; } .map-bg { position: absolute; inset: 0; opacity: .08; background-image: linear-gradient(${T.muted} 1px, transparent 1px), linear-gradient(90deg, ${T.muted} 1px, transparent 1px); background-size: 40px 40px; } .map-route { position: absolute; left: 20%; top: 30%; width: 60%; height: 40%; border: 3px dashed ${T.accent}; border-radius: 50%; opacity: .4; } .map-pin { position: absolute; font-size: 28px; cursor: pointer; transition: transform .2s; } .map-pin:hover { transform: scale(1.2); } .map-pin.start { left: 18%; top: 42%; } .map-pin.end { left: 70%; top: 30%; } .map-label { position: absolute; background: ${T.card}; border: 1px solid ${T.border}; border-radius: 8px; padding: 4px 10px; font-size: 12px; font-weight: 600; } .map-label.start { left: 22%; top: 35%; } .map-label.end { left: 74%; top: 22%; } .courier-dot { position: absolute; left: 38%; top: 48%; width: 18px; height: 18px; background: ${T.accent}; border-radius: 50%; border: 3px solid #fff; animation: pulse 1.5s infinite; } @keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(249,115,22,.4); } 50% { box-shadow: 0 0 0 10px transparent; } } /* Notification */ .notif { background: ${T.card}; border: 1px solid ${T.border}; border-left: 3px solid ${T.accent}; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; display: flex; align-items: flex-start; gap: 12px; } .notif.new { border-left-color: ${T.green}; } .notif-icon { font-size: 20px; } .notif-text { flex: 1; } .notif-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; } .notif-desc { font-size: 12px; color: ${T.muted}; } .notif-time { font-size: 11px; color: ${T.muted}; white-space: nowrap; } /* Fee breakdown */ .fee-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid ${T.border}; font-size: 14px; } .fee-row.total { border-bottom: none; font-family: 'Syne', sans-serif; font-weight: 800; font-size: 16px; color: ${T.accent}; padding-top: 12px; } /* Earnings */ .earn-bar { height: 8px; background: ${T.subtle}; border-radius: 4px; margin-top: 6px; overflow: hidden; } .earn-fill { height: 100%; background: ${T.green}; border-radius: 4px; transition: width .6s; } /* Tabs */ .inner-tabs { display: flex; gap: 0; border-bottom: 1px solid ${T.border}; margin-bottom: 20px; } .inner-tab { padding: 8px 18px; cursor: pointer; font-size: 14px; color: ${T.muted}; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: all .2s; } .inner-tab.active { color: ${T.accent}; border-bottom-color: ${T.accent}; font-weight: 600; } /* Form row */ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .tag { display: inline-block; padding: 2px 8px; background: ${T.subtle}; border-radius: 4px; font-size: 11px; color: ${T.muted}; margin-right: 4px; } .divider { border: none; border-top: 1px solid ${T.border}; margin: 16px 0; } .empty { text-align: center; padding: 40px; color: ${T.muted}; font-size: 14px; } .empty .icon { font-size: 40px; margin-bottom: 10px; } .info-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: ${T.muted}; margin-bottom: 6px; } .highlight { color: ${T.text}; font-weight: 500; } input[type=range] { accent-color: ${T.accent}; } `; // ─── Mock Data ──────────────────────────────────────────────────────────────── const PRODUCTS = [ { id: 1, name: "滷肉飯", price: 60, emoji: "🍚", desc: "台東招牌滷肉飯,香Q入味", category: "主食" }, { id: 2, name: "排骨便當", price: 90, emoji: "🍱", desc: "香煎排骨搭配白飯", category: "主食" }, { id: 3, name: "珍珠奶茶", price: 55, emoji: "🧋", desc: "濃郁奶茶配手工珍珠", category: "飲料" }, { id: 4, name: "蚵仔煎", price: 70, emoji: "🥘", desc: "新鮮蚵仔現煎", category: "小吃" }, { id: 5, name: "臭豆腐", price: 65, emoji: "🫕", desc: "酥脆外皮,搭配泡菜", category: "小吃" }, { id: 6, name: "木瓜牛奶", price: 50, emoji: "🥤", desc: "台東特產木瓜現打", category: "飲料" }, { id: 7, name: "控肉飯", price: 80, emoji: "🍖", desc: "慢燉三層肉,軟嫩入味", category: "主食" }, { id: 8, name: "芋圓甜湯", price: 55, emoji: "🍡", desc: "手工芋圓,Q彈有嚼勁", category: "甜點" }, ]; const STORES = [ { id: 1, name: "台東老街小吃", address: "台東市中正路123號", lat: 22.76, lng: 121.14, rating: 4.8, products: [1,2,4,5] }, { id: 2, name: "卑南鄉飲料店", address: "卑南鄉大南路88號", lat: 22.73, lng: 121.12, rating: 4.6, products: [3,6,8] }, { id: 3, name: "台東排骨專賣", address: "台東市更生路55號", lat: 22.75, lng: 121.15, rating: 4.9, products: [2,7] }, ]; const INITIAL_ORDERS = [ { id: "ORD-001", storeId: 1, storeName: "台東老街小吃", items: [{id:1,name:"滷肉飯",price:60,qty:2},{id:4,name:"蚵仔煎",price:70,qty:1}], status: "active", address: "台東市中山路99號", total: 240, fee: 40, distance: 2.3, bonus: 12, time: "14:32" }, { id: "ORD-002", storeId: 2, storeName: "卑南鄉飲料店", items: [{id:3,name:"珍珠奶茶",price:55,qty:2}], status: "pending", address: "卑南鄉初鹿路22號", total: 160, fee: 55, distance: 4.8, bonus: 26, time: "14:45" }, { id: "ORD-003", storeId: 1, storeName: "台東老街小吃", items: [{id:2,name:"排骨便當",price:90,qty:1}], status: "delivered", address: "台東市光明路15號", total: 140, fee: 35, distance: 1.8, bonus: 8, time: "13:10" }, ]; // ─── Haversine distance ─────────────────────────────────────────────────────── function haversine(lat1, lng1, lat2, lng2) { const R = 6371, dLat = (lat2-lat1)*Math.PI/180, dLng = (lng2-lng1)*Math.PI/180; const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLng/2)**2; return +(R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a))).toFixed(1); } function calcFee(dist, isRemote = false) { let fee = dist <= 3 ? 30 : 30 + Math.ceil((dist-3)/0.5)*5; if (isRemote) fee += 20; return fee; } function calcBonus(dist) { return dist > 5 ? Math.round(dist * 5.5) : Math.round(dist * 3); } // ─── Main App ───────────────────────────────────────────────────────────────── export default function App() { const [mode, setMode] = useState("customer"); // customer | courier const [auth, setAuth] = useState(null); // null = not logged in const [authView, setAuthView] = useState("login"); // login | register const [page, setPage] = useState("home"); const [orders, setOrders] = useState(INITIAL_ORDERS); const [cart, setCart] = useState([]); const [selectedStore, setSelectedStore] = useState(null); const [notifications, setNotifications] = useState([ { id: 1, title: "新訂單!", desc: "ORD-002 卑南鄉,距離 4.8 km", time: "剛剛", type: "new" }, { id: 2, title: "訂單已取件", desc: "ORD-001 已從台東老街小吃取貨", time: "13分鐘前", type: "info" }, ]); const switchMode = (m) => { setMode(m); setAuth(null); setPage("home"); setCart([]); setSelectedStore(null); }; return (
    {!auth ? : mode === "customer" ? : }
    ); } // ─── TopBar ─────────────────────────────────────────────────────────────────── function TopBar({ mode, auth, switchMode }) { return (
    🛵 台東快送
    {auth &&
    👤 {auth.name}
    }
    ); } // ─── Auth ───────────────────────────────────────────────────────────────────── function AuthScreen({ mode, view, setView, onAuth }) { const [form, setForm] = useState({ name:"", phone:"", email:"", password:"", address:"", district:"", vehicle:"" }); const set = (k,v) => setForm(f=>({...f,[k]:v})); const handleSubmit = () => { if (!form.phone || !form.password) return alert("請填寫必要欄位"); onAuth({ name: form.name || (mode==="customer"?"顧客":"外送員"), phone: form.phone, address: form.address, district: form.district }); }; return (
    {view==="login"?"歡迎回來 👋":"建立帳號"}
    {mode==="customer" ? "訂餐,輕鬆送到府" : "加入台東快送外送團隊"}
    {view==="register" && <>
    set("name",e.target.value)} />
    {mode==="customer" && <>
    set("address",e.target.value)} />
    } {mode==="courier" && <>
    } }
    set("phone",e.target.value)} />
    set("password",e.target.value)} />
    {view==="login" ? <>還沒有帳號? : <>已有帳號?}
    {view==="login" &&
    💡 測試用:任意輸入手機號碼與密碼即可登入
    }
    ); } // ─── Customer App ───────────────────────────────────────────────────────────── function CustomerApp({ page, setPage, auth, orders, setOrders, cart, setCart, selectedStore, setSelectedStore }) { const myOrders = orders.filter(o => o.status !== "cancelled"); const nav = [ { id:"home", icon:"🏠", label:"首頁" }, { id:"stores", icon:"🏪", label:"餐廳列表" }, { id:"cart", icon:"🛒", label:`購物車${cart.length>0?` (${cart.reduce((a,c)=>a+c.qty,0)})`:""}`}, { id:"orders", icon:"📦", label:"我的訂單" }, { id:"profile", icon:"👤", label:"個人設定" }, ]; return (
    {nav.map(n => (
    setPage(n.id)}> {n.icon}{n.label}
    ))}
    {page==="home" && } {page==="stores" && } {page==="cart" && } {page==="orders" && } {page==="profile" && } {page==="store_detail" && selectedStore && ( )}
    ); } function CustHome({ auth, orders, setPage }) { const active = orders.filter(o=>o.status==="active"); return (<>
    你好,{auth.name} 👋
    台東・卑南地區快速外送服務
    歷史訂單
    {orders.length}
    進行中
    {active.length}
    已送達
    {orders.filter(o=>o.status==="delivered").length}
    {active.length > 0 &&
    🔴 訂單進行中
    {active.map(o => (
    {o.id}外送中
    🏪
    📍
    {o.storeName}
    {o.address.substring(0,8)}
    🚴 預計 10-15 分鐘 送達・距離 {o.distance} km
    ))}
    }
    setPage("stores")}>
    🍽️ 立即點餐
    {STORES.map(s=>(
    {s.name}
    ⭐ {s.rating} · {s.address.substring(0,10)}
    ))}
    ); } function StoreList({ setPage, setSelectedStore }) { return (<>
    餐廳列表
    選擇你想點的餐廳
    {STORES.map(s => (
    {setSelectedStore(s);setPage("store_detail");}}>
    {s.name}
    📍 {s.address}
    ⭐ {s.rating} 外送中{s.products.length}項商品
    ))} ); } function StoreDetail({ store, cart, setCart, setPage }) { const storeProducts = PRODUCTS.filter(p => store.products.includes(p.id)); const addToCart = (p) => { setCart(c => { const ex = c.find(i=>i.id===p.id&&i.storeId===store.id); if (ex) return c.map(i=>i.id===p.id&&i.storeId===store.id?{...i,qty:i.qty+1}:i); return [...c, {...p, storeId: store.id, storeName: store.name, qty:1}]; }); }; const inCart = (id) => cart.find(i=>i.id===id&&i.storeId===store.id)?.qty || 0; return (<>
    {store.name}
    📍 {store.address}・⭐ {store.rating}
    {storeProducts.map(p => (
    {p.emoji}
    {p.name}
    {p.desc}
    NT${p.price}
    {inCart(p.id) > 0 && <> {inCart(p.id)} }
    ))}
    {cart.length > 0 && (
    )} ); } function CartPage({ auth, cart, setCart, orders, setOrders, setPage }) { const [address, setAddress] = useState(auth.address || ""); const [district, setDistrict] = useState(auth.district || "台東市"); const [note, setNote] = useState(""); const isRemote = ["卑南鄉","鹿野鄉","關山鎮"].includes(district); const subtotal = cart.reduce((a,c)=>a+c.price*c.qty,0); const dist = isRemote ? 5.2 : 2.1; const fee = calcFee(dist, isRemote); const platform = Math.round(subtotal * 0.05); const total = subtotal + fee + platform; const bonus = calcBonus(dist); const placeOrder = () => { if (!address) return alert("請填寫送餐地址"); const id = "ORD-" + String(orders.length + 1).padStart(3, "0"); const storeId = cart[0]?.storeId; const storeName = cart[0]?.storeName || "餐廳"; const newOrder = { id, storeId, storeName, items: cart.map(c=>({id:c.id,name:c.name,price:c.price,qty:c.qty})), status: "pending", address, district, total, fee, distance: dist, bonus, time: new Date().toLocaleTimeString("zh-TW",{hour:"2-digit",minute:"2-digit"}) }; setOrders(o=>[...o, newOrder]); setCart([]); alert(`✅ 訂單 ${id} 已成立!\n平台維護費:NT$${platform}\n外送費:NT$${fee}`); setPage("orders"); }; if (cart.length === 0) return (<>
    購物車
    🛒
    購物車是空的
    ); return (<>
    確認訂單
    確認商品及配送資訊
    🛍️ 商品明細
    {cart.map(item => (
    {PRODUCTS.find(p=>p.id===item.id)?.emoji}
    {item.name}
    NT${item.price} × {item.qty}
    {item.qty}
    NT${item.price*item.qty}
    ))}
    📍 配送資訊
    setAddress(e.target.value)} placeholder="台東市中正路123號" />