Compare commits
7 Commits
Author | SHA1 | Date |
---|---|---|
Keith Irwin | a7c5fd2489 | |
Keith Irwin | 79da1842c8 | |
Keith Irwin | 31fb820e9c | |
Keith Irwin | 7c35fc4b31 | |
Keith Irwin | 6cec722430 | |
Keith Irwin | a168d4b9f8 | |
Keith Irwin | 2d86de1fc8 |
10
.env.sample
|
@ -6,6 +6,14 @@ STRIPE_LISTENER_PORT="8082"
|
|||
# Domains
|
||||
SITE_DOMAIN="https://www.example.com"
|
||||
API_DOMAIN="https://api.example.com"
|
||||
API_DOMAIN_INTERNAL="http://my_api"
|
||||
|
||||
# Database
|
||||
DB_HOST="my_db"
|
||||
DB_NAME="myapp"
|
||||
DB_PORT="27017"
|
||||
DB_USER="myapp"
|
||||
DB_PASS="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
|
||||
# Email recipients
|
||||
ADMIN_EMAIL="sysadmin@example.com"
|
||||
|
@ -25,7 +33,7 @@ NTFY_DOMAIN="https://ntfy.example.com"
|
|||
NTFY_TOPIC="orders-paid_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
|
||||
# Monero
|
||||
MONEROPAY_URL="http://localhost:5000"
|
||||
MONEROPAY_URL="http://my_moneropay:5000"
|
||||
MONEROD_URL="node.supportxmr.com:18081"
|
||||
MONERO_PRICE_LEEWAY="0.02"
|
||||
MONERO_PRICECHECK_SEC="600"
|
||||
|
|
|
@ -3,8 +3,8 @@ node_modules/
|
|||
|
||||
# environment and data
|
||||
.env
|
||||
db/
|
||||
docker-compose.yml
|
||||
orders/*
|
||||
sold/*
|
||||
view-wallet/*
|
||||
moneropay-db/*
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
'use strict'
|
||||
require('dotenv').config()
|
||||
module.exports = {
|
||||
STRIPE_PUB: process.env.STRIPE_PUB,
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
'use strict'
|
||||
require('dotenv').config()
|
||||
const mongoose = require('mongoose')
|
||||
mongoose.connect('mongodb://'
|
||||
+ process.env.DB_USER + ':'
|
||||
+ process.env.DB_PASS + '@'
|
||||
+ process.env.DB_HOST + ':'
|
||||
+ process.env.DB_PORT + '/'
|
||||
+ process.env.DB_NAME )
|
||||
const models = require('../../api/models')
|
||||
|
||||
module.exports = async () => {
|
||||
let listings; try {
|
||||
listings = await Promise.all([
|
||||
new Promise(async (resolve, reject) => {
|
||||
let units; try {
|
||||
units = await models.Unit.find({
|
||||
listed: true,
|
||||
soldDate: null,
|
||||
}).populate('product')
|
||||
} catch (err) { reject(err) }
|
||||
resolve(units.map((unit) => { return {
|
||||
title: unit.title || unit.product.title,
|
||||
category: unit.product.category,
|
||||
description: unit.description || unit.product.description,
|
||||
slug: unit.slug,
|
||||
content: unit.content || unit.product.content,
|
||||
sid: unit._id,
|
||||
pid: unit.product._id,
|
||||
imgs: [...unit.imgs, ...unit.product.imgs],
|
||||
price: unit.price || unit.product.price,
|
||||
specs: unit.specs,
|
||||
emoji: unit.product.emoji,
|
||||
dims: unit.product.dims,
|
||||
weight: unit.product.weight,
|
||||
leadTime: unit.leadTime || unit.product.leadTime,
|
||||
refundPolicy: unit.refundPolicy || unit.product.refundPolicy,
|
||||
|
||||
} }))
|
||||
}),
|
||||
new Promise(async (resolve, reject) => {
|
||||
let products; try {
|
||||
products = await models.Product.find({
|
||||
listed: true,
|
||||
})
|
||||
} catch (err) { reject(err) }
|
||||
resolve(products.map((product) => { return {
|
||||
title: product.title,
|
||||
category: product.category,
|
||||
description: product.description,
|
||||
slug: product.slug,
|
||||
content: product.content,
|
||||
sid: '',
|
||||
pid: product._id,
|
||||
imgs: product.imgs,
|
||||
price: product.price,
|
||||
emoji: product.emoji,
|
||||
dims: product.dims,
|
||||
weight: product.weight,
|
||||
leadTime: product.leadTime,
|
||||
refundPolicy: product.refundPolicy,
|
||||
} }))
|
||||
}),
|
||||
])
|
||||
} catch (err) { return console.error(err) }
|
||||
return [...listings[0], ...listings[1]]
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
layout: layouts/base.njk
|
||||
---
|
||||
|
||||
<h1>{% if listing.emoji %}{{listing.emoji}} {% endif %}{{listing.title}}</h1>
|
||||
<p>{{listing.description}}</p>
|
||||
<link rel="stylesheet" href="/assets/shop.css">
|
||||
<div id="product_container">
|
||||
<script src="/assets/scripts/cart-add.js" integrity="{{'/assets/scripts/cart-add.js'|srintegrity}}"></script>
|
||||
|
||||
<aside id="product_sidebar">
|
||||
{% if listing.imgs.length -%}
|
||||
<a href="/shop/{{listing.imgs[0]}}"><img src="/shop/{{listing.imgs[0]}}" alt="{{listing.title}}"/></a>
|
||||
{%- else -%}
|
||||
<p>No image available.</p>
|
||||
{%- endif %}
|
||||
<p><b>$<span data-bind>{{listing.price.usd}}</b></p>
|
||||
<button class="add-to-cart" id="add-to-cart_{{listing.pid}}_{{listing.sid}}" onclick="addToCart('{{listing.pid}}','{{listing.sid}}','{{listing.price.usd}}','{% if listing.emoji %}{{listing.emoji}} {% endif %}{{listing.title|noApostrophes}}','{{listing.weight.lbs}}', '{{listing.dims.in.height*listing.dims.in.depth*listing.dims.in.width}}','/shop/{% if listing.slug %}{{listing.slug}}{% else %}{{listing.category}}/{% if listing.sid %}{{listing.sid}}/{% endif %}{{listing.title|slugify}}{% endif %}')"><b>🛒 Add to cart</b></button>
|
||||
<div class="qty-wrap" id="qty-select-wrap_{{listing.pid}}_{{listing.sid}}">
|
||||
<div style="display:flex">
|
||||
<button id="qty-minus_{{listing.pid}}_{{listing.sid}}" class="qty-adjust" onclick="qtyMinus('{{listing.pid}}','{{listing.sid}}')">-</button>
|
||||
<input class="qty" id="qty-select_{{listing.pid}}_{{listing.sid}}" type="number" name="qty-select_{{listing.pid}}/{{listing.sid}}" onchange="qtyChanged('{{listing.pid}}','{{listing.sid}}',{{listing.price.usd}},'{% if listing.emoji %}{{listing.emoji}} {% endif %}{{listing.title|noApostrophes}}','{{listing.weight.lbs}}', '{{listing.dims.in.height*listing.dims.in.depth*listing.dims.in.width}}','/shop/{% if listing.slug %}{{listing.slug}}{% else %}{{listing.category}}/{% if listing.sid %}{{listing.sid}}/{% endif %}{{listing.title|slugify}}{% endif %}')" min="0" max="{% if listing.sid %}1{% elif listing.inStock %}{{listing.inStock}}{% else %}99{% endif %}"/>
|
||||
<button id="qty-plus_{{listing.pid}}_{{listing.sid}}" class="qty-adjust" onclick="qtyPlus('{{listing.pid}}','{{listing.sid}}')">+</button>
|
||||
</div><span style="margin-left:1rem">in cart</span>
|
||||
</div>
|
||||
<script>productLoaded('{{listing.pid}}','{{listing.sid}}')</script>
|
||||
<hr>
|
||||
<p><b>Shipping:</b> Get it in {% if listing.leadTime %}{{listing.leadTime}}{% else %}10-30 days{% endif %}.
|
||||
<p><b>Refund policy:</b> {% if listing.refundPolicy %}{{listing.refundPolicy}}{% else %}Return within 60 days for a refund (shipping not refunded).{% endif %}</p>
|
||||
<hr>
|
||||
{% markdown %}{% include 'product-specs.md' %}{% endmarkdown %}
|
||||
</aside>
|
||||
|
||||
<section id="product_content">
|
||||
{{listing.content|safe}}
|
||||
</section>
|
||||
|
||||
</div>
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
layout: layouts/base.njk
|
||||
templateClass: tmpl-product
|
||||
---
|
||||
{% set stripe_backend = 'https://stripe.blue.slvit.us/' %}
|
||||
|
||||
<p><a href="/shop/"><b>< Back to all products</b></a><p>
|
||||
|
||||
<h1>{% if emoji %}{{emoji}} {% endif %}{{title}}</h1>
|
||||
<p>{{description}}</p>
|
||||
<link rel="stylesheet" href="/assets/shop.css">
|
||||
<div id="product_container">
|
||||
<script src="/assets/scripts/cart-add.js" integrity="{{'/assets/scripts/cart-add.js'|srintegrity}}"></script>
|
||||
|
||||
<aside id="product_sidebar">
|
||||
{% if img -%}
|
||||
<a href="/shop/{{img}}"><img src="/shop/{{img}}" alt="{{title}}"/></a>
|
||||
{%- else -%}
|
||||
<p>No image available.</p>
|
||||
{%- endif %}
|
||||
<p><b>$<span data-bind>{{price.usd}}</b></p>
|
||||
<button class="add-to-cart" id="add-to-cart_{{pid}}_{{sid}}" onclick="addToCart('{{pid}}','{{sid}}','{{price.usd}}','{% if emoji %}{{emoji}} {% endif %}{{title|noApostrophes}}','{{specs.weight.lbs}}', '{{specs.dimensions.in.height*specs.dimensions.in.depth*specs.dimensions.in.width}}','{{page.url}}')"><b>🛒 Add to cart</b></button>
|
||||
<div class="qty-wrap" id="qty-select-wrap_{{pid}}_{{sid}}">
|
||||
<div style="display:flex">
|
||||
<button id="qty-minus_{{pid}}_{{sid}}" class="qty-adjust" onclick="qtyMinus('{{pid}}','{{sid}}')">-</button>
|
||||
<input class="qty" id="qty-select_{{pid}}_{{sid}}" type="number" name="qty-select_{{pid}}/{{sid}}" onchange="qtyChanged('{{pid}}','{{sid}}',{{price.usd}},'{% if emoji %}{{emoji}} {% endif %}{{title|noApostrophes}}','{{specs.weight.lbs}}', '{{specs.dimensions.in.height*specs.dimensions.in.depth*specs.dimensions.in.width}}','{{page.url}}')" min="0" max="99"/>
|
||||
<button id="qty-plus_{{pid}}_{{sid}}" class="qty-adjust" onclick="qtyPlus('{{pid}}','{{sid}}')">+</button>
|
||||
</div><span style="margin-left:1rem">in cart</span>
|
||||
</div>
|
||||
<script>productLoaded('{{pid}}','{{sid}}')</script>
|
||||
<hr>
|
||||
<p><b>Shipping:</b> Get it in {% if leadTime %}{{leadTime}}{% else %}10-30 days{% endif %}.
|
||||
<p><b>Refund policy:</b> {% if refundPolicy %}{{refundPolicy}}{% else %}Return within 60 days for a refund (shipping not refunded).{% endif %}</p>
|
||||
<hr>
|
||||
{% md %}{% include 'product-specs.md' %}{% endmd %}
|
||||
</aside>
|
||||
|
||||
<section id="product_content">
|
||||
{{content|safe}}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<p><a href="/shop/"><b>< Back to all products</b></a><p>
|
|
@ -0,0 +1,34 @@
|
|||
<link rel="stylesheet" href="/assets/shop.css">
|
||||
{% if listings.length === 0 %}
|
||||
<p>Nothing in stock.</p>
|
||||
{% else %}<section class="productlist">
|
||||
<script src="/assets/scripts/cart-add.js" integrity="{{'/assets/scripts/cart-add.js'|srintegrity}}"></script>
|
||||
{% for listing in listings -%}
|
||||
{% if listing.slug %}
|
||||
{% set url = '/shop/' + listing.slug %}
|
||||
{% else %}
|
||||
{% if listing.sid %}
|
||||
{% set url = '/shop/' + listing.category + '/' + listing.title|slugify + '/' + listing.sid + '/' %}
|
||||
{% else %}
|
||||
{% set url = '/shop/' + listing.category + '/' + listing.title|slugify + '/' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="productlist-item">
|
||||
<a href="{{url}}" style="text-decoration:none">
|
||||
<h4>{{listing.title|noApostrophes}}</h4>
|
||||
{% if listing.imgs.length -%}
|
||||
<img src="/shop/{{listing.imgs[0]}}" width="100%"/>
|
||||
{%- else %}<p>No image available.</p>{% endif %}
|
||||
</a>
|
||||
<p><b>${{listing.price.usd}}</b></p>
|
||||
<button class="add-to-cart" id="add-to-cart_{{listing.pid}}_{{listing.sid}}" onclick="addToCart('{{listing.pid}}','{{listing.sid}}','{{listing.price.usd}}','{% if listing.emoji %}{{listing.emoji}} {% endif %}{{listing.title|noApostrophes}}','{{listing.weight.lbs}}', '{{listing.dims.in.height*listing.dims.in.depth*listing.dims.in.width}}','/shop/{% if listing.slug %}{{listing.slug}}{% else %}{{listing.category}}/{% if listing.sid %}{{listing.sid}}/{% endif %}{{listing.title|slugify}}{% endif %}')"><b>🛒 Add to cart</b></button>
|
||||
<div class="qty-wrap" id="qty-select-wrap_{{listing.pid}}_{{listing.sid}}">
|
||||
<div style="display:flex">
|
||||
<button id="qty-minus_{{listing.pid}}_{{listing.sid}}" class="qty-adjust" onclick="qtyMinus('{{listing.pid}}','{{listing.sid}}')">-</button>
|
||||
<input class="qty" id="qty-select_{{listing.pid}}_{{listing.sid}}" type="number" name="qty_{{listing.pid}}/{{listing.sid}}" onchange="qtyChanged('{{listing.pid}}','{{listing.sid}}',{{listing.price.usd}},'{% if listing.emoji %}{{listing.emoji}} {% endif %}{{listing.title|noApostrophes}}','{{listing.weight.lbs}}', '{{listing.dims.in.height*listing.dims.in.depth*listing.dims.in.width}}','/shop/{% if listing.slug %}{{listing.slug}}{% else %}{{listing.category}}/{% if listing.sid %}{{listing.sid}}/{% endif %}{{listing.title|slugify}}{% endif %}')" min="0" max="{% if listing.sid %}1{% elif listing.inStock %}{{listing.inStock}}{% else %}99{% endif %}"/>
|
||||
<button id="qty-plus_{{listing.pid}}_{{listing.sid}}" class="qty-adjust" onclick="qtyPlus('{{listing.pid}}','{{listing.sid}}')">+</button>
|
||||
</div><span style="margin-left:1rem">in cart</span>
|
||||
</div>
|
||||
<script>productLoaded('{{listing.pid}}','{{listing.sid}}')</script>
|
||||
</div>{% endfor %}
|
||||
</section>{% endif %}
|
|
@ -1,27 +0,0 @@
|
|||
<link rel="stylesheet" href="/assets/shop.css">
|
||||
{% if productslist.length === 0 %}
|
||||
<p>Nothing in stock.</p>
|
||||
{% else %}<section class="productlist">
|
||||
<script src="/assets/scripts/cart-add.js" integrity="{{'/assets/scripts/cart-add.js'|srintegrity}}"></script>
|
||||
{% for product in productslist -%}
|
||||
<div class="productlist-item">
|
||||
<a href="{{product.url}}" style="text-decoration:none">
|
||||
<h4>{{product.data.title|noApostrophes}}</h4>
|
||||
{% if product.data.img -%}
|
||||
<img src="/shop/{{product.data.img}}" width="100%"/>
|
||||
{%- else -%}
|
||||
<p>No image available.</p>
|
||||
{%- endif %}
|
||||
</a>
|
||||
<p><b>${{product.data.price.usd}}</b></p>
|
||||
<button class="add-to-cart" id="add-to-cart_{{product.data.pid}}_{{product.data.sid}}" onclick="addToCart('{{product.data.pid}}','{{product.data.sid}}','{{product.data.price.usd}}','{% if product.data.emoji %}{{product.data.emoji}} {% endif %}{{product.data.title|noApostrophes}}','{{product.data.specs.weight.lbs}}', '{{product.data.specs.dimensions.in.height*product.data.specs.dimensions.in.depth*product.data.specs.dimensions.in.width}}','{{product.url}}')"><b>🛒 Add to cart</b></button>
|
||||
<div class="qty-wrap" id="qty-select-wrap_{{product.data.pid}}_{{product.data.sid}}">
|
||||
<div style="display:flex">
|
||||
<button id="qty-minus_{{product.data.pid}}_{{product.data.sid}}" class="qty-adjust" onclick="qtyMinus('{{product.data.pid}}','{{product.data.sid}}')">-</button>
|
||||
<input class="qty" id="qty-select_{{product.data.pid}}_{{product.data.sid}}" type="number" name="qty_{{product.data.pid}}/{{product.data.sid}}" onchange="qtyChanged('{{product.data.pid}}','{{product.data.sid}}',{{product.data.price.usd}},'{% if product.data.emoji %}{{product.data.emoji}} {% endif %}{{product.data.title|noApostrophes}}','{{product.data.specs.weight.lbs}}', '{{product.data.specs.dimensions.in.height*product.data.specs.dimensions.in.depth*product.data.specs.dimensions.in.width}}','{{product.url}}')" min="0" max="99"/>
|
||||
<button id="qty-plus_{{product.data.pid}}_{{product.data.sid}}" class="qty-adjust" onclick="qtyPlus('{{product.data.pid}}','{{product.data.sid}}')">+</button>
|
||||
</div><span style="margin-left:1rem">in cart</span>
|
||||
</div>
|
||||
<script>productLoaded('{{product.data.pid}}','{{product.data.sid}}')</script>
|
||||
</div>{% endfor %}
|
||||
</section>{% endif %}
|
|
@ -21,9 +21,12 @@ class CartItem { constructor(data) {
|
|||
self.qty = ko.observable(data.qty)
|
||||
self.maxQty = (self.sid)? 1:99
|
||||
|
||||
self.subtotal = ko.pureComputed(() => {
|
||||
return formatUSD(self.price * self.qty())
|
||||
})
|
||||
self.subtotal = ko.pureComputed(() =>
|
||||
self.price * self.qty()
|
||||
)
|
||||
self.subtotal_pretty = ko.pureComputed(() =>
|
||||
formatUSD(self.subtotal())
|
||||
)
|
||||
|
||||
// Subscribe to changes in qty
|
||||
self.qty.subscribe((qty) => {
|
||||
|
@ -287,7 +290,7 @@ class Cart { constructor() {
|
|||
console.error(err)
|
||||
} finally { self.isLoading(false) }
|
||||
localStorage.setItem('cartOrder', parsedRes.id)
|
||||
window.location = `/shop/order/?id=${parsedRes.id}&key=${parsedRes.key}`
|
||||
window.location = `/shop/order/?id=${parsedRes.id}`
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -59,7 +59,7 @@ const emptyCart = () => {
|
|||
|
||||
const cancel = async () => {
|
||||
try { await fetch(
|
||||
`${API_DOMAIN}/order/${qstr.id}?key=${qstr.key}`,
|
||||
`${API_DOMAIN}/order/${qstr.id}`,
|
||||
{ method:'DELETE' }
|
||||
) } catch (err) { console.error(err) }
|
||||
finally { window.location = '/shop/cart/' }
|
||||
|
@ -84,12 +84,14 @@ class MoneroTransaction { constructor(data) {
|
|||
self.locked = ko.observable(true)
|
||||
} }
|
||||
|
||||
class Checkout { constructor(data) {
|
||||
class Order { constructor(data) {
|
||||
let self = this
|
||||
self.orderId = ko.observable(qstr.id||qstr.order)
|
||||
self.orderKey = ko.observable(qstr.key)
|
||||
self.orderId = ko.observable(qstr.id)
|
||||
self.orderId_short = ko.pureComputed(() =>
|
||||
'ORD-' + self.orderId().slice(-8)
|
||||
)
|
||||
self.orderUrl = ko.pureComputed(() =>
|
||||
`${SITE_DOMAIN}/order/?id=${self.orderId()}&key=${self.orderKey().replace(/ /g,'+')}`
|
||||
`${SITE_DOMAIN}/shop/order/?id=${self.orderId()}`
|
||||
)
|
||||
self.items = ko.observableArray([])
|
||||
self.shipname = ko.observable('')
|
||||
|
@ -133,7 +135,7 @@ class Checkout { constructor(data) {
|
|||
( self.submitted() >= self.totalxmr() )
|
||||
)
|
||||
self.paidDate = ko.observable(new Date())
|
||||
self.paidDate_pretty = ko.pureComputed(() =>
|
||||
self.paidDate_pretty = ko.pureComputed( () =>
|
||||
self.paidDate().toDateString()
|
||||
)
|
||||
self.isPaidUSD = ko.observable(false)
|
||||
|
@ -144,6 +146,9 @@ class Checkout { constructor(data) {
|
|||
self.isPaid = ko.pureComputed(() =>
|
||||
(self.isPaidUSD() || self.isPaidXMR())
|
||||
)
|
||||
self.titleStatus = ko.pureComputed(() =>
|
||||
(self.isPaid())?'(paid)':'(unpaid)'
|
||||
)
|
||||
self.isOverpaid = ko.pureComputed( () =>
|
||||
( self.submitted() > self.totalxmr() )
|
||||
)
|
||||
|
@ -155,7 +160,7 @@ class Checkout { constructor(data) {
|
|||
)
|
||||
self.transactions = ko.observableArray([])
|
||||
self.xmr_uri = ko.pureComputed(() =>
|
||||
`monero:${self.xmr_address()}?tx_amount=${self.unsubmitted()}&tx_description=sales@slvit.us%20order%20${self.orderId()}`
|
||||
`monero:${self.xmr_address()}?tx_amount=${self.unsubmitted()}&tx_description=sales@slvit.us%20${self.orderId_short()}`
|
||||
)
|
||||
let xmr_qr = new QRCode(
|
||||
document.getElementById('xmr_qr'),
|
||||
|
@ -171,7 +176,6 @@ class Checkout { constructor(data) {
|
|||
`Place order for ${self.total_pretty()}`
|
||||
)
|
||||
self.checkingStatus = ko.observable('')
|
||||
self.titleStatus = ko.observable('')
|
||||
self.stripeText = ko.observable('')
|
||||
self.submitStripePayment= ko.observable(()=>{})
|
||||
self.shipDate = ko.observable(null)
|
||||
|
@ -201,36 +205,31 @@ class Checkout { constructor(data) {
|
|||
if (paymentIntent.last_payment_error !== null) {
|
||||
|
||||
if (paymentIntent.last_payment_error.type==='StripeCardError') {
|
||||
self.titleStatus(' (declined)')
|
||||
self.stripeText(`Your payment was declined. Our payment processor, Stripe, sent back this error: <code>${paymentIntent.last_payment_error.code||'generic_decline'}</code>. Here's what they think that means: </p>
|
||||
<blockquote>${DECLINE_CODES[paymentIntent.last_payment_error.code||'generic_decline']||DECLINE_CODES['generic_decline']}</blockquote>
|
||||
<p>What you do now is up to you. Maybe you want to <a href="/shop/checkout/stripe/">go back to the checkout page</a> and try again.`)
|
||||
}
|
||||
|
||||
else {
|
||||
self.titleStatus(' (failed)')
|
||||
self.stripeText(`The payment failed due to an ${paymentIntent.last_payment_error.type} error.`)
|
||||
}
|
||||
self.isPaidUSD(false)
|
||||
} else {
|
||||
|
||||
if (paymentIntent.status==='succeeded') {
|
||||
self.titleStatus(' (paid)')
|
||||
self.stripeText(`A ${self.total_pretty()} credit card payment for this order was processed ${(self.paidDate())?'on '+self.paidDate_pretty():''}. It will appear on your statement as "slvit.us ${self.orderId()}".`)
|
||||
self.stripeText(`A ${self.total_pretty()} credit card payment for this order was processed ${(self.paidDate()!=null)?'on '+self.paidDate_pretty():''}. It will appear on your statement as "slvit.us ${self.orderId_short()}".`)
|
||||
self.isPaidUSD(true)
|
||||
if (!self.paidDate()) self.paidDate(new Date())
|
||||
if (self.paidDate()==null) self.paidDate(new Date())
|
||||
if (localStorage.getItem('cartOrder')==self.orderId())
|
||||
emptyCart()
|
||||
}
|
||||
|
||||
else if (paymentIntent.status==='processing') {
|
||||
self.titleStatus(' (processing)')
|
||||
self.stripeText(`Your payment wasn't processed <i>yet</i>. Wait a few minutes and then try <a href="javascript:window.location.reload(true)">refreshing this page</a>. `)
|
||||
self.isPaidUSD(false)
|
||||
}
|
||||
|
||||
else {
|
||||
self.titleStatus(' (failed)')
|
||||
self.stripeText('The payment failed due to an unknown error.')
|
||||
self.isPaidUSD(false)
|
||||
}
|
||||
|
@ -244,7 +243,7 @@ class Checkout { constructor(data) {
|
|||
|
||||
// Fetch order from API
|
||||
let res; try {
|
||||
res = await fetch(`${API_DOMAIN}/order/${qstr.id}?key=${qstr.key}`, {
|
||||
res = await fetch(`${API_DOMAIN}/order/${qstr.id}`, {
|
||||
headers: {'Accept':'application/json'},
|
||||
})
|
||||
if (!res.ok) throw res.statusText
|
||||
|
@ -257,16 +256,17 @@ class Checkout { constructor(data) {
|
|||
}
|
||||
|
||||
// Populate view model with order data
|
||||
self.paidDate(new Date(order.paidDate))
|
||||
self.subtotal(formatUSD(order.subtotal))
|
||||
self.shipping(formatUSD(order.shipping.amount))
|
||||
self.processing(order.processing)
|
||||
if (order.payment.date!=null)
|
||||
self.paidDate(new Date(order.payment.date))
|
||||
self.subtotal(formatUSD(order.amount.subtotal))
|
||||
self.shipping(formatUSD(order.amount.shipping))
|
||||
self.processing(order.amount.processing)
|
||||
self.processing_pretty = ko.pureComputed(() =>
|
||||
self.processing()
|
||||
)
|
||||
self.tax(formatUSD(order.taxAmount))
|
||||
self.total(formatUSD(order.total))
|
||||
self.paymentMethod(order.paymentMethod)
|
||||
self.tax(formatUSD(order.amount.tax))
|
||||
self.total(formatUSD(order.amount.total))
|
||||
self.paymentMethod(order.payment.method)
|
||||
order.items.forEach((item) =>
|
||||
self.items.push({
|
||||
name: item.name,
|
||||
|
@ -276,8 +276,8 @@ class Checkout { constructor(data) {
|
|||
})
|
||||
)
|
||||
self.shipname(order.shipping.address.name)
|
||||
self.addr1(order.shipping.address.addr1)
|
||||
self.addr2(order.shipping.address.addr2)
|
||||
self.addr1(order.shipping.address.line1)
|
||||
self.addr2(order.shipping.address.line2)
|
||||
self.city(order.shipping.address.city)
|
||||
self.zip(order.shipping.address.zip)
|
||||
self.state(order.shipping.address.state)
|
||||
|
@ -290,19 +290,19 @@ class Checkout { constructor(data) {
|
|||
self.trackingNumbers(order.shipping.tracking)
|
||||
|
||||
// USD payment
|
||||
if (order.paymentMethod==='USD') {
|
||||
self.isPaidUSD((qstr.redirect_status=='succeeded'||order.paidDate!=null))
|
||||
if (order.payment.method==='USD') {
|
||||
self.isPaidUSD((qstr.redirect_status=='succeeded'||order.payment.date!=null))
|
||||
|
||||
// Try to get payment status if we didn't already
|
||||
if (self.isPaidUSD()) {
|
||||
if (qstr.payment_intent_client_secret==null)
|
||||
getStripeStatus(order.stripe_secret)
|
||||
getStripeStatus(order.payment.stripeSecret)
|
||||
}
|
||||
|
||||
// Create stripe payment form
|
||||
else {
|
||||
const elements = stripe.elements({
|
||||
clientSecret: order.stripe_secret,
|
||||
clientSecret: order.payment.stripeSecret,
|
||||
appearance: {
|
||||
theme: 'night',
|
||||
variables: {
|
||||
|
@ -323,7 +323,7 @@ class Checkout { constructor(data) {
|
|||
let payment_intent; try {
|
||||
payment_intent = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: { return_url: `${SUCCESS_URL}?id=${order.id}&key=${order.key}` },
|
||||
confirmParams: { return_url: `${SUCCESS_URL}?id=${order._id}` },
|
||||
})
|
||||
if (payment_intent.error) throw payment_intent.error
|
||||
} catch (err) { alert(err.message) }
|
||||
|
@ -334,9 +334,9 @@ class Checkout { constructor(data) {
|
|||
}
|
||||
|
||||
// Monero payment
|
||||
else if (order.paymentMethod==='XMR') {
|
||||
self.xmr_address(order.xmr_address)
|
||||
self.totalxmr(order.totalxmr)
|
||||
else if (order.payment.method==='XMR') {
|
||||
self.xmr_address(order.payment.xmrAddress)
|
||||
self.totalxmr(order.amount.totalxmr)
|
||||
|
||||
// Price checking
|
||||
const getXmrPrice = async () => {
|
||||
|
@ -360,9 +360,9 @@ class Checkout { constructor(data) {
|
|||
const getTransactions = async () => {
|
||||
isChecking = true
|
||||
let res; try {
|
||||
res = await fetch(`${API_DOMAIN}/xmr-receive/${order.xmr_address}`)
|
||||
res = await fetch(`${API_DOMAIN}/xmr-receive/${order.payment.xmrAddress}`)
|
||||
} catch (err) {
|
||||
return console.error(`Failed to get update about xmr address ${order.xmr_address}\n${err}`)
|
||||
return console.error(`Failed to get update about xmr address ${order.payment.xmrAddress}\n${err}`)
|
||||
} if (!res.ok)
|
||||
return console.error(`Got a bad response when requesting update on this xmr address: ${res.status}\n${res}`)
|
||||
let resData; try {
|
||||
|
@ -430,9 +430,9 @@ class Checkout { constructor(data) {
|
|||
}
|
||||
|
||||
else alert(
|
||||
`Invalid payment method in order: ${order.paymentMethod}`
|
||||
`Invalid payment method in order: ${order.payment.method}`
|
||||
)
|
||||
|
||||
} )()
|
||||
|
||||
} }; ko.applyBindings(new Checkout())
|
||||
} }; ko.applyBindings(new Order())
|
||||
|
|
|
@ -3,8 +3,10 @@ layout: layouts/base.njk
|
|||
eleventyNavigation:
|
||||
key: About
|
||||
order: 1
|
||||
eleventyImport:
|
||||
collections: ['featured']
|
||||
pagination:
|
||||
data: listings
|
||||
size: 3
|
||||
alias: listings
|
||||
---
|
||||
|
||||
**{{metadata.title}}** is a web development, digital marketing, and information technologies company. As a managed service provider (MSP), SLVIT offers [websites](/services/websites/), [hosting](/services/hosting), [email](/services/hosting/email/), and [tech support](/services/support/). We also sell new and used [computers](/shop/computers/), [phones](/shop/phones/), [routers](/shop/routers/), and [accessories](/shop/accessories). We're based in Antonito, CO and operate all over southern Colorado and northern New Mexico.
|
||||
|
@ -15,8 +17,7 @@ eleventyImport:
|
|||
|
||||
## 💻 Featured products
|
||||
|
||||
{% set productslist = collections.featured | head(-3) -%}
|
||||
{% include 'productslist.njk' %}
|
||||
{% include 'listings.njk' %}
|
||||
|
||||
> [**See all products >**](/shop/)
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
tags: ['product', 'computer', 'all-in-one']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
year: 2013
|
||||
make: Dell
|
||||
model: Inspiron One 2330
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
sid: d8578682
|
||||
tags: ['product', 'computer', 'desktop']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
specs: {
|
||||
year: 2016,
|
||||
make: Dell,
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 2.9 MiB |
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 3.5 MiB |
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 3.5 MiB |
Before Width: | Height: | Size: 7.2 MiB After Width: | Height: | Size: 7.2 MiB |
Before Width: | Height: | Size: 805 KiB After Width: | Height: | Size: 805 KiB |
Before Width: | Height: | Size: 6.6 MiB After Width: | Height: | Size: 6.6 MiB |
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
sid: d9079897
|
||||
tags: ['product', 'computer', 'desktop']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
specs: {
|
||||
year: 2016,
|
||||
make: Dell,
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.0 MiB |
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 3.3 MiB |
Before Width: | Height: | Size: 6.5 MiB After Width: | Height: | Size: 6.5 MiB |
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
Before Width: | Height: | Size: 476 KiB After Width: | Height: | Size: 476 KiB |
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 4.3 MiB |
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.5 MiB |
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
sid: d2646191
|
||||
tags: ['product', 'computer', 'desktop']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
specs: {
|
||||
year: 2010,
|
||||
make: HP,
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 3.8 MiB |
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.8 MiB |
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 3.7 MiB |
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 5.0 MiB |
Before Width: | Height: | Size: 735 KiB After Width: | Height: | Size: 735 KiB |
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
sid: d0664022
|
||||
tags: ['product', 'computer', 'desktop']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
specs: {
|
||||
year: 2016,
|
||||
make: HP,
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.4 MiB |
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 2.9 MiB |
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 5.0 MiB |
Before Width: | Height: | Size: 614 KiB After Width: | Height: | Size: 614 KiB |
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
sid: d1674407
|
||||
tags: ['product', 'computer', 'desktop']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
specs: {
|
||||
year: 2016,
|
||||
make: HP,
|
Before Width: | Height: | Size: 4.7 MiB After Width: | Height: | Size: 4.7 MiB |
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 4.6 MiB |
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.8 MiB |
Before Width: | Height: | Size: 5.6 MiB After Width: | Height: | Size: 5.6 MiB |
Before Width: | Height: | Size: 658 KiB After Width: | Height: | Size: 658 KiB |
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
sid: d4531351
|
||||
tags: ['product', 'computer', 'desktop', 'featured']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
specs: {
|
||||
year: 2016,
|
||||
make: HP,
|
Before Width: | Height: | Size: 5.8 MiB After Width: | Height: | Size: 5.8 MiB |
Before Width: | Height: | Size: 6.7 MiB After Width: | Height: | Size: 6.7 MiB |
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
Before Width: | Height: | Size: 5.3 MiB After Width: | Height: | Size: 5.3 MiB |
Before Width: | Height: | Size: 6.5 MiB After Width: | Height: | Size: 6.5 MiB |
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
Before Width: | Height: | Size: 647 KiB After Width: | Height: | Size: 647 KiB |
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
sid: l7278286
|
||||
tags: ['product', 'computer', 'laptop', 'featured']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
title: 2015 Dell Latitude E7240 (Linux Fedora 37) Laptop
|
||||
emoji: 💻
|
||||
specs: {
|
Before Width: | Height: | Size: 835 KiB After Width: | Height: | Size: 835 KiB |
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.4 MiB |
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 3.5 MiB |
Before Width: | Height: | Size: 737 KiB After Width: | Height: | Size: 737 KiB |
Before Width: | Height: | Size: 632 KiB After Width: | Height: | Size: 632 KiB |
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 4.6 MiB |
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
Before Width: | Height: | Size: 1018 KiB After Width: | Height: | Size: 1018 KiB |
Before Width: | Height: | Size: 990 KiB After Width: | Height: | Size: 990 KiB |
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
sid: l8575077
|
||||
tags: ['product', 'computer', 'laptop']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
title: 2018 Lenovo IdeaPad 330-17 (Linux Mint Cinnamon) Laptop
|
||||
emoji: 💻
|
||||
specs: {
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 4.5 MiB |
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 3.5 MiB |
Before Width: | Height: | Size: 532 KiB After Width: | Height: | Size: 532 KiB |
Before Width: | Height: | Size: 455 KiB After Width: | Height: | Size: 455 KiB |
Before Width: | Height: | Size: 8.3 MiB After Width: | Height: | Size: 8.3 MiB |
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
Before Width: | Height: | Size: 523 KiB After Width: | Height: | Size: 523 KiB |
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 4.3 MiB |
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 250 KiB |
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
pid: fc7422
|
||||
tags: ['product', 'phone', 'featured']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
emoji: 📱
|
||||
title: Privacy Pixel 6a Phone with CalyxOS
|
||||
specs: {
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
pid: fc2144
|
||||
tags: ['product', 'phone']
|
||||
layout: layouts/product.njk
|
||||
layout: layouts/listing.njk
|
||||
title: Privacy Pixel 6a Phone with GrapheneOS
|
||||
emoji: 📱
|
||||
specs: {
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 244 KiB |
|
@ -28,7 +28,7 @@ title: Cart
|
|||
<input class="qty" type="number" data-bind="value:qty,attr:{max:maxQty}" min="0">
|
||||
<button class="qty-adjust" data-bind="click:$parent.itemPlus,enable:(qty()<maxQty)">+</button>
|
||||
</div>
|
||||
<div class="tar" data-bind="text:subtotal()"></div>
|
||||
<div class="tar" data-bind="text:subtotal_pretty()"></div>
|
||||
</div></td>
|
||||
</tr></tbody></table><hr>
|
||||
|
||||
|
@ -68,7 +68,7 @@ title: Cart
|
|||
</tr><tr data-bind="visible:payment_method()==='XMR'">
|
||||
<td colspan="2" style="text-align:right"><b data-bind="text:totalxmr_pretty"></b></td>
|
||||
</tr><tr data-bind="visible:payment_method()==='XMR'"><td colspan="2">
|
||||
<i>You will pay a ~$0.01 transaction fee in your wallet app.</i>
|
||||
<i>(You will pay a ½¢ transaction fee in your wallet app)</i>
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
</div><hr>
|
||||
|
|
|
@ -4,13 +4,14 @@ title: Shop
|
|||
eleventyNavigation:
|
||||
key: Shop
|
||||
order: 2
|
||||
eleventyImport:
|
||||
collections: ['products']
|
||||
pagination:
|
||||
data: listings
|
||||
size: 20
|
||||
alias: listings
|
||||
---
|
||||
|
||||
# 🛍️ {{title}}
|
||||
|
||||
These are the gadgets we have in stock right now. When you've picked out what you want, visit your [cart](/shop/cart/) to check out.
|
||||
|
||||
{% set productslist = collections.products -%}
|
||||
{% include 'productslist.njk' %}
|
||||
{% include 'listings.njk' %}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
layout: layouts/listing.njk
|
||||
pagination:
|
||||
data: listings
|
||||
size: 1
|
||||
alias: listing
|
||||
permalink: "shop/{% if listing.slug %}{{listing.slug}}/{% else %}{{listing.category}}/{{listing.title|slugify}}/{% if listing.sid %}{{listing.sid}}/{% endif %}{% endif %}"
|
||||
---
|
||||
|
||||
# {{listing.title}}
|
||||
|
||||
{% set content %}{{listing.content}}{% endset%}
|
||||
{% md content %}
|