This commit is contained in:
count-null 2025-02-09 12:02:22 -05:00
parent 7428ff8b8f
commit 9b15ac9fd3
87 changed files with 4975 additions and 1 deletions

19
.env.example Normal file
View file

@ -0,0 +1,19 @@
APP_HOST="localhost:8080"
APP_NAME="BuysForLife"
SQLITE_DB="db-dev.sqlite"
# SMTP for login, order, and admin notifications
SMTP_HOST="smtp.example.com" # SMTP uses TLS
SMTP_USER="user@example.com"
SMTP_PASS="your-super-secure-pass"
SMTP_FROM="noreply@example.com"
# Bitcoin Lightning Netowrk Address LNURL
# Used for recieving payment at checkout
# !! Choose your LN_SERVICE carefully!!
# NOTE: The LN_SERVICE must support LUD-21 Payment Verification
LN_ADDRESS="your@node.win"
# maps.co for GeoCoder (postal address verification)
# Get your free API key: https://geocode.maps.co
GEOCODE_MAPS_CO_API_KEY="your-api-key-from-geocode.maps.co"
# Plausible for privacy-respecting page analytics
# https://github.com/plausible/analytics
PLAUSIBLE_HOST="https://plausible.io/"

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
vendor
db.sqlite
.env
public/style.css

View file

@ -1,2 +1,73 @@
# ecomm-store
# Ecomm Store
PHP MVC Ecomm App w/SqliteDB
## Goals for MVP
- Avoid using JavaScript - make it usable without
- Multicurrency sats/cents
- Store Credit
- Products, carts, checkout, orders, returns/refunds.
- Subscriptions/recurring payments
- Email sign-in links, email notifications
- Light/dark theme
## Initial setup/install
Copy the `.env.example` into `.env` and edit with your preferences/credentials.
`cp .env.example .env`
`vim .env`
install composer packages using the `composer.phar` executable included in this repo
`chmod +x composer.phar`
`php composer.phar install`
Use tailwind-cli to automatically generate `/public/style.css` from `/src/style.css`
This repo includes an older `tailwindcss` binary for Apple ARM M-series processors. I could not get recent versions to work properly.
`chmod +x tailwindcss`
`./tailwindcss -i ./src/style.css -o ./public/style.css --watch`
You should only use `--watch` in development.
Tailwind (JavaScript) only generates the CSS file used in prod. We're still "avoiding JS".
## Run it
Start the local Php server from the project root, serves only the /public folder
`php -S localhost:8080 -t public`
Goto `http://localhost:8080` in your browser.
## Development
Fork it and make changes if you want! Make a PR into `main`.
### Routing
`/public/index.php` is the router, define all your urls there and assign a controller method for each route
Other app-wide defaults and vars are also set here.
### Themeing
`public/index.php` is also where all colors are defined. It uses tailwind class strings to cover both light and dark themes.
`tailwind.config.js` can be used to define a custom color pallete.
There is a bit of JS used to set a cookie that the server can read. This cookie just contains the user's browser's theme preference so the server can efficiently deliver light/dark image assets based on the theme.
### Composer
To add additional composer packages:
`php composer.phar require <package/package>`
Then commit the `composer.json`.

15
composer.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "lyberry/lyberry.com",
"description": "homepage",
"autoload": {
"psr-4": {
"app\\": "src/"
}
},
"require": {
"phpmailer/phpmailer": "^6.9.2",
"vlucas/phpdotenv": "^5.6",
"web-auth/webauthn-lib": "^5.0",
"twig/twig": "^3.0"
}
}

2677
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

BIN
composer.phar Executable file

Binary file not shown.

BIN
public/.DS_Store vendored Normal file

Binary file not shown.

BIN
public/img/.DS_Store vendored Normal file

Binary file not shown.

BIN
public/img/empty/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
public/img/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

BIN
public/img/logo-dark.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/img/logo-light.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

165
public/index.php Normal file
View file

@ -0,0 +1,165 @@
<?php
//
// It all starts here..
//
use app\app;
use app\controllers\account;
use app\controllers\category;
use app\controllers\cart;
use app\controllers\checkout;
use app\controllers\home;
use app\controllers\lnurlp;
use app\controllers\lost;
use app\controllers\magic_link;
use app\controllers\support;
require_once __DIR__ . '/../vendor/autoload.php';
// Load environment variables from the .env file at project root
Dotenv\Dotenv::createImmutable(__DIR__ . '/../')->load();
// Start the session
app::init_db();
use app\models\addresses;
use app\models\carts;
use app\models\magic_links;
use app\models\orders;
use app\models\products;
use app\models\user_addresses;
use app\models\users;
if (!app::$db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")->fetch()) {
addresses::init();
carts::init();
magic_links::init();
orders::init();
products::init();
user_addresses::init();
users::init();
}
session_start();
session_regenerate_id(true); // prevent session fixation attacks
// prevent session hijack
if (!isset($_SESSION['fingerprint'])) {
$_SESSION['fingerprint'] = hash('sha256', $_SERVER['REMOTE_ADDR'] . $_SERVER['HTTP_USER_AGENT']);
} else {
if ($_SESSION['fingerprint'] !== hash('sha256', $_SERVER['REMOTE_ADDR'] . $_SERVER['HTTP_USER_AGENT'])) {
session_unset();
session_destroy();
}
}
// these will be available to use in all twig templates
$defaults = [
'copyright_year' => date('Y'),
'session' => $_SESSION,
'env' => $_ENV,
// uses cookie-js to get the client's preferred theme
// used to conditionally deliver image assets
// or styles based on theme
'theme' => isset($_COOKIE["theme"]) ? $_COOKIE["theme"] : 'light',
// set your tailwind colors here for app themeing
// the idea is to avoid using colors in your templates
'colors' => [
'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"
],
]
];
// Setup a twig
$loader = new \Twig\Loader\FilesystemLoader(paths: dirname(__DIR__) . '/src/views');
$GLOBALS['twig'] = new \Twig\Environment($loader, [
//'cache' => dirname(__DIR__) . '/cache',
'cache' => false,
]);
$route = explode(separator: '?', string: $_SERVER['REQUEST_URI'])[0];
if (str_starts_with(haystack: $route, needle: '/.well-known/lnurlp/')) {
$route = '/lnurlp';
}
$controller = match ($route) {
'/' => home::index($defaults),
'/account' => account::index($defaults),
'/account/profile' => account::profile(),
'/account/login' => account::login($defaults),
'/account/logout' => account::logout(),
'/magic-link' => magic_link::index(),
'/account/returns' => account::returns($defaults),
'/account/signup' => account::signup($defaults),
'/account/billing' => account::billing($defaults),
'/account/orders' => account::orders($defaults),
'/account/shipping' => account::shipping($defaults),
'/checkout/confirmed' => checkout::confirmed($defaults),
'/checkout/review-pay' => checkout::review_pay($defaults),
'/checkout/shipping-billing' => checkout::shipping_billing($defaults),
'/support/ask' => support::index($defaults),
'/support/bitcoin' => support::bitcoin($defaults),
'/cart' => cart::index($defaults),
'/lnurlp' => lnurlp::index(),
// product categories
'/power-meters' => category::power_meters($defaults),
default => lost::index($defaults)
};
// Clear alerts after rendering
foreach (['error', 'warning', 'info', 'success'] as $alert) {
unset($_SESSION[$alert]);
}

50
src/app.php Normal file
View file

@ -0,0 +1,50 @@
<?php
namespace app;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
class app
{
public static $db;
public static function init_db()
{
try {
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)
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
}

331
src/controllers/account.php Normal file
View file

@ -0,0 +1,331 @@
<?php
namespace app\controllers;
use app\models\addresses;
use app\models\users;
use app\models\user_addresses;
class account
{
public static function index($defaults): void
{
if (!isset($_SESSION['user_id'])) {
header('Location: /account/login');
}
$email = $_SESSION['user_email'];
$user = users::getByEmail($email);
$default_shipping = null;
$default_billing = null;
$ship_addrs = [];
$bill_addrs = [];
$addresses = user_addresses::getShippingByUserId($user['id']);
foreach ($addresses as $address) {
if ($address['id'] == $user['shipping_address_id']){
$default_shipping = $address;
} else {
$ship_addrs[] = $address;
}
}
$bill_addresses = user_addresses::getBillingByUserId($_SESSION['user_id']);
foreach ($bill_addresses as $addr) {
if ($addr['id'] == $user['billing_address_id']){
$default_billing = $addr;
} else {
$bill_addrs[] = $addr;
}
}
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'account/index.twig',
'page_title' => 'Manage Account - ' . $_ENV['APP_NAME'],
'user' => $user,
'shipping' => $ship_addrs,
'billing' => $bill_addrs,
'default_shipping' => $default_shipping,
'default_billing' => $default_billing,
'breadcrumbs' => [
[
'url' => null,
'title' => 'My Account',
]
]
]));
}
public static function billing($defaults)
{
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
if (!$_SESSION['user_id']) {
http_response_code(403);
}
$bill = addresses::validatePost("billing");
$bill_id = addresses::add(
$bill['name'],
$bill['company'],
$bill['street'],
$bill['boxapt'],
$bill['city'],
$bill['state'],
$bill['zip'],
$bill['phone'],
1,
0
);
user_addresses::add(
$_SESSION['user_id'],
$bill_id
);
$_SESSION['success'] = "Billing address saved!";
header('Location: /account/billing');
}
$email = $_SESSION['user_email'];
$user = users::getByEmail($email);
$default_billing = null;
$bill_addrs = [];
$bill_addresses = user_addresses::getBillingByUserId($_SESSION['user_id']);
foreach ($bill_addresses as $addr) {
if ($addr['id'] == $user['billing_address_id']){
$default_billing = $addr;
} else {
$bill_addrs[] = $addr;
}
}
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'account/billing.twig',
'page_title' => 'Billing Information - ' . $_ENV['APP_NAME'],
'billing' => $bill_addrs,
'default_billing' => $default_billing,
'breadcrumbs' => [
[
'url' => '/account',
'title' => 'My Account'
],
[
'url' => null,
'title' => 'Billing'
]
]
]));
}
public static function profile()
{
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
if (!$_SESSION['user_id']) {
http_response_code(403);
}
users::updateProfileById($_SESSION['user_id'], $_POST);
header('Location: /account');
}
}
public static function login($defaults)
{
if (isset($_SESSION['user_id'])) {
header('Location: /account');
}
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'account/login.twig',
'page_title' => 'Sign In or Create an Account!',
'breadcrumbs' => [
[
'url' => null,
'title' => 'My Account'
],
]
]));
}
public static function logout()
{
session_unset();
session_destroy();
header('Location: /');
}
public static function orders($defaults)
{
if (!isset($_SESSION['user_id'])) {
header('Location: /account/login');
}
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'account/orders.twig',
'page_title' => 'View ' . $_ENV['APP_NAME'] . ' Orders',
'breadcrumbs' => [
[
'url' => '/account',
'title' => 'My Account'
],
[
'url' => null,
'title' => 'Orders'
]
]
]));
}
public static function returns($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'account/returns.twig',
'page_title' => 'View ' . $_ENV['APP_NAME'] . ' Returns',
'breadcrumbs' => [
[
'url' => '/account',
'title' => 'My Account'
],
[
'url' => null,
'title' => 'Returns'
]
]
]));
}
public static function shipping($defaults)
{
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
if (!$_SESSION['user_id']) {
http_response_code(403);
}
$ship = addresses::validatePost("shipping");
$ship_id = addresses::add(
$ship['name'],
$ship['company'],
$ship['street'],
$ship['boxapt'],
$ship['city'],
$ship['state'],
$ship['zip'],
$ship['phone'],
0,
1
);
user_addresses::add(
$_SESSION['user_id'],
$ship_id
);
$_SESSION['success'] = "Shipping address saved!";
header('Location: /account/shipping');
}
$email = $_SESSION['user_email'];
$user = users::getByEmail($email);
$addresses = user_addresses::getShippingByUserId($user['id']);
$default_shipping = null;
$ship_addrs = [];
foreach ($addresses as $addr) {
if ($addr['id'] == $user['shipping_address_id']){
$default_shipping = $addr;
} else {
$ship_addrs[] = $addr;
}
}
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'account/shipping.twig',
'page_title' => $_ENV['APP_NAME'] . ' Shipping',
'shipping' => $ship_addrs,
'default_shipping' => $default_shipping,
'breadcrumbs' => [
[
'url' => '/account',
'title' => 'My Account'
],
[
'url' => null,
'title' => 'Shipping'
]
]
]));
}
public static function signup($defaults)
{
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$email = $_POST['email'];
$existingUser = users::getByEmail($email);
if ($existingUser) {
$_SESSION['error'] = 'Email already exists. Please choose a different email or log in.';
header('Location: /account/signup');
exit;
}
if (empty($email)) {
$_SESSION['error'] = 'Email is required.';
}
if (isset($_SESSION['error'])) {
header('Location: /account/signup');
}
$useShipping = $_POST['use_shipping'] ?? false;
if ($useShipping) {
$ship = addresses::validatePost("shipping");
} else {
$ship = addresses::validatePost("shipping");
$bill = addresses::validatePost("billing");
}
if (empty($email)) {
$_SESSION['error'] = 'Email is required.';
}
if (isset($_SESSION['error'])) {
$_SESSION['last_post'] = $_POST;
header('Location: /account/signup');
}
$ship_id = addresses::add(
$ship['name'],
$ship['company'],
$ship['street'],
$ship['boxapt'],
$ship['city'],
$ship['state'],
$ship['zip'],
$ship['phone'],
$useShipping == 'on',
1
);
$bill_id = $ship_id;
if (!$useShipping) {
$bill_id = addresses::add(
$bill['name'],
$bill['company'],
$bill['street'],
$bill['boxapt'],
$bill['city'],
$bill['state'],
$bill['zip'],
$bill['phone'],
1,
0
);
}
$opt_in_promotional = $_POST['opt_in_promotional'] ?? false;
$verified = isset($_SESSION['user_email']);
$dark_theme = $defaults['theme'] == 'dark';
$user_id = users::add(
$email,
$ship_id,
$bill_id,
$opt_in_promotional,
$verified,
$dark_theme
);
user_addresses::add(
user_id: $user_id,
address_id: $ship_id
);
if (!$useShipping) {
user_addresses::add(
user_id: $user_id,
address_id: $bill_id
);
}
$_SESSION['user_id'] = $user_id;
if (!$verified) {
header("Location: /magic-link?email=$email&signup=1");
exit;
}
header('Location: /account');
exit;
} // endif request === POST
if (isset($_SESSION['user_id'])) {
header('Location: /account');
exit;
}
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'account/signup.twig',
'page_title' => 'Create an Account - ' . $_ENV['APP_NAME']
]));
}
}

18
src/controllers/cart.php Normal file
View file

@ -0,0 +1,18 @@
<?php
namespace app\controllers;
class cart
{
public static function index($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'cart.twig',
'page_title' => $_ENV['APP_NAME'] . ' Cart',
'breadcrumbs' => [
[
'url' => null,
'title' => 'Cart'
]
],
]));
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace app\controllers;
class category
{
public static function power_meters($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', context: array_merge($defaults, [
'child_template' => 'lib/page/category.twig',
'page_title' => 'Power Meters - ' . $_ENV['APP_NAME'],
'product_category' => 'power_meters',
]));
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace app\controllers;
class checkout
{
public static function shipping_billing($defaults)
{
echo $GLOBALS['twig']->render('lib/page/flow.twig', array_merge($defaults, [
'child_template' => 'checkout/shipping_billing.twig',
'page_title' => 'Checkout with ' . $_ENV['APP_NAME'],
]));
}
public static function review_pay($defaults)
{
echo $GLOBALS['twig']->render('lib/page/flow.twig', array_merge($defaults, [
'child_template' => 'checkout/review_pay.twig',
'page_title' => 'Review & Payment | ' . $_ENV['APP_NAME']
]));
}
public static function confirmed($defaults)
{
echo $GLOBALS['twig']->render('lib/page/flow.twig', array_merge($defaults, [
'child_template' => 'checkout/confirmed.twig',
'page_title' => 'Order Recieved! - Thank You'
]));
}
}

12
src/controllers/home.php Normal file
View file

@ -0,0 +1,12 @@
<?php
namespace app\controllers;
class home
{
public static function index($defaults)
{
echo $GLOBALS['twig']->render(name: 'lib/page/index.twig', context: array_merge($defaults, [
'child_template' => 'home.twig',
'page_title' => $_ENV['APP_NAME'] . ": Specialty Hardware"
]));
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace app\controllers;
use app\app;
class lnurlp
{
public static function index()
{
header(header: 'Content-Type: application/json');
$host = $_SERVER['HTTP_HOST'];
$user = $_GET["username"] ?? false;
$paymentRequest = $_GET["pay"] ?? false;
$verify = $_GET["verify"] ?? false;
function returnJson($arr): never
{
echo json_encode(value: $arr);
exit();
}
// for when the callback is used incorrectly
if ($paymentRequest != 1 && $paymentRequest != false) {
returnJson([
'status' => 'ERROR',
'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) {
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 client makes it's first call (querying the lightning address)
$metadata = "[[\"text/plain\",\"Funding @$user on $host\"],[\"text/identifier\",\"$user@$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",
'maxSendable' => $res['maxSendable'],
'minSendable' => $res['minSendable'],
'metadata' => $metadata,
'commentAllowed' => $res['commentAllowed'],
'payerData' => $res['payerData'],
'tag' => "payRequest",
]
);
}
// for when the client makes it's second call (callback)
if ($paymentRequest == "1") {
$proxy_url = "https://$proxy_host/lnurlp?pay=1&username=$proxy_user";
if (isset($_GET["amount"])) {
$proxy_url .= "&amount=" . urlencode($_GET["amount"]);
}
$res = json_decode(file_get_contents($proxy_url), true);
if ($res['status'] === 'OK'){
$boom = explode("=", $res['verify']);
$proxy_verify = end($boom);
returnJson([
'status' => 'OK',
'pr' => $res['pr'],
'routes' => $res['routes'],
'verify' => "https://$host/lnurlp?verify=$proxy_verify"
]);
} else {
returnJson($res);
}
}
// 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);
}
// for when none of the above conditions are met
returnJson([
'status' => 'ERROR',
'reason' => 'unhandled error (how did you get here?)',
]);
}
}

12
src/controllers/lost.php Normal file
View file

@ -0,0 +1,12 @@
<?php
namespace app\controllers;
class lost
{
public static function index($defaults)
{
echo $GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => '404.twig',
'page_title' => 'Not Found - ' . $_ENV['APP_NAME'],
]));
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace app\controllers;
use app\app;
use app\models\users;
use app\models\magic_links;
class magic_link
{
public static function index()
{
$email = $_GET['email'] ?? null;
$token = $_GET['token'] ?? null;
$signup = $_GET['signup'] ?? null;
if (empty($email) && empty($token)) {
$_SESSION['error'] = "Enter your email to get a login link";
header('Location: /account/login');
exit;
}
if ($email && empty($token) && empty($signup)) {
$link = magic_links::add(email: $email);
$subject = "Your Magic Sign-In Link";
$message = "Copy and paste the link into your browser: $link";
$HTML_message = "Click the link to sign in: <a href='$link'>$link</a>";
app::send_mail(to: $email, from: $_ENV['SMTP_FROM'], from_name: $_ENV['APP_NAME'], subject: $subject, message: $message, HTML_message: $HTML_message);
$_SESSION['success'] = 'Link sent to your email!';
header('Location: /account/login');
exit;
}
if ($email && empty($token) && $signup == "1") {
$link = magic_links::add(email: $email);
$subject = "Your Magic Sign-In Link";
$message = "Copy and paste the link into your browser: $link";
$HTML_message = "Click the link to sign in: <a href='$link'>$link</a>";
app::send_mail(to: $email, from: $_ENV['SMTP_FROM'], from_name: $_ENV['APP_NAME'], subject: $subject, message: $message, HTML_message: $HTML_message);
$_SESSION['success'] = 'Account created! Please check your email inbox for the verification link.';
header('Location: /account/login');
exit;
}
if ($token && empty($email)) {
$link = magic_links::validate(token: $token);
if (!$link) {
$_SESSION['error'] = "Invalid or expired link.";
header('Location: /account/login');
}
// handle signup vs. login
$user = 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');
} else {
// used to pre-fill email signup field
$_SESSION['user_email'] = $link['email'];
header('Location: /account/signup');
}
exit();
}
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace app\controllers;
class support
{
public static function index($defaults)
{
$GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'support/ask.twig',
'page_title' => $_ENV['APP_NAME'] . ': Frequently Asked Questions',
'breadcrumbs' => [
[
'url' => null,
'title' => 'Support'
]
]
]));
}
public static function bitcoin($defaults)
{
$GLOBALS['twig']->render('lib/page/index.twig', array_merge($defaults, [
'child_template' => 'support/bitcoin.twig',
'page_title' => $_ENV['APP_NAME'] . ' Bitcoin Accepted',
'breadcrumbs' => [
[
'url' => '/support/ask',
'title' => 'Support'
],
[
'url' => null,
'title' => 'Bitcoin'
]
],
]));
}
}

91
src/models/addresses.php Normal file
View file

@ -0,0 +1,91 @@
<?php
namespace app\models;
use app\app;
class addresses
{
public static function init()
{
app::$db->exec("CREATE TABLE IF NOT EXISTS addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
company TEXT,
street TEXT NOT NULL,
boxapt TEXT NOT NULL,
city TEXT NOT NULL,
state TEXT NOT NULL,
zip TEXT NOT NULL,
phone TEXT,
billing BOOLEAN NOT NULL,
shipping BOOLEAN NOT NULL
)");
}
public static function validatePost($type)
{
$name = $_POST["{$type}_name"];
$company = $_POST["{$type}_company"] ?? null;
$boxapt = $_POST["{$type}_boxapt"] ?? null;
$street = $_POST["{$type}_street"];
$city = $_POST["{$type}_city"];
$state = $_POST["{$type}_state"];
$zip = $_POST["{$type}_zip"];
$phone = $_POST["{$type}_phone"];
// check all required fields are set
if (empty($name) || empty($street) || empty($city) || empty($state) || empty($zip) || empty($phone)) {
$_SESSION['error'] = "Missing required {$type} information.";
}
// TODO: find a match using postal database and return that
return [
'name' => $name,
'company' => $company,
'street' => $street,
'boxapt' => $boxapt,
'city' => $city,
'state' => $state,
'zip' => $zip,
'phone' => $phone
];
}
public static function add($name, $company, $street, $boxapt, $city, $state, $zip, $phone, $billing, $shipping)
{
$query = "INSERT INTO addresses (
name,
company,
street,
boxapt,
city,
state,
zip,
phone,
billing,
shipping
) VALUES (
:name,
:company,
:street,
:boxapt,
:city,
:state,
:zip,
:phone,
:billing,
:shipping
)";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':name', $name);
$stmt->bindParam(':company', $company);
$stmt->bindParam(':street', $street);
$stmt->bindParam(':boxapt', $boxapt);
$stmt->bindParam(':city', $city);
$stmt->bindParam(':state', $state);
$stmt->bindParam(':zip', $zip);
$stmt->bindParam(':phone', $phone);
$stmt->bindParam(':billing', $billing);
$stmt->bindParam(':shipping', $shipping);
$stmt->execute();
return app::$db->lastInsertId();
}
}

21
src/models/carts.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace app\models;
use app\app;
class carts
{
public static function init()
{
app::$db->exec("CREATE TABLE IF NOT EXISTS order_items (
order_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL CHECK(quantity > 0),
price REAL NOT NULL CHECK(price >= 0),
FOREIGN KEY (order_id) REFERENCES orders(order_id),
FOREIGN KEY (product_id) REFERENCES products(product_id)
);");
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace app\models;
use app\app;
class magic_links
{
public static function init()
{
app::$db->exec("CREATE TABLE IF NOT EXISTS magic_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
token TEXT NOT NULL,
expires_at DATETIME NOT NULL,
used BOOLEAN DEFAULT FALSE
)");
}
public static function add($email)
{
$token = bin2hex(random_bytes(32));
$expires_at = date('Y-m-d H:i:s', time() + 60 * 15);
app::$db->query("INSERT INTO magic_links (
email,
token,
expires_at
) VALUES (
'$email',
'$token',
'$expires_at'
)");
return $_ENV['APP_HOST'] . "/magic-link?token=" . urlencode($token);
}
public static function validate($token)
{
$link = app::$db->query("SELECT * FROM magic_links
WHERE token = '$token'
AND used = FALSE
AND expires_at > datetime('now')
")->fetch(\PDO::FETCH_ASSOC);
// void the token once validated
app::$db->query("UPDATE magic_links SET used = TRUE WHERE token = '$token'");
return $link;
}
}

18
src/models/orders.php Normal file
View file

@ -0,0 +1,18 @@
<?php
namespace app\models;
use app\app;
class orders
{
public static function init()
{
app::$db->exec("CREATE TABLE IF NOT EXISTS orders (
order_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
total_amount REAL NOT NULL CHECK(total_amount >= 0),
status TEXT NOT NULL CHECK(status IN ('pending', 'completed', 'cancelled')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);");
}
}

17
src/models/products.php Normal file
View file

@ -0,0 +1,17 @@
<?php
namespace app\models;
use app\app;
class products
{
public static function init()
{
app::$db->exec("CREATE TABLE IF NOT EXISTS products (
product_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL CHECK(price >= 0),
qty INTEGER NOT NULL DEFAULT 0 CHECK(qty >= 0)
)");
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace app\models;
use app\app;
class transactions
{
public static function init()
{
app::$db->exec("CREATE TABLE transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT CHECK(transaction_type IN ('credit', 'spend', 'withdraw')) NOT NULL,
cents REAL DEFAULT 0,
sats REAL DEFAULT 0,
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)");
}
public static function add($user_id, $transaction_type, $cents, $sats_amount)
{
$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);
$stmt->bindParam(':transaction_type', $transaction_type);
$stmt->bindParam(':cents', $cents);
$stmt->bindParam(':sats', $sats_amount);
$stmt->execute();
return app::$db->lastInsertId();
}
public static function getUserBalance($user_id)
{
$query = "SELECT SUM(cents) AS total_cents,
SUM(sats) AS total_sats
FROM transactions
WHERE user_id = :user_id";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':user_id', $user_id);
$stmt->execute();
$result = $stmt->fetch();
return $result;
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace app\models;
use app\app;
use PDO; // Added PDO class import
class user_addresses
{
public static function init()
{
app::$db->exec("CREATE TABLE IF NOT EXISTS user_addresses (
user_id INTEGER NOT NULL,
address_id INTEGER NOT NULL,
PRIMARY KEY (user_id, address_id),
FOREIGN KEY (address_id) REFERENCES addresses(id)
)");
}
public static function getShippingByUserId($id)
{
$addrs = app::$db->query("SELECT a.* FROM users u
JOIN user_addresses ua ON u.id = ua.user_id
JOIN addresses a ON ua.address_id = a.id
WHERE u.id = '$id' AND a.shipping = 1")->fetch(\PDO::FETCH_ASSOC);
return [$addrs];
}
public static function getBillingByUserId($id)
{
$addrs = app::$db->query("SELECT a.* FROM users u
JOIN user_addresses ua ON u.id = ua.user_id
JOIN addresses a ON ua.address_id = a.id
WHERE u.id = '$id' AND a.billing = 1")->fetch(\PDO::FETCH_ASSOC);
return [$addrs];
}
public static function add($user_id, $address_id)
{
$query = "INSERT INTO user_addresses (
user_id,
address_id
) VALUES (
:user_id,
:address_id
)";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':user_id', $user_id);
$stmt->bindParam(':address_id', $address_id);
$stmt->execute();
return app::$db->lastInsertId();
}
}

83
src/models/users.php Normal file
View file

@ -0,0 +1,83 @@
<?php
namespace app\models;
use app\app;
class users
{
public static function init()
{
app::$db->exec("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE,
shipping_address_id INTEGER,
billing_address_id INTEGER,
opt_in_promotional BOOLEAN NOT NULL,
verified BOOLEAN NOT NULL,
dark_theme BOOLEAN NOT NULL,
generated_base58 TEXT UNIQUE,
attached_lightning_address TEXT,
name TEXT,
company_name TEXT,
company_type TEXT,
company_size TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
app::$db->exec('CREATE INDEX IF NOT EXISTS idx_user_email ON users (email)');
}
public static function updateProfileById($user_id, $post)
{
$query = "UPDATE users SET
name = :name,
company_name = :company_name,
company_type = :company_type,
company_size = :company_size
WHERE id = :user_id";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':name', $post['name']);
$stmt->bindParam(':company_name', $post['company_name']);
$stmt->bindParam(':company_type', $post['company_type']);
$stmt->bindParam(':company_size', $post['company_size']);
$stmt->bindParam(':user_id', $user_id);
$stmt->execute();
}
public static function add($email, $ship_id, $bill_id, $opt_in_promotional, $verified, $dark_theme)
{
$query = "INSERT INTO users (
email,
shipping_address_id,
billing_address_id,
opt_in_promotional,
verified,
dark_theme
) VALUES (
:email,
:shipping_address_id,
:billing_address_id,
:opt_in_promotional,
:verified,
:dark_theme
)";
$stmt = app::$db->prepare($query);
$stmt->bindParam(':email', $email);
$stmt->bindParam(':shipping_address_id', $ship_id);
$stmt->bindParam(':billing_address_id', $bill_id);
$stmt->bindParam(':opt_in_promotional', $opt_in_promotional);
$stmt->bindParam(':verified', $verified);
$stmt->bindParam(':dark_theme', $dark_theme);
$stmt->execute();
return app::$db->lastInsertId();
}
public static function verify($email)
{
app::$db->exec("UPDATE users SET verified = 1 WHERE email = '$email'");
}
public static function getByEmail($email)
{
return app::$db->query("SELECT * FROM users WHERE email = '$email'")->fetch(\PDO::FETCH_ASSOC);
}
}

32
src/style.css Normal file
View file

@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* #Mega Menu Styles
*/
.mega-menu {
opacity: 0;
visibility: hidden;
z-index: -900;
left: 0;
top: 38px;
position: absolute;
text-align: left;
width: 100%;
transition: all 0.15s linear 0s;
}
/* #hoverable Class Styles */
.hoverable {
position: static;
}
.hoverable > a:after {
content: "\25BC";
font-size: 10px;
padding-left: 6px;
position: relative;
top: -1px;
}
.hoverable:hover .mega-menu {
opacity: 1;
visibility: visible;
z-index: 900;
}

7
src/views/404.twig Normal file
View file

@ -0,0 +1,7 @@
<section>
{% include 'lib/empty.twig' with {
type: '404',
title: 'Page not found.',
subtitle: 'Shop our latest products!'
} %}
</section>

View file

@ -0,0 +1,40 @@
<section>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h3 class="text-2xl font-semibold">Payment Methods</h3>
{% include 'lib/rule.twig' %}
</div>
{% include 'lib/empty.twig' with {
type: 'payment',
title: "Let's get you set up!",
subtitle: 'Add a payment method'
} %}
<div class="flex flex-col gap-1">
<h3 class="text-2xl font-semibold">Billing Address</h3>
{% include 'lib/rule.twig' %}
<p class="text-xs">Your billing information must match the information associatied with the credit card making the purchase.</p>
<div class='flex flex-col'>
<span>{{ default_billing.name }}</span>
<span>{{ default_billing.company }}</span>
<span>{{ default_billing.street }}</span>
<span>{{ default_billing.boxapt }}</span>
<span>{{ default_billing.city }}, {{ default_billing.state }} {{ default_billing.zip }}</span>
<span>{{ default_billing.phone }}</span>
</div>
</div>
<form action="/account/billing" method="post" class="flex flex-col gap-2">
{% include 'lib/form/address.twig' with {
action: 'billing'
} %}
{% include 'lib/button.twig' with {
label: 'Add Address',
onclick: 'this.parentNode.submit()',
} %}
</form>
{% include 'lib/rule.twig' with {
text: 'OR'
} %}
<span>Use saved address</span>
</div>
</section>

View file

@ -0,0 +1,114 @@
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
{% include 'lib/alert.twig' %}
<h3 class="text-2xl font-semibold">Profile</h3>
{% include 'lib/rule.twig' %}
<form action="/account/profile" method="post">
{% include 'lib/form/profile.twig' with {
name: user.name,
company_name: user.company_name,
company_type: user.company_type,
company_size: user.company_size
} %}
{% include 'lib/button.twig' with {
label: 'Save Profile',
onclick: 'this.parentNode.submit()',
} %}
</form>
</div>
<div class="flex flex-col gap-1">
<h3 class="text-2xl font-semibold">Email</h3>
<form action="/account/email" method="post">
{% include 'lib/input.twig' with {
type: 'text',
name: 'email',
value: user.email
} %}
<h4 class="font-semibold">Verified: {{ user.verified ? 'Yes' : 'No' }}</h4>
{% include 'lib/button.twig' with {
label: 'Save Email',
onclick: 'this.parentNode.submit()',
} %}
</form>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<h3 class="text-2xl font-semibold">Shipping</h3>
<a href="/account/shipping">Edit</a>
</div>
{% include 'lib/rule.twig' %}
<div class="flex flex-col gap-1">
<h4 class="font-semibold">{{ default_shipping.name }}</h4>
<h4 class="font-semibold">{{ default_shipping.company }}</h4>
<h4 class="font-semibold">{{ default_shipping.street }}</h4>
<h4 class="font-semibold">{{ default_billing.boxapt }}</h4>
<h4 class="font-semibold">{{ default_shipping.city }}, {{ default_shipping.state }}, {{ default_shipping.zip }}</h4>
<h4 class="font-semibold">{{ default_shipping.phone }}</h4>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<h3 class="text-2xl font-semibold">Billing</h3>
<a href="/account/billing">Edit</a>
</div>
{% include 'lib/rule.twig' %}
<div class="flex flex-col gap-1">
<h4 class="font-semibold">{{ default_billing.name }}</h4>
<h4 class="font-semibold">{{ default_billing.company }}</h4>
<h4 class="font-semibold">{{ default_billing.street }}</h4>
<h4 class="font-semibold">{{ default_billing.boxapt }}</h4>
<h4 class="font-semibold">{{ default_billing.city }}, {{ default_billing.state }}, {{ default_billing.zip }}</h4>
<h4 class="font-semibold">{{ default_billing.phone }}</h4>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<h3 class="text-2xl font-semibold">Credit Card</h3>
<a href="/account/billing">Edit</a>
</div>
{% include 'lib/rule.twig' %}
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<h3 class="text-2xl font-semibold">Store Credit</h3>
<a href='#store-credit'><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></a>
</div>
<span>$0.00</span>
</div>
{% include 'lib/rule.twig' %}
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<h3 class="text-2xl font-semibold">Sats</h3>
<a href='#sats'><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></a>
</div>
<span>0</span>
</div>
{% include 'lib/rule.twig' %}
</div>
<div class="flex flex-col gap-1">
<h3 class="text-2xl font-semibold">Marketing</h3>
{% include 'lib/rule.twig' %}
<form action="/account/promotionals" method="post" class="flex flex-col gap-4">
{% include 'lib/toggle.twig' with {
label: 'Recieve coupons & more',
name: 'opt_in_promotional',
on: user.opt_in_promotional
} %}
{% include 'lib/button.twig' with {
label: 'Save',
onclick: 'this.parentNode.submit()',
} %}
</form>
</div>
</section>
{% include 'lib/modal.twig' with {
id: 'store-credit',
content: 'lib/policy/credit.twig'
} %}
{% include 'lib/modal.twig' with {
id: 'sats',
content: 'lib/policy/sats.twig'
} %}

View file

@ -0,0 +1,23 @@
<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">Login</h3>
{% include 'lib/rule.twig' %}
</div>
{% include 'lib/alert.twig' %}
<form action="/magic-link" method="get" class="flex flex-col gap-4">
{% include 'lib/input.twig' with {
type: 'email',
name: 'email',
label: 'Email link',
subtext: 'Get a one-time link sent to your email. No passwords!',
placeholder: 'Enter your e-mail'
} %}
{% include 'lib/button.twig' with {
label: 'Get login link',
onclick: 'this.parentNode.submit()',
captcha: true
} %}
</form>
</div>
</section>

View file

@ -0,0 +1,11 @@
<section>
<div class="flex flex-col gap-1">
<h3 class="text-2xl font-semibold">Order History</h3>
{% include 'lib/rule.twig' %}
</div>
{% include 'lib/empty.twig' with {
type: 'order',
title: 'No Orders Yet',
subtitle: 'Your orders will show here.'
} %}
</section>

View file

@ -0,0 +1,11 @@
<section>
<div class="flex flex-col gap-1">
<h3 class="text-2xl font-semibold">Returns</h3>
{% include 'lib/rule.twig' %}
</div>
{% include 'lib/empty.twig' with {
type: 'file',
title: "No returns started",
subtitle: 'Click here to start a return'
} %}
</section>

View file

@ -0,0 +1,31 @@
<section class="flex flex-col gap-4">
<div>
<div class="flex flex-col gap-1">
<h3 class="text-2xl font-semibold">Saved Shipping Address</h3>
{% include 'lib/rule.twig' %}
<p class="text-xs">This is your default shipping address for orders at checkout.</p>
</div>
<div class='flex flex-col'>
<span>{{ default_shipping.name }}</span>
<span>{{ default_shipping.company }}</span>
<span>{{ default_shipping.street }}</span>
<span>{{ default_shipping.boxapt }}</span>
<span>{{ default_shipping.city }}, {{ default_shipping.state }} {{ default_shipping.zip }}</span>
<span>{{ default_shipping.phone }}</span>
</div>
</div>
<form action="/account/shipping" method="post" class="flex flex-col gap-2">
{% include 'lib/form/address.twig' with {
action: 'shipping'
} %}
{% include 'lib/button.twig' with {
label: 'Add Address',
onclick: 'this.parentNode.submit()'
} %}
</form>
{% include 'lib/rule.twig' with {
text: 'OR'
} %}
<span>Use a saved address</span>
</section>

View file

@ -0,0 +1,91 @@
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<div class="flex flex-col">
<h3 class="font-semibold text-4xl">Create an Account</h3>
<span class="text-sm">You can manage all your orders, addresses, and payment cards in one place!</span>
</div>
{% include 'lib/alert.twig' %}
<form action="/account/signup" method="post" class="flex flex-col gap-4">
{% include 'lib/rule.twig' with { text: 'STEP 1' } %}
<div class="flex flex-col">
<h4 class="font-semibold text-2xl">Email Address</h4>
<span class="text-sm">Recieve login link to verify. Order and account updates will be sent here.</span>
</div>
<div class="w-full flex items-center justify-between gap-4">
<div class="w-3/5">
{% include 'lib/input.twig' with {
type: 'text',
name: 'email',
value: session.user_email is defined ? session.user_email : null,
readonly: session.user_email is defined ? true : null
} %}
</div>
{% include 'lib/toggle.twig' with {
label: 'Recieve coupons & more',
name: 'opt_in_promotional',
on: true
} %}
</div>
{% include 'lib/rule.twig' with { text: 'STEP 2' } %}
<div class="flex flex-col">
<h4 class="font-semibold text-2xl">Shipping Address</h4>
<span class="text-sm">Your orders will ship to this address (USA only).</span>
</div>
{% include 'lib/form/address.twig' with {
action: 'shipping',
name: session.last_post.shipping_name,
street: session.last_post.shipping_street,
company: session.last_post.shipping_company,
boxapt: session.last_post.shipping_boxapt,
city: session.last_post.shipping_city,
state: session.last_post.shipping_state,
zip: session.last_post.shipping_zip,
phone: session.last_post.shipping_phone,
} %}
{% include 'lib/rule.twig' with { text: 'STEP 3' } %}
<div class="flex items-center justify-between">
<div class="flex flex-col">
<h4 class="font-semibold text-2xl">Billing Address</h4>
<span class="text-sm">Info must match the credit card making the purchase. </span>
</div>
</h4>
{% include 'lib/toggle.twig' with {
label: 'Same as shipping',
name: 'use_shipping'
} %}
</div>
<div id="billing-address" style="display: none;">
{% include 'lib/form/address.twig' with {
action: 'billing',
name: session.last_post.billing_name,
street: session.last_post.billing_street,
company: session.last_post.billing_company,
boxapt: session.last_post.billing_boxapt,
city: session.last_post.billing_city,
state: session.last_post.billing_state,
zip: session.last_post.billing_zip,
phone: session.last_post.billing_phone,
} %}
</div>
{% include 'lib/rule.twig' with { text: 'ALL DONE!' } %}
{% include 'lib/button.twig' with {
label: 'Register',
onclick: 'this.parentNode.submit()',
captcha: true
} %}
</form>
</div>
<script>
// this bit-of-script handles show/hide the billing address form fields
const useShippingCheckbox = document.getElementById('use_shipping');
const billingAddress = document.getElementById('billing-address');
useShippingCheckbox.addEventListener('change', () => {
if (useShippingCheckbox.checked) {
billingAddress.style.display = 'none';
} else {
billingAddress.style.display = 'flex';
}
});
</script>
</section>

11
src/views/cart.twig Normal file
View file

@ -0,0 +1,11 @@
<section>
<div class="flex flex-col gap-1">
<h3 class="text-2xl font-semibold">Cart</h3>
{% include 'lib/rule.twig' %}
</div>
{% include 'lib/empty.twig' with {
type: 'cart',
title: 'Your cart is empty.',
subtitle: 'Log in to see items you may have added previously, or continue shopping.'
} %}
</section>

View file

@ -0,0 +1,3 @@
<section>
checkout order confirmed boi
</section>

View file

@ -0,0 +1,3 @@
<section>
checkout review pay boi
</section>

View file

@ -0,0 +1,3 @@
<section>
checkout shipping/billing boi
</section>

60
src/views/footer.twig Normal file
View file

@ -0,0 +1,60 @@
<footer class="">
<div class="{{ colors.footer.primary }} flex justify-center py-8 mt-8">
<div class="w-4/5 flex flex-col gap-4">
<div class="flex gap-2">
<div class="w-1/3 text-sm">
<a href="/">
<img src="{{ theme == 'dark' ? '/img/logo-dark.webp' : '/img/logo-light.webp' }}" width="200px" />
</a>
Since 2021, we offer specialty hardware solutions, the best pricing, and fast shipping to keep your
business
functioning at its best.
</div>
<div class="w-1/6 text-sm">
<a href="/">
<img src="/img/logo-light.webp" width="200px" />
</a>
Since 2021, we offer specialty hardware solutions, the best pricing, and fast shipping to keep your
business
functioning at its best.
</div>
<div class="w-1/6 text-sm">
<a href="/">
<img src="/img/logo-light.webp" width="200px" />
</a>
Since 2021, we offer specialty hardware solutions, the best pricing, and fast shipping to keep your
business
functioning at its best.
</div>
<div class="w-1/6 text-sm">
<a href="/">
<img src="/img/logo-light.webp" width="200px" />
</a>
Since 2021, we offer specialty hardware solutions, the best pricing, and fast shipping to keep your
business
functioning at its best.
</div>
<div class="w-1/6 text-sm">
<a href="/">
<img src="/img/logo-light.webp" width="200px" />
</a>
Since 2021, we offer specialty hardware solutions, the best pricing, and fast shipping to keep your
business
functioning at its best.
</div>
</div>
</div>
</div>
<div class="{{ colors.footer.policy }} flex justify-center w-full py-4">
<div class="flex justify-between text-xs w-4/5">
<div>© {{ copyright_year }} BuysForLife - All Rights Reserved.</div>
<div class='text-right'>
<a href="/policy#terms-of-sale" class="hover:underline">Terms of Sale</a> |
<a href="/policy#privacy-policy" class="hover:underline">Privacy Policy</a> |
<a href="/policy#terms-of-use" class="hover:underline">Terms of Use</a> |
<a href="/policy#accessibility-policy" class="hover:underline">Accessibility Policy</a> |
<a href="/policy#do-not-sell-my-personal-information" class="hover:underline">Do Not Sell My Personal Information</a>
</div>
</div>
</div>
</footer>

27
src/views/head.twig Normal file
View file

@ -0,0 +1,27 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="robots" content="noindex">
<link rel="stylesheet" href="/style.css">
<title>{{ page_title }}</title>
<link rel="icon" href="/img/icon.png">
<script>/*! js-cookie v3.0.5 | MIT */
!function (e, t) { "object" == typeof exports && "undefined" != typeof module ? module.exports = t() : "function" == typeof define && define.amd ? define(t) : (e = "undefined" != typeof globalThis ? globalThis : e || self, function () { var n = e.Cookies, o = e.Cookies = t(); o.noConflict = function () { return e.Cookies = n, o } }()) }(this, (function () { "use strict"; function e(e) { for (var t = 1; t < arguments.length; t++) { var n = arguments[t]; for (var o in n) e[o] = n[o] } return e } var t = function t(n, o) { function r(t, r, i) { if ("undefined" != typeof document) { "number" == typeof (i = e({}, o, i)).expires && (i.expires = new Date(Date.now() + 864e5 * i.expires)), i.expires && (i.expires = i.expires.toUTCString()), t = encodeURIComponent(t).replace(/%(2[346B]|5E|60|7C)/g, decodeURIComponent).replace(/[()]/g, escape); var c = ""; for (var u in i) i[u] && (c += "; " + u, !0 !== i[u] && (c += "=" + i[u].split(";")[0])); return document.cookie = t + "=" + n.write(r, t) + c } } return Object.create({ set: r, get: function (e) { if ("undefined" != typeof document && (!arguments.length || e)) { for (var t = document.cookie ? document.cookie.split("; ") : [], o = {}, r = 0; r < t.length; r++) { var i = t[r].split("="), c = i.slice(1).join("="); try { var u = decodeURIComponent(i[0]); if (o[u] = n.read(c, u), e === u) break } catch (e) { } } return e ? o[e] : o } }, remove: function (t, n) { r(t, "", e({}, n, { expires: -1 })) }, withAttributes: function (n) { return t(this.converter, e({}, this.attributes, n)) }, withConverter: function (n) { return t(e({}, this.converter, n), this.attributes) } }, { attributes: { value: Object.freeze(o) }, converter: { value: Object.freeze(n) } }) }({ read: function (e) { return '"' === e[0] && (e = e.slice(1, -1)), e.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent) }, write: function (e) { return encodeURIComponent(e).replace(/%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g, decodeURIComponent) } }, { path: "/" }); return t }));</script>
<script>
// code to set the `color_scheme` cookie
var $color_scheme = Cookies.get("theme");
function get_color_scheme() {
return (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
}
function update_color_scheme() {
Cookies.set("theme", get_color_scheme());
}
// read & compare cookie `color-scheme`
if ((typeof $color_scheme === "undefined") || (get_color_scheme() != $color_scheme))
update_color_scheme();
// detect changes and change the cookie
if (window.matchMedia)
window.matchMedia("(prefers-color-scheme: dark)").addListener(update_color_scheme);
</script>
</head>

178
src/views/header.twig Normal file
View file

@ -0,0 +1,178 @@
<header class="flex flex-col items-center w-full gap-3 mb-8">
<div class="{{ colors.header.banner }} py-1 text-sm flex w-full justify-center">
<div class="w-[97%] lg:w-[90%] xl:w-4/5 flex justify-between">
<a href="/support/ask">Help Center</a>
<span>Save 5% when you pay with Bitcoin</span><a href="/support/bitcoin">Learn More</a>
</div>
</div>
<div class="w-[97%] lg:w-[90%] xl:w-4/5 flex flex-col gap-3">
<div class="flex w-full items-center justify-between gap-3">
<div class="flex flex-1 md:basis-[100px] md:min-w-[280px] md:max-w-[280px] xl:basis-[200px] xl:min-w-[300px] xl:max-w-[300px]">
<a href="/" class="w-[200px]">
<img src="/img/logo-{{ theme }}.webp" />
</a>
</div>
<form action="/search" method="post" class="flex-grow max-w-[900px]">
{% include 'lib/input.twig' with {
name: 'search',
type: 'search',
placeholder: 'What are you looking for?',
submit: true,
icon: 'search'
} %}
</form>
<div class="flex flex-1 items-center justify-end gap-4 md:basis-[100px] md:min-w-[280px] md:max-w-[280px] xl:basis-[200px] xl:min-w-[300px] xl:max-w-[300px]">
<div class="flex gap-1">
<div class="relative" id="dropdown-bound">
<!-- Hidden checkbox to control the dropdown -->
<input type="checkbox" id="dropdown-toggle" class="hidden peer">
<!-- Dropdown button -->
<label for="dropdown-toggle"
class="{{ colors.button.default }} flex gap-1 items-center cursor-pointer p-1 py-2 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="lucide lucide-user-round">
<circle cx="12" cy="8" r="5" />
<path d="M20 21a8 8 0 0 0-16 0" />
</svg>
<div>
<div class="text-xs leading-none">
{% if session.user_id is defined %}
Welcome
{% else %}
Sign In
{% endif %}
</div>
<div class="flex items-center gap-[1px]">
<div class="text-sm font-semibold leading-none">Account</div> <svg
xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="lucide lucide-chevron-down">
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</div>
</label>
<div
class="absolute mt-2 {{ colors.dropdown.list }} border rounded-md shadow-md w-48 hidden peer-checked:block z-50">
<ul class="py-2">
{% if session.user_id is defined %}
<li><a href="/account" class="block px-4 py-2 m-1 rounded-lg {{ colors.dropdown.item }}">Account</a></li>
<li><a href="/account/orders" class="block px-4 py-2 m-1 rounded-lg {{ colors.dropdown.item }}">Orders</a></li>
<li><a href="/account/returns" class="block px-4 py-2 m-1 rounded-lg {{ colors.dropdown.item }}">Returns</a>
</li>
<li><a href="/account/shipping" class="block px-4 py-2 m-1 rounded-lg {{ colors.dropdown.item }}">Shipping</a>
</li>
<li><a href="/account/billing" class="block px-4 py-2 m-1 rounded-lg {{ colors.dropdown.item }}">Billing</a>
</li>
{% else %}
<li><a href="/account/login" class="block px-4 py-2 m-1 rounded-lg {{ colors.dropdown.item }}">Sign In</a></li>
<li><a href="/account/signup" class="block px-4 py-2 m-1 rounded-lg {{ colors.dropdown.item }}">Create an
Account</a>
</li>
{% endif %}
</ul>
{% if session.user_id is defined %}
{% include 'lib/rule.twig' %}
<ul class="py-2">
<li><a href="/account/logout" class="block px-4 py-2 m-1 rounded-lg {{ colors.dropdown.item }}">Logout</a></li>
</ul>
{% endif %}
</div>
</div>
<script>
document.addEventListener('click', function (event) {
if (!document.getElementById('dropdown-bound').contains(event.target)) {
document.getElementById('dropdown-toggle').checked = false;
}
});
</script>
<a href="/account/orders"
class="{{ colors.button.default }} flex items-center gap-1 p-1 py-2 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-box">
<path
d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<div>
<div class="text-xs whitespace-nowrap leading-none">Returns &</div>
<div class="text-sm font-semibold leading-none">Orders</div>
</div>
</a>
</div>
<a href="/cart">
<div class="flex items-center">
<div
class="{{ colors.button.primary }} h-[42px] flex items-center border p-2 rounded-tl-lg rounded-bl-lg">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="lucide lucide-shopping-cart lucide-icon">
<circle cx="8" cy="21" r="1"></circle>
<circle cx="19" cy="21" r="1"></circle>
<path
d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12">
</path>
</svg>
</div>
<div
class="{{ colors.input }} flex h-[42px] font-semibold justify-center items-center rounded-tr-lg rounded-br-lg border border-l-0 px-3 py-2">
7</div>
</div>
</a>
</div>
</div>
<nav class="w-full relative rounded-lg {{ colors.nav.bar }}">
<div class="flex">
<ul class="flex">
<li
class="hoverable rounded-xl border-[5px] {{ colors.nav.item }}">
<a href="/power-meters" class="relative p-1 rounded-xl block text-sm font-bold">220V
Power Meters</a>
<div class="mega-menu">
<div class="bg-transparent h-[3px]">
<!-- invisible content to keep menu shown when cursor is b/t the item and the content -->
</div>
<div class="p-6 mb-16 rounded-b shadow-lg {{ colors.nav.hovercontent }}">
<div class='flex gap-3 items-baseline'>
<h4 class="text-xl font-semibold">220V Power Meters</h4>
<a href="/power-meters" class="hover:underline font-semibold text-xs {{ colors.anchor.primary }}">Shop All</a>
</div>
</div>
</div>
</li>
</ul>
</div>
</nav>
{% if breadcrumbs is defined %}
<div class='flex flex-col gap-1'>
<div class="flex gap-2 text-xs">
<a href="/" class="hover:underline {{ colors.breadcrumb.parent }}">
{{ env.APP_NAME }}
</a>
<span class="{{ colors.breadcrumb.seperator }}">></span>
{% for crumb in breadcrumbs %}
{% if crumb.url is null %}
<span class="font-bold {{ colors.breadcrumb.child }}">{{ crumb.title }}</span>
{% else %}
<a href="{{ crumb.url }}" class="hover:underline {{ colors.breadcrumb.parent }}">
{{ crumb.title }}
</a>
{% endif %}
{% if loop.index < breadcrumbs|length %}
<span class="{{ colors.breadcrumb.seperator }}">></span>
{% endif %}
{% endfor %}
</div>
{% include 'lib/rule.twig' %}
</div>
{% endif %}
</div>
</header>

3
src/views/home.twig Normal file
View file

@ -0,0 +1,3 @@
<section>
home boi
</section>

45
src/views/lib/alert.twig Normal file
View file

@ -0,0 +1,45 @@
{% if session.error is defined %}
<div class="flex gap-3 items-center p-3 border rounded-md {{ colors.error.alert }}">
<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-circle-x {{ colors.error.text }}">
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>
<p>{{ session.error }}</p>
</div>
{% endif %}
{% if session.warning is defined %}
<div class="flex gap-3 items-center p-3 border rounded-md {{ colors.warning.alert }}">
<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-circle-alert {{ colors.warning.text }}">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
<p>{{ session.warning }}</p>
</div>
{% endif %}
{% if session.success is defined %}
<div class="flex gap-3 items-center p-3 border rounded-md {{ colors.success.alert }}">
<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-circle-check {{ colors.success.text }}">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
<p>{{ session.success }}</p>
</div>
{% endif %}
{% if session.info is defined %}
<div class="flex gap-3 items-center p-3 border rounded-md {{ colors.info.alert }}">
<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-info {{ colors.info.text }}">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
<p>{{ session.info }}</p>
</div>
{% endif %}

37
src/views/lib/button.twig Normal file
View file

@ -0,0 +1,37 @@
<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 label is defined %}
<span>{{ label }}</span>
{% endif %}
{% if icon is defined %}
{% if icon == 'search' %}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
{% 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' %}
<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 %}
</div>
{% 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
<a class="underline" href="https://policies.google.com/privacy">Privacy Policy</a> and <a class="underline"
href="https://policies.google.com/terms">Terms of Service</a> apply.
</p>
</div>
{% endif %}

5
src/views/lib/empty.twig Normal file
View file

@ -0,0 +1,5 @@
<div class="text-center flex flex-col items-center mb-24 mt-20">
<img src="/img/empty/{{ type }}-{{ theme }}.svg" width="200px" />
<h3 class="text-2xl font-semibold">{{ title }}</h3>
<p class="w-[300px] text-sm">{{ subtitle }}</p>
</div>

View file

@ -0,0 +1,54 @@
<div class="flex flex-col gap-4 mb-4">
{% include 'lib/input.twig' with {
type: 'text',
name: action ~ '_name',
label: 'Name',
value: name
} %}
{% include 'lib/input.twig' with {
type: 'text',
name: action ~ '_company',
label: 'Company',
optional: true,
value: company
} %}
{% include 'lib/input.twig' with {
type: 'text',
name: action ~ '_street',
label: 'Street',
value: street
} %}
{% include 'lib/input.twig' with {
type: 'text',
name: action ~ '_boxapt',
label: 'PO Box/Apt#',
optional: true,
value: boxapt
} %}
<div class="flex gap-4">
{% include 'lib/input.twig' with {
type: 'text',
name: action ~ '_city',
label: 'City',
value: city
} %}
{% include 'lib/input.twig' with {
type: 'text',
name: action ~ '_state',
label: 'State',
value: state
} %}
{% include 'lib/input.twig' with {
type: 'text',
name: action ~ '_zip',
label: 'Zip',
value: zip
} %}
</div>
{% include 'lib/input.twig' with {
type: 'text',
name: action ~ '_phone',
label: 'Phone',
value: phone
} %}
</div>

View file

@ -0,0 +1,33 @@
<div class="flex flex-col gap-4 my-4">
{% include 'lib/input.twig' with {
type: 'text',
name: 'name',
label: 'Name',
value: user.name
} %}
<div class="flex gap-4">
{% include 'lib/input.twig' with {
type: 'text',
name: 'company_name',
label: 'Company Name',
value: user.company_name
} %}
{% include 'lib/input.twig' with {
type: 'text',
name: 'company_type',
label: 'Company Type',
value: user.company_type
} %}
{% include 'lib/input.twig' with {
type: 'text',
name: 'company_size',
label: 'Company Size',
value: user.company_size
} %}
</div>
{% include 'lib/toggle.twig' with {
name: 'dark_theme',
label: 'Use dark theme',
on: user.dark_theme
} %}
</div>

40
src/views/lib/input.twig Normal file
View file

@ -0,0 +1,40 @@
<div class="flex flex-col gap-4">
{% if label is defined %}
<label for="{{ name }}" class="flex flex-col gap-2">
<span class="font-semibold">
{{ label }}
{% if required is defined %}
<span class="{{ colors.error.text }} ml-4">*</span>
{% endif %}
{% if optional is defined %}
<span class="text-sm font-normal {{ colors.text.muted }}"> - (optional)</span>
{% endif %}
</span>
{% if subtext is defined %}
<span class="text-sm {{ colors.text.muted }}">{{ subtext }}</span>
{% endif %}
</label>
{% endif %}
{% if submit is defined %}
<div class="flex items-center">
{% endif %}
<input type="{{ type }}" name="{{ name }}"
{% if placeholder is defined %}
placeholder="{{ placeholder }}"
{% endif %}
{% if value is not null %}
value="{{ value }}"
{% endif %}
{% if readonly is not null %}
readonly
{% 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 {
icon: 'search'
} %}
{% endif %}
{% if submit is defined %}
</div>
{% endif %}
</div>

14
src/views/lib/modal.twig Normal file
View file

@ -0,0 +1,14 @@
<style>
#{{ id }}:target {
visibility: visible;
opacity: 1;
}
</style>
<div id="{{ id }}" class="{{ colors.modal.shadow }} invisible opacity-0 fixed inset-0 flex items-center justify-center transition-all duration-400 h-full">
<div id="hide-{{ id }}">
<div class="{{ colors.modal.content }} p-8 border rounded relative">
{% include content %}
<a href="#hide-{{ id }}" class="absolute top-2 right-2 no-underline"><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-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></a>
</div>
</div>
</div>

View file

@ -0,0 +1,3 @@
<section>
category: {{ product_category }}
</section>

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="robots" content="noindex">
<link rel="stylesheet" href="style.css">
<title>{{ page_title }}</title>
<link rel="icon" href="icon.png">
</head>
<body scroll="no" class="{{ colors.body }} h-full flex flex-col items-center whitespace-pre leading-4 cursor-default">
{% include child_template %}
</body>
</html>

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
{% include 'head.twig' %}
{% include 'header.twig' %}
<body scroll="no"
class="{{ colors.body }} h-full flex flex-col items-center leading-4 cursor-default">
<div class="w-4/5 md:w-1/2 xl:w-1/3">
{% include child_template %}
</div>
</body>
{% include 'footer.twig' %}
</html>

View file

@ -0,0 +1,12 @@
<div>
<div class="text-lg font-semibold mb-4">Why do I have store credit?</div>
<ul class="list-disc pl-6 mb-4">
<li>You may have received credit as a refund, dispute resolution, or promotional event.</li>
<li>You can also reload your store credit by ordering gift cards.</li>
</ul>
<div class="text-lg font-semibold mb-4">What can I do with store credit?</div>
<ul class="list-disc pl-6">
<li>You may spend store credit at checkout.</li>
<li>Your subscriptions and recurring purchases can also be paid with store credit.</li>
</ul>
</div>

View file

@ -0,0 +1,13 @@
<div>
<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>
</ul>
<div class="text-lg font-semibold mb-4">What can I do with sats?</div>
<ul class="list-disc pl-6">
<li>You may spend sats at checkout</li>
<li>Your subscriptions and recurring purchases can also be paid with sats</li>
<li>You can configure these sats to autowithdraw by attaching a Lightning Address (LNURL)</li>
</ul>
</div>

10
src/views/lib/rule.twig Normal file
View file

@ -0,0 +1,10 @@
{% if text is defined %}
<div class="relative">
<div class="absolute inset-0 flex items-center"><span class="w-full border-t {{ colors.rule }}"></span></div>
<div class="relative flex justify-center text-xs"><span
class="px-2 {{ colors.body }}">{{ text }}</span>
</div>
</div>
{% else %}
<hr class="{{ colors.rule }}" />
{% endif %}

View file

@ -0,0 +1,7 @@
<label class="relative flex gap-4 items-center text-sm cursor-pointer">
<input type="checkbox" {% if on %}checked{% endif %} name="{{ name }}" id="{{ name }}"
class="absolute left-1/2 -translate-x-1/2 w-1/2 h-1/2 peer appearance-none rounded-md" />
<span
class="{{ colors.toggle }} w-8 h-5 flex items-center flex-shrink-0 p-1 rounded-full duration-300 ease-in-out after:w-4 after:h-4 after:rounded-full after:shadow-md after:duration-300 peer-checked:after:translate-x-3"></span>
<div class="whitespace-nowrap">{{ label }}</div>
</label>

3
src/views/policies.twig Normal file
View file

@ -0,0 +1,3 @@
<section>
policy boi
</section>

View file

@ -0,0 +1,3 @@
<section>
faq and chat boi
</section>

View file

@ -0,0 +1,3 @@
<section>
bitcoin promo and how to get sats
</section>

3
tailwind.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
content: ["./src/**/*.twig", "./public/index.php"],
};

BIN
tailwindcss Executable file

Binary file not shown.