This commit is contained in:
count-null 2025-02-25 19:21:31 -05:00
parent 27df1a73b5
commit a0cb5fb6b0
36 changed files with 1886 additions and 187 deletions

View file

@ -1,9 +1,7 @@
<?php
namespace app;
// for email
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
class app
{
@ -12,37 +10,12 @@ class app
public static function init_db()
{
try {
self::$db = new \PDO('sqlite:../' . $_ENV['SQLITE_DB']);
self::$db = new \PDO('sqlite:' . $_ENV['SQLITE_DB']);
self::$db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
} catch (\PDOException $e) {
die("Database error: " . $e->getMessage());
}
}
public static function send_mail($to, $from, $from_name, $subject, $message, $HTML_message)
{
$mail = new PHPMailer(exceptions: true);
//Server settings
$mail->SMTPDebug = SMTP::DEBUG_SERVER;
$mail->isSMTP();
$mail->Host = $_ENV['SMTP_HOST'];
$mail->SMTPAuth = true;
$mail->Username = $_ENV['SMTP_USER'];
$mail->Password = $_ENV['SMTP_PASS'];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
$mail->isHTML(true);
$mail->setFrom($from, $from_name);
$mail->addAddress(address: $to);
$mail->Subject = $subject;
$mail->Body = $HTML_message;
$mail->AltBody = $message;
// Buffer the output
ob_start();
$mail->send();
ob_end_clean();
}
public static function sendJson($data, $status = 200)
{

58
src/colors.php Normal file
View file

@ -0,0 +1,58 @@
<?php
return [
'header' => [
'banner' => 'bg-gray-100 dark:bg-gray-600 text-gray-200 dark:text-gray-200',
],
'anchor' => [
'primary' => 'text-blue-400 dark:text-blue-200'
],
'body' => 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300',
'button' => [
'primary' => 'border-blue-400 dark:border-blue-600 dark:hover:border-blue-800 bg-blue-400 dark:bg-blue-600 hover:bg-blue-600 hover:dark:bg-blue-800 text-white dark:text-white',
'default' => 'hover:bg-gray-50 dark:hover:bg-gray-900'
],
'breadcrumb' => [
'parent' => 'text-gray-300 dark:text-gray-400 hover:text-gray-400 dark:hover:text-gray-500',
'seperator' => 'text-gray-200 dark:text-gray-200',
'child' => 'text-gray-200 dark:text-gray-300'
],
'dropdown' => [
'list' => 'bg-white dark:bg-blue-900 border-gray-600 dark:border-gray-300',
'item' => 'hover:bg-gray-200 dark:hover:bg-gray-900'
],
'input' => 'text-gray-800 dark:text-gray-300 bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-500 focus:ring-blue-500',
'error' => [
'text' => 'text-red-600',
'alert' => 'bg-red-100 text-gray-800 border-red-600'
],
'warning' => [
'text' => 'text-yellow-400',
'alert' => 'bg-yellow-100 text-gray-800 border-yellow-400'
],
'success' => [
'text' => 'text-green-600',
'alert' => 'bg-green-100 text-gray-800 border-green-600'
],
'info' => [
'text' => 'text-blue-400',
'alert' => 'bg-blue-200 text-gray-800 border-blue-400'
],
'modal' => [
'content' => 'bg-white dark:bg-blue-900 border-gray-600 dark:border-gray-300',
'shadow' => 'bg-black/70'
],
'nav' => [
'bar' => 'bg-blue-400 dark:bg-blue-600 text-gray-200 dark:text-gray-200',
'item' => 'hover:bg-blue-600 dark:hover:bg-blue-800 hover:text-gray-200 dark:hover:text-gray-300 text-white border-blue-400 dark:border-blue-600',
'hovercontent' => 'bg-white dark:bg-slate-700 text-gray-800 dark:text-gray-300'
],
'rule' => 'border-gray-400 dark:border-gray-400',
'text' => [
'muted' => 'text-gray-400 dark:text-gray-300'
],
'toggle' => "bg-gray-300 peer-checked:bg-green-400 after:bg-white",
'footer' => [
"primary" => "bg-gray-200 dark:bg-slate-600 text-gray-500 dark:text-gray-300",
"policy" => "bg-slate-400 dark:bg-slate-800 text-gray-200 dark:text-gray-400"
],
];

View file

@ -3,6 +3,7 @@ namespace app\controllers;
use app\models\addresses;
use app\models\users;
use app\models\emails;
use app\models\user_addresses;
use app\models\magic_links;
@ -138,6 +139,49 @@ class account
}
}
public static function verify($defaults)
{
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$code = $_POST['code'];
$link = magic_links::validateCode($code);
if ($link) {
$user = $link['user_id'] ? users::getById($link['user_id']) : users::getByEmail($link['email']);
if ($user) {
$_SESSION['user_email'] = $link['email'];
$_SESSION['user_id'] = $user['id'];
if (!$user['verified']) {
users::verify($link['email']);
}
header('Location: /account');
exit;
} else {
$_SESSION['user_email'] = $link['email'];
header('Location: /account/signup');
exit;
}
} else {
$_SESSION['error'] = "Invalid or expired verification code.";
header('Location: /account/verify');
exit;
}
}
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'account/verify.twig',
'page_title' => $_ENV['APP_NAME'],
'breadcrumbs' => [
[
'url' => '/account',
'title' => 'My Account'
],
[
'url' => null,
'title' => 'Verify'
]
]
]));
}
public static function login($defaults)
{
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
@ -148,7 +192,7 @@ class account
exit;
} else {
$token = magic_links::add($email, null);
header('Location: /account/login');
header('Location: /account/verify');
exit;
}
}
@ -339,6 +383,7 @@ class account
$verified,
$dark_theme
);
emails::updateUserIdByEmail($email, $user_id);
user_addresses::add(
user_id: $user_id,
address_id: $ship_id

184
src/controllers/admin.php Normal file
View file

@ -0,0 +1,184 @@
<?php
namespace app\controllers;
use app\models\transactions;
use app\models\emails;
use app\models\users;
class admin
{
public static function index($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/index.twig',
'page_title' => 'Dashboard',
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
]
],
]));
}
public static function users($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/users.twig',
'page_title' => 'Users',
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/users',
'title' => 'Users'
]
],
]));
}
public static function orders($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/orders.twig',
'page_title' => 'Orders',
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/orders',
'title' => 'Orders'
]
],
]));
}
public static function returns($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/returns.twig',
'page_title' => 'Returns',
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/returns',
'title' => 'Returns'
]
],
]));
}
public static function transactions($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/transactions/index.twig',
'page_title' => 'Transactions',
'recent_sats' => transactions::getRecent(10, 'sats'),
'recent_cents' => transactions::getRecent(10, 'cents'),
'whales_sats' => transactions::getWhales(10, 'sats'),
'whales_cents' => transactions::getWhales(10, 'cents'),
'sats_liability' => transactions::getLiabilities('sats'),
'cents_liability' => transactions::getLiabilities('cents'),
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/transactions',
'title' => 'Transactions'
]
],
]));
}
public static function transactions_add($defaults)
{
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$amount = $_POST['amount'] ?? null;
$currency = $_POST['currency'];
$user_identifier = $_POST['user_identifier'] ?? null;
if (!$amount || !$user_identifier) {
$_SESSION['error'] = !$amount ? "Please enter an amount for the transaction." : "Please enter a user email or id for the transaction.";
$_SESSION['last_post'] = $_POST;
} else {
if (strpos($user_identifier, '@') !== false && strpos($user_identifier, '.') !== false) {
$user = users::getByEmail($user_identifier);
} elseif (is_numeric($user_identifier)) {
$user = users::getById((int)$user_identifier);
} else {
$_SESSION['error'] = "Invalid user identifier. Please enter a valid email or user ID.";
$_SESSION['last_post'] = $_POST;
}
if (!$user) {
$_SESSION['error'] = "User not found. Please enter a valid email or user ID.";
$_SESSION['last_post'] = $_POST;
} else {
if($_POST['confirm']){
// create the transaction
$txid = transactions::add($user['id'], $amount > 0 ? 'CREDIT' : 'REVOKE', $currency == 'cents' ? $amount : 0, $currency == 'sats' ? $amount : 0);
header('Location: /transaction/' . $txid);
exit;
} else {
$_SESSION['last_post'] = $_POST;
$_SESSION['last_post']['confirm'] = true;
$_SESSION['last_post']['email'] = $user['email'];
$_SESSION['last_post']['id'] = $user['id'];
}
}
}
}
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/transactions/add.twig',
'page_title' => 'Add a Transaction',
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/transactions',
'title' => 'Transactions'
],
[
'url' => '/admin/transactions/add',
'title' => 'Add'
]
],
]));
}
public static function emails($defaults)
{
$recent_emails = emails::getRecent(20);
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/emails.twig',
'page_title' => 'Emails',
'recent_emails' => $recent_emails,
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/emails',
'title' => 'Emails'
]
],
]));
}
public static function transactions_reset($defaults)
{
$_SESSION['last_post'] = null;
header('Location: /admin/transactions/add');
exit;
}
}

View file

@ -2,13 +2,18 @@
namespace app\controllers;
use app\app;
use app\models\invoices;
$invoice = 'lnbc234340n1pnm5uh2pp5pwekmjzj3hgadahfeprtc6f2ppmhnqjzh556q4fvve8z953v3t0sdqqcqzysxqrrsssp5uq25yjnmvdpnglmv42nf64wk0pugrynq549f3wgghgtkfapwdfhq9qxpqysgqyjq2ewqkm6s2dlhuruuc4k9md22wraz829tlhfeuxrsnwmephfkjz8e9g7j6373989mfccajy3cxexac8xu6yen4qfs4947fkrg9ynsq7x72ze';
use Jorijn\Bitcoin\Bolt11\Encoder\PaymentRequestDecoder;
use Jorijn\Bitcoin\Bolt11\Model\Tag;
use Jorijn\Bitcoin\Bolt11\Normalizer\PaymentRequestDenormalizer;
class lnurlp
{
public static function index()
public static function index($defaults)
{
header(header: 'Content-Type: application/json');
$host = $_SERVER['HTTP_HOST'];
$user = $_GET["username"] ?? false;
$username = $_GET["username"] ?? false;
$paymentRequest = $_GET["pay"] ?? false;
$verify = $_GET["verify"] ?? false;
@ -25,25 +30,29 @@ class lnurlp
'reason' => 'invalid value for `pay` param (set `pay=1` or exclude `pay` from the url)',
]);
}
// for when the user is missing
if ($user == false && $verify == false) {
// for when the user is not specified
if ($username == false && $verify == false) {
returnJson([
'status' => 'ERROR',
'reason' => 'no user specified (set `username=<name>` in the url)',
]);
}
list($proxy_user, $proxy_host) = explode("@", $_ENV['LN_ADDRESS']);
// for when the user is not registered
$user = users::getByNpub($username);
if (!$user){
returnJson([
'status' => 'ERROR',
'reason' => "@$username is not registered"
]);
}
// for when the client makes it's first call (querying the lightning address)
$metadata = "[[\"text/plain\",\"Funding @$user on $host\"],[\"text/identifier\",\"$user@$host\"]]";
list($proxy_user, $proxy_host) = explode("@", $_ENV['LN_ADDRESS']);
$metadata = "[[\"text/plain\",\"Funding @$username on $host\"],[\"text/identifier\",\"$username@$host\"]]";
if ($paymentRequest == false && $verify == false) {
$res = json_decode(file_get_contents("https://$proxy_host/.well-known/lnurlp/$proxy_user"), true);
returnJson(
[
'callback' => "https://$host/lnurlp?pay=1&username=$user",
'callback' => "https://$host/lnurlp?pay=1&username=$username",
'maxSendable' => $res['maxSendable'],
'minSendable' => $res['minSendable'],
'metadata' => $metadata,
@ -54,19 +63,34 @@ class lnurlp
);
}
// for when the client makes it's second call (callback)
// for when the client makes it's second call (callback) to get an invoice
if ($paymentRequest == "1") {
$proxy_url = "https://$proxy_host/lnurlp?pay=1&username=$proxy_user";
$res = json_decode(file_get_contents("https://$proxy_host/.well-known/lnurlp/$proxy_user"), true);
$proxy_url = $res['callback'];
if (isset($_GET["amount"])) {
$proxy_url .= "&amount=" . urlencode($_GET["amount"]);
}
$res = json_decode(file_get_contents($proxy_url), true);
if ($res['status'] === 'OK'){
// subscribe to this invoice by adding to our db
$invoice = $res['pr'];
$decoder = new PaymentRequestDecoder();
$denormalizer = new PaymentRequestDenormalizer();
$paymentRequest = $denormalizer->denormalize($decoder->decode($invoice));
invoices::add(
$user['id'],
null,
$invoice,
$res['verify'],
$paymentRequest->getMilliSatoshis(),
$paymentRequest->getExpiryDateTime()['date']
);
$boom = explode("=", $res['verify']);
$proxy_verify = end($boom);
returnJson([
'status' => 'OK',
'pr' => $res['pr'],
'pr' => $invoice,
'routes' => $res['routes'],
'verify' => "https://$host/lnurlp?verify=$proxy_verify"
]);
@ -77,8 +101,10 @@ class lnurlp
// for when they want to verify the payment succeeded
if ($verify) {
$res = json_decode(file_get_contents("https://$proxy_host/lnurlp?verify=$verify"), true);
returnJson($res);
$res = json_decode(file_get_contents("https://$proxy_host/.well-known/lnurlp/$proxy_user"), true);
$proxy_url = $res['verify'];
$prox_res = json_decode(file_get_contents($proxy_url), true);
returnJson($prox_res);
}
// for when none of the above conditions are met

View file

@ -0,0 +1,172 @@
<?php
namespace app\controllers;
use app\models\transactions;
use app\models\users;
use app\controllers\lost;
class transaction
{
public static function view($defaults, $txid)
{
$tx = transactions::getById($txid);
if (!$tx) {
lost::index($defaults);
}
$user = users::getById($tx['user_id']);
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'transaction.twig',
'page_title' => 'Transaction Reciept #' . $txid,
'tx' => $tx,
'user' => $user,
'breadcrumbs' => [
[
'url' => "/transaction/" . $txid,
'title' => 'Transaction Reciept'
]
],
]));
}
public static function users($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/users.twig',
'page_title' => $_ENV['APP_NAME'] . ' Users',
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/users',
'title' => 'Users'
]
],
]));
}
public static function orders($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/orders.twig',
'page_title' => $_ENV['APP_NAME'] . ' Orders',
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/orders',
'title' => 'Orders'
]
],
]));
}
public static function returns($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/returns.twig',
'page_title' => $_ENV['APP_NAME'] . ' Returns',
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/returns',
'title' => 'Returns'
]
],
]));
}
public static function transactions($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/transactions/index.twig',
'page_title' => $_ENV['APP_NAME'] . ' Transactions',
'recent_sats' => transactions::getRecent(10, 'sats'),
'recent_cents' => transactions::getRecent(10, 'cents'),
'whales_sats' => transactions::getWhales(10, 'sats'),
'whales_cents' => transactions::getWhales(10, 'cents'),
'sats_liability' => transactions::getLiabilities('sats'),
'cents_liability' => transactions::getLiabilities('cents'),
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/transactions',
'title' => 'Transactions'
]
],
]));
}
public static function transactions_add($defaults)
{
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$amount = $_POST['amount'] ?? null;
$currency = $_POST['currency'];
$user_identifier = $_POST['user_identifier'] ?? null;
if (!$amount || !$user_identifier) {
$_SESSION['error'] = !$amount ? "Please enter an amount for the transaction." : "Please enter a user email or id for the transaction.";
$_SESSION['last_post'] = $_POST;
} else {
if (strpos($user_identifier, '@') !== false && strpos($user_identifier, '.') !== false) {
$user = users::getByEmail($user_identifier);
} elseif (is_numeric($user_identifier)) {
$user = users::getById((int)$user_identifier);
} else {
$_SESSION['error'] = "Invalid user identifier. Please enter a valid email or user ID.";
$_SESSION['last_post'] = $_POST;
}
if (!$user) {
$_SESSION['error'] = "User not found. Please enter a valid email or user ID.";
$_SESSION['last_post'] = $_POST;
} else {
if($_POST['confirm']){
// create the transaction
$txid = transactions::add($user['id'], 'CREDIT', $currency == 'cents' ? $amount : 0, $currency == 'sats' ? $amount : 0);
header('Location: /transaction/' . $txid);
exit;
} else {
$_SESSION['last_post'] = $_POST;
$_SESSION['last_post']['confirm'] = true;
$_SESSION['last_post']['email'] = $user['email'];
$_SESSION['last_post']['id'] = $user['id'];
}
}
}
}
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'admin/transactions/add.twig',
'page_title' => 'Add a Transaction',
'breadcrumbs' => [
[
'url' => '/admin',
'title' => 'Admin'
],
[
'url' => '/admin/transactions',
'title' => 'Transactions'
],
[
'url' => '/admin/transactions/add',
'title' => 'Add'
]
],
]));
}
public static function transactions_reset($defaults)
{
$_SESSION['last_post'] = null;
header('Location: /admin/transactions/add');
exit;
}
}

94
src/models/emails.php Normal file
View file

@ -0,0 +1,94 @@
<?php
namespace app\models;
use app\app;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
class emails
{
public static function init()
{
app::$db->exec("CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
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)
);");
}
public static function getRecentByUserId($user_id, $n)
{
$query = "SELECT * FROM emails WHERE user_id = :user_id ORDER BY created_at DESC LIMIT :n";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':user_id', $user_id, \PDO::PARAM_INT);
$stmt->bindParam(':n', $n, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
public static function getRecent($n)
{
$query = "SELECT * FROM emails ORDER BY created_at DESC LIMIT :n";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':n', $n, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
public static function updateUserIdByEmail($email, $user_id)
{
$query = "UPDATE emails SET user_id = :user_id WHERE to_email = :email AND user_id IS NULL";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':user_id', $user_id, \PDO::PARAM_INT);
$stmt->bindParam(':email', $email);
$stmt->execute();
}
public static function send($subject, $from_email, $from_name, $to_email, $message, $template, $template_vars)
{
$user = users::getByEmail($to_email);
$user_id = $user ? $user['id'] : null;
$HTML_message = $GLOBALS['twig']->render("lib/emails/$template", $template_vars);
$query = "INSERT INTO emails (user_id, from_email, from_name, to_email, subject, message, html_message)
VALUES (:user_id, :from_email, :from_name, :to_email, :subject, :message, :html_message)";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':user_id', $user_id);
$stmt->bindParam(':from_email', $from_email);
$stmt->bindParam(':from_name', $from_name);
$stmt->bindParam(':to_email', $to_email);
$stmt->bindParam(':subject', $subject);
$stmt->bindParam(':message', $message);
$stmt->bindParam(':html_message', $HTML_message);
$stmt->execute();
$mail = new PHPMailer(exceptions: true);
// Mail Server settings
$mail->SMTPDebug = SMTP::DEBUG_SERVER;
$mail->isSMTP();
$mail->Host = $_ENV['SMTP_HOST'];
$mail->SMTPAuth = true;
$mail->Username = $_ENV['SMTP_USER'];
$mail->Password = $_ENV['SMTP_PASS'];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
$mail->isHTML(true);
$mail->setFrom($from_email, $from_name);
$mail->addAddress($to_email);
$mail->Subject = $subject;
$mail->Body = $HTML_message;
$mail->AltBody = $message;
// Buffer the output
ob_start();
$mail->send();
ob_end_clean();
}
}

96
src/models/invoices.php Normal file
View file

@ -0,0 +1,96 @@
<?php
namespace app\models;
use app\models\transactions;
use app\app;
class invoices
{
public static function init()
{
$query = "CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
order_id INTEGER,
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)
)";
app::$db->exec($query);
}
public static function add($user_id, $order_id, $invoice, $verify, $amount_msats, $expiry_date)
{
$query = "INSERT INTO invoices (user_id, order_id, invoice, verify, amount_msats, expiry_date) VALUES (:user_id, :order_id, :invoice, :verify, :amount_msats, :expiry_date)";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':user_id', $user_id, \PDO::PARAM_INT);
$stmt->bindParam(':order_id', $order_id, \PDO::PARAM_INT);
$stmt->bindParam(':invoice', $invoice);
$stmt->bindParam(':verify', $verify);
$stmt->bindParam(':amount_msats', $amount_milisats);
$stmt->bindParam(':expiry_date', $expiry_date);
$stmt->execute();
return app::$db->lastInsertId();
}
public static function getById($invoice_id)
{
$stmt = app::$db->prepare("SELECT * FROM invoices WHERE id = :invoice_id");
$stmt->bindParam(':invoice_id', $invoice_id, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
public static function delete($invoice_id)
{
$deleteStmt = app::$db->prepare("DELETE FROM invoices WHERE id = :invoice_id");
$deleteStmt->bindParam(':invoice_id', $invoice_id, \PDO::PARAM_INT);
$deleteStmt->execute();
}
public static function markSettled($invoice_id)
{
$stmt = app::$db->prepare("UPDATE invoices SET settled = 1 WHERE id = :invoice_id");
$stmt->bindParam(':invoice_id', $invoice_id, \PDO::PARAM_INT);
$stmt->execute();
}
public static function checkAll()
{
$stmt = app::$db->prepare("SELECT id FROM invoices WHERE settled = 0");
$stmt->execute();
$invoiceIds = $stmt->fetchAll(\PDO::FETCH_COLUMN);
foreach ($invoiceIds as $invoiceId) {
self::check($invoiceId);
}
}
public static function check($invoice_id)
{
$invoice = self::get_byId($invoice_id);
// Use LUD-21 Payment Verify
$responseData = json_decode(file_get_contents($invoice['verify']), true);
if ($responseData['settled']) {
if ($invoice['order_id']) {
orders::updateStatus($invoice['order_id'], "PROCESSING");
self::markSettled($invoice_id);
} else {
transactions::add($invoice['user_id'], 'DEPOSIT', 0, $invoice['amount_msats'] / 1000);
}
} else {
// Check if more than 24 hours past expiry
$expiryDate = new \DateTime($invoice['expiry_date']);
$currentDate = new \DateTime();
$interval = $expiryDate->diff($currentDate);
if ($interval->days >= 1) {
self::delete($invoice_id);
}
}
}
}

View file

@ -2,6 +2,8 @@
namespace app\models;
use app\app;
use app\models\emails;
class magic_links
{
public static function init()
@ -12,6 +14,7 @@ class magic_links
email TEXT NOT NULL,
code TEXT NOT NULL,
token TEXT NOT NULL,
ipv4 TEXT NOT NULL,
expires_at DATETIME NOT NULL,
used BOOLEAN DEFAULT FALSE
)");
@ -19,21 +22,26 @@ class magic_links
public static function add($email, $user_id)
{
$code = str_pad(strval(random_int(0, 999999)), 6, "0", STR_PAD_LEFT);
$token = bin2hex(random_bytes(32));
$seed = hexdec(substr($token, 0, 8)); // Use the first 8 characters of the token as a seed
mt_srand($seed);
$code = str_pad(strval(mt_rand(0, 999999)), 6, "0", STR_PAD_LEFT);
$expires_at = date('Y-m-d H:i:s', time() + 60 * 15);
$ipv4 = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; // Get client's IPv4 address
$query = "INSERT INTO magic_links (
email,
user_id,
token,
code,
expires_at
expires_at,
ipv4
) VALUES (
:email,
:user_id,
:token,
:code,
:expires_at
:expires_at,
:ipv4
)";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':email', $email);
@ -41,12 +49,21 @@ class magic_links
$stmt->bindParam(':token', $token);
$stmt->bindParam(':code', $code);
$stmt->bindParam(':expires_at', $expires_at);
$stmt->bindParam(':ipv4', $ipv4);
$stmt->execute();
$link = $_ENV['APP_HOST'] . "/magic-link?token=" . urlencode($token);
$subject = "Your Magic Sign-In Link";
$message = "Enter this code into the sign-in form\n$code\n or copy-paste this link into your browser to sign in:\n$link";
$HTML_message = "Click the link to sign in: <a href='$link'>$link</a> or enter this code:<br/>$code";
app::send_mail(to: $email, from: $_ENV['SMTP_FROM'], from_name: $_ENV['APP_NAME'], subject: $subject, message: $message, HTML_message: $HTML_message);
$template_vars = ['code' => $code, 'link' => $link];
emails::send(
$subject,
$_ENV['SMTP_FROM'],
$_ENV['APP_NAME'],
$email,
$message,
'verify.twig',
$template_vars
);
$_SESSION['success'] = 'Link sent to your email!';
return $token;
}

View file

@ -3,7 +3,7 @@ namespace app\models;
use app\app;
class Subscriptions
class subscriptions
{
const STATES = [
'TRIAL', 'START', 'RENEWAL'
@ -29,7 +29,7 @@ class Subscriptions
);");
}
public static function createSubscription(int $userId, int $productId, string $state = 'TRIAL', string $status = 'COMPLETED', string $startDate, string $renewAt, string $invoiceDate): int
public static function createSubscription( $userId, $productId, $state, $startDate, $renewAt, $invoiceDate)
{
self::validateState($state);
self::validateStatus($status);

View file

@ -20,6 +20,15 @@ class transactions
FOREIGN KEY (user_id) REFERENCES users(id)
)");
}
public static function getById($id)
{
$query = "SELECT * FROM transactions WHERE id = :id";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':id', $id, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetch();
}
public static function add($user_id, $transaction_type, $cents, $sats)
{
@ -29,13 +38,10 @@ class transactions
if ($cents < 0 || $sats < 0) {
throw new \Exception("Amounts must be non-negative integers.");
}
$currentBalance = self::getUserBalance($user_id);
if (in_array($transaction_type, ['REDEEM', 'REVOKE']) && ($currentBalance['total_cents'] < $cents || $currentBalance['total_sats'] < $sats)) {
throw new \Exception("Insufficient funds.");
}
$query = "INSERT INTO transactions (user_id, type, cents, sats) VALUES (:user_id, :transaction_type, :cents, :sats)";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':user_id', $user_id);
@ -43,7 +49,6 @@ class transactions
$stmt->bindParam(':cents', $cents);
$stmt->bindParam(':sats', $sats);
$stmt->execute();
return app::$db->lastInsertId();
}
@ -56,9 +61,9 @@ class transactions
return $stmt->fetch();
}
public static function getRecent($n)
public static function getRecent($n, $currency)
{
$query = "SELECT * FROM transactions ORDER BY date DESC LIMIT :n";
$query = "SELECT * FROM transactions WHERE $currency > 0 ORDER BY date DESC LIMIT :n";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':n', $n, \PDO::PARAM_INT);
$stmt->execute();
@ -77,7 +82,7 @@ class transactions
return $stmt->fetchAll();
}
public static function liabilities($currency)
public static function getLiabilities($currency)
{
if (!in_array($currency, ['cents', 'sats'])) {
throw new \Exception("Invalid currency type.");

View file

@ -14,6 +14,10 @@ class users
shipping_address_id INTEGER,
billing_address_id INTEGER,
opt_in_promotional BOOLEAN NOT NULL,
opt_in_subscription BOOLEAN DEFAULT TRUE,
opt_in_orders BOOLEAN DEFAULT TRUE,
lifetime_spend INTEGER DEFAULT 0,
lifetime_orders INTEGER DEFAULT 0,
verified BOOLEAN NOT NULL,
dark_theme BOOLEAN NOT NULL,
nsec TEXT,
@ -129,6 +133,15 @@ class users
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
public static function getByNpub($npub)
{
$query = "SELECT * FROM users WHERE npub = :npub";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':npub', $npub);
$stmt->execute();
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
public static function getByEmail($email)
{
$query = "SELECT * FROM users WHERE email = :email";

View file

@ -0,0 +1,25 @@
<?php
//
// Run this script to check if any proxied invocies from the LN_SERVICE are paid or expired
// It will apply store_credit if the invoice was paid and associated with a deposit
// It will make adjustments to quotes or orders if the invoice was paid and associated with a quote or order payment
// It will mark invoices as expired if they are expired
//
// Run this every 10 seconds with cron like this...
// * * * * * bash -c 'start=$(date +%s); for i in {1..6}; do php /path/to/scripts/check_all_invoices.php; sleep $((10 - ($(date +%s) - start) % 10)); done'
//
// Cron only lets you run every minute, using this we can run every 10 seconds.
// It accounts for the execution time of this script
// so there will be minimal 'drift' over time.
//
require_once __DIR__ . '/../../vendor/autoload.php';
// Load environment variables from the .env file at project root
Dotenv\Dotenv::createImmutable(__DIR__ . '/../../')->load();
use app\app;
use app\models\invoices;
app::init_db();
invoices::checkAll();

View file

@ -0,0 +1,21 @@
<?php
//
// Run this script to check the status of subscriptions
// It should run at least once a day with cron or some other task runner
// It will check for subscriptions that have expired
// If the expired subscription was CANCELED, do nothing
// If the expired subscription was not CANCELED, update it to COMPLETED
// If the newly COMPLETED subscription was TRIAL, then START the subscription based on the TRIAL - bill the user
// If the newly COMPLETED subscription was START, then RENEW the subscription - bill the user
// If the newly COMPLETED subscription was RENEW, then RENEW the subscription - bill the user
// It will check for subcriptions that are about to expire
// It sends an email notification to user based on their email preferences
// The content of the notification is based on:
// If user has enough credit to cover the upcomming bill
// If the user does not have enough credit to cover the upcomming bill
// php /path/to/scripts/check_lightning_invoices.php
//
require_once __DIR__ . '/../../vendor/autoload.php';
// Load environment variables from the .env file at project root
Dotenv\Dotenv::createImmutable(__DIR__ . '/../../')->load();

60
src/scripts/init_db.php Normal file
View file

@ -0,0 +1,60 @@
<?php
//
// Use this to initialize the database models
// Run it in command-line like
// php scripts/init_db.php
//
require_once __DIR__ . '/../../vendor/autoload.php';
// Load environment variables from the .env file at project root
Dotenv\Dotenv::createImmutable(__DIR__ . '/../../')->load();
// establish the db connection
use app\app;
app::init_db();
// db models go brrr...
use app\models\addresses;
addresses::init();
use app\models\cart_items;
cart_items::init();
use app\models\carts;
carts::init();
use app\models\emails;
emails::init();
use app\models\invoices;
invoices::init();
use app\models\magic_links;
magic_links::init();
use app\models\order_items;
order_items::init();
use app\models\orders;
orders::init();
use app\models\products;
products::init();
use app\models\quote_items;
quote_items::init();
use app\models\quotes;
quotes::init();
use app\models\subscriptions;
subscriptions::init();
use app\models\transactions;
transactions::init();
use app\models\user_addresses;
user_addresses::init();
use app\models\users;
users::init();

View file

@ -0,0 +1,70 @@
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1 mb-4">
<h3 class="text-2xl font-semibold">Check Your Email</h3>
<p>We have sent a verification code to your email.</p>
{% include 'lib/rule.twig' %}
</div>
{% include 'lib/alert.twig' %}
<style>
.code-input {
font-size: 24px;
font-family: monospace;
text-align: left;
letter-spacing: 1.15em;
border: 2px solid #000;
padding: 10px;
width: 250px;
outline: none;
background: linear-gradient(
90deg,
white 0, white 36px, /* Box width */
black 36px, black 38px /* Border between boxes */
);
background-size: 42px 100%; /* Adjust size to fit each character in a cell */
background-clip: padding-box;
}
</style>
<form action="/account/verify" method="post" class="flex flex-col items-center gap-4">
<input type="tel" name="code" placeholder="******" class="code-input" maxlength="6" inputmode="numeric" pattern="[0-9]*">
<script>
document.addEventListener("DOMContentLoaded", function () {
const input = document.querySelector(".code-input");
input.addEventListener("input", function (e) {
// Remove any non-digit characters immediately
this.value = this.value.replace(/\D/g, "");
// Move the cursor back one space then blur when the 6th digit is entered
if (this.value.length === 6) {
this.setSelectionRange(this.value.length - 1, this.value.length - 1);
this.blur();
}
});
input.addEventListener("paste", function (e) {
e.preventDefault();
let pastedData = e.clipboardData.getData("text").replace(/\D/g, ""); // Allow only digits
this.value = pastedData.substring(0, this.maxLength);
// Clear focus if pasted data fills the input
if (this.value.length === 6) {
this.blur();
}
});
input.addEventListener("keypress", function (e) {
if (!/[0-9]/.test(e.key)) {
e.preventDefault(); // Block non-numeric input
}
});
});
</script>
{% include 'lib/button.twig' with {
label: 'Verify Code',
onclick: 'this.parentNode.submit()',
captcha: true
} %}
</form>
</div>
</section>

View file

@ -0,0 +1,27 @@
<section class="flex flex-col gap-4">
<h3 class="text-2xl font-semibold">Recently Sent Emails</h3>
<table class="min-w-full bg-white">
<thead>
<tr>
<th class="py-2">To</th>
<th class="py-2">From</th>
<th class="py-2">Subject</th>
<th class="py-2">Created At</th>
</tr>
</thead>
<tbody>
{% for email in recent_emails %}
<tr>
<td class="border px-4 py-2">{{ email.to_email }}</td>
<td class="border px-4 py-2">{{ email.from_email }}</td>
<td class="border px-4 py-2">{{ email.subject }}</td>
<td class="border px-4 py-2">{{ email.created_at }}</td>
</tr>
{% else %}
<tr>
<td class="border px-4 py-2" colspan="4">No recent emails found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>

View file

@ -0,0 +1,10 @@
<section class="flex flex-col gap-4">
<a href="/admin">Dashboard</a>
<a href="/admin/users">Users</a>
<a href="/admin/orders">Orders</a>
<a href="/admin/returns">Returns</a>
<a href="/admin/emails">Emails</a>
<a href="/admin/transactions">Transactions</a>
INDEX
</section>

View file

@ -0,0 +1,3 @@
<section class="flex flex-col gap-4">
ORDERS
</section>

View file

@ -0,0 +1,3 @@
<section class="flex flex-col gap-4">
RETURNS
</section>

View file

@ -0,0 +1,61 @@
<section class="flex flex-col gap-4">
{% include 'lib/alert.twig' %}
<form action="/admin/transactions/add" method="post" class="flex flex-col gap-4">
{% if session.last_post.confirm %}
Confirm
<input type="hidden" name="confirm" id="confirm" value="{{ session.last_post.confirm }}">
{% endif %}
{% if session.last_post.amount is defined %}
{{ session.last_post.amount }} {{ session.last_post.currency }}
<input type="hidden" name="amount" id="amount" value="{{ session.last_post.amount }}">
{% else %}
{% include 'lib/number_input.twig' with {
id: 'amount',
name: 'amount',
label: 'Amount',
placeholder: 'Enter the amount',
value: session.last_post.amount,
required: true
} %}
{% endif %}
{% if session.last_post.currency is defined %}
<input type="hidden" name="currency" id="currency" value="{{ session.last_post.currency }}">
{% else %}
{% include 'lib/select.twig' with {
id: 'currency',
name: 'currency',
label: 'Currency',
value: session.last_post.currency,
options: [
{ 'value': 'sats', 'text': 'Sats' },
{ 'value': 'cents', 'text': 'Cents' }
],
required: true
} %}
{% endif %}
{% if session.last_post.user_identifier is defined %}
{% if session.last_post.email is defined %}
{{ session.last_post.id }} {{ session.last_post.email }}
{% endif %}
<input type="hidden" name="user_identifier" id="user_identifier" value="{{ session.last_post.user_identifier }}">
{% else %}
{% include 'lib/input.twig' with {
type: 'text',
name: 'user_identifier',
label: 'User Identifier',
placeholder: 'Enter email or user index',
value: session.last_post.user_identifier
} %}
{% endif %}
{% include 'lib/button.twig' with {
label: 'Submit',
onclick: 'this.parentNode.submit()'
} %}
</form>
{% if session.last_post %}
{% include 'lib/button.twig' with {
label: 'Cancel',
href: '/admin/transactions/reset'
} %}
{% endif %}
</section>

View file

@ -0,0 +1,144 @@
<section class="flex flex-col gap-4">
{% include 'lib/alert.twig' %}
<form action="/admin/transactions/add" method="post" class="flex flex-col gap-4">
{% include 'lib/number_input.twig' with {
id: 'amount',
name: 'amount',
label: 'Amount',
required: true
} %}
{% include 'lib/select.twig' with {
id: 'currency',
name: 'currency',
label: 'Currency',
options: [
{ value: 'cents', text: 'Cents' },
{ value: 'sats', text: 'Sats' }
],
required: true
} %}
{% include 'lib/button.twig' with {
label: 'Submit',
onclick: 'this.parentNode.submit()'
} %}
</form>
<h3 class="text-2xl font-semibold">Liabilities</h3>
<table class="min-w-full bg-white">
<thead>
<tr>
<th class="py-2">Currency</th>
<th class="py-2">Total Liability</th>
</tr>
</thead>
<tbody>
<tr>
<td class="border px-4 py-2">Sats</td>
<td class="border px-4 py-2">{{ sats_liability }}</td>
</tr>
<tr>
<td class="border px-4 py-2">Cents</td>
<td class="border px-4 py-2">{{ cents_liability }}</td>
</tr>
</tbody>
</table>
<h3 class="text-2xl font-semibold">Sats Transactions</h3>
<table class="min-w-full bg-white">
<thead>
<tr>
<th class="py-2">Type</th>
<th class="py-2">Amount (Sats)</th>
<th class="py-2">Date</th>
</tr>
</thead>
<tbody>
{% if recent_sats is not empty %}
{% for transaction in recent_sats %}
<tr>
<td class="border px-4 py-2">{{ transaction.type }}</td>
<td class="border px-4 py-2">{{ transaction.sats }}</td>
<td class="border px-4 py-2">{{ transaction.date }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td class="border px-4 py-2" colspan="3">No Sats transactions available.</td>
</tr>
{% endif %}
</tbody>
</table>
<h3 class="text-2xl font-semibold">Cents Transactions</h3>
<table class="min-w-full bg-white">
<thead>
<tr>
<th class="py-2">Type</th>
<th class="py-2">Amount (Cents)</th>
<th class="py-2">Date</th>
</tr>
</thead>
<tbody>
{% if recent_cents is not empty %}
{% for transaction in recent_cents %}
<tr>
<td class="border px-4 py-2">{{ transaction.type }}</td>
<td class="border px-4 py-2">{{ transaction.cents }}</td>
<td class="border px-4 py-2">{{ transaction.date }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td class="border px-4 py-2" colspan="3">No Cents transactions available.</td>
</tr>
{% endif %}
</tbody>
</table>
<h3 class="text-2xl font-semibold">Whales Sats</h3>
<table class="min-w-full bg-white">
<thead>
<tr>
<th class="py-2">User ID</th>
<th class="py-2">Total Sats</th>
</tr>
</thead>
<tbody>
{% if whales_sats is not empty %}
{% for whale in whales_sats %}
<tr>
<td class="border px-4 py-2">{{ whale.user_id }}</td>
<td class="border px-4 py-2">{{ whale.total }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td class="border px-4 py-2" colspan="2">No Sats whales available.</td>
</tr>
{% endif %}
</tbody>
</table>
<h3 class="text-2xl font-semibold">Whales Cents</h3>
<table class="min-w-full bg-white">
<thead>
<tr>
<th class="py-2">User ID</th>
<th class="py-2">Total Cents</th>
</tr>
</thead>
<tbody>
{% if whales_cents is not empty %}
{% for whale in whales_cents %}
<tr>
<td class="border px-4 py-2">{{ whale.user_id }}</td>
<td class="border px-4 py-2">{{ whale.total }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td class="border px-4 py-2" colspan="2">No Cents whales available.</td>
</tr>
{% endif %}
</tbody>
</table>
</section>

View file

@ -0,0 +1,3 @@
<section class="flex flex-col gap-4">
USERS
</section>

View file

@ -1,6 +1,4 @@
<style>
/* #Mega Menu Styles
*/
.mega-menu {
opacity: 0;
visibility: hidden;
@ -12,7 +10,6 @@
width: 100%;
transition: all 0.15s linear 0s;
}
/* #hoverable Class Styles */
.hoverable {
position: static;
}
@ -106,6 +103,12 @@
{% endif %}
</ul>
{% if is_admin %}
{% include 'lib/rule.twig' %}
<ul class="py-2">
<li><a href="/admin" class="block px-4 py-2 m-1 rounded-lg {{ colors.dropdown.item }}">Admin</a></li>
</ul>
{% endif %}
{% if session.user_id is defined %}
{% include 'lib/rule.twig' %}
<ul class="py-2">

View file

@ -1,5 +1,10 @@
<div onclick="{{ onclick }}"
class="cursor-pointer {{ submit is defined ? 'px-4 rounded-l-none' : 'w-full' }} {{ colors.button.primary }} rounded-lg h-[42px] flex items-center justify-center">
{% if href is defined %}
<a href="{{ href }}"
class="cursor-pointer {{ submit is defined ? 'px-4 rounded-l-none' : 'w-full' }} {{ colors.button.primary }} rounded-lg h-[42px] flex items-center justify-center">
{% else %}
<div onclick="{{ onclick }}"
class="cursor-pointer {{ submit is defined ? 'px-4 rounded-l-none' : 'w-full' }} {{ colors.button.primary }} rounded-lg h-[42px] flex items-center justify-center">
{% endif %}
{% if label is defined %}
<span>{{ label }}</span>
{% endif %}
@ -10,23 +15,27 @@
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
{% elseif icon == 'add' %}
{% elseif icon == 'add' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-plus">
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
{% elseif icon == 'enter' %}
{% elseif icon == 'enter' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-arrow-right">
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
{% endif %}
{% endif %}
{% endif %}
</div>
{% if href is defined %}
</a>
{% else %}
</div>
{% endif %}
{% if captcha is defined %}
<div class="flex justify-center {{ colors.text.muted }}">
<p class="w-[250px] text-[10px] text-center">This form is protected by reCAPTCHA and the Google

View file

@ -0,0 +1,89 @@
<table align="center" class="x_225906249wrapper x_225906249wrapper-callout x_225906249wrapper-without-padding-top" border="0" cellpadding="0" cellspacing="0" style="background: rgb(251, 251, 251); background-color: rgb(251, 251, 251); width: 100%">
<tbody>
<tr>
<td>
<div style="margin: 0px auto; max-width: 648px">
<table align="center" border="0" cellpadding="0" cellspacing="0" style="width: 100%">
<tbody>
<tr>
<td style="direction: ltr; font-size: 0px; padding: 60px 0px; text-align: center">
<div style="margin: 0px auto; max-width: 648px">
<table align="center" border="0" cellpadding="0" cellspacing="0" style="width: 100%">
<tbody>
<tr>
<td style="direction: ltr; font-size: 0px; padding: 0; text-align: center">
<div class="x_225906249mj-column-per-100 x_225906249mj-outlook-group-fix" style="font-size: 0px; text-align: left; direction: ltr; display: inline-block; vertical-align: top; width: 100%">
<table border="0" cellpadding="0" cellspacing="0" style="vertical-align: top" width="100%">
<tbody>
<tr>
<td align="left" class="x_225906249text x_225906249text-surtitle" style="font-size: 0px; padding: 0 24px; padding-bottom: 12px">
<div style="font-family: Greycliff, system-ui, -apple-system, &quot;Segoe UI&quot;, Roboto, Ubuntu, Cantarell, &quot;Noto Sans&quot;, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;; font-size: 16px; letter-spacing: 0.12em; line-height: 24px; text-align: left; text-transform: uppercase; color: rgb(49, 89, 128)">Your Account</div>
</td>
</tr>
<tr>
<td align="left" class="x_225906249text x_225906249text-header-title" style="font-size: 0px; padding: 0 24px; padding-bottom: 24px">
<div style="font-family: &quot;Source Serif Pro&quot;, Georgia, Cambria, &quot;Times New Roman&quot;, Times, serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;; font-size: 41px; font-weight: 600; line-height: 48px; text-align: left; color: rgb(0, 9, 19)">One-Time Passcode</div>
</td>
</tr>
<tr>
<td align="left" class="x_225906249text" style="font-size: 0px; padding: 0 24px">
<div style="font-family: Greycliff, system-ui, -apple-system, &quot;Segoe UI&quot;, Roboto, Ubuntu, Cantarell, &quot;Noto Sans&quot;, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;; font-size: 16px; line-height: 24px; text-align: left; color: rgb(0, 9, 19)">Click the button below to access your secure login form, then enter your one-time passcode.</div>
</td>
</tr>
<tr>
<td align="left" class="x_225906249text x_225906249text-code" style="font-size: 0px; padding: 0 24px; padding-top: 24px">
<div style="font-family: &quot;Source Code Pro&quot;, &quot;ui-monospace&quot;, Menlo, Consolas, &quot;Roboto Mono&quot;, &quot;Ubuntu Monospace&quot;, &quot;Noto Mono&quot;, &quot;Oxygen Mono&quot;, &quot;Liberation Mono&quot;, monospace, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot; !important; font-size: 41px; font-weight: bold; letter-spacing: 0.12em; line-height: 48px; text-align: left; color: rgb(0, 48, 94)">{{ code }}</div>
</td>
</tr>
<tr>
<td align="left" class="x_225906249button" style="font-size: 0px; padding: 24px 24px">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; line-height: 100%">
<tbody>
<tr>
<td align="center" bgcolor="#FCC800" style="border: none; border-radius: 6px; cursor: auto; background: rgb(252, 200, 0)" valign="middle">
<a href="{{ link }}" style="display: inline-block; background: rgb(252, 200, 0); color: rgb(0, 48, 94); font-family: Greycliff, system-ui, -apple-system, &quot;Segoe UI&quot;, Roboto, Ubuntu, Cantarell, &quot;Noto Sans&quot;, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;; font-size: 16px; font-weight: bold; line-height: 24px; margin: 0; text-decoration: none; text-transform: none; padding: 18px 24px; border-radius: 6px" target="_blank"> Log in&nbsp;→ </a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" class="x_225906249text" style="font-size: 0px; padding: 0 24px">
<div style="font-family: Greycliff, system-ui, -apple-system, &quot;Segoe UI&quot;, Roboto, Ubuntu, Cantarell, &quot;Noto Sans&quot;, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;; font-size: 16px; font-weight: bold; line-height: 24px; text-align: left">BuysForLife agents will never ask you for this code. Do not share this passcode with anyone for any reason.</div>
</td>
</tr>
<tr>
<td align="left" style="font-size: 0px; padding: 0 24px">
<div style="font-family: Greycliff, system-ui, -apple-system, &quot;Segoe UI&quot;, Roboto, Ubuntu, Cantarell, &quot;Noto Sans&quot;, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;; font-size: 16px; line-height: 24px; text-align: left; color: rgb(0, 9, 19)"></div>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>

View file

@ -28,6 +28,14 @@
{% 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 }} {{ submit is defined ? 'rounded-l-lg border-r-0' : 'rounded-lg' }} w-full p-3 h-[42px] border focus:ring-1 focus:outline-none">
{% if submit is defined %}
{% include 'lib/button.twig' with {

View file

@ -0,0 +1,17 @@
<label for="{{ id }}" class="block text-sm font-medium text-gray-700 mt-2">
{{ label }}
</label>
<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') }}">
{% if subtext is defined %}
<p class="text-xs text-gray-500">{{ subtext }}</p>
{% endif %}

View file

@ -2,7 +2,7 @@
<div class="text-lg font-semibold mb-4">Why do I have sats?</div>
<ul class="list-disc pl-6 mb-4">
<li>You may have received sats from a promotional event</li>
<li>You may have recieved sats sent to your default generated Lightning Address (LNURL)</li>
<li>You may have recieved sats sent to your default generated Lightning Address (LNURL): {{ user.npub }}@{{ http_host }}</li>
</ul>
<div class="text-lg font-semibold mb-4">What can I do with sats?</div>
<ul class="list-disc pl-6">

View file

@ -0,0 +1,8 @@
<label for="{{ id }}" class="block text-sm font-medium text-gray-700 mt-2">
{{ label }}
</label>
<select id="{{ id }}" name="{{ name }}" class="border rounded-lg p-2 w-full" {% if required %} required {% endif %}>
{% for option in options %}
<option value="{{ option.value }}" {% if option.value == value %} selected {% endif %}>{{ option.text }}</option>
{% endfor %}
</select>

View file

@ -0,0 +1,8 @@
<section class="flex flex-col gap-4">
Transaction Id: {{ tx.id }}
Date: {{ tx.date }}
User: {{ user.email }}
Transaction Type: {{ tx.type }}
Sats: {{ tx.sats }}
Cents: {{ tx.cents }}
</section>