diff --git a/public/index.php b/public/index.php index b11557f..849a664 100644 --- a/public/index.php +++ b/public/index.php @@ -75,7 +75,7 @@ if (str_starts_with(haystack: $route, needle: '/.well-known/lnurlp/')) { } // Use this controller for routes that include a model ID -if (preg_match('/^\/(address(?:\/edit|\/delete)?|transaction|user|order|quote|product|subscription|cart|admin\/categories\/edit)\/([\w-]+)$/', $route, $matches)) { +if (preg_match('/^\/(address(?:\/edit|\/delete)?|transaction|user|order|quote|product|subscription|cart|admin\/categories\/edit|admin\/products\/edit)\/([\w-]+)$/', $route, $matches)) { [$full, $type, $id] = $matches; $controller = [ 'address/edit' => fn($id) => address::edit($defaults, $id), @@ -88,6 +88,7 @@ if (preg_match('/^\/(address(?:\/edit|\/delete)?|transaction|user|order|quote|pr 'subscription' => fn($id) => subscriptions::view($id), 'cart' => fn($id) => cart::index($id), 'admin/categories/edit' => fn($id) => $defaults['is_admin'] ? admin::categories_edit($defaults, $id) : lost::index($defaults), + 'admin/products/edit' => fn($id) => $defaults['is_admin'] ? admin::products_edit($defaults, $id) : lost::index($defaults), ]; if (isset($controller[$type])) { @@ -114,6 +115,7 @@ if (preg_match('/^\/(address(?:\/edit|\/delete)?|transaction|user|order|quote|pr '/admin' => $defaults['is_admin'] ? admin::index($defaults) : lost::index($defaults), '/admin/users' => $defaults['is_admin'] ? admin::users($defaults) : lost::index($defaults), '/admin/products' => $defaults['is_admin'] ? admin::products($defaults) : lost::index($defaults), + '/admin/products/add' => $defaults['is_admin'] ? admin::products_add($defaults) : lost::index($defaults), '/admin/orders' => $defaults['is_admin'] ? admin::orders($defaults) : lost::index($defaults), '/admin/emails' => $defaults['is_admin'] ? admin::emails($defaults) : lost::index($defaults), '/admin/categories' => $defaults['is_admin'] ? admin::categories($defaults) : lost::index($defaults), diff --git a/src/controllers/admin.php b/src/controllers/admin.php index bd8a793..c5a6a4f 100644 --- a/src/controllers/admin.php +++ b/src/controllers/admin.php @@ -3,6 +3,7 @@ namespace app\controllers; use app\models\categories; use app\models\emails; +use app\models\products; use app\models\transactions; use app\models\users; @@ -27,6 +28,7 @@ class admin echo $GLOBALS['twig']->render('lib/pages/index.twig', array_merge($defaults, [ 'child_template' => 'admin/users.twig', 'page_title' => 'Users', + 'users' => users::getUsers(20), 'breadcrumbs' => [ [ 'url' => '/admin', @@ -46,6 +48,7 @@ class admin 'child_template' => 'admin/products/index.twig', 'page_title' => 'Products', 'categories' => categories::getTree(), + 'products' => products::get(20), 'breadcrumbs' => [ [ 'url' => '/admin', @@ -59,6 +62,84 @@ class admin ])); } + public static function products_add($defaults) + { + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $_SESSION['last_post'] = $_POST; + $valid = products::validateProductData($_POST); + if (! empty($valid['errors'])) { + $_SESSION['error'] = implode(' ', $valid['errors']); + header('Location: /admin/products/add'); + exit; + } + products::add($valid); + $_SESSION['success'] = 'Product added successfully.'; + unset($_SESSION['last_post']); + header('Location: /admin/products/add'); + exit; + } + echo $GLOBALS['twig']->render('lib/pages/index.twig', array_merge($defaults, [ + 'child_template' => 'admin/products/add.twig', + 'page_title' => 'Add a Product', + 'categories' => categories::getOptions(), + 'breadcrumbs' => [ + [ + 'url' => '/admin', + 'title' => 'Admin', + ], + [ + 'url' => '/admin/products', + 'title' => 'Products', + ], + [ + 'url' => '/admin/products/add', + 'title' => 'Add Product', + ], + ], + ])); + } + + public static function products_edit($defaults, $id) + { + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $_SESSION['last_post'] = $_POST; + $valid = products::validateProductData($_POST); + + if (! empty($valid['errors'])) { + $_SESSION['error'] = implode(' ', $valid['errors']); + header("Location: /admin/products/edit/$id"); + exit; + } + products::update($id, $valid); + $_SESSION['success'] = 'Product updated successfully.'; + unset($_SESSION['last_post']); + header("Location: /admin/products/edit/$id"); + exit; + } + $product = products::getById($id); + + echo $GLOBALS['twig']->render('lib/pages/index.twig', array_merge($defaults, [ + 'child_template' => 'admin/products/edit.twig', + 'page_title' => 'Edit Product', + 'product' => $product, + 'categories' => categories::getOptions(), + 'breadcrumbs' => [ + [ + 'url' => '/admin', + 'title' => 'Admin', + ], + [ + 'url' => '/admin/products', + 'title' => 'Products', + ], + [ + 'url' => "/admin/products/edit/$id", + 'title' => 'Edit Product', + ], + ], + ])); + } + public static function categories($defaults) { echo $GLOBALS['twig']->render('lib/pages/index.twig', array_merge($defaults, [ diff --git a/src/models/addresses.php b/src/models/addresses.php index cefcd90..06bc148 100644 --- a/src/models/addresses.php +++ b/src/models/addresses.php @@ -9,7 +9,7 @@ class addresses { app::$db->exec("CREATE TABLE IF NOT EXISTS addresses ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, + user_id INTEGER REFERENCES users(id), name TEXT NOT NULL, company TEXT, addressLine1 TEXT NOT NULL, @@ -17,8 +17,7 @@ class addresses city TEXT NOT NULL, state TEXT NOT NULL, zip TEXT NOT NULL, - phone TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) + phone TEXT )"); } diff --git a/src/models/cart_items.php b/src/models/cart_items.php index edbaef1..8d4f829 100644 --- a/src/models/cart_items.php +++ b/src/models/cart_items.php @@ -9,12 +9,10 @@ class cart_items { app::$db->exec("CREATE TABLE IF NOT EXISTS cart_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, - cart_id INTEGER NOT NULL, - product_id INTEGER NOT NULL, + cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE, + product_id INTEGER NOT NULL REFERENCES products(id), quantity INTEGER NOT NULL CHECK(quantity > 0), - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE, - FOREIGN KEY (product_id) REFERENCES products(id) + added_at DATETIME DEFAULT CURRENT_TIMESTAMP );"); } diff --git a/src/models/carts.php b/src/models/carts.php index 7bf304d..84eb24e 100644 --- a/src/models/carts.php +++ b/src/models/carts.php @@ -9,10 +9,9 @@ class carts { app::$db->exec("CREATE TABLE IF NOT EXISTS carts ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id), short_id TEXT UNIQUE NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) + created_at DATETIME DEFAULT CURRENT_TIMESTAMP );"); } diff --git a/src/models/categories.php b/src/models/categories.php index 5d30a96..45c7f18 100644 --- a/src/models/categories.php +++ b/src/models/categories.php @@ -9,10 +9,9 @@ class categories { app::$db->exec("CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, - parent_id INTEGER, + parent_id INTEGER REFERENCES categories(id), label TEXT UNIQUE NOT NULL, - value TEXT UNIQUE NOT NULL, - FOREIGN KEY (parent_id) REFERENCES categories(id) + value TEXT UNIQUE NOT NULL );"); } diff --git a/src/models/emails.php b/src/models/emails.php index b39f3ed..818eab0 100644 --- a/src/models/emails.php +++ b/src/models/emails.php @@ -11,15 +11,14 @@ class emails { app::$db->exec("CREATE TABLE IF NOT EXISTS emails ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, + user_id INTEGER REFERENCES users(id), from_email TEXT NOT NULL, from_name TEXT NOT NULL, to_email TEXT NOT NULL, subject TEXT NOT NULL, message TEXT NOT NULL, html_message TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) + created_at DATETIME DEFAULT CURRENT_TIMESTAMP );"); } diff --git a/src/models/invoices.php b/src/models/invoices.php index 3d98880..661b939 100644 --- a/src/models/invoices.php +++ b/src/models/invoices.php @@ -10,15 +10,13 @@ class invoices { $query = "CREATE TABLE IF NOT EXISTS invoices ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - order_id INTEGER, + user_id INTEGER NOT NULL REFERENCES users(id), + order_id INTEGER REFERENCES orders(id), invoice TEXT NOT NULL, verify TEXT NOT NULL, amount_msats REAL NOT NULL, expiry_date DATETIME NOT NULL, - settled BOOLEAN DEFAULT FALSE, - FOREIGN KEY(user_id) REFERENCES users(id), - FOREIGN KEY(order_id) REFERENCES orders(id) + settled BOOLEAN DEFAULT FALSE )"; app::$db->exec($query); } diff --git a/src/models/order_items.php b/src/models/order_items.php index 3b71a78..4992dec 100644 --- a/src/models/order_items.php +++ b/src/models/order_items.php @@ -9,12 +9,10 @@ class order_items { app::$db->exec("CREATE TABLE IF NOT EXISTS order_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, - order_id INTEGER NOT NULL, - product_id INTEGER NOT NULL, + order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id INTEGER NOT NULL REFERENCES products(id), quantity INTEGER NOT NULL CHECK(quantity > 0), - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, - FOREIGN KEY (product_id) REFERENCES products(id) + added_at DATETIME DEFAULT CURRENT_TIMESTAMP );"); } diff --git a/src/models/orders.php b/src/models/orders.php index 931ba7e..c417fa6 100644 --- a/src/models/orders.php +++ b/src/models/orders.php @@ -14,12 +14,11 @@ class orders { app::$db->exec("CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id), value_sats INTEGER NOT NULL CHECK(value_sats >= 0), value_cents INTEGER NOT NULL CHECK(value_cents >= 0), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - status TEXT CHECK(status IN ('" . implode("', '", self::STATUSES) . "')) NOT NULL DEFAULT 'PENDING', - FOREIGN KEY (user_id) REFERENCES users(id) + status TEXT CHECK(status IN ('" . implode("', '", self::STATUSES) . "')) NOT NULL DEFAULT 'PENDING' );"); } diff --git a/src/models/products.php b/src/models/products.php index 6bcdb23..71f757a 100644 --- a/src/models/products.php +++ b/src/models/products.php @@ -7,64 +7,295 @@ class products { public static function init() { + // max 12 images and 12 specification key/value pairs allowed + $imageColumns = ''; + $specColumns = ''; + for ($i = 0; $i < 12; $i++) { + $imageColumns .= "image_url_$i TEXT" . ($i === 0 ? " NOT NULL" : "") . ", "; + $specColumns .= "spec_key_$i TEXT, spec_val_$i TEXT, "; + } + $imageColumns = rtrim($imageColumns, ', '); + $specColumns = rtrim($specColumns, ', '); + app::$db->exec("CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER REFERENCES categories(id), title TEXT NOT NULL, - desc TEXT, - stock_qty INTEGER NOT NULL DEFAULT 0 CHECK(stock_qty >= 0), - specs_json TEXT, - sats_price INTEGER NOT NULL DEFAULT 0 CHECK(sats_price >= 0), - cents_price INTEGER NOT NULL DEFAULT 0 CHECK(cents_price >= 0), - digital BOOLEAN NOT NULL DEFAULT 0, - subscription BOOLEAN NOT NULL DEFAULT 0, - image_url_0 TEXT, - image_url_1 TEXT, - image_url_2 TEXT, - image_url_3 TEXT, - image_url_4 TEXT, - image_url_5 TEXT, - image_url_6 TEXT, - image_url_7 TEXT, - image_url_8 TEXT, - image_url_9 TEXT, - image_url_10 TEXT, - image_url_11 TEXT + description TEXT, + stock_qty INTEGER, + is_unlimited BOOLEAN, + is_enabled BOOLEAN, + is_quote_only BOOLEAN, + price_sats INTEGER, + sats_back_percent INTEGER, + is_accepting_sats BOOLEAN, + is_sats_back_only BOOLEAN, + price_cents INTEGER, + cents_back_percent INTEGER, + is_accepting_cents BOOLEAN, + is_cents_back_only BOOLEAN, + is_digital BOOLEAN, + oz REAL, + lbs REAL, + length REAL, + width REAL, + height REAL, + is_subscription BOOLEAN, + is_subscription_only BOOLEAN, + subscription_frequency TEXT, + $imageColumns, + $specColumns )"); } - public static function add($title, $desc, $stock_qty, $specs_json, $sats_price, $cents_price, $digital, $subscription, $images) + public static function validateProductData($data) { + $error_messages = []; + $title = $data['title'] ?? null; + $description = $data['description'] ?? null; + $price_sats = $data['price_sats'] ?? null; + $price_cents = $data['price_cents'] ?? null; + $stock_qty = $data['stock_qty'] ?? null; + $is_digital = $data['is_digital'] ?? false; + $is_subscription = $data['is_subscription'] ?? false; + $is_subscription_only = $data['is_subscription_only'] ?? false; + $is_quote_only = $data['is_quote_only'] ?? false; + $oz = $data['oz'] ?? null; + $lbs = $data['lbs'] ?? null; + $length = $data['length'] ?? null; + $width = $data['width'] ?? null; + $height = $data['height'] ?? null; + $category_id = $data['category_id'] ?? null; + $is_unlimited = $data['is_unlimited'] ?? false; + $is_sats_back_only = $data['is_sats_back_only'] ?? false; + $is_cents_back_only = $data['is_cents_back_only'] ?? false; + $is_enabled = $data['is_enabled'] ?? false; + $is_accepting_sats = $data['is_accepting_sats'] ?? false; + $is_accepting_cents = $data['is_accepting_cents'] ?? false; + $subscription_frequency = $data['subscription_frequency'] ?? null; + $images = []; + for ($i = 0; $i <= 11; $i++) { + $images[] = $data['image_url_' . $i] ?? null; + } + $specs = []; + for ($i = 0; $i <= 11; $i++) { + $key = $_POST['spec_key_' . $i] ?? null; + $val = $_POST['spec_val_' . $i] ?? null; + if ($key !== null || $val !== null) { + if ($key === null || $val === null) { + $error_messages[] = 'Each specification key must have a corresponding value.'; + } + $specs[] = ['key' => $key, 'value' => $val]; + } + } + + if (! $title) { + $error_messages[] = 'Title is a required field.'; + } + if (! $description) { + $error_messages[] = 'Description is a required field.'; + } + if (! $category_id) { + $error_messages[] = 'Category is a required field.'; + } + if (! $images[0]) { + $error_messages[] = 'The first image URL is a required field.'; + } + if (! $is_unlimited && ! $stock_qty) { + $error_messages[] = 'Quantity is required unless the product is marked as unlimited.'; + } + + if (! $is_quote_only) { + if ($price_sats === null && $price_cents === null) { + $error_messages[] = 'Either price in sats or price in cents must be set. They can be zero.'; + } + if ($is_cents_back_only && $is_sats_back_only) { + $error_messages[] = 'A product cannot be both cents back only and sats back only.'; + } + } + + if (! $is_digital) { + if ($oz === null || $lbs === null || $length === null || $width === null || $height === null) { + $error_messages[] = 'For non-digital products, weight (oz and lbs), length, width, and height are required fields.'; + } + } + if ($is_subscription_only && ! $is_subscription) { + $error_messages[] = 'If the product is subscription only, it must also enable subscriptions.'; + } + + return [ + 'errors' => $error_messages, + 'title' => $title, + 'description' => $description, + 'category_id' => $category_id, + 'images' => $images, + 'specs' => $specs, + 'stock_qty' => $stock_qty, + 'price_sats' => $price_sats, + 'price_cents' => $price_cents, + 'is_digital' => $is_digital, + 'is_subscription' => $is_subscription, + 'is_subscription_only' => $is_subscription_only, + 'is_enabled' => $is_enabled, + 'is_unlimited' => $is_unlimited, + 'is_accepting_sats' => $is_accepting_sats, + 'is_accepting_cents' => $is_accepting_cents, + 'is_sats_back_only' => $is_sats_back_only, + 'is_cents_back_only' => $is_cents_back_only, + 'subscription_frequency' => $subscription_frequency, + 'is_quote_only' => $is_quote_only, + 'oz' => $oz, + 'lbs' => $lbs, + 'length' => $length, + 'width' => $width, + 'height' => $height, + ]; + } + + public static function add($productData) + { + $imageColumns = ''; + $imageValues = ''; + $specColumns = ''; + $specValues = ''; + + for ($i = 0; $i < 12; $i++) { + $imageColumns .= "image_url_$i, "; + $imageValues .= ":image_url_$i, "; + $specColumns .= "spec_key_$i, spec_val_$i, "; + $specValues .= ":spec_key_$i, :spec_val_$i, "; + } + + $imageColumns = rtrim($imageColumns, ', '); + $imageValues = rtrim($imageValues, ', '); + $specColumns = rtrim($specColumns, ', '); + $specValues = rtrim($specValues, ', '); + $stmt = app::$db->prepare("INSERT INTO products ( - title, desc, stock_qty, specs_json, sats_price, cents_price, digital, subscription, - image_url_0, image_url_1, image_url_2, image_url_3, image_url_4, image_url_5, - image_url_6, image_url_7, image_url_8, image_url_9, image_url_10, image_url_11 + title, description, stock_qty, price_sats, price_cents, is_digital, is_subscription, is_subscription_only, is_enabled, is_unlimited, is_accepting_sats, is_accepting_cents, is_sats_back_only, is_cents_back_only, subscription_frequency, is_quote_only, category_id, oz, lbs, length, width, height, + $imageColumns, $specColumns ) VALUES ( - :title, :desc, :stock_qty, :specs_json, :sats_price, :cents_price, :digital, :subscription, - :image_url_0, :image_url_1, :image_url_2, :image_url_3, :image_url_4, :image_url_5, - :image_url_6, :image_url_7, :image_url_8, :image_url_9, :image_url_10, :image_url_11 + :title, :description, :stock_qty, :price_sats, :price_cents, :is_digital, :is_subscription, :is_subscription_only, :is_enabled, :is_unlimited, :is_accepting_sats, :is_accepting_cents, :is_sats_back_only, :is_cents_back_only, :subscription_frequency, :is_quote_only, :category_id, :oz, :lbs, :length, :width, :height, + $imageValues, $specValues )"); - $stmt->execute([ - ':title' => $title, - ':desc' => $desc, - ':stock_qty' => $stock_qty, - ':specs_json' => $specs_json, - ':sats_price' => $sats_price, - ':cents_price' => $cents_price, - ':digital' => (int) $digital, - ':subscription' => (int) $subscription, - ':image_url_0' => $images[0] ?? null, - ':image_url_1' => $images[1] ?? null, - ':image_url_2' => $images[2] ?? null, - ':image_url_3' => $images[3] ?? null, - ':image_url_4' => $images[4] ?? null, - ':image_url_5' => $images[5] ?? null, - ':image_url_6' => $images[6] ?? null, - ':image_url_7' => $images[7] ?? null, - ':image_url_8' => $images[8] ?? null, - ':image_url_9' => $images[9] ?? null, - ':image_url_10' => $images[10] ?? null, - ':image_url_11' => $images[11] ?? null, - ]); + $stmt->bindValue(':title', $productData['title']); + $stmt->bindValue(':description', $productData['description']); + $stmt->bindValue(':stock_qty', $productData['stock_qty']); + $stmt->bindValue(':price_sats', $productData['price_sats']); + $stmt->bindValue(':price_cents', $productData['price_cents']); + $stmt->bindValue(':is_digital', (bool) $productData['is_digital'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_subscription', (bool) $productData['is_subscription'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_subscription_only', (bool) $productData['is_subscription_only'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_enabled', (bool) $productData['is_enabled'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_unlimited', (bool) $productData['is_unlimited'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_accepting_sats', (bool) $productData['is_accepting_sats'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_accepting_cents', (bool) $productData['is_accepting_cents'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_sats_back_only', (bool) $productData['is_sats_back_only'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_cents_back_only', (bool) $productData['is_cents_back_only'], \PDO::PARAM_BOOL); + $stmt->bindValue(':subscription_frequency', $productData['subscription_frequency']); + $stmt->bindValue(':is_quote_only', (bool) $productData['is_quote_only'], \PDO::PARAM_BOOL); + $stmt->bindValue(':category_id', $productData['category_id'], \PDO::PARAM_INT); + $stmt->bindValue(':oz', $productData['oz']); + $stmt->bindValue(':lbs', $productData['lbs']); + $stmt->bindValue(':length', $productData['length']); + $stmt->bindValue(':width', $productData['width']); + $stmt->bindValue(':height', $productData['height']); + + for ($i = 0; $i < 12; $i++) { + $stmt->bindValue(":image_url_$i", $productData['images'][$i] ?? null); + $stmt->bindValue(":spec_key_$i", $productData['specs'][$i]['key'] ?? null); + $stmt->bindValue(":spec_val_$i", $productData['specs'][$i]['value'] ?? null); + } + + $stmt->execute(); + } + + public static function update($id, $productData) + { + $imageColumns = ''; + $specColumns = ''; + + for ($i = 0; $i < 12; $i++) { + $imageColumns .= "image_url_$i = :image_url_$i, "; + $specColumns .= "spec_key_$i = :spec_key_$i, spec_val_$i = :spec_val_$i, "; + } + + $imageColumns = rtrim($imageColumns, ', '); + $specColumns = rtrim($specColumns, ', '); + + $stmt = app::$db->prepare("UPDATE products SET + title = :title, + description = :description, + stock_qty = :stock_qty, + price_sats = :price_sats, + price_cents = :price_cents, + is_digital = :is_digital, + is_subscription = :is_subscription, + is_subscription_only = :is_subscription_only, + is_enabled = :is_enabled, + is_unlimited = :is_unlimited, + is_accepting_sats = :is_accepting_sats, + is_accepting_cents = :is_accepting_cents, + is_sats_back_only = :is_sats_back_only, + is_cents_back_only = :is_cents_back_only, + subscription_frequency = :subscription_frequency, + is_quote_only = :is_quote_only, + category_id = :category_id, + oz = :oz, + lbs = :lbs, + length = :length, + width = :width, + height = :height, + $imageColumns, + $specColumns + WHERE id = :id"); + + $stmt->bindValue(':id', (int) $id, \PDO::PARAM_INT); + $stmt->bindValue(':title', $productData['title']); + $stmt->bindValue(':description', $productData['description']); + $stmt->bindValue(':stock_qty', $productData['stock_qty']); + $stmt->bindValue(':price_sats', $productData['price_sats']); + $stmt->bindValue(':price_cents', $productData['price_cents']); + $stmt->bindValue(':is_digital', (bool) $productData['is_digital'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_subscription', (bool) $productData['is_subscription'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_subscription_only', (bool) $productData['is_subscription_only'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_enabled', (bool) $productData['is_enabled'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_unlimited', (bool) $productData['is_unlimited'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_accepting_sats', (bool) $productData['is_accepting_sats'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_accepting_cents', (bool) $productData['is_accepting_cents'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_sats_back_only', (bool) $productData['is_sats_back_only'], \PDO::PARAM_BOOL); + $stmt->bindValue(':is_cents_back_only', (bool) $productData['is_cents_back_only'], \PDO::PARAM_BOOL); + $stmt->bindValue(':subscription_frequency', $productData['subscription_frequency']); + $stmt->bindValue(':is_quote_only', (bool) $productData['is_quote_only'], \PDO::PARAM_BOOL); + $stmt->bindValue(':category_id', $productData['category_id'], \PDO::PARAM_INT); + $stmt->bindValue(':oz', $productData['oz']); + $stmt->bindValue(':lbs', $productData['lbs']); + $stmt->bindValue(':length', $productData['length']); + $stmt->bindValue(':width', $productData['width']); + $stmt->bindValue(':height', $productData['height']); + + for ($i = 0; $i < 12; $i++) { + $stmt->bindValue(":image_url_$i", $productData['images'][$i] ?? null); + $stmt->bindValue(":spec_key_$i", $productData['specs'][$i]['key'] ?? null); + $stmt->bindValue(":spec_val_$i", $productData['specs'][$i]['value'] ?? null); + } + + $stmt->execute(); + } + + public static function get($limit) + { + $stmt = app::$db->prepare("SELECT * FROM products LIMIT :limit"); + $stmt->bindValue(':limit', (int) $limit, \PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + public static function getById($id) + { + $stmt = app::$db->prepare("SELECT * FROM products WHERE id = :id"); + $stmt->bindValue(':id', (int) $id, \PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetch(\PDO::FETCH_ASSOC); } } diff --git a/src/models/quote_items.php b/src/models/quote_items.php index af78494..4cbc54a 100644 --- a/src/models/quote_items.php +++ b/src/models/quote_items.php @@ -9,13 +9,11 @@ class quote_items { app::$db->exec("CREATE TABLE IF NOT EXISTS quote_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, - quote_id INTEGER NOT NULL, - product_id INTEGER NOT NULL, + quote_id INTEGER NOT NULL REFERENCES quotes(id) ON DELETE CASCADE, + product_id INTEGER NOT NULL REFERENCES products(id), quantity INTEGER NOT NULL CHECK(quantity > 0), price REAL NOT NULL CHECK(price >= 0), - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (quote_id) REFERENCES quotes(id) ON DELETE CASCADE, - FOREIGN KEY (product_id) REFERENCES products(id) + added_at DATETIME DEFAULT CURRENT_TIMESTAMP );"); } diff --git a/src/models/quotes.php b/src/models/quotes.php index 8ee2dac..cf6db31 100644 --- a/src/models/quotes.php +++ b/src/models/quotes.php @@ -14,10 +14,9 @@ class quotes { app::$db->exec("CREATE TABLE IF NOT EXISTS quotes ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - status TEXT CHECK(status IN ('" . implode("', '", self::STATUSES) . "')) NOT NULL DEFAULT 'DRAFT', - FOREIGN KEY (user_id) REFERENCES users(id) + status TEXT CHECK(status IN ('" . implode("', '", self::STATUSES) . "')) NOT NULL DEFAULT 'DRAFT' );"); } diff --git a/src/models/subscriptions.php b/src/models/subscriptions.php index 236779f..65f64a9 100644 --- a/src/models/subscriptions.php +++ b/src/models/subscriptions.php @@ -17,15 +17,13 @@ class subscriptions { app::$db->exec("CREATE TABLE IF NOT EXISTS subscriptions ( subscription_id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - product_id INTEGER NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id), + product_id INTEGER NOT NULL REFERENCES products(id), start_date DATETIME NOT NULL, renews_at DATETIME NOT NULL, status TEXT CHECK(status IN ('" . implode("', '", self::STATUS) . "')) NOT NULL DEFAULT 'COMPLETED', state TEXT CHECK(state IN ('" . implode("', '", self::STATES) . "')) NOT NULL DEFAULT 'TRIAL', - invoice_date DATETIME NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (product_id) REFERENCES products(id) + invoice_date DATETIME NOT NULL );"); } diff --git a/src/models/transactions.php b/src/models/transactions.php index e557abd..8a4d7a0 100644 --- a/src/models/transactions.php +++ b/src/models/transactions.php @@ -12,12 +12,11 @@ class transactions $typesList = "'" . implode("', '", self::TYPES) . "'"; app::$db->exec("CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id), type TEXT CHECK(type IN ($typesList)) NOT NULL, cents INTEGER DEFAULT 0, sats INTEGER DEFAULT 0, - date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) + date TIMESTAMP DEFAULT CURRENT_TIMESTAMP )"); } diff --git a/src/models/user_settings.php b/src/models/user_settings.php index c0a67c3..84b3c35 100644 --- a/src/models/user_settings.php +++ b/src/models/user_settings.php @@ -9,12 +9,11 @@ class user_settings { app::$db->exec("CREATE TABLE IF NOT EXISTS user_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id), opt_in_promotional BOOLEAN NOT NULL, opt_in_subscription BOOLEAN DEFAULT TRUE, opt_in_order BOOLEAN DEFAULT TRUE, - dark_theme BOOLEAN NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) + dark_theme BOOLEAN NOT NULL );"); } diff --git a/src/models/users.php b/src/models/users.php index 6a25fd2..80dd060 100644 --- a/src/models/users.php +++ b/src/models/users.php @@ -14,8 +14,8 @@ class users app::$db->exec("CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE, - shipping_address_id INTEGER, - billing_address_id INTEGER, + shipping_address_id INTEGER REFERENCES addresses(id), + billing_address_id INTEGER REFERENCES addresses(id), lifetime_spend INTEGER DEFAULT 0, lifetime_orders INTEGER DEFAULT 0, verified BOOLEAN NOT NULL, @@ -29,6 +29,7 @@ class users created_at DATETIME DEFAULT CURRENT_TIMESTAMP )"); } + public static function setDefaultShipping($user_id, $shipping_address_id) { $query = "UPDATE users SET shipping_address_id = :shipping_address_id WHERE id = :user_id"; @@ -179,4 +180,13 @@ class users $stmt->execute(); return $stmt->fetch(\PDO::FETCH_ASSOC); } + + public static function getUsers($n) + { + $query = "SELECT * FROM users LIMIT :limit"; + $stmt = app::$db->prepare($query); + $stmt->bindParam(':limit', $n, \PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } } diff --git a/src/views/admin/products/add.twig b/src/views/admin/products/add.twig new file mode 100644 index 0000000..710f5e1 --- /dev/null +++ b/src/views/admin/products/add.twig @@ -0,0 +1,9 @@ +
+ {% include 'lib/alert.twig' %} +
+ {% include 'lib/forms/product.twig' %} + {% include 'lib/buttons/submit.twig' with { + label: 'Add Product' + } %} +
+
diff --git a/src/views/admin/products/edit.twig b/src/views/admin/products/edit.twig new file mode 100644 index 0000000..ea29ac7 --- /dev/null +++ b/src/views/admin/products/edit.twig @@ -0,0 +1,9 @@ +{% include 'lib/alert.twig' %} +
+ {% include 'lib/forms/product.twig' with { + product: session.last_post ?? product + } %} + {% include 'lib/buttons/submit.twig' with { + label: 'Save Product' + } %} +
diff --git a/src/views/admin/products/index.twig b/src/views/admin/products/index.twig index 90e213e..b56af00 100644 --- a/src/views/admin/products/index.twig +++ b/src/views/admin/products/index.twig @@ -2,61 +2,75 @@ {% include 'lib/alert.twig' %}

- Categories + Products

- + {% include 'lib/buttons/primary.twig' with { - label: 'Create a Category' - } %} + label: 'Create a Product' + } %} - - - - - - - - {% macro renderCategory(category, depth) %} + - - - + + + + + + + - {% if category.children is defined and category.children is not empty %} - {% for child in category.children %} - {{ _self.renderCategory(child, depth + 1) }} + + + {% if products %} + {% for product in products %} + + + + + + + + + {% endfor %} + {% else %} + + + {% endif %} - {% endmacro %} - - {% if categories is not empty %} - {% for category in categories %} - {{ _self.renderCategory(category, 0) }} - {% endfor %} - {% else %} - - - - {% endif %} - -
- Category ID - - Title - - Slug -
- {{ category.id }} - - {% if depth > 0 %} - {% for i in 1..depth %} - - - {% endfor %} - {% endif %} - {{ category.label }} - - {{ category.value }} - + Product ID + + Title + + Description + + Stock Quantity + + Price (Sats) + + Price (Cents) + + Action +
+ {{ product.id }} + + {{ product.title }} + + {{ product.description }} + + {{ product.stock_qty }} + + {{ product.price_sats }} + + {{ product.price_cents }} + + + Edit + +
+ No products found. +
- No categories yet. -
\ No newline at end of file + + + diff --git a/src/views/admin/users.twig b/src/views/admin/users.twig index 0686839..0ee7d3f 100644 --- a/src/views/admin/users.twig +++ b/src/views/admin/users.twig @@ -1,3 +1,61 @@
- USERS + + {% include 'lib/alert.twig' %} + +

+ Users +

+ + + + + + + + + + + + + {% if users %} + {% for user in users %} + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
+ User ID + + Name + + Email + + Verified + + Action +
+ {{ user.id }} + + {{ user.name }} + + {{ user.email }} + + {{ user.verified ? 'Yes' : 'No' }} + + + Edit + +
+ No users found. +
diff --git a/src/views/lib/forms/product.twig b/src/views/lib/forms/product.twig new file mode 100644 index 0000000..6d9b0d5 --- /dev/null +++ b/src/views/lib/forms/product.twig @@ -0,0 +1,243 @@ +
+
+ {% include 'lib/inputs/text.twig' with { + name: 'title', + label: 'Title', + placeholder: 'Enter product title', + value: product.title, + required: true + } %} +
+
+ {% include 'lib/inputs/toggle.twig' with { + name: 'is_enabled', + label: 'Enable Product?', + on: product.is_enabled + } %} +
+
+{% include 'lib/inputs/text.twig' with { + name: 'description', + label: 'Description', + placeholder: 'Enter product description', + value: product.description, + required: true +} %} +{% include 'lib/inputs/select.twig' with { + name: 'category_id', + label: 'Category', + options: categories, + value: product.category_id, + no_option: { 'label': '-- None --', 'value': null}, + required: true +} %} +{% include 'lib/inputs/number.twig' with { + name: 'stock_qty', + label: 'Stock Quantity', + placeholder: 'Enter stock quantity', + value: product.stock_qty, + required: true +} %} +{% include 'lib/inputs/toggle.twig' with { + name: 'is_umlimited', + label: 'Unlimited Quantity?', + on: product.is_umlimited +} %} +{% include 'lib/inputs/text.twig' with { + name: 'image_url_0', + label: 'Primary Photo URL', + placeholder: 'Enter image URL 0 - (required)', + value: attribute(product, 'image_url_0'), + required: true +} %} + +

+ Pricing +

+{% include 'lib/inputs/toggle.twig' with { + name: 'is_quote_only', + label: 'Buyer Must Ask for Quote?', + on: product.is_quote_only +} %} + +
+
+

+ Sats Pricing +

+ {% include 'lib/inputs/toggle.twig' with { + name: 'is_accepting_sats', + label: 'Accept Sats?', + on: product.is_accepting_sats + } %} + {% include 'lib/inputs/number.twig' with { + name: 'price_sats', + label: 'Price (Sats)', + desc: 'Leave empty to auto-convert from cents price.', + placeholder: 'Price sats', + value: product.price_sats, + } %} + {% include 'lib/inputs/number.twig' with { + name: 'sats_back_percent', + label: 'Sats Back Percent', + desc: 'Promotional reward when paid with sats', + placeholder: 'Sats Back %', + value: product.sats_back_percent, + } %} + {% include 'lib/inputs/toggle.twig' with { + name: 'is_sats_back_only', + label: 'Only give sats back?', + on: product.is_sats_back_only + } %} +
+
+

+ Cents Pricing +

+ {% include 'lib/inputs/toggle.twig' with { + name: 'is_accepting_cents', + label: 'Accept Cents?', + on: product.is_accepting_cents + } %} + {% include 'lib/inputs/number.twig' with { + name: 'price_cents', + label: 'Price (Cents)', + desc: 'Leave empty to auto-convert from sats price.', + placeholder: 'Price cents', + value: product.price_cents, + } %} + {% include 'lib/inputs/number.twig' with { + name: 'cents_back_percent', + label: 'Cents Back %', + desc: 'Promotional reward when paid with cents', + placeholder: 'Cents Back %', + value: product.cents_back_percent, + } %} + {% include 'lib/inputs/toggle.twig' with { + name: 'is_cents_back_only', + label: 'Only give cents back?', + on: product.is_cents_back_only + } %} +
+
+

+ Subscription +

+
+ {% include 'lib/inputs/toggle.twig' with { + name: 'is_subscription', + label: 'Enable Subscriptions?', + on: product.is_subscription + } %} + {% include 'lib/inputs/toggle.twig' with { + name: 'is_subscription_only', + label: 'Subscription Only?', + on: product.is_subscription_only + } %} + {% include 'lib/inputs/select.twig' with { + name: 'subscription_frequency', + label: 'Subscription Frequency', + options: [ + { 'label': 'Per Minute', 'value': 'minute' }, + { 'label': 'Hourly', 'value': 'hour' }, + { 'label': 'Daily', 'value': 'day' }, + { 'label': 'Weekly', 'value': 'week' }, + { 'label': 'Monthly', 'value': 'month' }, + { 'label': 'Yearly', 'value': 'year' } + ], + value: product.subscription_frequency, + no_option: { 'label': '-- Select Frequency --', 'value': null } + } %} +
+

+ Shipping +

+

+ Non-digital products require shipping information. +

+{% include 'lib/inputs/toggle.twig' with { + name: 'is_digital', + label: 'Digital Product?', + on: product.is_digital +} %} +
+
+ {% include 'lib/inputs/number.twig' with { + name: 'lbs', + label: 'Weight (lbs)', + placeholder: 'Enter pounds', + value: product.lbs, + } %} +
+
+ {% include 'lib/inputs/number.twig' with { + name: 'oz', + label: 'Weight (oz)', + placeholder: 'Enter ounces', + value: product.oz, + } %} +
+
+
+
+ {% include 'lib/inputs/number.twig' with { + name: 'length', + label: 'Length', + placeholder: 'Enter length', + value: product.length, + } %} +
+
+ {% include 'lib/inputs/number.twig' with { + name: 'width', + label: 'Width', + placeholder: 'Enter width', + value: product.width, + } %} +
+
+ {% include 'lib/inputs/number.twig' with { + name: 'height', + label: 'Height', + placeholder: 'Enter height', + value: product.height, + } %} +
+
+

+ Additional Product Photos +

+{% for i in 1..11 %} + {% include 'lib/inputs/text.twig' with { + name: 'image_url_' ~ i, + placeholder: 'Enter image URL ' ~ i, + value: attribute(product, 'image_url_' ~ i), + } %} +{% endfor %} +

+ Product Specifications + + - (optional) + +

+

+ Provide any relevant specifications such as brand, material, model, year, color, and more. +

+
+ {% for i in 0..11 %} +
+ {% include 'lib/inputs/text.twig' with { + name: 'spec_key_' ~ i, + placeholder: 'Spec ' ~ i, + value: attribute(product, 'spec_key_' ~ i), + } %} +
+
+ {% include 'lib/inputs/text.twig' with { + name: 'spec_val_' ~ i, + placeholder: 'Value ' ~ i, + value: attribute(product, 'spec_val_' ~ i), + } %} +
+ {% endfor %} +
diff --git a/src/views/lib/inputs/number.twig b/src/views/lib/inputs/number.twig index b3cc3f0..c5850f5 100644 --- a/src/views/lib/inputs/number.twig +++ b/src/views/lib/inputs/number.twig @@ -1,5 +1,15 @@ +{% if desc is defined %} +

+ {{ desc }} +

+{% endif %} \ No newline at end of file diff --git a/src/views/lib/inputs/select.twig b/src/views/lib/inputs/select.twig index 65a697b..415e6b4 100644 --- a/src/views/lib/inputs/select.twig +++ b/src/views/lib/inputs/select.twig @@ -1,6 +1,16 @@ +{% if desc is defined %} +

+ {{ desc }} +

+{% endif %} + \ No newline at end of file