Compare commits

...

11 Commits

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

@ -9,6 +9,22 @@
transition: all 0.3s cubic-bezier(.25,.8,.25,1); 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 { .card:last-child {
/* Fixes the odd flickering bug */ /* Fixes the odd flickering bug */
margin-bottom:0px; margin-bottom:0px;

@ -1,19 +1,36 @@
import { useState, useEffect } from 'react';
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import {Icon} from '@rmwc/icon' import {Icon} from '@rmwc/icon'
import FocusTrap from 'focus-trap-react' import FocusTrap from 'focus-trap-react'
import styles from './modal.module.css' import styles from './modal.module.css'
const PORTAL_ID = '__sos_modal';
export default function Modal({visible, title, children, onDeactivate}){ 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 null
return ReactDOM.createPortal(( return ReactDOM.createPortal((
<div className={styles.modalContainer + (visible?' ' + styles.modalShown:'')}> <div className={styles.modalContainer + (visible?' ' + styles.modalShown:'')}>
<div className={styles.modalBackground}/> <div className={styles.modalBackground} onClick={onDeactivate}/>
{visible && ( {visible && (
<FocusTrap active={visible} focusTrapOptions={{onDeactivate}}> <FocusTrap active={visible} focusTrapOptions={{onDeactivate}}>
<div className={styles.modalScroll} onClick={onDeactivate}> <div className={styles.modalScroll}>
<dialog open className={styles.modal}> <dialog open className={styles.modal}>
<div className={styles.titleContainer}> <div className={styles.titleContainer}>
<h2>{title}</h2> <h2>{title}</h2>
@ -28,5 +45,5 @@ export default function Modal({visible, title, children, onDeactivate}){
</FocusTrap> </FocusTrap>
)} )}
</div> </div>
), window.document.body) ), document.querySelector(`#${PORTAL_ID}`))
} }

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

@ -84,6 +84,19 @@ function formatTime(time){
} }
function ShippingStatus({order, totalShipping, delivery, isAdmin}) { 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) if(!delivery)
return ( 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 ) 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" "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."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 create or replace view sos.v_cart_price as
select cart_uuid, select cart_uuid,

@ -1131,11 +1131,87 @@ begin
'shipment', 'shipment',
_shipment_uuid _shipment_uuid
); );
perform sos.mark_preorders_shippable(_item_uuids[i], _counts[i]);
end loop; end loop;
return query select * from sos.v_shipment where shipment_uuid = _shipment_uuid; return query select * from sos.v_shipment where shipment_uuid = _shipment_uuid;
end; $function$; 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) 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 returns setof sos.v_order
language plpgsql 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: 'Item Price', extractor: getItemPrice},
{name: 'Shipping', extractor: getShippingEstimate}, {name: 'Shipping', extractor: getShippingEstimate},
{name: 'Total', extractor: getAmountPaid}, {name: 'Total', extractor: getAmountPaid},
{name: 'Status', extractor: getStatus},
{name: '', extractor: order => {name: '', extractor: order =>
<button className="buttonLink" onClick={() => Router.push(`/account/orders/${order.number}`)}>Details</button> <button className="buttonLink" onClick={() => Router.push(`/account/orders/${order.number}`)}>Details</button>
} }
@ -104,6 +105,21 @@ function getAmountPaid(order){
.reduce((a,b)=>(a+b))) .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}){ function parsePaymentTime({payments}){
for(const payment of payments) { for(const payment of payments) {
if(typeof payment.time === 'string') if(typeof payment.time === 'string')

@ -10,7 +10,8 @@ Orders.getInitialProps = async ({ctx}) => {
} }
export default function Orders({orders}){ 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() const shippedOrders = orders.filter(order => order.delivery).reverse()
return ( return (
@ -18,7 +19,7 @@ export default function Orders({orders}){
<ActionBar title="Orders" actions={[ <ActionBar title="Orders" actions={[
{label: 'Add Manual Order', url: `/admin/orders/new`} {label: 'Add Manual Order', url: `/admin/orders/new`}
]}/> ]}/>
<h4>Unsent:</h4> <h4>Unsent (sendable):</h4>
<Table <Table
columns={[ columns={[
{name: 'Purchased', extractor: getPurchaseTime}, {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> <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> <h4>Sent:</h4>
@ -88,6 +105,12 @@ function getAmountPaid(order){
.reduce((a,b)=>(a+b), 0)) .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}){ function parsePaymentTime({payments}){
for(const payment of payments) { for(const payment of payments) {
if(typeof payment.time === 'string') if(typeof payment.time === 'string')

@ -9,7 +9,7 @@ import axios from 'axios'
import router from 'next/router' import router from 'next/router'
NewShipment.getInitialProps = async ({ctx: {axios}}) => { 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} return {allItems}
} }

@ -1,11 +1,38 @@
import React from 'react' import React from 'react'
import Head from 'next/head' import Head from 'next/head'
import {DateTime} from 'luxon'
import Hero from '~/components/hero' import Hero from '~/components/hero'
import Card from '~/components/card' import Card from '~/components/card'
Index.getInitialProps = async ({ctx})=>{ Index.getInitialProps = async ({ctx})=>{
const {data: items} = await ctx.axios.get('/api/items') 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} return {items}
} }

@ -16,9 +16,9 @@ export default function CheckoutComplete({order}){
const items = order.transactions.map(transaction => transaction.cart.items).flat() const items = order.transactions.map(transaction => transaction.cart.items).flat()
const latestTransaction = order.transactions.sort(sortTransactions)[0] 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)) || [] const preorderDates = order.transactions.filter(t => t.has_preorder).map(t => DateTime.fromISO(t.preorder_fulfill_date))
preorderItems.sort((b, a) => a < b ? -1 : a > b ? 1 : 0) preorderDates.sort((b, a) => a < b ? -1 : a > b ? 1 : 0)
const latestPreorder = preorderItems[0]?.toLocaleString(DateTime.DATE_FULL) const latestPreorder = preorderDates[0]?.toLocaleString(DateTime.DATE_FULL)
let shippingEstimate = 'Orders typically are shipped within 3-5 business days of purchase.' 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 => ( {addresses.map(address => (
<div className={styles.addressCard}> <div className={styles.addressCard}>
<span> <span>
{address.name || address.company} / {address.street1}<br/> {address.name || address.company}<br/>
{address.street1}<br/>
{address.street2 && <> {address.street2} <br/> </>} {address.street2 && <> {address.street2} <br/> </>}
{address.city} / {address.state} / {address.zip} {address.city} / {address.state} / {address.zip}
</span> </span>

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

@ -101,3 +101,28 @@
margin-left: 0; margin-left: 0;
width: 100%; 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; padding-right: 50px;
max-width:2000px; max-width:2000px;
column-count: 3; display: flex;
flex-direction: row;
flex-wrap: wrap;
} }
button.buttonLink { button.buttonLink {
@ -97,16 +99,9 @@ button.buttonLink:focus {
} }
} }
@media (max-width: 1000px) {
.cardContainer {
column-count: 2;
}
}
@media (max-width: 600px) { @media (max-width: 600px) {
.cardContainer { .cardContainer {
margin-top: 20px; margin-top: 20px;
column-count: 1;
} }
} }

Loading…
Cancel
Save