40087-vm/store.php
2026-05-26 08:54:27 +00:00

1163 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
if (basename((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === basename(__FILE__)) {
header('Location: index.php');
exit;
}
const STORE_CART_KEY = 'sekut_cart';
const STORE_FLASH_KEY = 'sekut_flash';
const STORE_LAST_ORDER_KEY = 'sekut_last_order';
const STORE_USER_KEY = 'sekut_user';
const STORE_PENDING_CART_ADD_KEY = 'sekut_pending_cart_add';
function app_env(string $key, string $fallback = ''): string
{
$serverValue = $_SERVER[$key] ?? '';
if (is_string($serverValue) && $serverValue !== '') {
return $serverValue;
}
$envValue = getenv($key);
if (is_string($envValue) && $envValue !== '') {
return $envValue;
}
return $fallback;
}
function h($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function store_brand(): string
{
return 'SEKUT BAKERY';
}
function store_current_user(): ?array
{
$user = $_SESSION[STORE_USER_KEY] ?? null;
if (!is_array($user)) {
return null;
}
$id = (int)($user['id'] ?? 0);
$fullName = store_sanitize_line((string)($user['full_name'] ?? ''), 120);
$email = store_lower(trim((string)($user['email'] ?? '')));
if ($id <= 0 || $fullName === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return null;
}
return [
'id' => $id,
'full_name' => $fullName,
'email' => $email,
];
}
function store_is_logged_in(): bool
{
return store_current_user() !== null;
}
function store_user_first_name(string $fullName): string
{
$fullName = trim(preg_replace('/\s+/', ' ', $fullName) ?? '');
if ($fullName === '') {
return 'Pelanggan';
}
$parts = explode(' ', $fullName);
return store_substr((string)$parts[0], 0, 24);
}
function store_set_user_session(array $user): void
{
$_SESSION[STORE_USER_KEY] = [
'id' => (int)($user['id'] ?? 0),
'full_name' => store_sanitize_line((string)($user['full_name'] ?? ''), 120),
'email' => store_lower(trim((string)($user['email'] ?? ''))),
];
}
function store_logout_user(): void
{
unset($_SESSION[STORE_USER_KEY], $_SESSION[STORE_PENDING_CART_ADD_KEY]);
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
}
function store_queue_cart_add_after_login(string $slug, int $quantity = 1, string $redirectTo = 'index.php'): bool
{
$product = store_product($slug);
if (!$product) {
return false;
}
$_SESSION[STORE_PENDING_CART_ADD_KEY] = [
'slug' => (string)$product['slug'],
'quantity' => max(1, min(20, $quantity)),
'redirect_to' => store_safe_redirect($redirectTo, 'index.php'),
];
return true;
}
function store_pending_cart_add(): ?array
{
$pending = $_SESSION[STORE_PENDING_CART_ADD_KEY] ?? null;
if (!is_array($pending)) {
return null;
}
$slug = (string)($pending['slug'] ?? '');
if ($slug === '') {
unset($_SESSION[STORE_PENDING_CART_ADD_KEY]);
return null;
}
$product = store_product($slug);
if (!$product) {
unset($_SESSION[STORE_PENDING_CART_ADD_KEY]);
return null;
}
return [
'slug' => (string)$product['slug'],
'quantity' => max(1, min(20, (int)($pending['quantity'] ?? 1))),
'redirect_to' => store_safe_redirect((string)($pending['redirect_to'] ?? 'index.php'), 'index.php'),
'product' => $product,
];
}
function store_consume_pending_cart_add(): ?array
{
$pending = store_pending_cart_add();
unset($_SESSION[STORE_PENDING_CART_ADD_KEY]);
if (!$pending) {
return null;
}
return [
'slug' => (string)$pending['slug'],
'quantity' => (int)$pending['quantity'],
'redirect_to' => (string)$pending['redirect_to'],
];
}
function store_auth_password_error(string $password): string
{
if (store_strlen($password) < 8) {
return 'Password minimal 8 karakter.';
}
return '';
}
function store_register_user(array $source): array
{
store_ensure_schema();
$form = [
'full_name' => store_sanitize_line((string)($source['full_name'] ?? ''), 120),
'email' => store_lower(trim((string)($source['email'] ?? ''))),
];
$password = (string)($source['password'] ?? '');
$confirmPassword = (string)($source['confirm_password'] ?? '');
$errors = [];
if (store_strlen($form['full_name']) < 3) {
$errors['full_name'] = 'Nama lengkap minimal 3 karakter.';
}
if (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Masukkan email yang valid.';
}
$passwordError = store_auth_password_error($password);
if ($passwordError !== '') {
$errors['password'] = $passwordError;
}
if ($confirmPassword === '') {
$errors['confirm_password'] = 'Ulangi password untuk konfirmasi.';
} elseif ($password !== $confirmPassword) {
$errors['confirm_password'] = 'Konfirmasi password belum cocok.';
}
if (!isset($errors['email'])) {
$exists = db()->prepare('SELECT id FROM customer_users WHERE email = :email LIMIT 1');
$exists->bindValue(':email', $form['email']);
$exists->execute();
if ($exists->fetch()) {
$errors['email'] = 'Email sudah terdaftar. Silakan login.';
}
}
if ($errors) {
return [
'success' => false,
'message' => 'Pendaftaran belum berhasil. Periksa kembali data Anda.',
'errors' => $errors,
'form' => $form,
];
}
$stmt = db()->prepare('INSERT INTO customer_users (full_name, email, password_hash, last_login_at) VALUES (:full_name, :email, :password_hash, NOW())');
$stmt->bindValue(':full_name', $form['full_name']);
$stmt->bindValue(':email', $form['email']);
$stmt->bindValue(':password_hash', password_hash($password, PASSWORD_DEFAULT));
$stmt->execute();
$user = [
'id' => (int)db()->lastInsertId(),
'full_name' => $form['full_name'],
'email' => $form['email'],
];
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
store_set_user_session($user);
return [
'success' => true,
'message' => 'Akun berhasil dibuat. Anda sudah login.',
'user' => $user,
'form' => ['full_name' => '', 'email' => ''],
];
}
function store_login_user(array $source): array
{
store_ensure_schema();
$form = [
'email' => store_lower(trim((string)($source['email'] ?? ''))),
];
$password = (string)($source['password'] ?? '');
if (!filter_var($form['email'], FILTER_VALIDATE_EMAIL) || $password === '') {
$errors = [];
if (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Masukkan email yang valid.';
}
if ($password === '') {
$errors['password'] = 'Password wajib diisi.';
}
return [
'success' => false,
'message' => 'Masukkan email dan password yang benar.',
'errors' => $errors,
'form' => $form,
];
}
$stmt = db()->prepare('SELECT id, full_name, email, password_hash FROM customer_users WHERE email = :email LIMIT 1');
$stmt->bindValue(':email', $form['email']);
$stmt->execute();
$user = $stmt->fetch();
if (!$user || !password_verify($password, (string)($user['password_hash'] ?? ''))) {
return [
'success' => false,
'message' => 'Email atau password tidak cocok.',
'errors' => [],
'form' => $form,
];
}
if (password_needs_rehash((string)($user['password_hash'] ?? ''), PASSWORD_DEFAULT)) {
$rehash = db()->prepare('UPDATE customer_users SET password_hash = :password_hash WHERE id = :id LIMIT 1');
$rehash->bindValue(':password_hash', password_hash($password, PASSWORD_DEFAULT));
$rehash->bindValue(':id', (int)$user['id'], PDO::PARAM_INT);
$rehash->execute();
}
$touch = db()->prepare('UPDATE customer_users SET last_login_at = NOW() WHERE id = :id LIMIT 1');
$touch->bindValue(':id', (int)$user['id'], PDO::PARAM_INT);
$touch->execute();
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
store_set_user_session($user);
return [
'success' => true,
'message' => 'Login berhasil. Selamat datang kembali.',
'user' => [
'id' => (int)$user['id'],
'full_name' => (string)$user['full_name'],
'email' => (string)$user['email'],
],
'form' => ['email' => ''],
];
}
function store_categories(): array
{
return [
'all' => [
'label' => 'Semua',
'description' => 'Seluruh katalog bakery untuk daily order dan pre-order.',
],
'signature-cakes' => [
'label' => 'Signature Cakes',
'description' => 'Whole cake dan sliced cake untuk momen spesial.',
],
'artisan-bread' => [
'label' => 'Artisan Bread',
'description' => 'Roti harian dengan tekstur bersih dan fermentasi lebih lama.',
],
'cookies-pastry' => [
'label' => 'Cookies & Pastry',
'description' => 'Pilihan pastry dan cookies untuk coffee break dan hampers.',
],
];
}
function store_products(): array
{
return [
'burnt-cheesecake' => [
'slug' => 'burnt-cheesecake',
'name' => 'Burnt Cheesecake',
'category' => 'signature-cakes',
'category_label' => 'Signature Cakes',
'price' => 185000.0,
'unit' => 'whole cake',
'short_description' => 'Cheesecake creamy dengan permukaan karamel gelap dan finish yang clean.',
'description' => 'Whole cake 18 cm dengan rasa vanilla-cream cheese yang lembut dan lapisan atas yang sedikit smoky. Cocok untuk 810 porsi dan ideal untuk dessert keluarga.',
'lead_time' => 'Pre-order 1 hari',
'serves' => '810 porsi',
'tone' => 'ink',
'visual_code' => 'BC',
'highlights' => ['18 cm', 'Best seller', 'Pre-order 1 hari'],
],
'matcha-roll-cake' => [
'slug' => 'matcha-roll-cake',
'name' => 'Matcha Roll Cake',
'category' => 'signature-cakes',
'category_label' => 'Signature Cakes',
'price' => 158000.0,
'unit' => 'roll cake',
'short_description' => 'Sponge cake tipis dengan filling krim matcha yang ringan.',
'description' => 'Roll cake dengan sponge lembut, rasa matcha yang clean, dan krim yang tidak terlalu manis. Cocok untuk sharing di meeting kecil atau hadiah praktis.',
'lead_time' => 'Ready setiap pagi',
'serves' => '68 potong',
'tone' => 'stone',
'visual_code' => 'MR',
'highlights' => ['68 potong', 'Fresh daily', 'Light sweetness'],
],
'sesame-sourdough' => [
'slug' => 'sesame-sourdough',
'name' => 'Sesame Sourdough Loaf',
'category' => 'artisan-bread',
'category_label' => 'Artisan Bread',
'price' => 52000.0,
'unit' => 'loaf',
'short_description' => 'Roti sourdough dengan crust renyah, crumb lembut, dan taburan wijen.',
'description' => 'Loaf fermentasi alami dengan aroma gandum yang lebih dalam. Enak untuk sandwich, toast sarapan, atau stok roti rumah selama 23 hari.',
'lead_time' => 'Ready setiap siang',
'serves' => '8 slice tebal',
'tone' => 'sand',
'visual_code' => 'SD',
'highlights' => ['Fermentasi alami', '8 slice', 'Crust renyah'],
],
'focaccia-olive' => [
'slug' => 'focaccia-olive',
'name' => 'Focaccia Tomato Olive',
'category' => 'artisan-bread',
'category_label' => 'Artisan Bread',
'price' => 48000.0,
'unit' => 'tray',
'short_description' => 'Focaccia savory dengan olive oil, tomat, dan olive hitam.',
'description' => 'Roti focaccia bertekstur empuk dengan permukaan yang sedikit garing. Cocok untuk snack meeting, teman sup, atau dibuat sandwich terbuka.',
'lead_time' => 'Ready terbatas',
'serves' => '45 porsi',
'tone' => 'taupe',
'visual_code' => 'FO',
'highlights' => ['Savory', '45 porsi', 'Ready terbatas'],
],
'butter-croissant' => [
'slug' => 'butter-croissant',
'name' => 'Butter Croissant',
'category' => 'cookies-pastry',
'category_label' => 'Cookies & Pastry',
'price' => 28000.0,
'unit' => 'pcs',
'short_description' => 'Croissant berlapis dengan aroma butter dan tekstur flaky.',
'description' => 'Pastry klasik dengan lapisan tipis yang renyah di luar dan ringan di dalam. Cocok untuk sarapan cepat atau pairing dengan kopi hitam.',
'lead_time' => 'Ready dari pagi',
'serves' => '1 pcs besar',
'tone' => 'stone',
'visual_code' => 'CR',
'highlights' => ['Flaky', 'Fresh baked', 'Coffee pairing'],
],
'almond-cookies-tin' => [
'slug' => 'almond-cookies-tin',
'name' => 'Almond Cookies Tin',
'category' => 'cookies-pastry',
'category_label' => 'Cookies & Pastry',
'price' => 68000.0,
'unit' => 'tin',
'short_description' => 'Cookies renyah dengan potongan almond panggang dalam kemasan kaleng.',
'description' => 'Cookies butter dengan tekstur crisp dan rasa toasted almond yang hangat. Praktis untuk hampers kecil, pantry kantor, atau stok camilan rumah.',
'lead_time' => 'Ready stock',
'serves' => '2024 pcs',
'tone' => 'ink',
'visual_code' => 'AC',
'highlights' => ['2024 pcs', 'Giftable', 'Ready stock'],
],
];
}
function store_product(string $slug): ?array
{
$products = store_products();
return $products[$slug] ?? null;
}
function store_filtered_products(string $category = 'all'): array
{
$products = array_values(store_products());
if ($category === 'all' || !isset(store_categories()[$category])) {
return $products;
}
return array_values(array_filter(
$products,
static fn(array $product): bool => $product['category'] === $category
));
}
function store_related_products(string $slug, string $category, int $limit = 3): array
{
$products = array_values(array_filter(
store_products(),
static fn(array $product): bool => $product['slug'] !== $slug && $product['category'] === $category
));
if (count($products) < $limit) {
$fallback = array_values(array_filter(
store_products(),
static fn(array $product): bool => $product['slug'] !== $slug && $product['category'] !== $category
));
$products = array_merge($products, $fallback);
}
return array_slice($products, 0, $limit);
}
function store_cart(): array
{
if (!isset($_SESSION[STORE_CART_KEY]) || !is_array($_SESSION[STORE_CART_KEY])) {
$_SESSION[STORE_CART_KEY] = [];
}
return $_SESSION[STORE_CART_KEY];
}
function store_set_cart(array $cart): void
{
$_SESSION[STORE_CART_KEY] = $cart;
}
function store_cart_count(): int
{
return array_sum(store_cart());
}
function store_add_to_cart(string $slug, int $quantity = 1): bool
{
$product = store_product($slug);
if (!$product) {
return false;
}
$quantity = max(1, min(20, $quantity));
$cart = store_cart();
$cart[$slug] = min(20, ((int)($cart[$slug] ?? 0)) + $quantity);
store_set_cart($cart);
return true;
}
function store_resume_pending_cart_add(string $fallback = 'index.php'): string
{
$pending = store_consume_pending_cart_add();
if (!$pending) {
return store_safe_redirect($fallback, 'index.php');
}
$redirectTo = store_safe_redirect((string)($pending['redirect_to'] ?? $fallback), $fallback);
$product = store_product((string)$pending['slug']);
if (!$product) {
store_flash('danger', 'Produk yang dipilih sudah tidak tersedia.');
return $redirectTo;
}
$quantity = (int)($pending['quantity'] ?? 1);
if (store_add_to_cart((string)$pending['slug'], $quantity)) {
$message = ($product['name'] ?? 'Produk') . ' ditambahkan ke keranjang.';
if ($quantity > 1) {
$message = ($product['name'] ?? 'Produk') . ' ditambahkan ke keranjang sebanyak ' . $quantity . ' item.';
}
store_flash('success', $message);
} else {
store_flash('danger', 'Produk tidak ditemukan.');
}
return $redirectTo;
}
function store_update_cart(array $quantities): void
{
$products = store_products();
$updated = [];
foreach ($quantities as $slug => $quantity) {
if (!isset($products[$slug])) {
continue;
}
$qty = (int)$quantity;
if ($qty > 0) {
$updated[$slug] = min(20, $qty);
}
}
store_set_cart($updated);
}
function store_remove_from_cart(string $slug): void
{
$cart = store_cart();
unset($cart[$slug]);
store_set_cart($cart);
}
function store_money(float $amount): string
{
return 'Rp ' . number_format($amount, 0, ',', '.');
}
function store_substr(string $value, int $start, int $length): string
{
if (function_exists('mb_substr')) {
return (string)mb_substr($value, $start, $length);
}
return (string)substr($value, $start, $length);
}
function store_strlen(string $value): int
{
if (function_exists('mb_strlen')) {
return (int)mb_strlen($value);
}
return strlen($value);
}
function store_lower(string $value): string
{
if (function_exists('mb_strtolower')) {
return (string)mb_strtolower($value);
}
return strtolower($value);
}
function store_cart_summary(): array
{
$products = store_products();
$lines = [];
foreach (store_cart() as $slug => $quantity) {
if (!isset($products[$slug])) {
continue;
}
$product = $products[$slug];
$lineTotal = ((float)$product['price']) * (int)$quantity;
$lines[] = [
'slug' => $slug,
'quantity' => (int)$quantity,
'product' => $product,
'line_total' => $lineTotal,
];
}
$subtotal = array_reduce(
$lines,
static fn(float $carry, array $line): float => $carry + (float)$line['line_total'],
0.0
);
$shippingFee = $subtotal <= 0 ? 0.0 : ($subtotal >= 250000 ? 0.0 : 18000.0);
return [
'lines' => $lines,
'subtotal' => $subtotal,
'shipping_fee' => $shippingFee,
'grand_total' => $subtotal + $shippingFee,
];
}
function store_payment_methods(): array
{
return [
'bank_transfer' => [
'label' => 'Transfer Bank',
'description' => 'Instruksi pembayaran tampil setelah checkout selesai.',
'instruction' => 'Transfer ke rekening demo bakery lalu simpan bukti bayar untuk verifikasi admin.',
],
'qris' => [
'label' => 'QRIS',
'description' => 'Praktis untuk mobile banking dan e-wallet.',
'instruction' => 'Gunakan QRIS demo yang diberikan admin setelah pesanan masuk ke sistem.',
],
'cod' => [
'label' => 'Bayar di Tempat',
'description' => 'Tersedia untuk pengantaran area terdekat.',
'instruction' => 'Siapkan nominal pas saat kurir mengantarkan pesanan ke alamat Anda.',
],
];
}
function store_status_steps(): array
{
return [
[
'value' => 'Menunggu Pembayaran',
'label' => 'Menunggu Pembayaran',
'description' => 'Pesanan tersimpan dan menunggu pembayaran pelanggan.',
],
[
'value' => 'Diproses',
'label' => 'Diproses',
'description' => 'Kitchen sedang menyiapkan item sesuai pesanan.',
],
[
'value' => 'Dikirim',
'label' => 'Dikirim',
'description' => 'Pesanan sedang diantar ke alamat tujuan.',
],
[
'value' => 'Selesai',
'label' => 'Selesai',
'description' => 'Pesanan telah diterima pelanggan.',
],
];
}
function store_status_index(string $status): int
{
foreach (store_status_steps() as $index => $step) {
if ($step['value'] === $status) {
return $index;
}
}
return -1;
}
function store_status_class(string $status): string
{
return match ($status) {
'Menunggu Pembayaran' => 'status-pill status-pill--pending',
'Diproses' => 'status-pill status-pill--processing',
'Dikirim' => 'status-pill status-pill--shipping',
'Selesai' => 'status-pill status-pill--done',
'Batal' => 'status-pill status-pill--cancelled',
default => 'status-pill',
};
}
function store_flash(string $type, string $message): void
{
$_SESSION[STORE_FLASH_KEY][] = [
'type' => $type,
'message' => $message,
];
}
function store_consume_flashes(): array
{
$flashes = $_SESSION[STORE_FLASH_KEY] ?? [];
unset($_SESSION[STORE_FLASH_KEY]);
return is_array($flashes) ? $flashes : [];
}
function store_input_class(array $errors, string $field): string
{
return isset($errors[$field]) ? ' is-invalid' : '';
}
function store_checkout_defaults(): array
{
$currentUser = store_current_user();
return [
'customer_name' => (string)($currentUser['full_name'] ?? ''),
'email' => (string)($currentUser['email'] ?? ''),
'phone' => '',
'address' => '',
'note' => '',
'payment_method' => 'bank_transfer',
];
}
function store_sanitize_line(string $value, int $max = 255): string
{
$value = trim(preg_replace('/\s+/', ' ', $value) ?? '');
return store_substr($value, 0, $max);
}
function store_normalize_checkout_input(array $source): array
{
$defaults = store_checkout_defaults();
return [
'customer_name' => store_sanitize_line((string)($source['customer_name'] ?? $defaults['customer_name']), 120),
'email' => store_lower(trim((string)($source['email'] ?? $defaults['email']))),
'phone' => store_sanitize_line((string)($source['phone'] ?? $defaults['phone']), 40),
'address' => trim(store_substr((string)($source['address'] ?? $defaults['address']), 0, 500)),
'note' => trim(store_substr((string)($source['note'] ?? $defaults['note']), 0, 500)),
'payment_method' => store_sanitize_line((string)($source['payment_method'] ?? $defaults['payment_method']), 40),
];
}
function store_validate_checkout_input(array $data): array
{
$errors = [];
$methods = store_payment_methods();
if (store_strlen($data['customer_name']) < 3) {
$errors['customer_name'] = 'Nama minimal 3 karakter.';
}
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Masukkan email yang valid.';
}
$normalizedPhone = preg_replace('/[^0-9+]/', '', $data['phone']) ?? '';
if (strlen($normalizedPhone) < 8) {
$errors['phone'] = 'Nomor telepon minimal 8 digit.';
}
if (store_strlen($data['address']) < 10) {
$errors['address'] = 'Alamat minimal 10 karakter.';
}
if (!isset($methods[$data['payment_method']])) {
$errors['payment_method'] = 'Pilih metode pembayaran yang tersedia.';
}
return $errors;
}
function store_ensure_schema(): void
{
static $ready = false;
if ($ready) {
return;
}
db()->exec(
"CREATE TABLE IF NOT EXISTS orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_number VARCHAR(30) NOT NULL UNIQUE,
customer_name VARCHAR(120) NOT NULL,
email VARCHAR(160) NOT NULL,
phone VARCHAR(40) NOT NULL,
address TEXT NOT NULL,
note TEXT NULL,
payment_method VARCHAR(40) NOT NULL,
status VARCHAR(40) NOT NULL DEFAULT 'Menunggu Pembayaran',
subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,
shipping_fee DECIMAL(12,2) NOT NULL DEFAULT 0,
grand_total DECIMAL(12,2) NOT NULL DEFAULT 0,
items_json LONGTEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_order_number (order_number),
INDEX idx_email_created_at (email, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
db()->exec(
"CREATE TABLE IF NOT EXISTS customer_users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
full_name VARCHAR(120) NOT NULL,
email VARCHAR(160) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_login_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_customer_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$ready = true;
}
function store_generate_order_number(): string
{
store_ensure_schema();
$pdo = db();
for ($attempt = 0; $attempt < 6; $attempt++) {
$candidate = 'SB' . date('ymd') . '-' . (string)random_int(1000, 9999);
$stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = :order_number LIMIT 1');
$stmt->execute(['order_number' => $candidate]);
if (!$stmt->fetch()) {
return $candidate;
}
}
return 'SB' . date('ymdHis');
}
function store_create_order(array $input): array
{
$data = store_normalize_checkout_input($input);
$errors = store_validate_checkout_input($data);
$summary = store_cart_summary();
if (empty($summary['lines'])) {
$errors['cart'] = 'Keranjang masih kosong. Tambahkan produk terlebih dahulu.';
}
if ($errors) {
return [
'success' => false,
'errors' => $errors,
'data' => $data,
];
}
store_ensure_schema();
$items = array_map(
static function (array $line): array {
return [
'slug' => $line['slug'],
'name' => $line['product']['name'],
'price' => (float)$line['product']['price'],
'quantity' => (int)$line['quantity'],
'line_total' => (float)$line['line_total'],
];
},
$summary['lines']
);
$orderNumber = store_generate_order_number();
$stmt = db()->prepare(
'INSERT INTO orders (
order_number,
customer_name,
email,
phone,
address,
note,
payment_method,
status,
subtotal,
shipping_fee,
grand_total,
items_json
) VALUES (
:order_number,
:customer_name,
:email,
:phone,
:address,
:note,
:payment_method,
:status,
:subtotal,
:shipping_fee,
:grand_total,
:items_json
)'
);
$stmt->bindValue(':order_number', $orderNumber);
$stmt->bindValue(':customer_name', $data['customer_name']);
$stmt->bindValue(':email', $data['email']);
$stmt->bindValue(':phone', $data['phone']);
$stmt->bindValue(':address', $data['address']);
$stmt->bindValue(':note', $data['note'] === '' ? null : $data['note']);
$stmt->bindValue(':payment_method', $data['payment_method']);
$stmt->bindValue(':status', 'Menunggu Pembayaran');
$stmt->bindValue(':subtotal', $summary['subtotal']);
$stmt->bindValue(':shipping_fee', $summary['shipping_fee']);
$stmt->bindValue(':grand_total', $summary['grand_total']);
$stmt->bindValue(':items_json', json_encode($items, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$stmt->execute();
$_SESSION[STORE_CART_KEY] = [];
$_SESSION[STORE_LAST_ORDER_KEY] = [
'order_number' => $orderNumber,
'email' => $data['email'],
];
return [
'success' => true,
'order_number' => $orderNumber,
'data' => $data,
];
}
function store_find_order(string $orderNumber, string $email = ''): ?array
{
$orderNumber = store_sanitize_line($orderNumber, 30);
$email = trim(store_lower($email));
if ($orderNumber === '') {
return null;
}
store_ensure_schema();
$sql = 'SELECT * FROM orders WHERE order_number = :order_number';
$params = ['order_number' => $orderNumber];
if ($email !== '') {
$sql .= ' AND email = :email';
$params['email'] = $email;
}
$sql .= ' LIMIT 1';
$stmt = db()->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue(':' . $key, $value);
}
$stmt->execute();
$order = $stmt->fetch();
if (!$order) {
return null;
}
$order['items'] = json_decode((string)$order['items_json'], true) ?: [];
$paymentMethods = store_payment_methods();
$order['payment_method_label'] = $paymentMethods[$order['payment_method']]['label'] ?? $order['payment_method'];
$order['payment_instruction'] = $paymentMethods[$order['payment_method']]['instruction'] ?? 'Tim admin akan menghubungi Anda untuk instruksi berikutnya.';
return $order;
}
function store_last_order_lookup(): array
{
$lookup = $_SESSION[STORE_LAST_ORDER_KEY] ?? [];
return is_array($lookup) ? $lookup : [];
}
function store_safe_redirect(string $target, string $fallback = 'cart.php'): string
{
$target = trim($target);
if ($target === '') {
return $fallback;
}
$parts = parse_url($target);
if ($parts === false || isset($parts['scheme']) || isset($parts['host'])) {
return $fallback;
}
$path = $parts['path'] ?? '';
if ($path === '' || str_contains($path, '..')) {
return $fallback;
}
$path = ltrim($path, '/');
if ($path === '') {
$path = $fallback;
}
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
return $path . $query;
}
function store_format_datetime(string $value): string
{
$timestamp = strtotime($value);
if ($timestamp === false) {
return $value;
}
$months = [
1 => 'Jan',
2 => 'Feb',
3 => 'Mar',
4 => 'Apr',
5 => 'Mei',
6 => 'Jun',
7 => 'Jul',
8 => 'Agu',
9 => 'Sep',
10 => 'Okt',
11 => 'Nov',
12 => 'Des',
];
$month = $months[(int)date('n', $timestamp)] ?? date('M', $timestamp);
return date('d', $timestamp) . ' ' . $month . ' ' . date('Y, H:i', $timestamp);
}
function store_nav_link(string $href, string $label, string $currentPath): string
{
$active = $currentPath === $href ? ' nav-link active' : ' nav-link';
return '<li class="nav-item"><a class="' . $active . '" href="' . h($href) . '">' . h($label) . '</a></li>';
}
function store_page_start(string $title, string $description = '', array $options = []): void
{
$projectName = app_env('PROJECT_NAME', store_brand());
$projectDescription = app_env('PROJECT_DESCRIPTION', 'Toko online bakery dengan katalog, keranjang, checkout, dan halaman status pesanan.');
$projectImageUrl = app_env('PROJECT_IMAGE_URL', '');
$metaDescription = $description !== '' ? $description : $projectDescription;
$fullTitle = trim($title) !== '' ? $title . ' • ' . $projectName : $projectName;
$cssVersion = file_exists(__DIR__ . '/assets/css/custom.css') ? (string)filemtime(__DIR__ . '/assets/css/custom.css') : (string)time();
$faviconVersion = file_exists(__DIR__ . '/assets/images/favicon.svg') ? (string)filemtime(__DIR__ . '/assets/images/favicon.svg') : $cssVersion;
$currentPath = basename(parse_url($_SERVER['REQUEST_URI'] ?? '/index.php', PHP_URL_PATH) ?: 'index.php');
if ($currentPath === '' || $currentPath === '/') {
$currentPath = 'index.php';
}
$robots = !empty($options['noindex']) ? '<meta name="robots" content="noindex, nofollow" />' : '';
$cartCount = store_cart_count();
$currentUser = store_current_user();
echo '<!doctype html>';
echo '<html lang="id">';
echo '<head>';
echo '<meta charset="utf-8" />';
echo '<meta name="viewport" content="width=device-width, initial-scale=1" />';
echo '<meta name="theme-color" content="#2B2927" />';
echo '<title>' . h($fullTitle) . '</title>';
echo $robots;
if ($metaDescription !== '') {
echo '<meta name="description" content="' . h($metaDescription) . '" />';
echo '<meta property="og:title" content="' . h($fullTitle) . '" />';
echo '<meta property="og:description" content="' . h($metaDescription) . '" />';
echo '<meta name="twitter:title" content="' . h($fullTitle) . '" />';
echo '<meta property="twitter:description" content="' . h($metaDescription) . '" />';
}
if ($projectImageUrl !== '') {
echo '<meta property="og:image" content="' . h($projectImageUrl) . '" />';
echo '<meta property="twitter:image" content="' . h($projectImageUrl) . '" />';
}
echo '<link rel="preconnect" href="https://fonts.googleapis.com">';
echo '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>';
echo '<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">';
echo '<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg?v=' . h($faviconVersion) . '">';
echo '<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">';
echo '<link rel="stylesheet" href="assets/css/custom.css?v=' . h($cssVersion) . '">';
echo '</head>';
echo '<body>';
echo '<header class="site-header sticky-top">';
echo ' <nav class="navbar navbar-expand-lg navbar-light">';
echo ' <div class="container-xxl">';
echo ' <a class="navbar-brand brand-mark" href="index.php">';
echo ' <span class="brand-mark__title">' . h(store_brand()) . '</span>';
echo ' <span class="brand-mark__subtitle">Cake • Bread • Pastry</span>';
echo ' </a>';
echo ' <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#siteNav" aria-controls="siteNav" aria-expanded="false" aria-label="Toggle navigation">';
echo ' <span class="navbar-toggler-icon"></span>';
echo ' </button>';
echo ' <div class="collapse navbar-collapse" id="siteNav">';
echo ' <ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">';
echo store_nav_link('index.php', 'Home', $currentPath);
echo store_nav_link('index.php#catalog', 'Daftar Kue', $currentPath);
echo store_nav_link('index.php#payment-info', 'Info Pembayaran', $currentPath);
echo store_nav_link('index.php#contact', 'Kontak Kami', $currentPath);
if ($currentUser) {
echo store_nav_link('auth.php', 'Akun', $currentPath);
echo ' <li class="nav-item"><a class="btn btn-outline-secondary btn-sm" href="logout.php">Logout</a></li>';
} else {
echo store_nav_link('auth.php', 'Login / Register', $currentPath);
}
echo ' <li class="nav-item ms-lg-2">';
echo ' <a class="btn btn-dark btn-sm btn-cart" href="cart.php">Keranjang <span class="badge rounded-pill text-bg-light ms-2">' . h((string)$cartCount) . '</span></a>';
echo ' </li>';
echo ' </ul>';
echo ' </div>';
echo ' </div>';
echo ' </nav>';
echo '</header>';
$flashes = store_consume_flashes();
if ($flashes) {
echo '<div class="toast-stack position-fixed top-0 end-0 p-3">';
foreach ($flashes as $index => $flash) {
$type = $flash['type'] ?? 'info';
$class = match ($type) {
'success' => 'toast-theme toast-theme--success',
'warning' => 'toast-theme toast-theme--warning',
'danger' => 'toast-theme toast-theme--danger',
default => 'toast-theme',
};
echo '<div class="toast ' . h($class) . '" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="3800">';
echo ' <div class="toast-body">' . h((string)($flash['message'] ?? '')) . '</div>';
echo '</div>';
}
echo '</div>';
}
echo '<main class="site-main">';
echo ' <div class="container-xxl py-4 py-lg-5">';
}
function store_page_end(): void
{
$jsVersion = file_exists(__DIR__ . '/assets/js/main.js') ? (string)filemtime(__DIR__ . '/assets/js/main.js') : (string)time();
$currentUser = store_current_user();
echo ' </div>';
echo '</main>';
echo '<footer class="site-footer">';
echo ' <div class="container-xxl d-flex flex-column flex-lg-row justify-content-between gap-3 py-4">';
echo ' <div>';
echo ' <div class="footer-title">' . h(store_brand()) . '</div>';
echo ' <p class="footer-copy">Tampilan user bakery dengan halaman home, daftar kue, info pembayaran, kontak, login user, dan tracking pesanan.</p>';
echo ' </div>';
echo ' <div class="footer-links d-flex flex-wrap gap-3">';
echo ' <a href="index.php">Home</a>';
echo ' <a href="index.php#catalog">Daftar kue</a>';
echo ' <a href="index.php#payment-info">Info pembayaran</a>';
echo ' <a href="index.php#contact">Kontak kami</a>';
echo ' <a href="' . h($currentUser ? 'auth.php' : 'auth.php') . '">' . h($currentUser ? 'Akun saya' : 'Login / Register') . '</a>';
echo ' <a href="use_case.php">Use case diagram</a>';
echo ' <a href="order_status.php">Lacak pesanan</a>';
echo ' </div>';
echo ' </div>';
echo '</footer>';
echo '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>';
echo '<script src="assets/js/main.js?v=' . h($jsVersion) . '"></script>';
echo '</body>';
echo '</html>';
}