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 // 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; [$full, $type, $id] = $matches;
$controller = [ $controller = [
'address/edit' => fn($id) => address::edit($defaults, $id), '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), 'subscription' => fn($id) => subscriptions::view($id),
'cart' => fn($id) => cart::index($id), 'cart' => fn($id) => cart::index($id),
'admin/categories/edit' => fn($id) => $defaults['is_admin'] ? admin::categories_edit($defaults, $id) : lost::index($defaults), '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])) { 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' => $defaults['is_admin'] ? admin::index($defaults) : lost::index($defaults),
'/admin/users' => $defaults['is_admin'] ? admin::users($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' => $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/orders' => $defaults['is_admin'] ? admin::orders($defaults) : lost::index($defaults),
'/admin/emails' => $defaults['is_admin'] ? admin::emails($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), '/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\categories;
use app\models\emails; use app\models\emails;
use app\models\products;
use app\models\transactions; use app\models\transactions;
use app\models\users; use app\models\users;
@ -27,6 +28,7 @@ class admin
echo $GLOBALS['twig']->render('lib/pages/index.twig', array_merge($defaults, [ echo $GLOBALS['twig']->render('lib/pages/index.twig', array_merge($defaults, [
'child_template' => 'admin/users.twig', 'child_template' => 'admin/users.twig',
'page_title' => 'Users', 'page_title' => 'Users',
'users' => users::getUsers(20),
'breadcrumbs' => [ 'breadcrumbs' => [
[ [
'url' => '/admin', 'url' => '/admin',
@ -46,6 +48,7 @@ class admin
'child_template' => 'admin/products/index.twig', 'child_template' => 'admin/products/index.twig',
'page_title' => 'Products', 'page_title' => 'Products',
'categories' => categories::getTree(), 'categories' => categories::getTree(),
'products' => products::get(20),
'breadcrumbs' => [ 'breadcrumbs' => [
[ [
'url' => '/admin', '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) public static function categories($defaults)
{ {
echo $GLOBALS['twig']->render('lib/pages/index.twig', array_merge($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 ( app::$db->exec("CREATE TABLE IF NOT EXISTS addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, user_id INTEGER REFERENCES users(id),
name TEXT NOT NULL, name TEXT NOT NULL,
company TEXT, company TEXT,
addressLine1 TEXT NOT NULL, addressLine1 TEXT NOT NULL,
@ -17,8 +17,7 @@ class addresses
city TEXT NOT NULL, city TEXT NOT NULL,
state TEXT NOT NULL, state TEXT NOT NULL,
zip TEXT NOT NULL, zip TEXT NOT NULL,
phone TEXT, phone TEXT
FOREIGN KEY (user_id) REFERENCES users(id)
)"); )");
} }

View file

@ -9,12 +9,10 @@ class cart_items
{ {
app::$db->exec("CREATE TABLE IF NOT EXISTS cart_items ( app::$db->exec("CREATE TABLE IF NOT EXISTS cart_items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
cart_id INTEGER NOT NULL, cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL, product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK(quantity > 0), quantity INTEGER NOT NULL CHECK(quantity > 0),
added_at DATETIME DEFAULT CURRENT_TIMESTAMP, added_at DATETIME DEFAULT CURRENT_TIMESTAMP
FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id)
);"); );");
} }

View file

@ -9,10 +9,9 @@ class carts
{ {
app::$db->exec("CREATE TABLE IF NOT EXISTS carts ( app::$db->exec("CREATE TABLE IF NOT EXISTS carts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id),
short_id TEXT UNIQUE NOT NULL, short_id TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
FOREIGN KEY (user_id) REFERENCES users(id)
);"); );");
} }

View file

@ -9,10 +9,9 @@ class categories
{ {
app::$db->exec("CREATE TABLE IF NOT EXISTS categories ( app::$db->exec("CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER, parent_id INTEGER REFERENCES categories(id),
label TEXT UNIQUE NOT NULL, label TEXT UNIQUE NOT NULL,
value TEXT UNIQUE NOT NULL, value TEXT UNIQUE NOT NULL
FOREIGN KEY (parent_id) REFERENCES categories(id)
);"); );");
} }

View file

@ -11,15 +11,14 @@ class emails
{ {
app::$db->exec("CREATE TABLE IF NOT EXISTS emails ( app::$db->exec("CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, user_id INTEGER REFERENCES users(id),
from_email TEXT NOT NULL, from_email TEXT NOT NULL,
from_name TEXT NOT NULL, from_name TEXT NOT NULL,
to_email TEXT NOT NULL, to_email TEXT NOT NULL,
subject TEXT NOT NULL, subject TEXT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
html_message TEXT NOT NULL, html_message TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
FOREIGN KEY (user_id) REFERENCES users(id)
);"); );");
} }

View file

@ -10,15 +10,13 @@ class invoices
{ {
$query = "CREATE TABLE IF NOT EXISTS invoices ( $query = "CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id),
order_id INTEGER, order_id INTEGER REFERENCES orders(id),
invoice TEXT NOT NULL, invoice TEXT NOT NULL,
verify TEXT NOT NULL, verify TEXT NOT NULL,
amount_msats REAL NOT NULL, amount_msats REAL NOT NULL,
expiry_date DATETIME NOT NULL, expiry_date DATETIME NOT NULL,
settled BOOLEAN DEFAULT FALSE, settled BOOLEAN DEFAULT FALSE
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(order_id) REFERENCES orders(id)
)"; )";
app::$db->exec($query); app::$db->exec($query);
} }

View file

@ -9,12 +9,10 @@ class order_items
{ {
app::$db->exec("CREATE TABLE IF NOT EXISTS order_items ( app::$db->exec("CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL, order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL, product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK(quantity > 0), quantity INTEGER NOT NULL CHECK(quantity > 0),
added_at DATETIME DEFAULT CURRENT_TIMESTAMP, added_at DATETIME DEFAULT CURRENT_TIMESTAMP
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id)
);"); );");
} }

View file

@ -14,12 +14,11 @@ class orders
{ {
app::$db->exec("CREATE TABLE IF NOT EXISTS orders ( app::$db->exec("CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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_sats INTEGER NOT NULL CHECK(value_sats >= 0),
value_cents INTEGER NOT NULL CHECK(value_cents >= 0), value_cents INTEGER NOT NULL CHECK(value_cents >= 0),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT CHECK(status IN ('" . implode("', '", self::STATUSES) . "')) NOT NULL DEFAULT 'PENDING', status TEXT CHECK(status IN ('" . implode("', '", self::STATUSES) . "')) NOT NULL DEFAULT 'PENDING'
FOREIGN KEY (user_id) REFERENCES users(id)
);"); );");
} }

View file

@ -7,64 +7,295 @@ class products
{ {
public static function init() 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 ( app::$db->exec("CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER REFERENCES categories(id),
title TEXT NOT NULL, title TEXT NOT NULL,
desc TEXT, description TEXT,
stock_qty INTEGER NOT NULL DEFAULT 0 CHECK(stock_qty >= 0), stock_qty INTEGER,
specs_json TEXT, is_unlimited BOOLEAN,
sats_price INTEGER NOT NULL DEFAULT 0 CHECK(sats_price >= 0), is_enabled BOOLEAN,
cents_price INTEGER NOT NULL DEFAULT 0 CHECK(cents_price >= 0), is_quote_only BOOLEAN,
digital BOOLEAN NOT NULL DEFAULT 0, price_sats INTEGER,
subscription BOOLEAN NOT NULL DEFAULT 0, sats_back_percent INTEGER,
image_url_0 TEXT, is_accepting_sats BOOLEAN,
image_url_1 TEXT, is_sats_back_only BOOLEAN,
image_url_2 TEXT, price_cents INTEGER,
image_url_3 TEXT, cents_back_percent INTEGER,
image_url_4 TEXT, is_accepting_cents BOOLEAN,
image_url_5 TEXT, is_cents_back_only BOOLEAN,
image_url_6 TEXT, is_digital BOOLEAN,
image_url_7 TEXT, oz REAL,
image_url_8 TEXT, lbs REAL,
image_url_9 TEXT, length REAL,
image_url_10 TEXT, width REAL,
image_url_11 TEXT 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 ( $stmt = app::$db->prepare("INSERT INTO products (
title, desc, stock_qty, specs_json, sats_price, cents_price, digital, subscription, 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,
image_url_0, image_url_1, image_url_2, image_url_3, image_url_4, image_url_5, $imageColumns, $specColumns
image_url_6, image_url_7, image_url_8, image_url_9, image_url_10, image_url_11
) VALUES ( ) VALUES (
:title, :desc, :stock_qty, :specs_json, :sats_price, :cents_price, :digital, :subscription, :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,
:image_url_0, :image_url_1, :image_url_2, :image_url_3, :image_url_4, :image_url_5, $imageValues, $specValues
:image_url_6, :image_url_7, :image_url_8, :image_url_9, :image_url_10, :image_url_11
)"); )");
$stmt->execute([ $stmt->bindValue(':title', $productData['title']);
':title' => $title, $stmt->bindValue(':description', $productData['description']);
':desc' => $desc, $stmt->bindValue(':stock_qty', $productData['stock_qty']);
':stock_qty' => $stock_qty, $stmt->bindValue(':price_sats', $productData['price_sats']);
':specs_json' => $specs_json, $stmt->bindValue(':price_cents', $productData['price_cents']);
':sats_price' => $sats_price, $stmt->bindValue(':is_digital', (bool) $productData['is_digital'], \PDO::PARAM_BOOL);
':cents_price' => $cents_price, $stmt->bindValue(':is_subscription', (bool) $productData['is_subscription'], \PDO::PARAM_BOOL);
':digital' => (int) $digital, $stmt->bindValue(':is_subscription_only', (bool) $productData['is_subscription_only'], \PDO::PARAM_BOOL);
':subscription' => (int) $subscription, $stmt->bindValue(':is_enabled', (bool) $productData['is_enabled'], \PDO::PARAM_BOOL);
':image_url_0' => $images[0] ?? null, $stmt->bindValue(':is_unlimited', (bool) $productData['is_unlimited'], \PDO::PARAM_BOOL);
':image_url_1' => $images[1] ?? null, $stmt->bindValue(':is_accepting_sats', (bool) $productData['is_accepting_sats'], \PDO::PARAM_BOOL);
':image_url_2' => $images[2] ?? null, $stmt->bindValue(':is_accepting_cents', (bool) $productData['is_accepting_cents'], \PDO::PARAM_BOOL);
':image_url_3' => $images[3] ?? null, $stmt->bindValue(':is_sats_back_only', (bool) $productData['is_sats_back_only'], \PDO::PARAM_BOOL);
':image_url_4' => $images[4] ?? null, $stmt->bindValue(':is_cents_back_only', (bool) $productData['is_cents_back_only'], \PDO::PARAM_BOOL);
':image_url_5' => $images[5] ?? null, $stmt->bindValue(':subscription_frequency', $productData['subscription_frequency']);
':image_url_6' => $images[6] ?? null, $stmt->bindValue(':is_quote_only', (bool) $productData['is_quote_only'], \PDO::PARAM_BOOL);
':image_url_7' => $images[7] ?? null, $stmt->bindValue(':category_id', $productData['category_id'], \PDO::PARAM_INT);
':image_url_8' => $images[8] ?? null, $stmt->bindValue(':oz', $productData['oz']);
':image_url_9' => $images[9] ?? null, $stmt->bindValue(':lbs', $productData['lbs']);
':image_url_10' => $images[10] ?? null, $stmt->bindValue(':length', $productData['length']);
':image_url_11' => $images[11] ?? null, $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 ( app::$db->exec("CREATE TABLE IF NOT EXISTS quote_items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
quote_id INTEGER NOT NULL, quote_id INTEGER NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL, product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK(quantity > 0), quantity INTEGER NOT NULL CHECK(quantity > 0),
price REAL NOT NULL CHECK(price >= 0), price REAL NOT NULL CHECK(price >= 0),
added_at DATETIME DEFAULT CURRENT_TIMESTAMP, added_at DATETIME DEFAULT CURRENT_TIMESTAMP
FOREIGN KEY (quote_id) REFERENCES quotes(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id)
);"); );");
} }

View file

@ -14,10 +14,9 @@ class quotes
{ {
app::$db->exec("CREATE TABLE IF NOT EXISTS quotes ( app::$db->exec("CREATE TABLE IF NOT EXISTS quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT CHECK(status IN ('" . implode("', '", self::STATUSES) . "')) NOT NULL DEFAULT 'DRAFT', status TEXT CHECK(status IN ('" . implode("', '", self::STATUSES) . "')) NOT NULL DEFAULT 'DRAFT'
FOREIGN KEY (user_id) REFERENCES users(id)
);"); );");
} }

View file

@ -17,15 +17,13 @@ class subscriptions
{ {
app::$db->exec("CREATE TABLE IF NOT EXISTS subscriptions ( app::$db->exec("CREATE TABLE IF NOT EXISTS subscriptions (
subscription_id INTEGER PRIMARY KEY AUTOINCREMENT, subscription_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id),
product_id INTEGER NOT NULL, product_id INTEGER NOT NULL REFERENCES products(id),
start_date DATETIME NOT NULL, start_date DATETIME NOT NULL,
renews_at DATETIME NOT NULL, renews_at DATETIME NOT NULL,
status TEXT CHECK(status IN ('" . implode("', '", self::STATUS) . "')) NOT NULL DEFAULT 'COMPLETED', status TEXT CHECK(status IN ('" . implode("', '", self::STATUS) . "')) NOT NULL DEFAULT 'COMPLETED',
state TEXT CHECK(state IN ('" . implode("', '", self::STATES) . "')) NOT NULL DEFAULT 'TRIAL', state TEXT CHECK(state IN ('" . implode("', '", self::STATES) . "')) NOT NULL DEFAULT 'TRIAL',
invoice_date DATETIME NOT NULL, invoice_date DATETIME NOT NULL
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (product_id) REFERENCES products(id)
);"); );");
} }

View file

@ -12,12 +12,11 @@ class transactions
$typesList = "'" . implode("', '", self::TYPES) . "'"; $typesList = "'" . implode("', '", self::TYPES) . "'";
app::$db->exec("CREATE TABLE IF NOT EXISTS transactions ( app::$db->exec("CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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, type TEXT CHECK(type IN ($typesList)) NOT NULL,
cents INTEGER DEFAULT 0, cents INTEGER DEFAULT 0,
sats INTEGER DEFAULT 0, sats INTEGER DEFAULT 0,
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
FOREIGN KEY (user_id) REFERENCES users(id)
)"); )");
} }

View file

@ -9,12 +9,11 @@ class user_settings
{ {
app::$db->exec("CREATE TABLE IF NOT EXISTS user_settings ( app::$db->exec("CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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_promotional BOOLEAN NOT NULL,
opt_in_subscription BOOLEAN DEFAULT TRUE, opt_in_subscription BOOLEAN DEFAULT TRUE,
opt_in_order BOOLEAN DEFAULT TRUE, opt_in_order BOOLEAN DEFAULT TRUE,
dark_theme BOOLEAN NOT NULL, dark_theme BOOLEAN NOT NULL
FOREIGN KEY (user_id) REFERENCES users(id)
);"); );");
} }

View file

@ -14,8 +14,8 @@ class users
app::$db->exec("CREATE TABLE IF NOT EXISTS users ( app::$db->exec("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE, email TEXT UNIQUE,
shipping_address_id INTEGER, shipping_address_id INTEGER REFERENCES addresses(id),
billing_address_id INTEGER, billing_address_id INTEGER REFERENCES addresses(id),
lifetime_spend INTEGER DEFAULT 0, lifetime_spend INTEGER DEFAULT 0,
lifetime_orders INTEGER DEFAULT 0, lifetime_orders INTEGER DEFAULT 0,
verified BOOLEAN NOT NULL, verified BOOLEAN NOT NULL,
@ -29,6 +29,7 @@ class users
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)"); )");
} }
public static function setDefaultShipping($user_id, $shipping_address_id) public static function setDefaultShipping($user_id, $shipping_address_id)
{ {
$query = "UPDATE users SET shipping_address_id = :shipping_address_id WHERE id = :user_id"; $query = "UPDATE users SET shipping_address_id = :shipping_address_id WHERE id = :user_id";
@ -179,4 +180,13 @@ class users
$stmt->execute(); $stmt->execute();
return $stmt->fetch(\PDO::FETCH_ASSOC); 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' %} {% include 'lib/alert.twig' %}
<h3 class="text-2xl font-semibold"> <h3 class="text-2xl font-semibold">
Categories Products
</h3> </h3>
<a href="/admin/categories/add"> <a href="/admin/products/add">
{% include 'lib/buttons/primary.twig' with { {% include 'lib/buttons/primary.twig' with {
label: 'Create a Category' label: 'Create a Product'
} %} } %}
</a> </a>
<table class="min-w-full"> <table class="min-w-full">
<thead>
<tr> <tr>
<th class="py-2"> <th class="py-2">
Category ID Product ID
</th> </th>
<th class="py-2"> <th class="py-2">
Title Title
</th> </th>
<th class="py-2"> <th class="py-2">
Slug 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> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% macro renderCategory(category, depth) %} {% if products %}
{% for product in products %}
<tr> <tr>
<td class="border px-4 py-2"> <td class="border px-4 py-2">
{{ category.id }} {{ product.id }}
</td> </td>
<td class="border px-4 py-2"> <td class="border px-4 py-2">
{% if depth > 0 %} {{ product.title }}
{% for i in 1..depth %}
-
{% endfor %}
{% endif %}
{{ category.label }}
</td> </td>
<td class="border px-4 py-2"> <td class="border px-4 py-2">
{{ category.value }} {{ 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> </td>
</tr> </tr>
{% if category.children is defined and category.children is not empty %}
{% for child in category.children %}
{{ _self.renderCategory(child, depth + 1) }}
{% endfor %}
{% endif %}
{% endmacro %}
{% if categories is not empty %}
{% for category in categories %}
{{ _self.renderCategory(category, 0) }}
{% endfor %} {% endfor %}
{% else %} {% else %}
<tr> <tr>
<td class="border px-4 py-2" colspan="3"> <td class="border px-4 py-2" colspan="7">
No categories yet. No products found.
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> </tbody>
</table></section> </table>
</section>

View file

@ -1,3 +1,61 @@
<section class="flex flex-col gap-4"> <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> </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 for="{{ id }}" class="block text-sm font-medium mt-2">
{{ label }} {{ label }}
{% if required is defined %}
<span class="{{ colors.error.text }} ml-4">
*
</span>
{% endif %}
</label> </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') }}"> <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 for="{{ id }}" class="block text-sm font-medium mt-2">
{{ label }} {{ label }}
{% if required is defined %}
<span class="{{ colors.error.text }} ml-4">
*
</span>
{% endif %}
</label> </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 %}> <select id="{{ id }}" name="{{ name }}" class="border rounded-lg p-2 w-full" {% if required %} required {% endif %}>
{% if no_option is defined %} {% if no_option is defined %}
<option value="{{ no_option.value }}"> <option value="{{ no_option.value }}">

View file

@ -21,6 +21,11 @@
{% endif %} {% endif %}
</label> </label>
{% endif %} {% 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>