Compare commits

...

11 Commits

@ -50,6 +50,10 @@ router.post('/', ensureAdmin, parseJSON, itemValidators, async (req, res) => {
router.get('/by-slug/:slug', async (req, res) => {
const item = await db.item.findBySlug(req.params.slug);
if(!item.published)
return res.json(null)
res.json(item)
})

@ -9,6 +9,22 @@
transition: all 0.3s cubic-bezier(.25,.8,.25,1);
}
:global(.cardContainer) .card {
width: calc(100% / 3 - 10%);
}
@media (max-width: 1000px) {
:global(.cardContainer) .card {
width: calc(100% / 2 - 10%);
}
}
@media (max-width: 600px) {
:global(.cardContainer) .card{
width: calc(100% / 1 - 10%);
}
}
.card:last-child {
/* Fixes the odd flickering bug */
margin-bottom:0px;

@ -1,19 +1,36 @@
import { useState, useEffect } from 'react';
import ReactDOM from 'react-dom'
import {Icon} from '@rmwc/icon'
import FocusTrap from 'focus-trap-react'
import styles from './modal.module.css'
const PORTAL_ID = '__sos_modal';
export default function Modal({visible, title, children, onDeactivate}){
if(typeof window === 'undefined')
const [portalExists, setPortalExists] = useState(false);
useEffect(() => {
const portalExists = !!document.querySelector(`#${PORTAL_ID}`);
if (!portalExists) {
const portal = document.createElement('div');
portal.id = PORTAL_ID;
document.body.appendChild(portal);
}
setPortalExists(true);
}, []);
if(!portalExists)
return null
return ReactDOM.createPortal((
<div className={styles.modalContainer + (visible?' ' + styles.modalShown:'')}>
<div className={styles.modalBackground}/>
<div className={styles.modalBackground} onClick={onDeactivate}/>
{visible && (
<FocusTrap active={visible} focusTrapOptions={{onDeactivate}}>
<div className={styles.modalScroll} onClick={onDeactivate}>
<div className={styles.modalScroll}>
<dialog open className={styles.modal}>
<div className={styles.titleContainer}>
<h2>{title}</h2>
@ -28,5 +45,5 @@ export default function Modal({visible, title, children, onDeactivate}){
</FocusTrap>
)}
</div>
), window.document.body)
), document.querySelector(`#${PORTAL_ID}`))
}

@ -31,10 +31,13 @@
.modalScroll {
overflow-y: auto;
cursor: pointer;
pointer-events: none;
z-index: 11;
}
.modal {
cursor: auto;
pointer-events: all;
border: none;
margin: 100px auto;
max-width: 600px;

@ -84,6 +84,19 @@ function formatTime(time){
}
function ShippingStatus({order, totalShipping, delivery, isAdmin}) {
const preorderDates = order.transactions.filter(t => t.has_preorder && !t.preorder_ready_to_ship).map(t => DateTime.fromISO(t.preorder_fulfill_date))
preorderDates.sort((b, a) => a < b ? -1 : a > b ? 1 : 0)
const latestPreorder = preorderDates[0]?.toLocaleString(DateTime.DATE_FULL)
if(!delivery && latestPreorder)
return (
<>
{isAdmin && (<p>{formatMoney(totalShipping)} charged to customer</p>)}
<p>Preorder - awaiting stock.</p>
<p>Expected to be in stock {latestPreorder}</p>
</>
)
if(!delivery)
return (
<>

@ -74,7 +74,8 @@ create or replace view sos.v_category as
) child_item_counts on "child_category".category_uuid = "child_item_counts".category_uuid
left join sos."category" "parent_category" on "parent_category_link".category_category_parent_uuid = "parent_category".category_uuid
left join sos."category_item" on "category".category_uuid = "category_item".category_item_category_uuid
left join sos.v_item on "category_item".category_item_item_uuid = item_uuid;
left join sos.v_item on "category_item".category_item_item_uuid = item_uuid
where item_published = true;
create or replace view sos.v_cart_price as
select cart_uuid,

@ -1131,11 +1131,87 @@ begin
'shipment',
_shipment_uuid
);
perform sos.mark_preorders_shippable(_item_uuids[i], _counts[i]);
end loop;
return query select * from sos.v_shipment where shipment_uuid = _shipment_uuid;
end; $function$;
create or replace function sos.mark_preorders_shippable(_item_uuid uuid, _count integer)
returns void
language plpgsql
as $function$
declare
_preorder_uuids uuid[];
_num_marked integer := 0;
_current_num integer;
_current_direction sos.stock_change_dir_enum;
_remaining_preorders integer;
begin
-- Get unfulfilled pre-order stockchanges for this item, ordered by transaction completion time
select array_agg(item_stockchange.stockchange_uuid order by transaction_completion_time) into _preorder_uuids
from sos.item_stockchange
left join sos.item_stockchange_preorder on item_stockchange.stockchange_uuid = item_stockchange_preorder.stockchange_uuid
left join sos.transaction on stockchange_transaction_uuid = transaction_uuid
where stockchange_item_uuid = _item_uuid
and item_stockchange.stockchange_type = 'preorder'
and stockchange_preorder_ready_to_ship = false
group by stockchange_item_uuid;
-- For all stockchanges
if _preorder_uuids is not null then
for i in array_lower(_preorder_uuids, 1) .. array_upper(_preorder_uuids, 1) loop
select
stockchange_direction,
stockchange_change
into
_current_direction,
_current_num
from sos.item_stockchange
where stockchange_uuid = _preorder_uuids[i];
-- Sanity check: Only fulfill ones that deducted stock
if _current_direction != 'subtracted' then
continue;
end if;
-- Don't fulfill more than we have
if _num_marked + _current_num > _count then
exit;
end if;
update sos.item_stockchange_preorder set
stockchange_preorder_ready_to_ship = true
where stockchange_uuid = _preorder_uuids[i];
_num_marked := _num_marked + _current_num;
end loop;
end if;
-- Count remaining pre-orders
select count(item_stockchange.stockchange_uuid) into _remaining_preorders
from sos.item_stockchange
left join sos.item_stockchange_preorder on item_stockchange.stockchange_uuid = item_stockchange_preorder.stockchange_uuid
where stockchange_item_uuid = _item_uuid
and item_stockchange.stockchange_type = 'preorder'
and stockchange_preorder_ready_to_ship = false
group by stockchange_item_uuid;
-- If we no longer have pre-orders clear item pre-order records
if _remaining_preorders < 1 then
update sos.item
set (
item_preorder_availability_date,
item_preorder_maximum
) = (
null,
null
)
where item_uuid = _item_uuid;
end if;
end; $function$;
create or replace function sos.set_delivery_tracking(_order_uuid uuid, _tracking_number text, _date_shipped timestamptz, _price_cents integer)
returns setof sos.v_order
language plpgsql

@ -0,0 +1,11 @@
import { useRef, useEffect } from "react";
export default function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}

8960
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -61,6 +61,7 @@ export default function AccountPage({orders}) {
{name: 'Item Price', extractor: getItemPrice},
{name: 'Shipping', extractor: getShippingEstimate},
{name: 'Total', extractor: getAmountPaid},
{name: 'Status', extractor: getStatus},
{name: '', extractor: order =>
<button className="buttonLink" onClick={() => Router.push(`/account/orders/${order.number}`)}>Details</button>
}
@ -104,6 +105,21 @@ function getAmountPaid(order){
.reduce((a,b)=>(a+b)))
}
function getStatus(order) {
if (order.delivery && order.delivery.type == "hand_delivered")
return 'Delivered in person'
if (order.delivery)
return `Shipped ${DateTime.fromISO(order.delivery.date_shipped).toLocaleString(DateTime.DATE_SHORT)}`
const hasPreorder = order.transactions.some(t => t.has_preorder && !t.preorder_ready_to_ship)
if (!hasPreorder)
return 'Awaiting Shipment'
return 'Preorder'
}
function parsePaymentTime({payments}){
for(const payment of payments) {
if(typeof payment.time === 'string')

@ -10,7 +10,8 @@ Orders.getInitialProps = async ({ctx}) => {
}
export default function Orders({orders}){
const unshippedOrders = orders.filter(order => !order.delivery)
const unshippedPreOrders = orders.filter(order => !order.delivery && order.transactions.some(t => t.has_preorder && !t.preorder_ready_to_ship))
const unshippedAndShippableOrders = orders.filter(order => !order.delivery && !order.transactions.some(t => t.has_preorder && !t.preorder_ready_to_ship))
const shippedOrders = orders.filter(order => order.delivery).reverse()
return (
@ -18,7 +19,7 @@ export default function Orders({orders}){
<ActionBar title="Orders" actions={[
{label: 'Add Manual Order', url: `/admin/orders/new`}
]}/>
<h4>Unsent:</h4>
<h4>Unsent (sendable):</h4>
<Table
columns={[
{name: 'Purchased', extractor: getPurchaseTime},
@ -30,7 +31,23 @@ export default function Orders({orders}){
<button className="buttonLink" onClick={() => Router.push(`/admin/orders/${row.id}`)}>Details</button>
}
]}
rows={unshippedOrders.map(order => ({id: order.uuid, ...order}))}
rows={unshippedAndShippableOrders.map(order => ({id: order.uuid, ...order}))}
/>
<h4>Unsent (pre-orders):</h4>
<Table
columns={[
{name: 'Purchased', extractor: getPurchaseTime},
{name: 'Items', extractor: getNumberItems},
{name: 'Price', extractor: getItemPrice},
{name: 'Shipping', extractor: getShippingEstimate},
{name: 'Amount Paid', extractor: getAmountPaid},
{name: 'Estimated availability', extractor: getPreorderEstimate},
{name: '', extractor: row =>
<button className="buttonLink" onClick={() => Router.push(`/admin/orders/${row.id}`)}>Details</button>
}
]}
rows={unshippedPreOrders.map(order => ({id: order.uuid, ...order}))}
/>
<h4>Sent:</h4>
@ -88,6 +105,12 @@ function getAmountPaid(order){
.reduce((a,b)=>(a+b), 0))
}
function getPreorderEstimate(order) {
const preorderDates = order.transactions.filter(t => t.has_preorder).map(t => DateTime.fromISO(t.preorder_fulfill_date))
preorderDates.sort((b, a) => a < b ? -1 : a > b ? 1 : 0)
return preorderDates[0]?.toLocaleString(DateTime.DATE_FULL)
}
function parsePaymentTime({payments}){
for(const payment of payments) {
if(typeof payment.time === 'string')

@ -9,7 +9,7 @@ import axios from 'axios'
import router from 'next/router'
NewShipment.getInitialProps = async ({ctx: {axios}}) => {
const {data: allItems} = await axios.get('/api/items')
const {data: allItems} = await axios.get('/api/items?showUnpublished=true')
return {allItems}
}

@ -1,11 +1,38 @@
import React from 'react'
import Head from 'next/head'
import {DateTime} from 'luxon'
import Hero from '~/components/hero'
import Card from '~/components/card'
Index.getInitialProps = async ({ctx})=>{
const {data: items} = await ctx.axios.get('/api/items')
items.sort((a,b) => {
// One or both has a preorder - favor item with earliest preorder availability
if(a.preorder_availability_date && !b.preorder_availability_date)
return -1;
if(b.preorder_availability_date && !a.preorder_availability_date)
return 1;
if(a.preorder_availability_date && b.preorder_availability_date) {
const aDate = DateTime.fromISO(a.preorder_availability_date)
const bDate = DateTime.fromISO(b.preorder_availability_date)
if (aDate > bDate)
return 1
if (bDate > aDate)
return -1
return 0
}
// One or both is in stock - favor item with the most stock
return b.number_in_stock - a.number_in_stock;
})
console.log(items)
return {items}
}

@ -16,9 +16,9 @@ export default function CheckoutComplete({order}){
const items = order.transactions.map(transaction => transaction.cart.items).flat()
const latestTransaction = order.transactions.sort(sortTransactions)[0]
const preorderItems = items.filter(({item}) => item.number_in_stock < 0 && item.preorder_availability_date).map(({item}) => DateTime.fromISO(item.preorder_availability_date)) || []
preorderItems.sort((b, a) => a < b ? -1 : a > b ? 1 : 0)
const latestPreorder = preorderItems[0]?.toLocaleString(DateTime.DATE_FULL)
const preorderDates = order.transactions.filter(t => t.has_preorder).map(t => DateTime.fromISO(t.preorder_fulfill_date))
preorderDates.sort((b, a) => a < b ? -1 : a > b ? 1 : 0)
const latestPreorder = preorderDates[0]?.toLocaleString(DateTime.DATE_FULL)
let shippingEstimate = 'Orders typically are shipped within 3-5 business days of purchase.'

@ -146,7 +146,8 @@ export default function CheckoutSummary({order: _order, addresses}){
{addresses.map(address => (
<div className={styles.addressCard}>
<span>
{address.name || address.company} / {address.street1}<br/>
{address.name || address.company}<br/>
{address.street1}<br/>
{address.street2 && <> {address.street2} <br/> </>}
{address.city} / {address.state} / {address.zip}
</span>

@ -1,12 +1,16 @@
import React, {useState} from 'react'
import React, {useState, useEffect} from 'react'
import Head from 'next/head'
import Router from 'next/router'
import {FormController, IntegerInput, Button} from '~/components/form'
import isNum from 'validator/lib/isNumeric'
import {DateTime} from 'luxon'
import {Icon} from '@rmwc/icon'
import Modal from '~/components/modal'
import useCart, {useSetCart} from '~/hooks/useCart'
import styles from './style.module.css'
import usePrevious from '~/hooks/usePrevious'
Item.getInitialProps = async function({ctx: {axios, query: {slug}}}){
const {data: item} = await axios.get(`/api/items/by-slug/${slug}`)
@ -24,13 +28,22 @@ Item.getInitialProps = async function({ctx: {axios, query: {slug}}}){
return {item}
}
// TODO: Modal with full image size on clicking preview
export default function Item({item}){
const cart = useCart()
const setCart = useSetCart()
// Pick first one with featured flag or 0
const featuredIndex = item.images.reduce((p, im, i) => ((p !== undefined) ? p : (im.featured ? i : undefined)), undefined) || 0
const [selectedIndex, setSelected] = useState(featuredIndex);
const lastImage = usePrevious(selectedIndex);
const [imageModalVisible, setImageModalVisible] = useState(false);
const [imageChanged, setImageChanged] = useState(false);
useEffect(() => {
if (selectedIndex !== lastImage)
setImageChanged(true);
else
setImageChanged(false);
}, [selectedIndex, lastImage]);
const numInCart = cart?.items?.find(i => i.item.uuid === item.uuid)?.count || 0
@ -57,15 +70,15 @@ export default function Item({item}){
<div className={styles.imageContainer}>
<div className={styles.card}>
<picture>
<source srcset={`/api/images/${item.images[selectedIndex].uuid}/thumb.webp`} type="image/webp"/>
<img src={`/api/images/${item.images[selectedIndex].uuid}/thumb.png`} />
<source srcSet={`/api/images/${item.images[selectedIndex].uuid}/thumb.webp`} type="image/webp"/>
<img src={`/api/images/${item.images[selectedIndex].uuid}/thumb.png`} onClick={() => setImageModalVisible(true)} />
</picture>
</div>
{item.images && item.images.length > 1 &&
<div className={styles.imageSelector}>
{item.images.map((image, index) => (
<picture>
<source srcset={`/api/images/${image.uuid}/thumb.webp`} type="image/webp"/>
<picture key={image.uuid}>
<source srcSet={`/api/images/${image.uuid}/thumb.webp`} type="image/webp"/>
<img key={image.uuid}
onClick={()=>setSelected(index)}
className={index === selectedIndex ? styles.selectedImage : undefined}
@ -75,6 +88,24 @@ export default function Item({item}){
))}
</div>
}
<Modal title={`${item.name} (Image ${selectedIndex + 1})`} visible={imageModalVisible} onDeactivate={() => setImageModalVisible(false)}>
<div className={styles.imageCarousel}>
<button onClick={() => setSelected(index => (index - 1 < 0) ? item.images.length - 1 : index - 1)}>
<Icon icon="chevron_left" />
</button>
<div className={styles.fullImage}>
{!imageChanged && (
<picture>
<source srcSet={`/api/images/${item.images[selectedIndex].uuid}/large.webp`} type="image/webp"/>
<img src={`/api/images/${item.images[selectedIndex].uuid}/large.png`} onClick={() => setImageModalVisible(true)} />
</picture>
)}
</div>
<button onClick={() => setSelected(index => (index + 1) % item.images.length)}>
<Icon icon="chevron_right" />
</button>
</div>
</Modal>
</div>
)}
<div className={styles.controls}>

@ -100,4 +100,29 @@
.form > div {
margin-left: 0;
width: 100%;
}
.imageCarousel {
margin-top: 8px;
display: flex;
align-items: stretch;
}
.imageCarousel button {
background: none;
border: none;
cursor: pointer;
}
.imageCarousel button:hover {
background: rgba(0,0,0,0.12);
}
.fullImage {
max-width: 100%;
}
.fullImage img {
width: 100%;
height: auto;
}

@ -69,7 +69,9 @@ main p.warning button.buttonLink {
padding-right: 50px;
max-width:2000px;
column-count: 3;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
button.buttonLink {
@ -97,16 +99,9 @@ button.buttonLink:focus {
}
}
@media (max-width: 1000px) {
.cardContainer {
column-count: 2;
}
}
@media (max-width: 600px) {
.cardContainer {
margin-top: 20px;
column-count: 1;
}
}

Loading…
Cancel
Save