This commit is contained in:
count-null 2025-03-08 21:26:58 -05:00
parent 642546040a
commit 4eb1d59230
25 changed files with 816 additions and 152 deletions

View file

@ -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),

View file

@ -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, [

View file

@ -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
)");
}

View file

@ -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
);");
}

View file

@ -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
);");
}

View file

@ -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
);");
}

View file

@ -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
);");
}

View file

@ -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);
}

View file

@ -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
);");
}

View file

@ -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'
);");
}

View file

@ -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);
}
}

View file

@ -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
);");
}

View file

@ -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'
);");
}

View file

@ -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
);");
}

View file

@ -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
)");
}

View file

@ -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
);");
}

View file

@ -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);
}
}

View file

@ -0,0 +1,9 @@
<section class="flex flex-col gap-4">
{% include 'lib/alert.twig' %}
<form action="/admin/products/add" method="POST" class="flex flex-col gap-4">
{% include 'lib/forms/product.twig' %}
{% include 'lib/buttons/submit.twig' with {
label: 'Add Product'
} %}
</form>
</section>

View file

@ -0,0 +1,9 @@
{% include 'lib/alert.twig' %}
<form action="/admin/products/edit/{{ product.id }}" method="post" class="flex flex-col gap-2">
{% include 'lib/forms/product.twig' with {
product: session.last_post ?? product
} %}
{% include 'lib/buttons/submit.twig' with {
label: 'Save Product'
} %}
</form>

View file

@ -2,61 +2,75 @@
{% include 'lib/alert.twig' %}
<h3 class="text-2xl font-semibold">
Categories
Products
</h3>
<a href="/admin/categories/add">
<a href="/admin/products/add">
{% include 'lib/buttons/primary.twig' with {
label: 'Create a Category'
} %}
label: 'Create a Product'
} %}
</a>
<table class="min-w-full">
<tr>
<th class="py-2">
Category ID
</th>
<th class="py-2">
Title
</th>
<th class="py-2">
Slug
</th>
</tr>
</thead>
<tbody>
{% macro renderCategory(category, depth) %}
<thead>
<tr>
<td class="border px-4 py-2">
{{ category.id }}
</td>
<td class="border px-4 py-2">
{% if depth > 0 %}
{% for i in 1..depth %}
-
{% endfor %}
{% endif %}
{{ category.label }}
</td>
<td class="border px-4 py-2">
{{ category.value }}
</td>
<th class="py-2">
Product ID
</th>
<th class="py-2">
Title
</th>
<th class="py-2">
Description
</th>
<th class="py-2">
Stock Quantity
</th>
<th class="py-2">
Price (Sats)
</th>
<th class="py-2">
Price (Cents)
</th>
<th class="py-2">
Action
</th>
</tr>
{% if category.children is defined and category.children is not empty %}
{% for child in category.children %}
{{ _self.renderCategory(child, depth + 1) }}
</thead>
<tbody>
{% if products %}
{% for product in products %}
<tr>
<td class="border px-4 py-2">
{{ product.id }}
</td>
<td class="border px-4 py-2">
{{ product.title }}
</td>
<td class="border px-4 py-2">
{{ product.description }}
</td>
<td class="border px-4 py-2">
{{ product.stock_qty }}
</td>
<td class="border px-4 py-2">
{{ product.price_sats }}
</td>
<td class="border px-4 py-2">
{{ product.price_cents }}
</td>
<td class="border px-4 py-2">
<a href="/admin/products/edit/{{ product.id }}">
Edit
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td class="border px-4 py-2" colspan="7">
No products found.
</td>
</tr>
{% endif %}
{% endmacro %}
{% if categories is not empty %}
{% for category in categories %}
{{ _self.renderCategory(category, 0) }}
{% endfor %}
{% else %}
<tr>
<td class="border px-4 py-2" colspan="3">
No categories yet.
</td>
</tr>
{% endif %}
</tbody>
</table></section>
</tbody>
</table>
</section>

View file

@ -1,3 +1,61 @@
<section class="flex flex-col gap-4">
USERS
{% include 'lib/alert.twig' %}
<h3 class="text-2xl font-semibold">
Users
</h3>
<table class="min-w-full">
<thead>
<tr>
<th class="py-2">
User ID
</th>
<th class="py-2">
Name
</th>
<th class="py-2">
Email
</th>
<th class="py-2">
Verified
</th>
<th class="py-2">
Action
</th>
</tr>
</thead>
<tbody>
{% if users %}
{% for user in users %}
<tr>
<td class="border px-4 py-2">
{{ user.id }}
</td>
<td class="border px-4 py-2">
{{ user.name }}
</td>
<td class="border px-4 py-2">
{{ user.email }}
</td>
<td class="border px-4 py-2">
{{ user.verified ? 'Yes' : 'No' }}
</td>
<td class="border px-4 py-2">
<a href="/admin/users/edit/{{ user.id }}">
Edit
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td class="border px-4 py-2" colspan="5">
No users found.
</td>
</tr>
{% endif %}
</tbody>
</table>
</section>

View file

@ -0,0 +1,243 @@
<div class="flex items-end">
<div class="w-2/3 pr-4">
{% include 'lib/inputs/text.twig' with {
name: 'title',
label: 'Title',
placeholder: 'Enter product title',
value: product.title,
required: true
} %}
</div>
<div class="w-1/3 mb-3">
{% include 'lib/inputs/toggle.twig' with {
name: 'is_enabled',
label: 'Enable Product?',
on: product.is_enabled
} %}
</div>
</div>
{% 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
} %}
<h3 class="text-xl font-semibold mt-4 mb-2">
Pricing
</h3>
{% include 'lib/inputs/toggle.twig' with {
name: 'is_quote_only',
label: 'Buyer Must Ask for Quote?',
on: product.is_quote_only
} %}
<div class="grid grid-cols-2 gap-4">
<div class="col-span-1">
<h4 class="font-semibold mb-2">
Sats Pricing
</h4>
{% 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
} %}
</div>
<div class="col-span-1">
<h4 class="font-semibold mb-2">
Cents Pricing
</h4>
{% 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
} %}
</div>
</div>
<h3 class="text-xl font-semibold mt-4 mb-2">
Subscription
</h3>
<div class="grid grid-cols-1 gap-4">
{% 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 }
} %}
</div>
<h3 class="text-xl font-semibold mt-4 mb-2">
Shipping
</h3>
<p>
Non-digital products require shipping information.
</p>
{% include 'lib/inputs/toggle.twig' with {
name: 'is_digital',
label: 'Digital Product?',
on: product.is_digital
} %}
<div class="grid grid-cols-2 gap-4">
<div class="col-span-1">
{% include 'lib/inputs/number.twig' with {
name: 'lbs',
label: 'Weight (lbs)',
placeholder: 'Enter pounds',
value: product.lbs,
} %}
</div>
<div class="col-span-1">
{% include 'lib/inputs/number.twig' with {
name: 'oz',
label: 'Weight (oz)',
placeholder: 'Enter ounces',
value: product.oz,
} %}
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="col-span-1">
{% include 'lib/inputs/number.twig' with {
name: 'length',
label: 'Length',
placeholder: 'Enter length',
value: product.length,
} %}
</div>
<div class="col-span-1">
{% include 'lib/inputs/number.twig' with {
name: 'width',
label: 'Width',
placeholder: 'Enter width',
value: product.width,
} %}
</div>
<div class="col-span-1">
{% include 'lib/inputs/number.twig' with {
name: 'height',
label: 'Height',
placeholder: 'Enter height',
value: product.height,
} %}
</div>
</div>
<h3 class="text-xl font-semibold mt-4 mb-2">
Additional Product Photos
</h3>
{% 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 %}
<h3 class="text-xl font-semibold mt-4 mb-2">
Product Specifications
<span class="text-sm font-normal">
- (optional)
</span>
</h3>
<p>
Provide any relevant specifications such as brand, material, model, year, color, and more.
</p>
<div class="grid grid-cols-3 gap-4">
{% for i in 0..11 %}
<div class="col-span-1">
{% include 'lib/inputs/text.twig' with {
name: 'spec_key_' ~ i,
placeholder: 'Spec ' ~ i,
value: attribute(product, 'spec_key_' ~ i),
} %}
</div>
<div class="col-span-2">
{% include 'lib/inputs/text.twig' with {
name: 'spec_val_' ~ i,
placeholder: 'Value ' ~ i,
value: attribute(product, 'spec_val_' ~ i),
} %}
</div>
{% endfor %}
</div>

View file

@ -1,5 +1,15 @@
<label for="{{ id }}" class="block text-sm font-medium mt-2">
{{ label }}
{% if required is defined %}
<span class="{{ colors.error.text }} ml-4">
*
</span>
{% endif %}
</label>
{% if desc is defined %}
<p class="text-sm mt-1">
{{ desc }}
</p>
{% endif %}
<input type="number" id="{{ id }}" name="{{ name }}" class="border rounded-lg p-2 w-full" {% if required %} required {% endif %} {% if min is defined %} min="{{ min }}" {% endif %} {% if max is defined %} max="{{ max }}" {% endif %} {% if step is defined %} step="{{ step }}" {% endif %} {% if value is defined %} value="{{ value }}" {% endif %} placeholder="{{ placeholder | default('Enter a number') }}">

View file

@ -1,6 +1,16 @@
<label for="{{ id }}" class="block text-sm font-medium mt-2">
{{ label }}
{% if required is defined %}
<span class="{{ colors.error.text }} ml-4">
*
</span>
{% endif %}
</label>
{% if desc is defined %}
<p class="text-sm mt-1">
{{ desc }}
</p>
{% endif %}
<select id="{{ id }}" name="{{ name }}" class="border rounded-lg p-2 w-full" {% if required %} required {% endif %}>
{% if no_option is defined %}
<option value="{{ no_option.value }}">

View file

@ -21,6 +21,11 @@
{% endif %}
</label>
{% endif %}
{% if desc is defined %}
<p class="text-sm mt-1">
{{ desc }}
</p>
{% endif %}
<input type="{{ type }}" name="{{ name }}" {% if placeholder %} placeholder="{{ placeholder }}" {% endif %} {% if value is not null %} value="{{ value }}" {% endif %} {% if readonly is not null %} readonly {% endif %} {% if type == 'number' %} {% if min is defined %} min="{{ min }}" {% endif %} {% if max is defined %} max="{{ max }}" {% endif %} {% endif %} class="{{ colors.input }} w-full p-3 h-[42px] border focus:ring-1 focus:outline-none"></div>
<input type="{{ type }}" name="{{ name }}" {% if placeholder %} placeholder="{{ placeholder }}" {% endif %} {% if value is not null %} value="{{ value }}" {% endif %} {% if readonly is not null %} readonly {% endif %} {% if required is defined %} required {% endif %} {% if type == 'number' %} {% if min is defined %} min="{{ min }}" {% endif %} {% if max is defined %} max="{{ max }}" {% endif %} {% endif %} class="{{ colors.input }} w-full p-3 h-[42px] border focus:ring-1 focus:outline-none"></div>