Rabu, 15 April 2026

Wardrobe

 


https://script.google.com/u/0/home/projects/1CFt-3Vgb_upRwPjPP7U6eW-Xps4xMoI96RYzu9rMgrf8t7d7cWxwlw2t/edit

https://docs.google.com/spreadsheets/d/1ixFMMNRcV5k1zYw3a0gcZN4UEQ2kV4BjqYRvhP9uD-c/edit?gid=0#gid=0


https://drive.google.com/drive/folders/1GknfnEvkyU-Do8yKqSmsRZXQvKSCSrsQ




/**
 * KONFIGURASI DATABASE
 */
const SPREADSHEET_ID = '1ixFMMNRcV5k1zYw3a0gcZN4UEQ2kV4BjqYRvhP9uD-c';
const FOLDER_ID = '1s9QXPrVaezDg6YAv3uzysm4Wfkpgg5y8';

/**
 * FUNGSI DEBUG UNTUK MEMAKSA OTORISASI DRIVE
 * Jalankan fungsi ini sekali di editor jika Anda mendapatkan error izin.
 */
function debug_forceAuth() {
  const folder = DriveApp.getFolderById(FOLDER_ID);
  const testFile = folder.createFile("test_auth.txt", "Tes Otorisasi Berhasil");
  testFile.setTrashed(true); // Hapus file tes setelah dibuat
  console.log("Otorisasi Drive berhasil dideteksi dan diberikan.");
}



PROMPT

Create an e-commerce app for a high-end women's fashion boutique called 'The Wardrobe.'

The target audience is style-conscious women aged 25-40.

The design should be minimalist and elegant, using a black, white and gold color palette with a sophisticated serif font.

Key features must include: A homepage with a large hero image showcasing the latest collection. Product pages with multiple high-resolution images, size and color options and detailed descriptions.A 'New Arrivals' section and curated 'Shop the Look' collections. A secure, one-page checkout process with Apple Pay and credit card options. User accounts for saving addresses and viewing order history.





Dokumentasi Teknis Frontend: The Wardrobe

Dokumen ini menjelaskan arsitektur, teknologi, dan komponen antarmuka pengguna (UI) yang digunakan dalam aplikasi butik "The Wardrobe". Frontend dibangun dengan pendekatan modern minimalist yang mengutamakan performa dan integrasi langsung dengan Google Apps Script.

1. Tumpukan Teknologi (Tech Stack)

Aplikasi ini dikembangkan sebagai file HTML mandiri (Single HTML File) yang memuat seluruh logika dan gaya tanpa memerlukan proses kompilasi berat atau server Node.js terpisah, sehingga sangat mudah untuk disematkan (embedded) ke dalam platform seperti Google Sites:

  • HTML5 & React 18 (Standalone): Menggunakan pustaka React melalui CDN dan Babel Standalone untuk mengeksekusi sintaks JSX secara langsung di sisi klien (browser).

  • Tailwind CSS: Framework CSS berbasis utilitas yang dimuat via CDN untuk desain responsif dan kustomisasi palet warna (Black, White, Gold).

  • Lucide React: Library ikon berbasis SVG untuk antarmuka yang bersih dan ringan.

  • Google Apps Script API: Jembatan komunikasi menggunakan google.script.run untuk memanggil fungsi backend di Code.gs.

2. Arsitektur Navigasi (MPA Simulation)

Meskipun berjalan dalam satu file HTML, aplikasi ini mensimulasikan Multi-Page Application (MPA) untuk memberikan pengalaman navigasi yang terstruktur:

  • Stateful Routing: Menggunakan state view untuk merender komponen halaman yang berbeda secara kondisional (switch-case).

  • Smooth Navigation: Fungsi Maps memastikan posisi gulir (scroll) kembali ke atas setiap kali berpindah halaman.

  • Persistent Header/Footer: Navigasi utama dan informasi kaki tetap ada di semua halaman untuk konsistensi UX.

3. Komponen dan Halaman Utama

HomeView (Beranda)

  • Fungsi: Menampilkan banner visual (Hero Section) dan produk unggulan.

  • Logika: Memfilter produk dari database yang memiliki status isNew: TRUE.

ShopView (Katalog)

  • Fungsi: Galeri produk lengkap.

  • Fitur: Menampilkan seluruh koleksi dalam tata letak grid responsif yang menyesuaikan jumlah kolom antara perangkat mobile dan desktop.

ProductDetailView (Detail Produk)

  • Fungsi: Memberikan informasi mendalam mengenai satu produk tertentu.

  • Logika: Mengelola pilihan lokal (ukuran dan warna) sebelum item dimasukkan ke dalam keranjang belanja.

CartView & CheckoutView

  • Fungsi: Manajemen transaksi.

  • Fitur: Kalkulasi total harga secara real-time dan formulir pengumpulan data pelanggan.

AdminDashboard (update_collection.html)

  • Fungsi: Antarmuka manajemen koleksi untuk pemilik butik.

  • Fitur: Konversi gambar ke format Base64 untuk pengunggahan file dan sinkronisasi data teks ke skrip server.

4. Integrasi Data dan Aset

Sinkronisasi Server (google.script.run)

  • Fetching: Fungsi getProducts dipanggil di dalam useEffect saat aplikasi pertama kali dimuat.

  • Submission: Form admin mengirimkan objek produk dan string gambar ke fungsi addNewProduct.

Pemrosesan Gambar (LH3 Logic)

  • Frontend dilengkapi dengan fungsi formatImageUrl yang secara otomatis mendeteksi ID file Google Drive dan mengubahnya menjadi format lh3.googleusercontent.com. Format ini memastikan gambar dimuat lebih cepat dan melewati pembatasan cross-origin pada Google Sites.

5. Prinsip Desain (UI/UX)

  • Minimalisme: Penggunaan ruang putih (whitespace) yang luas untuk menonjolkan produk.

  • Tipografi: Perpaduan font Serif (Playfair Display) untuk judul agar terkesan mewah dan Sans-Serif (Inter) untuk konten agar mudah dibaca.

  • Responsivitas: Desain sepenuhnya adaptif dari layar ponsel hingga desktop menggunakan breakpoints Tailwind.

6. Penanganan Status (Loading & Error)

  • Loading Screen: Menampilkan animasi spinner saat aplikasi menunggu respon dari Google Apps Script.

  • Error Guard: Menampilkan pesan yang ramah pengguna jika sinkronisasi database gagal, lengkap dengan tombol coba lagi.

Dokumentasi Teknis Backend: The Wardrobe (Google Apps Script)

Dokumen ini merinci logika, struktur, dan fungsi yang terdapat dalam skrip Code.gs yang digunakan untuk mengintegrasikan Google Spreadsheet dan Google Drive sebagai sistem manajemen konten (CMS) untuk butik "The Wardrobe".

1. Ikhtisar Sistem

Skrip ini bertindak sebagai API server-side yang menghubungkan antarmuka web (HTML/React) dengan layanan Google. Skrip menangani penyimpanan data teks ke Spreadsheet dan konversi file gambar dari format Base64 ke penyimpanan permanen di Google Drive.

2. Konfigurasi Global

Dua konstanta utama didefinisikan di bagian atas untuk menentukan target penyimpanan:

  • SPREADSHEET_ID: Identitas unik dari Google Spreadsheet yang digunakan sebagai database.

  • FOLDER_ID: Identitas unik dari folder Google Drive tempat foto koleksi akan disimpan.

3. Rincian Fungsi

addNewProduct(product, imageBase64)

Fungsi utama untuk menyimpan data koleksi baru yang dikirim dari formulir admin.

  • Input:

    • product: Objek JavaScript berisi detail baju (id, nama, harga, dll).

    • imageBase64: String gambar dalam format Base64 yang diambil dari input file.

  • Logika:

    1. Membuka Spreadsheet berdasarkan ID.

    2. Memanggil uploadToDrive jika ada data gambar.

    3. Menyusun array baris baru (newRow) sesuai urutan kolom database.

    4. Menambahkan data ke baris terakhir di "Sheet1".

  • Output: Objek status sukses atau melempar error jika gagal.

uploadToDrive(base64Data, productName)

Mengurus proses teknis pengunggahan gambar ke Google Drive.

  • Input: Data Base64 dan Nama Produk untuk penamaan file.

  • Logika:

    1. Mengakses folder tujuan menggunakan FOLDER_ID.

    2. Melakukan dekode Base64 menjadi Blob (Binary Large Object).

    3. Membuat file baru dengan timestamp agar nama file unik.

    4. Penanganan Izin: Mencoba menyetel file agar bisa dilihat oleh publik (setSharing). Jika gagal karena batasan akun, proses tetap dilanjutkan tanpa menghentikan penyimpanan ke Spreadsheet.

  • Output: URL gambar dalam format lh3.googleusercontent.com menggunakan ID file.

getProducts()

Fungsi untuk mengambil seluruh data produk untuk ditampilkan di sisi pelanggan.

  • Logika:

    1. Membaca seluruh rentang data di Spreadsheet.

    2. Memetakan baris menjadi objek JSON berdasarkan header kolom.

    3. Memproses kolom images, colors, dan sizes menjadi array dengan pemisah titik koma (;).

  • Output: Array of Objects berisi daftar produk.

debug_forceAuth()

Fungsi pembantu (utility) yang digunakan hanya di editor skrip untuk memicu dialog izin akses Google Drive secara manual jika terjadi error "Akses Ditolak".

4. Strategi Penanganan Kesalahan (Error Handling)

Skrip ini menggunakan blok try-catch di setiap fungsi utama untuk:

  1. Memberikan pesan error yang informatif ke antarmuka pengguna.

  2. Mencegah kegagalan total sistem jika salah satu proses non-kritis (seperti pengaturan izin publik pada file) gagal dilakukan secara otomatis.

5. Prasyarat Integrasi

Agar skrip ini berjalan lancar:

  • Nama Tab: Harus bernama "Sheet1".

  • Header Baris 1: Harus berisi id, name, price, category, description, images, colors, sizes, isNew.

  • Izin Folder: Folder di Drive disarankan sudah disetel ke "Anyone with the link" secara manual untuk keamanan tambahan.




Kode.gs

function doGet(e) {
  var page = e.parameter.page || 'wardrobe_embedded';
  return HtmlService.createTemplateFromFile(page)
      .evaluate()
      .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
      .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

/**
 * PENTING: CARA MENGATASI ERROR IZIN (AUTHORIZATION) SECARA TOTAL
 * * Jika error "Exception: Akses ditolak: DriveApp" muncul:
 * * 1. Ini sering disebabkan karena perintah setSharing (pembagian publik) diblokir.
 * * 2. Pastikan Anda tidak login di banyak akun Google secara bersamaan di satu browser.
 * * 3. Pastikan folder tujuan di Drive sudah disetel share-nya ke "Anyone with the link" secara manual.
 */

/**
 * KONFIGURASI DATABASE
 */
const SPREADSHEET_ID = '1ixFMMNRcV5k1zYw3a0gcZN4UEQ2kV4BjqYRvhP9uD-c';
const FOLDER_ID = '1s9QXPrVaezDg6YAv3uzysm4Wfkpgg5y8';

/**
 * FUNGSI DEBUG UNTUK MEMAKSA OTORISASI DRIVE
 */
function debug_forceAuth() {
  try {
    const folder = DriveApp.getFolderById(FOLDER_ID);
    const testFile = folder.createFile("test_auth.txt", "Tes Otorisasi Berhasil");
    testFile.setTrashed(true);
    console.log("Otorisasi Drive berhasil dideteksi dan diberikan.");
  } catch (e) {
    console.error("Gagal melakukan debug otorisasi: " + e.toString());
  }
}

/**
 * FUNGSI UNTUK MENERIMA DATA DARI CLIENT
 */
function addNewProduct(product, imageBase64) {
  try {
    const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
    const sheet = ss.getSheetByName("Sheet1");
   
    let imageUrl = "";
   
    // 1. Jika ada gambar, unggah ke Google Drive
    if (imageBase64) {
      imageUrl = uploadToDrive(imageBase64, product.name);
    }
   
    // 2. Susun data untuk baris baru
    const newRow = [
      product.id,
      product.name,
      product.price,
      product.category,
      product.description,
      imageUrl,
      product.colors,
      product.sizes,
      product.isNew ? "TRUE" : "FALSE"
    ];
   
    // 3. Tambahkan ke baris terakhir Spreadsheet
    sheet.appendRow(newRow);
   
    return { status: "success", message: "Koleksi berhasil disimpan ke Spreadsheet." };
   
  } catch (err) {
    throw new Error("Gagal menyimpan data ke Spreadsheet: " + err.toString());
  }
}

/**
 * FUNGSI UNTUK UNGGAH FILE KE DRIVE
 */
function uploadToDrive(base64Data, productName) {
  try {
    const folder = DriveApp.getFolderById(FOLDER_ID);
   
    // Bersihkan data base64
    const contentType = base64Data.substring(5, base64Data.indexOf(';'));
    const bytes = Utilities.base64Decode(base64Data.split(',')[1]);
   
    // Buat file baru
    const fileName = productName + "_" + new Date().getTime();
    const file = folder.createFile(Utilities.newBlob(bytes, contentType, fileName));
   
    // Ambil ID file terlebih dahulu
    const fileId = file.getId();

    // 2. Coba set izin agar publik (Tindakan ini sering memicu 'Akses Ditolak')
    // Kita bungkus dalam try-catch agar jika gagal, data tetap bisa masuk ke Spreadsheet
    try {
      file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
    } catch (sharingError) {
      console.warn("Peringatan: Gagal menyetel izin publik secara otomatis. Sila setel folder secara manual. " + sharingError.toString());
      // Lanjutkan saja, jangan lempar error agar Spreadsheet tetap terupdate
    }
   
    // Kembalikan URL dalam format lh3.googleusercontent.com
    return "https://lh3.googleusercontent.com/d/" + fileId;
  } catch (err) {
    throw new Error("Gagal proses unggah foto: " + err.toString());
  }
}

/**
 * FUNGSI UNTUK MENGAMBIL DATA
 */
function getProducts() {
  try {
    const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
    const sheet = ss.getSheetByName("Sheet1");
    const data = sheet.getDataRange().getValues();
   
    if (data.length < 1) return [];
   
    const headers = data[0].map(h => h.toString().toLowerCase());
   
    return data.slice(1).map(row => {
      let obj = {};
      headers.forEach((h, i) => {
        let val = row[i];
        if (['images', 'colors', 'sizes'].includes(h)) {
          obj[h] = val ? val.toString().split(';').map(v => v.trim()) : [];
        } else {
          obj[h] = val;
        }
      });
      return obj;
    });
  } catch (err) {
    console.error("Gagal mengambil data produk: " + err.toString());
    return [];
  }
}

Wardrobe.html

<!DOCTYPE html>
<html lang="ms">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>The Wardrobe Boutique</title>
    <!-- React & Babel Standalone -->
    <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
    <!-- Tailwind CSS -->
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Inter:wght@300;400;500;600;700&display=swap');
       
        :root {
            --font-serif: 'Playfair Display', serif;
            --font-sans: 'Inter', sans-serif;
        }

        body {
            font-family: var(--font-sans);
        }

        .font-serif {
            font-family: var(--font-serif);
        }
    </style>
</head>
<body class="bg-white text-zinc-900">
    <div id="root"></div>

    <script type="text/babel">
        const { useState, useEffect, useMemo } = React;

        /**
         * FUNGSI PEMFORMATAN IMEJ
         * Menukarkan ID Google Drive kepada format lh3.googleusercontent.com
         */
        const formatImageUrl = (url) => {
            if (!url) return 'https://placehold.co/600x900?text=Tiada+Imej';
            // Jika ia adalah ID atau URL Drive, tukar kepada format LH3
            const driveIdMatch = url.match(/(?:\/d\/|id=)([\w-]+)/);
            if (driveIdMatch && driveIdMatch[1]) {
                return `https://lh3.googleusercontent.com/d/${driveIdMatch[1]}`;
            }
            return url;
        };

        // Komponen Ikon Ringkas
        const Icon = ({ name, size = 20, className = "" }) => {
            const icons = {
                shoppingBag: (
                    <>
                        <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
                        <path d="M3 6h18"/>
                        <path d="M16 10a4 4 0 0 1-8 0"/>
                    </>
                ),
                user: (
                    <>
                        <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
                        <circle cx="12" cy="7" r="4"/>
                    </>
                ),
                search: (
                    <>
                        <circle cx="11" cy="11" r="8"/>
                        <path d="m21 21-4.3-4.3"/>
                    </>
                ),
                arrowLeft: (
                    <>
                        <path d="m12 19-7-7 7-7"/>
                        <path d="M19 12H5"/>
                    </>
                ),
                x: (
                    <>
                        <path d="M18 6 6 18"/>
                        <path d="m6 6 12 12"/>
                    </>
                ),
                chevronRight: <path d="m9 18 6-6-6-6"/>,
                checkCircle: (
                    <>
                        <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
                        <polyline points="22 4 12 14.01 9 11.01"/>
                    </>
                ),
                package: (
                    <>
                        <path d="M16.5 9.4 7.5 4.21"/>
                        <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
                        <polyline points="3.29 7 12 12 20.71 7"/>
                        <line x1="12" y1="22" x2="12" y2="12"/>
                    </>
                ),
                loader: (
                    <>
                        <path d="M12 2v4"/>
                        <path d="m16.2 7.8 2.9-2.9"/>
                        <path d="M18 12h4"/>
                        <path d="m16.2 16.2 2.9 2.9"/>
                        <path d="M12 18v4"/>
                        <path d="m4.9 19.1 2.9-2.9"/>
                        <path d="M2 12h4"/>
                        <path d="m4.9 4.9 2.9 2.9"/>
                    </>
                )
            };

            return (
                <svg
                    xmlns="http://www.w3.org/2000/svg"
                    width={size}
                    height={size}
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.5"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    className={className}
                >
                    {icons[name]}
                </svg>
            );
        };

        function App() {
            const [view, setView] = useState('home');
            const [products, setProducts] = useState([]);
            const [cart, setCart] = useState([]);
            const [selectedProduct, setSelectedProduct] = useState(null);
            const [isLoading, setIsLoading] = useState(true);
            const [error, setError] = useState(null);

            // Mengambil data menggunakan google.script.run
            useEffect(() => {
                const fetchData = () => {
                    if (typeof google !== 'undefined' && google.script && google.script.run) {
                        google.script.run
                            .withSuccessHandler((data) => {
                                // Proses data dari server-side getProducts()
                                setProducts(data);
                                setIsLoading(false);
                            })
                            .withFailureHandler((err) => {
                                console.error(err);
                                setError("Gagal memuatkan data daripada Skrip Google.");
                                setIsLoading(false);
                            })
                            .getProducts(); // Pastikan fungsi ini wujud dalam Code.gs
                    } else {
                        // Fallback untuk tujuan pembangunan/preview
                        setError("Sila jalankan aplikasi ini dalam persekitaran Google Apps Script.");
                        setIsLoading(false);
                    }
                };
                fetchData();
            }, []);

            const navigate = (to, data = null) => {
                if (data) setSelectedProduct(data);
                setView(to);
                window.scrollTo(0, 0);
            };

            const addToCart = (product, config) => {
                setCart([...cart, { ...product, ...config, cartId: Math.random().toString(36).substr(2, 9) }]);
                navigate('cart');
            };

            if (isLoading) return (
                <div className="h-screen flex flex-col items-center justify-center text-zinc-400">
                    <Icon name="loader" className="animate-spin mb-4" size={32} />
                    <p className="text-[10px] uppercase tracking-widest font-bold text-zinc-500">Menyediakan Butik...</p>
                </div>
            );

            if (error) return (
                <div className="h-screen flex flex-col items-center justify-center text-center px-6">
                    <p className="text-red-500 mb-4 font-serif">{error}</p>
                    <button onClick={() => window.location.reload()} className="text-[10px] uppercase font-bold border-b border-black">Cuba Lagi</button>
                </div>
            );

            return (
                <div className="min-h-screen">
                    {/* Navigasi Utama */}
                    <nav className="sticky top-0 z-50 bg-white/95 backdrop-blur-sm border-b border-zinc-100 px-6 py-4 flex items-center justify-between">
                        <div className="flex items-center space-x-8">
                            <button onClick={() => navigate('shop')} className="hidden md:block text-[10px] uppercase tracking-[0.2em] font-bold hover:text-amber-600 transition-colors">Koleksi</button>
                            <button onClick={() => navigate('home')} className="hidden md:block text-[10px] uppercase tracking-[0.2em] font-bold hover:text-amber-600 transition-colors">Jurnal</button>
                        </div>
                        <button onClick={() => navigate('home')} className="text-2xl font-serif tracking-tighter uppercase">THE WARDROBE</button>
                        <div className="flex items-center space-x-6">
                            <button onClick={() => navigate('account')} className="hover:text-amber-600"><Icon name="user" /></button>
                            <button onClick={() => navigate('cart')} className="relative hover:text-amber-600">
                                <Icon name="shoppingBag" />
                                {cart.length > 0 && <span className="absolute -top-1 -right-1 bg-zinc-900 text-white text-[8px] w-3.5 h-3.5 rounded-full flex items-center justify-center font-bold">{cart.length}</span>}
                            </button>
                        </div>
                    </nav>

                    <main>
                        {view === 'home' && <HomeView products={products} onNavigate={navigate} />}
                        {view === 'shop' && <ShopView products={products} onNavigate={navigate} />}
                        {view === 'product' && <ProductDetailView product={selectedProduct} onAddToCart={addToCart} onBack={() => navigate('shop')} />}
                        {view === 'cart' && <CartView cart={cart} onRemove={(id) => setCart(cart.filter(i => i.cartId !== id))} onCheckout={() => navigate('checkout')} />}
                        {view === 'checkout' && <CheckoutView total={cart.reduce((a, b) => a + b.price, 0)} onOrder={() => {setCart([]); setView('success');}} onBack={() => navigate('cart')} />}
                        {view === 'success' && <SuccessView onNavigate={navigate} />}
                        {view === 'account' && <AccountView />}
                    </main>

                    {/* Pengaki Halaman */}
                    <footer className="bg-zinc-50 border-t border-zinc-100 py-20 px-8 mt-20">
                        <div className="max-w-7xl auto grid grid-cols-1 md:grid-cols-4 gap-12 mx-auto">
                            <div>
                                <h3 className="font-serif text-xl mb-6">THE WARDROBE</h3>
                                <p className="text-sm text-zinc-500 leading-relaxed italic">Keanggunan abadi dalam setiap jahitan.</p>
                            </div>
                            <div>
                                <h4 className="text-[10px] uppercase tracking-widest font-bold mb-6 text-zinc-400">Maklumat</h4>
                                <ul className="text-sm text-zinc-600 space-y-3">
                                    <li>Jejak Pesanan</li>
                                    <li>Panduan Saiz</li>
                                    <li>Mengenai Kami</li>
                                </ul>
                            </div>
                            <div className="md:col-span-2">
                                <h4 className="text-[10px] uppercase tracking-widest font-bold mb-6 text-zinc-400">Hab Skrip Google</h4>
                                <p className="text-xs text-zinc-400">Laman web ini dihubungkan secara terus melalui Google Apps Script.</p>
                            </div>
                        </div>
                    </footer>
                </div>
            );
        }

        // Paparan Utama
        function HomeView({ products, onNavigate }) {
            const featured = products.filter(p => String(p.isnew).toUpperCase() === 'TRUE').slice(0, 4);
            return (
                <div className="animate-in fade-in duration-700">
                    <div className="relative h-[80vh] bg-zinc-100 overflow-hidden">
                        <img src="https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&q=80&w=2000" className="w-full h-full object-cover opacity-90" alt="Imej Utama" />
                        <div className="absolute inset-0 flex flex-col items-center justify-center text-center px-4">
                            <span className="uppercase tracking-[0.3em] text-[10px] mb-6 font-bold">Koleksi Musim Bunga 2024</span>
                            <h1 className="text-5xl md:text-7xl font-serif mb-10">Kemurnian Sutera</h1>
                            <button onClick={() => onNavigate('shop')} className="bg-zinc-900 text-white px-10 py-4 text-[10px] uppercase tracking-[0.2em] font-bold hover:bg-amber-600 transition-all">Terokai Koleksi</button>
                        </div>
                    </div>
                    <div className="max-w-7xl mx-auto px-6 py-24">
                        <h2 className="text-3xl font-serif mb-12 text-center">Ketibaan Baharu</h2>
                        <div className="grid grid-cols-1 md:grid-cols-4 gap-8">
                            {featured.map(p => <ProductCard key={p.id} product={p} onClick={() => onNavigate('product', p)} />)}
                        </div>
                    </div>
                </div>
            );
        }

        function ShopView({ products, onNavigate }) {
            return (
                <div className="max-w-7xl mx-auto px-6 py-20 animate-in slide-in-from-bottom-4">
                    <h1 className="text-4xl font-serif mb-16 text-center">Katalog Lengkap</h1>
                    <div className="grid grid-cols-1 md:grid-cols-4 gap-x-8 gap-y-16">
                        {products.map(p => <ProductCard key={p.id} product={p} onClick={() => onNavigate('product', p)} />)}
                    </div>
                </div>
            );
        }

        function ProductCard({ product, onClick }) {
            const imgUrl = formatImageUrl(product.images && product.images.length > 0 ? product.images[0] : null);
            return (
                <div onClick={onClick} className="group cursor-pointer">
                    <div className="aspect-[2/3] overflow-hidden bg-zinc-50 mb-4 relative">
                        <img src={imgUrl} className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105" alt={product.name} />
                        {String(product.isnew).toUpperCase() === 'TRUE' && <span className="absolute top-4 left-4 bg-white text-zinc-900 px-3 py-1 text-[8px] uppercase font-bold tracking-widest">Baharu</span>}
                    </div>
                    <h3 className="text-[10px] font-bold uppercase tracking-widest mb-1">{product.name}</h3>
                    <p className="font-serif text-zinc-500">RM {product.price}</p>
                </div>
            );
        }

        function ProductDetailView({ product, onAddToCart, onBack }) {
            if (!product) return null;
            const [size, setSize] = useState(product.sizes?.[0] || "");
            const [color, setColor] = useState(product.colors?.[0] || "");
            const imgUrl = formatImageUrl(product.images && product.images.length > 0 ? product.images[0] : null);

            return (
                <div className="max-w-7xl mx-auto px-6 py-12">
                    <button onClick={onBack} className="flex items-center text-[10px] uppercase font-bold mb-12 hover:text-amber-600">
                        <Icon name="arrowLeft" size={14} className="mr-2" /> Kembali ke Katalog
                    </button>
                    <div className="grid grid-cols-1 md:grid-cols-2 gap-16">
                        <div className="aspect-[4/5] bg-zinc-50 overflow-hidden">
                            <img src={imgUrl} className="w-full h-full object-cover" alt={product.name} />
                        </div>
                        <div>
                            <span className="text-amber-700 text-[10px] uppercase font-bold tracking-widest">{product.category}</span>
                            <h1 className="text-4xl font-serif mt-2 mb-6">{product.name}</h1>
                            <p className="text-2xl font-serif mb-10 text-zinc-900">RM {product.price}</p>
                            <div className="space-y-8 mb-12 border-t border-zinc-100 pt-8">
                                {product.colors?.length > 0 && (
                                    <div>
                                        <p className="text-[10px] uppercase font-bold text-zinc-400 mb-4 tracking-widest">Pilih Warna</p>
                                        <div className="flex flex-wrap gap-2">
                                            {product.colors.map(c => (
                                                <button key={c} onClick={() => setColor(c)} className={`px-4 py-2 border text-[10px] uppercase font-bold ${color === c ? 'bg-zinc-900 text-white' : 'hover:border-zinc-900'}`}>{c}</button>
                                            ))}
                                        </div>
                                    </div>
                                )}
                                {product.sizes?.length > 0 && (
                                    <div>
                                        <p className="text-[10px] uppercase font-bold text-zinc-400 mb-4 tracking-widest">Pilih Saiz</p>
                                        <div className="flex flex-wrap gap-2">
                                            {product.sizes.map(s => (
                                                <button key={s} onClick={() => setSize(s)} className={`w-10 h-10 border flex items-center justify-center text-[10px] font-bold ${size === s ? 'bg-zinc-900 text-white' : 'hover:border-zinc-900'}`}>{s}</button>
                                            ))}
                                        </div>
                                    </div>
                                )}
                            </div>
                            <button onClick={() => onAddToCart(product, { size, color })} className="w-full bg-zinc-900 text-white py-5 text-[10px] uppercase font-bold tracking-widest hover:bg-amber-600 transition-colors">Tambah ke Beg</button>
                            <div className="mt-12 text-sm text-zinc-500 leading-relaxed whitespace-pre-line">{product.description}</div>
                        </div>
                    </div>
                </div>
            );
        }

        function CartView({ cart, onRemove, onCheckout }) {
            const total = cart.reduce((acc, i) => acc + i.price, 0);
            return (
                <div className="max-w-xl mx-auto px-6 py-20">
                    <h1 className="text-3xl font-serif mb-12 text-center">Beg Belanja</h1>
                    {cart.length === 0 ? <p className="text-center italic text-zinc-400 font-serif">Beg belanja anda kosong.</p> : (
                        <div className="space-y-8">
                            {cart.map(item => (
                                <div key={item.cartId} className="flex gap-6 pb-8 border-b border-zinc-50">
                                    <img src={formatImageUrl(item.images?.[0])} className="w-20 h-28 object-cover bg-zinc-100" alt={item.name} />
                                    <div className="flex-1">
                                        <div className="flex justify-between items-start">
                                            <h3 className="font-serif">{item.name}</h3>
                                            <button onClick={() => onRemove(item.cartId)} className="text-zinc-400 hover:text-black"><Icon name="x" size={14} /></button>
                                        </div>
                                        <p className="text-[10px] text-zinc-400 uppercase mt-1">{item.size} / {item.color}</p>
                                        <p className="mt-4 font-serif text-amber-800">RM {item.price}</p>
                                    </div>
                                </div>
                            ))}
                            <div className="pt-8 flex justify-between items-center text-xl font-serif border-t border-zinc-100">
                                <span className="text-sm font-sans uppercase font-bold tracking-widest text-zinc-400">Jumlah Keseluruhan</span>
                                <span>RM {total}</span>
                            </div>
                            <button onClick={onCheckout} className="w-full bg-zinc-900 text-white py-4 text-[10px] uppercase font-bold tracking-widest hover:bg-zinc-800 transition-colors">Daftar Keluar</button>
                        </div>
                    )}
                </div>
            );
        }

        function CheckoutView({ total, onOrder, onBack }) {
            return (
                <div className="max-w-4xl mx-auto px-6 py-20">
                    <div className="grid grid-cols-1 md:grid-cols-2 gap-16">
                        <div className="space-y-8">
                            <h2 className="text-2xl font-serif text-zinc-900">Alamat Penghantaran</h2>
                            <div className="space-y-4">
                                <input placeholder="Nama Penuh" className="w-full p-4 border border-zinc-100 bg-zinc-50 outline-none text-sm focus:border-black transition-all" />
                                <input placeholder="Emel" className="w-full p-4 border border-zinc-100 bg-zinc-50 outline-none text-sm focus:border-black transition-all" />
                                <input placeholder="Alamat Lengkap" className="w-full p-4 border border-zinc-100 bg-zinc-50 outline-none text-sm focus:border-black transition-all" />
                            </div>
                            <button onClick={onOrder} className="w-full bg-zinc-900 text-white py-4 text-[10px] uppercase font-bold tracking-widest hover:bg-amber-600 transition-colors">Selesaikan Pembayaran</button>
                        </div>
                        <div className="bg-zinc-50 p-8 h-fit">
                            <h2 className="text-xl font-serif mb-6">Ringkasan Pesanan</h2>
                            <div className="flex justify-between text-sm mb-4"><span className="text-zinc-400">Jumlah Kecil</span><span>RM {total}</span></div>
                            <div className="flex justify-between text-sm mb-4 font-bold border-t border-zinc-200 pt-4"><span className="text-zinc-400 uppercase text-[10px]">Jumlah Akhir</span><span>RM {total}</span></div>
                        </div>
                    </div>
                </div>
            );
        }

        function SuccessView({ onNavigate }) {
            return (
                <div className="max-w-md mx-auto text-center py-32 px-6 animate-in zoom-in duration-500">
                    <Icon name="checkCircle" className="mx-auto text-emerald-500 mb-6" size={48} />
                    <h1 className="text-3xl font-serif mb-4">Terima Kasih</h1>
                    <p className="text-sm text-zinc-500 mb-10">Pesanan anda telah berjaya diterima dan akan diproses secepat mungkin.</p>
                    <button onClick={() => onNavigate('home')} className="w-full py-4 border border-zinc-900 text-[10px] uppercase font-bold tracking-widest hover:bg-zinc-900 hover:text-white transition-all">Kembali ke Laman Utama</button>
                </div>
            );
        }

        function AccountView() {
            return (
                <div className="max-w-4xl mx-auto px-6 py-20">
                    <h1 className="text-4xl font-serif mb-12">Akaun Saya</h1>
                    <div className="p-12 border border-zinc-100 text-center italic text-zinc-400 font-serif">Tiada rekod pesanan buat masa ini.</div>
                </div>
            );
        }

        const root = ReactDOM.createRoot(document.getElementById("root"));
        root.render(<App />);
    </script>
</body>
</html>


update_collection.html
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Admin - Update Koleksi The Wardrobe</title>
    <!-- React & Babel Standalone -->
    <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
    <!-- Tailwind CSS -->
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@300;400;600&display=swap');
       
        :root {
            --font-serif: 'Playfair Display', serif;
            --font-sans: 'Inter', sans-serif;
        }

        body {
            font-family: var(--font-sans);
            background-color: #fcfcfc;
        }

        .font-serif {
            font-family: var(--font-serif);
        }
    </style>
</head>
<body class="text-zinc-900">
    <div id="root"></div>

    <script type="text/babel">
        const { useState, useEffect } = React;

        // Komponen Utama Dashboard Admin
        function AdminDashboard() {
            const [products, setProducts] = useState([]);
            const [loading, setLoading] = useState(false);
            const [status, setStatus] = useState({ type: '', message: '' });
            const [formData, setFormData] = useState({
                id: '',
                name: '',
                price: '',
                category: 'Dresses',
                description: '',
                colors: '',
                sizes: '',
                isNew: false
            });
            const [imageFile, setImageFile] = useState(null);

            // Fungsi untuk menangani input form
            const handleInputChange = (e) => {
                const { name, value, type, checked } = e.target;
                setFormData(prev => ({
                    ...prev,
                    [name]: type === 'checkbox' ? checked : value
                }));
            };

            // Fungsi untuk konversi file ke base64
            const fileToBase64 = (file) => new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.readAsDataURL(file);
                reader.onload = () => resolve(reader.result);
                reader.onerror = error => reject(error);
            });

            // Fungsi Submit Form
            const handleSubmit = async (e) => {
                e.preventDefault();
                setLoading(true);
                setStatus({ type: 'info', message: 'Sedang memproses data...' });

                try {
                    let base64Image = "";
                    if (imageFile) {
                        base64Image = await fileToBase64(imageFile);
                    }

                    // Memanggil fungsi server-side di Code.gs
                    google.script.run
                        .withSuccessHandler((response) => {
                            setLoading(false);
                            setStatus({ type: 'success', message: 'Koleksi berhasil diperbarui!' });
                            // Reset form sederhana
                            setFormData({
                                id: '', name: '', price: '', category: 'Dresses',
                                description: '', colors: '', sizes: '', isNew: false
                            });
                            setImageFile(null);
                        })
                        .withFailureHandler((err) => {
                            setLoading(false);
                            setStatus({ type: 'error', message: 'Gagal: ' + err.message });
                        })
                        .addNewProduct(formData, base64Image); // Fungsi ini harus ada di Code.gs
                } catch (err) {
                    setLoading(false);
                    setStatus({ type: 'error', message: 'Terjadi kesalahan sistem.' });
                }
            };

            return (
                <div className="max-w-4xl mx-auto px-6 py-12">
                    <header className="mb-12 border-b border-zinc-100 pb-8 flex justify-between items-end">
                        <div>
                            <h1 className="text-3xl font-serif mb-2">Manajemen Koleksi</h1>
                            <p className="text-zinc-500 text-sm italic">Panel Admin The Wardrobe Boutique</p>
                        </div>
                        <div className="text-[10px] uppercase tracking-widest font-bold text-zinc-400">
                            Sinkronisasi Real-time
                        </div>
                    </header>

                    {status.message && (
                        <div className={`mb-8 p-4 text-xs font-bold uppercase tracking-widest flex items-center justify-between ${
                            status.type === 'success' ? 'bg-emerald-50 text-emerald-700' :
                            status.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-amber-50 text-amber-700'
                        }`}>
                            <span>{status.message}</span>
                            <button onClick={() => setStatus({type:'', message:''})} className="opacity-50">Tutup</button>
                        </div>
                    )}

                    <div className="bg-white border border-zinc-100 p-8 shadow-sm">
                        <form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-8">
                            {/* Kolom Kiri */}
                            <div className="space-y-6">
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">ID Produk</label>
                                    <input required name="id" value={formData.id} onChange={handleInputChange} placeholder="Contoh: TW-001" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm" />
                                </div>
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Nama Produk</label>
                                    <input required name="name" value={formData.name} onChange={handleInputChange} placeholder="Nama Baju" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm" />
                                </div>
                                <div className="grid grid-cols-2 gap-4">
                                    <div>
                                        <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Harga (RM)</label>
                                        <input required name="price" type="number" value={formData.price} onChange={handleInputChange} placeholder="0" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm" />
                                    </div>
                                    <div>
                                        <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Kategori</label>
                                        <select name="category" value={formData.category} onChange={handleInputChange} className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm">
                                            <option value="Dresses">Dresses</option>
                                            <option value="Outerwear">Outerwear</option>
                                            <option value="Tops">Tops</option>
                                            <option value="Accessories">Accessories</option>
                                        </select>
                                    </div>
                                </div>
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Deskripsi Produk</label>
                                    <textarea name="description" value={formData.description} onChange={handleInputChange} rows="4" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm resize-none"></textarea>
                                </div>
                            </div>

                            {/* Kolom Kanan */}
                            <div className="space-y-6">
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Foto Koleksi</label>
                                    <div className="relative border-2 border-dashed border-zinc-100 bg-zinc-50 p-6 text-center cursor-pointer hover:bg-zinc-100 transition-all">
                                        <input type="file" accept="image/*" onChange={(e) => setImageFile(e.target.files[0])} className="absolute inset-0 opacity-0 cursor-pointer" />
                                        <div className="text-zinc-400">
                                            {imageFile ? <span className="text-black font-bold text-xs">{imageFile.name}</span> : <span className="text-xs italic">Pilih atau Seret Foto ke Sini</span>}
                                        </div>
                                    </div>
                                    <p className="mt-2 text-[10px] text-zinc-400 italic">*Foto akan diunggah otomatis ke Google Drive Anda.</p>
                                </div>
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Warna (Pisahkan dengan Titik Koma ';')</label>
                                    <input name="colors" value={formData.colors} onChange={handleInputChange} placeholder="Hitam; Putih; Champagne" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm" />
                                </div>
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Saiz (Pisahkan dengan Titik Koma ';')</label>
                                    <input name="sizes" value={formData.sizes} onChange={handleInputChange} placeholder="S; M; L; XL" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm" />
                                </div>
                                <div className="flex items-center space-x-3 pt-4">
                                    <input type="checkbox" id="isNew" name="isNew" checked={formData.isNew} onChange={handleInputChange} className="w-4 h-4 accent-black" />
                                    <label htmlFor="isNew" className="text-[10px] uppercase font-bold tracking-widest text-zinc-500">Tandai sebagai Koleksi Baharu</label>
                                </div>
                            </div>

                            <div className="md:col-span-2 pt-8">
                                <button
                                    disabled={loading}
                                    type="submit"
                                    className={`w-full py-4 text-[10px] uppercase font-bold tracking-[0.2em] transition-all flex items-center justify-center space-x-2 ${
                                        loading ? 'bg-zinc-200 text-zinc-400' : 'bg-black text-white hover:bg-zinc-800'
                                    }`}
                                >
                                    {loading ? 'Sedang Memproses...' : 'Simpan ke Koleksi'}
                                </button>
                            </div>
                        </form>
                    </div>

                    <footer className="mt-20 text-center">
                        <button onClick={() => window.open('https://docs.google.com/spreadsheets/d/e/2PACX-1vQscRV1rweAv033WKZJLSSbdCnJt1yEWgSB5dQhu69Uf7m-0SEr8f-sdEwHlQWPNrQmXRowp039v46W/pub?gid=0&single=true&output=csv')} className="text-[10px] uppercase font-bold border-b border-black pb-1 opacity-50 hover:opacity-100 transition-opacity">
                            Buka Spreadsheet Database
                        </button>
                    </footer>
                </div>
            );
        }

        const root = ReactDOM.createRoot(document.getElementById("root"));
        root.render(<AdminDashboard />);
    </script>
</body>
</html>


Tidak ada komentar:

Posting Komentar

Apps Script - Dashboard CRUD

  https://docs.google.com/spreadsheets/d/1mOlgs49uHqfoGfSFstd__wJo02FbRB5ofQWzcvOq6tw/edit?gid=0#gid=0 https://script.google.com/u/0/home/pr...