init
19
.env.example
Normal 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
|
@ -0,0 +1,4 @@
|
|||
vendor
|
||||
db.sqlite
|
||||
.env
|
||||
public/style.css
|
73
README.md
|
@ -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
|
@ -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
BIN
composer.phar
Executable file
BIN
public/.DS_Store
vendored
Normal file
BIN
public/img/.DS_Store
vendored
Normal file
BIN
public/img/empty/.DS_Store
vendored
Normal file
1
public/img/empty/404-dark.svg
Normal file
After Width: | Height: | Size: 8.3 KiB |
1
public/img/empty/404-light.svg
Normal file
After Width: | Height: | Size: 8.4 KiB |
1
public/img/empty/cart-dark.svg
Normal file
After Width: | Height: | Size: 8.8 KiB |
1
public/img/empty/cart-light.svg
Normal file
After Width: | Height: | Size: 9.6 KiB |
1
public/img/empty/error-dark.svg
Normal file
After Width: | Height: | Size: 9 KiB |
1
public/img/empty/error-light.svg
Normal file
After Width: | Height: | Size: 8.2 KiB |
1
public/img/empty/file-dark.svg
Normal file
After Width: | Height: | Size: 8.6 KiB |
1
public/img/empty/file-light.svg
Normal file
After Width: | Height: | Size: 7.9 KiB |
1
public/img/empty/message-dark.svg
Normal file
After Width: | Height: | Size: 10 KiB |
1
public/img/empty/message-light.svg
Normal file
After Width: | Height: | Size: 9.6 KiB |
1
public/img/empty/notice-dark.svg
Normal file
After Width: | Height: | Size: 6.2 KiB |
1
public/img/empty/notice-light.svg
Normal file
After Width: | Height: | Size: 6.3 KiB |
1
public/img/empty/order-dark.svg
Normal file
After Width: | Height: | Size: 5.3 KiB |
1
public/img/empty/order-light.svg
Normal file
After Width: | Height: | Size: 5.4 KiB |
1
public/img/empty/payment-dark.svg
Normal file
After Width: | Height: | Size: 16 KiB |
1
public/img/empty/payment-light.svg
Normal file
After Width: | Height: | Size: 16 KiB |
1
public/img/empty/plug-dark.svg
Normal file
After Width: | Height: | Size: 11 KiB |
1
public/img/empty/plug-light.svg
Normal file
After Width: | Height: | Size: 13 KiB |
1
public/img/empty/search-dark.svg
Normal file
After Width: | Height: | Size: 9.3 KiB |
1
public/img/empty/search-light.svg
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
public/img/icon.png
Normal file
After Width: | Height: | Size: 6 KiB |
BIN
public/img/logo-dark.webp
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
public/img/logo-light.webp
Normal file
After Width: | Height: | Size: 20 KiB |
165
public/index.php
Normal 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
|
@ -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
|
@ -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
|
@ -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'
|
||||
]
|
||||
],
|
||||
]));
|
||||
}
|
||||
}
|
14
src/controllers/category.php
Normal 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',
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
26
src/controllers/checkout.php
Normal 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
|
@ -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"
|
||||
]));
|
||||
}
|
||||
}
|
90
src/controllers/lnurlp.php
Normal 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
|
@ -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'],
|
||||
]));
|
||||
}
|
||||
}
|
67
src/controllers/magic_link.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
35
src/controllers/support.php
Normal 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
|
@ -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
|
@ -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)
|
||||
);");
|
||||
}
|
||||
}
|
||||
|
||||
|
45
src/models/magic_links.php
Normal 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
|
@ -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
|
@ -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)
|
||||
)");
|
||||
}
|
||||
}
|
54
src/models/transactions.php
Normal 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;
|
||||
}
|
||||
}
|
53
src/models/user_addresses.php
Normal 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
|
@ -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
|
@ -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
|
@ -0,0 +1,7 @@
|
|||
<section>
|
||||
{% include 'lib/empty.twig' with {
|
||||
type: '404',
|
||||
title: 'Page not found.',
|
||||
subtitle: 'Shop our latest products!'
|
||||
} %}
|
||||
</section>
|
40
src/views/account/billing.twig
Normal 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>
|
114
src/views/account/index.twig
Normal 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'
|
||||
} %}
|
23
src/views/account/login.twig
Normal 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>
|
11
src/views/account/orders.twig
Normal 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>
|
11
src/views/account/returns.twig
Normal 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>
|
31
src/views/account/shipping.twig
Normal 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>
|
91
src/views/account/signup.twig
Normal 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
|
@ -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>
|
3
src/views/checkout/confirmed.twig
Normal file
|
@ -0,0 +1,3 @@
|
|||
<section>
|
||||
checkout order confirmed boi
|
||||
</section>
|
3
src/views/checkout/review_pay.twig
Normal file
|
@ -0,0 +1,3 @@
|
|||
<section>
|
||||
checkout review pay boi
|
||||
</section>
|
3
src/views/checkout/shipping_billing.twig
Normal file
|
@ -0,0 +1,3 @@
|
|||
<section>
|
||||
checkout shipping/billing boi
|
||||
</section>
|
60
src/views/footer.twig
Normal 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
|
@ -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
|
@ -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
|
@ -0,0 +1,3 @@
|
|||
<section>
|
||||
home boi
|
||||
</section>
|
45
src/views/lib/alert.twig
Normal 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
|
@ -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
|
@ -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>
|
54
src/views/lib/form/address.twig
Normal 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>
|
33
src/views/lib/form/profile.twig
Normal 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
|
@ -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
|
@ -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>
|
3
src/views/lib/page/category.twig
Normal file
|
@ -0,0 +1,3 @@
|
|||
<section>
|
||||
category: {{ product_category }}
|
||||
</section>
|
18
src/views/lib/page/flow.twig
Normal 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>
|
15
src/views/lib/page/index.twig
Normal 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>
|
12
src/views/lib/policy/credit.twig
Normal 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>
|
13
src/views/lib/policy/sats.twig
Normal 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
|
@ -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 %}
|
7
src/views/lib/toggle.twig
Normal 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
|
@ -0,0 +1,3 @@
|
|||
<section>
|
||||
policy boi
|
||||
</section>
|
3
src/views/support/ask.twig
Normal file
|
@ -0,0 +1,3 @@
|
|||
<section>
|
||||
faq and chat boi
|
||||
</section>
|
3
src/views/support/bitcoin.twig
Normal file
|
@ -0,0 +1,3 @@
|
|||
<section>
|
||||
bitcoin promo and how to get sats
|
||||
</section>
|
3
tailwind.config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
content: ["./src/**/*.twig", "./public/index.php"],
|
||||
};
|