Merge pull request #5 from KevinMidboe/feat/checkout-order-feedback

Feat: Checkout, order & feedback pages (and a lot more)
This commit is contained in:
2022-12-30 00:39:07 +01:00
committed by GitHub
88 changed files with 2546 additions and 917 deletions

View File

@@ -12,26 +12,26 @@
"format": "prettier --plugin-search-dir . --write src" "format": "prettier --plugin-search-dir . --write src"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "next", "@sveltejs/adapter-static": "1.0.0",
"@sveltejs/kit": "next", "@sveltejs/kit": "1.0.1",
"@types/cookie": "^0.5.1", "@types/cookie": "0.5.1",
"@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/eslint-plugin": "5.27.0",
"@typescript-eslint/parser": "^5.27.0", "@typescript-eslint/parser": "5.27.0",
"eslint": "^8.16.0", "eslint": "8.16.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "8.3.0",
"eslint-plugin-svelte3": "^4.0.0", "eslint-plugin-svelte3": "4.0.0",
"prettier": "^2.8.0", "prettier": "2.8.0",
"prettier-plugin-svelte": "^2.8.1", "prettier-plugin-svelte": "2.8.1",
"svelte": "^3.46.0", "sass": "1.57.0",
"svelte-check": "^2.7.1", "svelte": "3.55.0",
"svelte-preprocess": "^4.10.6", "svelte-check": "3.0.1",
"tslib": "^2.3.1", "svelte-preprocess": "5.0.0",
"typescript": "^4.7.4", "tslib": "2.4.1",
"vite": "^3.1.0" "typescript": "4.9.4",
"vite": "4.0.3"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@stripe/stripe-js": "^1.42.0", "@stripe/stripe-js": "1.46.0"
"sass": "^1.55.0"
} }
} }

View File

@@ -2,7 +2,7 @@
<html lang="no-NO"> <html lang="no-NO">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" />
<meta name="theme-color" content="#18332f" /> <meta name="theme-color" content="#18332f" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

12
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { HandleFetch } from '@sveltejs/kit';
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
const { origin } = new URL(request.url);
if (request.url.startsWith(`${origin}/api`)) {
// clone the original request, but change the URL
request = new Request(request.url.replace(origin, 'http://localhost:30010'), request);
}
return fetch(request);
};

View File

@@ -1,14 +1,15 @@
import { writable, get, derived } from 'svelte/store'; import { writable, derived } from 'svelte/store';
import type { Writable, Readable } from 'svelte/store'; import type { Writable, Readable } from 'svelte/store';
import type ICart from './interfaces/ICart';
export const cart: Writable<any> = writable([]); export const cart: Writable<ICart[]> = writable([]);
export const isOpen: Writable<boolean> = writable(false); export const isOpen: Writable<boolean> = writable(false);
export const count: Readable<number> = derived(cart, ($cart) => $cart.length || 0); export const count: Readable<number> = derived(cart, ($cart) => $cart.length || 0);
export const subTotal: Readable<number> = derived(cart, ($cart) => { export const subTotal: Readable<number> = derived(cart, ($cart) => {
let total = 0; let total = 0;
$cart.forEach((cartItem) => (total += cartItem.price * cartItem.quantity)); $cart.forEach((cartItem: ICart) => (total += cartItem.price * cartItem.quantity));
return total; return total;
}); });

View File

@@ -5,19 +5,28 @@
[BadgeType.INFO]: '⏳', [BadgeType.INFO]: '⏳',
[BadgeType.PENDING]: '📦', [BadgeType.PENDING]: '📦',
[BadgeType.WARNING]: '⚠️', [BadgeType.WARNING]: '⚠️',
[BadgeType.REFUNDED]: '🪷',
[BadgeType.SUCCESS]: '✓', [BadgeType.SUCCESS]: '✓',
[BadgeType.ERROR]: 'X' [BadgeType.ERROR]: 'X'
}; };
export let title = 'Info'; export let title = 'Info';
export let type: BadgeType = BadgeType.INFO; export let type: BadgeType = BadgeType.INFO;
export let icon: string = badgeIcons[type];
if (title === 'CONFIRMED' || title === 'succeeded') {
type = BadgeType.SUCCESS;
} else if (title === 'CANCELLED') {
type = BadgeType.ERROR;
} else if (title === 'REFUNDED') {
type = BadgeType.REFUNDED;
}
$: icon = badgeIcons[type];
$: badgeClass = `badge ${type}`; $: badgeClass = `badge ${type}`;
</script> </script>
<div class="{badgeClass}"> <div class="{badgeClass}">
<span>{title}</span> <span>{title?.toLowerCase()}</span>
<span class="icon">{icon}</span> <span class="icon">{icon}</span>
</div> </div>
@@ -32,27 +41,32 @@
color: rgb(84, 89, 105); color: rgb(84, 89, 105);
margin: 0.2rem; margin: 0.2rem;
> span {
text-transform: capitalize;
}
.icon { .icon {
margin-left: 0.3rem; margin-left: 0.3rem;
font-size: 0.8rem; font-size: 0.8rem;
} }
&.success { &.SUCCESS {
background-color: rgb(215, 247, 194); background-color: rgb(215, 247, 194);
color: rgb(0, 105, 8); color: rgb(0, 105, 8);
} }
&.error { &.ERROR {
background-color: rgb(255, 231, 242); background-color: rgb(255, 231, 242);
color: rgb(179, 9, 60); color: rgb(179, 9, 60);
} }
&.warning { &.WARNING,
&.REFUNDED {
background-color: rgb(252, 237, 185); background-color: rgb(252, 237, 185);
color: rgb(168, 44, 0); color: rgb(168, 44, 0);
} }
&.pending { &.PENDING {
background-color: lightblue; background-color: lightblue;
color: darkslateblue; color: darkslateblue;
} }

View File

@@ -1,9 +1,17 @@
<script lang="ts"> <script lang="ts">
import { count, toggleCart, isOpen } from '../cartStore'; import { count, toggleCart, isOpen } from '../cartStore';
import IconCart from '../icons/IconCart.svelte'; import IconCart from '../icons/IconCart.svelte';
$: openClass = $isOpen ? 'open' : '';
</script> </script>
<div id="cart" class="{$isOpen && 'open'}" on:click="{() => toggleCart()}"> <div
id="cart"
class="{openClass}"
on:click="{() => toggleCart()}"
on:keypress="{(e) => e.code === 'Enter' && toggleCart()}"
tabindex="0"
>
{#if $count > 0} {#if $count > 0}
<span>{$count}</span> <span>{$count}</span>
{/if} {/if}

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import { fly, slide } from 'svelte/transition';
export let errors: string[] = [];
let currentCard = 0;
let debounce = false;
const flyoutDuration = 425;
function offsetTop(index: number) {
const randomVal = Math.round(Math.random() * 20 * Math.random() > 0.5 ? 1 : -1);
return `top: ${
-7 * index
}px; transform: translate(${randomVal}px,${randomVal}px) rotate(${randomVal}deg)`;
}
function dismiss(index: number) {
debounce = true;
errors = errors?.filter((m, i) => i !== index);
setTimeout(() => (debounce = false), flyoutDuration);
}
</script>
{#if errors && errors?.length > 0}
<section style="position: relative;">
{#each errors as error, index (`${index}-${error}`)}
<p
class="error-card"
data-status="{index === currentCard ? 'current' : 'waiting'}"
style="{offsetTop(index)}"
in:slide
out:fly="{{ y: 100, duration: flyoutDuration }}"
>
<button
class="dismiss"
on:click="{() => dismiss(index)}"
on:keyup="{(e) => e.code === 'Enter' && debounce === false && dismiss(index)}">X</button
>
<span class="card_content">{error}</span>
<span class="help"
>Prøv igjen eller gi en lyd på <a class="link" href="mailto:kontakt@planetposen.no"
>kontakt@planetposen.no</a
> så tar vi kontakt med deg.</span
>
</p>
{/each}
</section>
{/if}
<style lang="scss">
.error-card {
position: absolute;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
box-sizing: border-box;
touch-action: none;
background-color: rgba(255, 0, 0, 0.6);
color: var(--color-theme-1);
font-size: 1.1rem;
padding: 1rem 1.25rem;
margin: 0.5rem 0;
display: flex;
flex-direction: column;
border-radius: 4px;
width: 100%;
max-width: 550px;
transition: all 0.6s ease;
border: 1px solid firebrick;
&[data-status='current'] {
z-index: 2;
// background-color: orange;
background-color: #ff3333;
border-color: transparent;
}
span {
display: block;
color: white;
font-size: 1.2rem;
padding: 0.4rem 0 0.4rem;
}
button.dismiss {
position: absolute;
top: 0.5rem;
right: 1rem;
font-size: 1.3rem;
color: white;
cursor: pointer;
border: none;
margin: 0;
padding: 0;
background: none;
}
.help {
color: rgba(0, 0, 0, 0.7);
font-size: 0.95rem;
// margin-top: 1.2rem;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import ProductTile from './ProductTile.svelte';
// import Button from './Button.svelte';
import type { IProduct } from '$lib/interfaces/IProduct';
import LinkArrow from './LinkArrow.svelte';
export let products: IProduct[];
export let title: string;
export let textColor = 'black';
export let backgroundColor = 'white';
let galleryStyles = `background-color: ${backgroundColor}; color: ${textColor}`;
</script>
<div class="product-gallery" style="{galleryStyles}">
<h1>{title}</h1>
{#each products as product}
<ProductTile product="{product}" />
{/each}
<footer>
<a href="/shop" class="link">Utforsk webshopen<LinkArrow /></a>
</footer>
</div>
<style lang="scss">
@import '../../styles/media-queries.scss';
h1 {
font-size: 3rem;
padding: 3rem;
padding-right: 1rem;
}
.product-gallery {
display: grid;
grid-template-columns: 1fr;
width: calc(100% - 2rem);
padding: 3rem 1rem;
margin: 4rem auto;
box-shadow: 0px -1px 30px 2px rgba(181, 196, 174) inset;
-webkit-box-shadow: 0px -1px 30px 2px rgba(181, 196, 174) inset;
-moz-box-shadow: 0px -1px 30px 2px rgba(181, 196, 174) inset;
footer {
grid-column: 1/-1;
justify-content: center;
display: flex;
font-size: 2rem;
}
@include mobile {
grid-template-columns: 1fr 1fr;
}
@include tablet {
grid-template-columns: 1fr 1fr;
}
@include desktop {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
}
</style>

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { IProduct, IImage } from '$lib/interfaces/IProduct';
import { onMount } from 'svelte';
export let product: IProduct;
function start(event: MouseEvent | TouchEvent) {
isDown = true;
imageFrameElement.classList.add('active');
startX = event.pageX - imageFrameElement.offsetLeft;
scrollLeft = imageFrameElement.scrollLeft;
imageFrameElement.style.cursor = 'grabbing';
}
function end() {
isDown = false;
imageFrameElement.style.cursor = 'pointer';
}
function move(event: MouseEvent | TouchEvent) {
if (!isDown) return; // stop the fn from running
// event.preventDefault();
const x = event.pageX - imageFrameElement.offsetLeft;
const walk = (x - startX) * 2;
imageFrameElement.scrollLeft = scrollLeft - walk;
}
function scrollToIndex(index = selected) {
selected = index;
const image = imageFrameElement.children[selected] as HTMLImageElement;
const imageWidth = image.getBoundingClientRect()?.width;
imageFrameElement.scrollTo({
left: imageWidth * selected,
behavior: 'smooth'
});
updateHeight();
}
function updateHeight() {
const image = imageFrameElement.children[selected] as HTMLImageElement;
const imageHeight = image.getBoundingClientRect()?.height;
imageFrameElement.style.maxHeight = `${imageHeight}px`;
}
let images: IImage[] = product?.images || [];
let selected = 0;
let isDown = false;
let startX = 0;
let scrollLeft = 0;
let imageFrameElement: HTMLElement;
let productStyles = `background-color: ${product?.primary_color || '#E6E0DC'};
color: ${product?.primary_color === '#231B1D' ? '#f3efeb' : '#37301e'}`;
let observer: IntersectionObserver;
onMount(() => {
updateHeight();
imageFrameElement.addEventListener('mousedown', start);
imageFrameElement.addEventListener('mouseleave', end);
imageFrameElement.addEventListener('mouseup', end);
imageFrameElement.addEventListener('mousemove', move);
// touchEvents
imageFrameElement.addEventListener('touchstart', start);
imageFrameElement.addEventListener('touchend', end);
imageFrameElement.addEventListener('touchcancel', end);
imageFrameElement.addEventListener('touchmove', move);
if (typeof IntersectionObserver !== 'undefined') {
const rootMargin = '0px -20% 0px -20%';
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
const target = entries[0]?.target as HTMLElement;
const targetIndex = Number(target?.dataset?.index);
if (isNaN(targetIndex)) return;
selected = targetIndex;
updateHeight();
}
},
{
root: imageFrameElement,
rootMargin
}
);
}
const imageChildren = imageFrameElement.children;
for (let i = 0; i < imageChildren.length; i++) {
observer.observe(imageChildren[i]);
}
});
</script>
{#if images?.length > 0}
<div class="carousel" in:fade="{{ duration: 400 }}">
<div bind:this="{imageFrameElement}" class="image-frame" style="{productStyles}">
{#each images as { image_id, url }, index (image_id)}
<div class="image-wrapper" data-index="{index}">
<img src="{url}" alt="" draggable="false" />
</div>
{/each}
</div>
{#if images?.length > 1}
<div class="thumbnails">
{#each images as image, index}
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<img
class="{index === selected ? 'selected' : ''}"
on:click="{() => scrollToIndex(index)}"
on:keypress="{(e) => e.code === 'Enter' && scrollToIndex(index)}"
tabindex="0"
src="{image.url}"
alt="{`Clickable product image thumbnail number ${index + 1}`}"
/>
{/each}
</div>
{/if}
</div>
{/if}
<style lang="scss">
@import '../../styles/media-queries.scss';
.image-frame {
overflow-x: scroll;
overflow-y: hidden;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
position: relative;
place-items: center;
margin: 1.5rem;
padding: 2.5rem 1.5rem;
background-color: #e6e0dc;
color: #37301e;
transition: all 0.3s ease-in-out;
cursor: pointer;
white-space: nowrap;
gap: 3rem;
max-height: 100px;
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
-webkit-user-select: none;
user-select: none;
.image-wrapper {
display: inline-block;
// background-color: red;
padding: 0.5rem;
min-width: 90%;
&:first-of-type {
margin-left: 1rem;
}
&:last-of-type {
margin-right: 1rem;
}
}
img {
transition: all 0.3s ease-in-out;
scroll-snap-align: center;
vertical-align: top;
width: 100%;
transform: scale(0.98);
}
&:hover {
margin: 1rem;
padding: 3rem 2rem;
img {
transform: scale(1);
box-shadow: 3px 3px 13px 3px rgba(0, 0, 0, 0.2);
}
}
&::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
}
.thumbnails {
display: flex;
flex-wrap: wrap;
justify-content: center;
img {
width: 4rem;
height: 4rem;
margin: 0 0.5rem;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.4s ease-in-out;
object-fit: cover;
&.selected {
border-color: black;
}
&:not(&.selected) {
filter: grayscale(90%);
}
}
}
</style>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Button from './Button.svelte';
import Loading from './loading/index.svelte';
const dispatch = createEventDispatcher();
let dragOver = false;
let fileInput: HTMLInputElement;
/* eslint-disable @typescript-eslint/no-explicit-any */
let resolveUploadPromise: (value: any) => void;
let rejectUploadPromise: (resason: any | null) => void;
let uploadPromise: Promise<any> | null;
/* eslint-enable @typescript-eslint/no-explicit-any */
async function uploadImages() {
const { files } = fileInput;
if (!files) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
await uploadImage(file);
}
setTimeout(() => resetUploadInput, 3000);
}
function resetUploadInput() {
fileInput.value = '';
uploadPromise = null;
}
async function uploadImage(file: File) {
console.log('uploading file:', file);
const formData = new FormData();
formData.append('file', file);
let url = '/api/v1/images';
if (window?.location?.href?.includes('localhost')) {
url = 'http://localhost:8001' + url;
}
const options = {
method: 'POST',
body: formData
};
startImageUploadLoader();
// return
try {
const imageUploadResponse = await fetch(url, options).then((resp) => resp.json());
console.log('response from image upload:', imageUploadResponse);
resolveUploadPromise(true);
const { remote_url } = imageUploadResponse;
if (remote_url) dispatch('added', remote_url);
} catch (error) {
console.log('Unexpected error from upload:', error);
rejectUploadPromise(error);
}
}
function startImageUploadLoader() {
uploadPromise = new Promise((resolve, reject) => {
resolveUploadPromise = resolve;
rejectUploadPromise = reject;
});
}
function onFileSelected(event: Event) {
let target = event.target as HTMLInputElement;
if (!target) return;
let file = target.files?.[0];
if (!file) return;
fileInput.files = target.files;
// uploadImage(file)
}
function onFileDrop(event: DragEvent) {
const files: FileList | undefined = event?.dataTransfer?.files;
if (files) {
fileInput.files = files;
}
}
$: hasFiles = (fileInput?.files && fileInput?.files?.length > 0) || false;
</script>
<div
id="drop_zone"
class="{dragOver ? 'highlighted' : ''}"
on:click="{() => fileInput.click()}"
on:keypress="{(e) => e.code === 'Enter' && fileInput.click()}"
on:drop|preventDefault="{onFileDrop}"
on:dragover|preventDefault="{() => (dragOver = true)}"
on:dragenter|preventDefault="{() => (dragOver = true)}"
on:dragleave|preventDefault="{() => (dragOver = false)}"
on:drop|preventDefault="{() => (dragOver = false)}"
>
{#if !dragOver}
<p>Click or drag images <i>here</i> to upload</p>
{:else}
<p>Drop image(s) to add to uploads</p>
{/if}
</div>
<input
bind:this="{fileInput}"
type="file"
accept=".jpg, .jpeg, .png"
on:change="{onFileSelected}"
multiple
/>
{#if hasFiles}
<span>Files found to upload:</span>
<div class="thumbnails">
{#each fileInput.files || [] as file, index}
<img
src="{window.URL.createObjectURL(file)}"
alt="{`Upload asset thumbnail number ${index + 1}`}"
/>
{/each}
</div>
<div class="upload">
<Button text="upload" on:click="{uploadImages}" />
{#if uploadPromise}
<Loading promise="{uploadPromise}" />
{/if}
</div>
{/if}
<style lang="scss">
input {
display: none;
}
#drop_zone {
margin: 1rem 0;
padding: 0 1rem;
border: 2px dashed rgba(0, 0, 0, 0.4);
width: 300px;
text-align: center;
cursor: pointer;
p {
margin: 0.75rem 0;
}
&.highlighted {
color: darkslategray;
background-color: rgb(215, 247, 194);
border-color: rgb(0, 105, 8);
}
}
.upload {
display: flex;
justify-content: space-between;
margin-top: 1rem;
}
.thumbnails {
display: flex;
flex-wrap: wrap;
img {
width: 80px;
margin: 0 0.5rem 0.5rem 0;
border-radius: 4px;
}
}
</style>

View File

@@ -4,6 +4,6 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{title}</title> <title>{title} | planetposen</title>
<meta name="description" content="{description}" /> <meta name="description" content="{description}" />
</svelte:head> </svelte:head>

View File

@@ -2,7 +2,6 @@
import type { IProduct } from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
export let product: IProduct; export let product: IProduct;
export let large = false;
</script> </script>
<a href="/shop/{product?.product_no}" class="product-tile"> <a href="/shop/{product?.product_no}" class="product-tile">
@@ -12,15 +11,13 @@
product?.primary_color === '#231B1D' ? '#f3efeb' : '#37301e' product?.primary_color === '#231B1D' ? '#f3efeb' : '#37301e'
}`}" }`}"
> >
{#if !large} <h3>{product?.name}</h3>
<h3>{product?.name}</h3>
{/if}
<div class="{`image-frame ${large ? 'large' : null}`}"> <div class="image-frame">
<img src="{product?.image}" alt="{product?.name}" /> <img src="{product?.image}" alt="{product?.name}" />
</div> </div>
{#if !large} {#if product?.subtext?.length > 3}
<p class="subtext">{product?.subtext}</p> <p class="subtext">{product?.subtext}</p>
{/if} {/if}
</div> </div>
@@ -73,34 +70,16 @@
display: grid; display: grid;
place-items: center; place-items: center;
&.large img {
width: 90%;
}
img { img {
margin: 3rem 0; margin: 3rem 0;
width: 66%; width: 66%;
transition: all 0.6s ease; transition: all 0.6s ease;
@include mobile { @include mobile {
width: 75%; margin: 2rem 0;
width: 90%;
} }
} }
} }
// TODO grid view on mobile
// @include mobile {
// margin: 0.5rem;
// padding: 0.5rem;
// h3 {
// margin: 0;
// }
// .image-frame img {
// width: 82%;
// margin: 1rem 0;
// }
// }
} }
</style> </style>

View File

@@ -24,7 +24,7 @@
}); });
</script> </script>
<ul> <ul class="product-variations">
{#each variations as variation} {#each variations as variation}
<li <li
class="{`variation ${variation.sku_id === selectedVariation?.sku_id && 'selected'}`}" class="{`variation ${variation.sku_id === selectedVariation?.sku_id && 'selected'}`}"
@@ -37,7 +37,7 @@
</ul> </ul>
<style lang="scss"> <style lang="scss">
ul { ul.product-variations {
list-style-type: none; list-style-type: none;
padding-left: 0; padding-left: 0;
@@ -59,11 +59,6 @@
&.selected { &.selected {
border-color: black; border-color: black;
} }
p {
padding: 0;
margin: 0;
}
} }
} }
</style> </style>

View File

@@ -0,0 +1,202 @@
<script lang="ts">
import { onMount } from 'svelte';
import Time from './Time.svelte';
import CircleLoading from './loading/CircleLoading.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
import type { IShipmentResponse } from '$lib/interfaces/IShipping';
export let shipment: IShipmentResponse;
interface TrackingEvent {
city: string;
consignmentEvent: boolean;
country: string;
countryCode: string;
date: string;
dateIso: Date;
description: string;
displayDate: string;
displayTime: string;
gpsMapUrl: string;
gpsXCoordinate: string;
gpsYCoordinate: string;
insignificant: boolean;
lmCauseCode: string;
lmEventCode: string;
lmMeasureCode: string;
postalCode: string;
recipientSignature: null;
status: string;
unitId: string;
unitInformationUrl: null;
unitType: string;
}
let trackedShipment: TrackingEvent[] | null = null;
async function getTracking() {
const url = buildApiUrl(`/api/v1/shipment/${shipment?.shipment_id}/track`);
try {
trackedShipment = null;
const trackingResponse = await fetch(url).then((resp) => resp.json());
trackedShipment = trackingResponse?.shipment?.events || [];
} catch (error) {
console.log('api error from track:', error);
}
}
onMount(() => {
if (!shipment) return;
getTracking();
});
</script>
<section>
<h2>Shipment history</h2>
{#if trackedShipment === null}
<div class="loading-content">
<CircleLoading />
</div>
{:else if trackedShipment?.length > 0}
<ul class="tracking">
{#each trackedShipment as event}
<li>
<span class="indicator"></span>
<div class="details">
<span class="message">{event.description}</span>
{#if event?.status}
<div>
<span>{event.status} - </span>
<span style="text-transform: capitalize;"><Time time="{event.date}" /></span>
</div>
{/if}
<div class="location">
{#if event.city.length > 0}
<span>{event?.city?.toLowerCase()}</span>
{/if}
{#if event.countryCode.length > 0}
<span data-separator=","
>{event.city.length > 0 ? event?.countryCode : event?.country}</span
>
{/if}
</div>
</div>
</li>
{/each}
</ul>
{:else}
<div>
<h3>Error! Unable to retrieve shipment history</h3>
<p>
Try again later or contact <a class="link" href="mailto:support@planet.schleppe.cloud"
>support@planet.schleppe.cloud</a
>
</p>
</div>
{/if}
</section>
<style lang="scss">
@import '../../styles/media-queries.scss';
@import '../../styles/effects.scss';
h2 {
font-size: 1.4rem;
margin-bottom: 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.3);
}
.tracking {
padding-left: 1.5rem;
@include mobile {
padding-left: 0;
}
li {
display: flex;
margin: 1rem 0.3rem;
padding: 0.8rem 0.3rem;
position: relative;
&:first-of-type .indicator {
@include pulse-dot;
&::after {
left: calc(0.6rem + 5.5px);
}
&::before {
height: 100%;
top: 50%;
}
}
&:last-of-type .indicator::before {
height: 100%;
top: -50%;
}
}
.indicator {
display: block;
align-self: center;
min-width: 20px;
min-height: 20px;
background-color: #e32d22;
border-radius: 50%;
margin-right: 2rem;
@include mobile {
margin-right: 1.5rem;
}
&::before {
content: '';
position: absolute;
top: 1rem;
left: calc(0.3rem + 8.5px);
height: calc(100% + 1rem);
width: 3px;
background-color: #e32d22;
}
}
.details {
display: flex;
flex-direction: column;
.message {
margin-bottom: 0.5rem;
font-weight: bold;
}
.location {
text-transform: capitalize;
span:nth-of-type(2) {
position: relative;
margin-left: 3px;
padding-left: 3px;
&::before {
content: attr(data-separator) ' ';
position: absolute;
left: -8px;
}
}
}
}
}
.loading-content {
width: 100%;
margin-top: 2rem;
display: flex;
justify-content: center;
}
</style>

View File

@@ -1,11 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onMount } from 'svelte';
import { loadStripe } from '@stripe/stripe-js/pure'; import stripeApi from '$lib/stripe/index';
import type { Stripe } from '@stripe/stripe-js'; import type { StripeCardElement } from '@stripe/stripe-js/types';
import { cart } from '../cartStore';
function mountCard() { export let card: StripeCardElement;
const elements = stripe.elements(); export let stripeApiKey: string;
async function mountCard() {
let stripe = await stripeApi.load(stripeApiKey);
const elements = stripe?.elements();
if (!elements) return;
const options = { const options = {
hidePostalCode: true, hidePostalCode: true,
@@ -27,61 +31,9 @@
card.mount(cardElement); card.mount(cardElement);
} }
// function makeIntent() { onMount(() => mountCard());
// let url = "/api/payment/stripe";
// if (window.location.href.includes("localhost"))
// url = "http://localhost:30010".concat(url);
// fetch(url, {
// method: "POST",
// headers: {
// "Content-Type": "application/json",
// },
// body: JSON.stringify({
// cart: $cart
// })
// })
// .then((resp) => resp.json())
// .then((data) => (clientSecret = data.paymentIntent.clientSecret));
// }
function pay() {
stripe
.confirmCardPayment(clientSecret, {
payment_method: {
card,
billing_details: {
name: 'Kevin Testost'
}
}
})
.then((result) => {
if (result.error) {
confirmDiag.innerText = result.error.message || 'Unexpected payment ERROR!';
} else {
if (result.paymentIntent.status === 'succeeded') {
confirmDiag.innerText = 'Confirmed transaction!';
}
}
});
}
async function initStripe() {
window.addEventListener('submit-stripe-payment', pay, false);
loadStripe.setLoadParameters({ advancedFraudSignals: false });
stripe = await loadStripe('pk_test_YiU5HewgBoClZCwHdhXhTxUn');
mountCard();
// makeIntent();
}
onMount(() => initStripe());
// onDestroy(() => window.removeEventListener('submit-stripe-payment', null))
let stripe: Stripe;
let card;
let cardElement: HTMLElement; let cardElement: HTMLElement;
let clientSecret: string;
let confirmDiag: HTMLElement; let confirmDiag: HTMLElement;
</script> </script>
@@ -96,7 +48,6 @@
.card { .card {
// padding: 1rem; // padding: 1rem;
margin: 0 0.5rem;
border: 2px solid black; border: 2px solid black;
@include desktop { @include desktop {

View File

@@ -0,0 +1,25 @@
<script lang="ts">
export let time: Date | string | undefined;
let dateLocaleString: string;
const options: Intl.DateTimeFormatOptions = {
weekday: 'short',
day: 'numeric',
month: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZone: 'Europe/Oslo'
};
if (time) {
dateLocaleString = new Date(time).toLocaleString('no-NB', options);
}
</script>
{#if dateLocaleString}
<span>{dateLocaleString}</span>
{:else}
<span>(no date found)</span>
{/if}

View File

@@ -1,8 +1,8 @@
<svg width="49" height="48" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"> <svg width="49" height="48" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<g transform="translate(24,24)"> <g transform="translate(24,24)">
<g class="CheckSuccess-checkGroup" opacity="0" style="animation-delay: 700ms"> <g class="loading-group CheckSuccess" opacity="0">
<path <path
class="CheckSuccess-check" class="loading-icon"
fill="none" fill="none"
d="M-10 1.5c0 0 6.5 6 6.5 6c0 0 13.5-13 13.5-13" d="M-10 1.5c0 0 6.5 6 6.5 6c0 0 13.5-13 13.5-13"
stroke="#24B47E" stroke="#24B47E"
@@ -10,12 +10,11 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-dasharray="28 28" stroke-dasharray="28 28"
stroke-dashoffset="28" stroke-dashoffset="28"></path>
style="animation-delay: 700ms"></path>
</g> </g>
</g> </g>
<path <path
class="CheckSuccess-circle" class="loading-circle"
fill="none" fill="none"
stroke="#24B47E" stroke="#24B47E"
stroke-width="2" stroke-width="2"
@@ -25,8 +24,7 @@
stroke-dasharray="145 145" stroke-dasharray="145 145"
stroke-linejoin="round" stroke-linejoin="round"
stroke-miterlimit="1" stroke-miterlimit="1"
transform="translate(24,24) rotate(-35)" transform="translate(24,24) rotate(-35)"></path>
style="animation-delay: 700ms"></path>
</svg> </svg>
<style lang="scss" module="scoped"> <style lang="scss" module="scoped">

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 967 B

View File

@@ -1,8 +1,8 @@
<svg width="49" height="48" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"> <svg width="49" height="48" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<g transform="translate(24,24)"> <g transform="translate(24,24)">
<g class="CheckError-checkGroup" opacity="0" style="animation-delay: 700ms"> <g class="loading-group CheckError" opacity="0">
<path <path
class="CheckError-cross" class="loading-icon"
fill="none" fill="none"
d="M -10 -10 l 20 20 M -10 10 l 20 -20" d="M -10 -10 l 20 20 M -10 10 l 20 -20"
stroke="#FF6245" stroke="#FF6245"
@@ -10,12 +10,11 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-dasharray="28 28" stroke-dasharray="28 28"
stroke-dashoffset="28" stroke-dashoffset="28"></path>
style="animation-delay: 700ms"></path>
</g> </g>
</g> </g>
<path <path
class="CheckError-circle" class="loading-circle"
fill="none" fill="none"
stroke="#FF6245" stroke="#FF6245"
stroke-width="2" stroke-width="2"
@@ -25,8 +24,7 @@
stroke-dasharray="145 145" stroke-dasharray="145 145"
stroke-linejoin="round" stroke-linejoin="round"
stroke-miterlimit="1" stroke-miterlimit="1"
transform="translate(24,24) rotate(-35)" transform="translate(24,24) rotate(-35)"></path>
style="animation-delay: 700ms"></path>
</svg> </svg>
<style lang="scss" module="scoped"> <style lang="scss" module="scoped">

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 956 B

View File

@@ -0,0 +1,48 @@
<svg width="49" height="48" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<path
class="loading-circle"
fill="none"
stroke="grey"
stroke-width="2"
d="M23 0c0 12.7-10.3 23-23 23c-12.7 0-23-10.3-23-23c0-12.7 10.3-23 23-23c12.7 0 23 10.3 23 23"
stroke-linecap="round"
stroke-dashoffset="145"
stroke-dasharray="145 145"
stroke-linejoin="round"
stroke-miterlimit="1"
transform="translate(24,24) rotate(-35)"
style="animation-delay: 100ms"></path>
</svg>
<style lang="scss" module="scoped">
.loading-circle {
-webkit-animation: drawCircle 1.5s linear both;
animation: drawCircle 1.5s linear both;
animation-iteration-count: infinite;
}
svg {
animation: rotateCircle 1.5s linear both;
animation-iteration-count: infinite;
}
@keyframes rotateCircle {
0% {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes drawCircle {
0% {
stroke-dashoffset: 145px;
}
to {
stroke-dashoffset: -145px;
}
}
</style>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,8 +1,8 @@
<svg width="49" height="48" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"> <svg width="49" height="48" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<g transform="translate(24,24)"> <g transform="translate(24,24)">
<g class="CheckWarning-checkGroup" opacity="0" style="animation-delay: 700ms"> <g class="loading-group CheckWarning" opacity="0">
<path <path
class="CheckWarning-exclamation" class="loading-icon"
fill="none" fill="none"
d="M 0 -12 v 15 m 0 6 v 3" d="M 0 -12 v 15 m 0 6 v 3"
stroke="#FFC107" stroke="#FFC107"
@@ -10,12 +10,11 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-dasharray="28 28" stroke-dasharray="28 28"
stroke-dashoffset="28" stroke-dashoffset="28"></path>
style="animation-delay: 700ms"></path>
</g> </g>
</g> </g>
<path <path
class="CheckWarning-circle" class="loading-circle"
fill="none" fill="none"
stroke="#FFC107" stroke="#FFC107"
stroke-width="2" stroke-width="2"
@@ -25,8 +24,7 @@
stroke-dasharray="145 145" stroke-dasharray="145 145"
stroke-linejoin="round" stroke-linejoin="round"
stroke-miterlimit="1" stroke-miterlimit="1"
transform="translate(24,24) rotate(-35)" transform="translate(24,24) rotate(-35)"></path>
style="animation-delay: 700ms"></path>
</svg> </svg>
<style lang="scss" module="scoped"> <style lang="scss" module="scoped">

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 945 B

View File

@@ -1,3 +1,18 @@
.loading-group {
-webkit-animation: fadeIn 1s linear both;
animation: fadeIn 1s linear both;
}
.loading-icon {
-webkit-animation: drawCheckmark 1s linear both;
animation: drawCheckmark 1s linear both;
}
.loading-circle {
-webkit-animation: checkmarkCircleShimmer 1s linear both, drawCircle 1s linear both;
animation: checkmarkCircleShimmer 1s linear both, drawCircle 1s linear both;
}
@-webkit-keyframes fadeIn { @-webkit-keyframes fadeIn {
0% { 0% {
opacity: 0; opacity: 0;
@@ -122,27 +137,6 @@
} }
} }
.CheckSuccess-checkGroup,
.CheckError-checkGroup,
.CheckWarning-checkGroup {
-webkit-animation: fadeIn 1s linear both;
animation: fadeIn 1s linear both;
}
.CheckSuccess-check,
.CheckError-cross,
.CheckWarning-exclamation {
-webkit-animation: drawCheckmark 1s linear both;
animation: drawCheckmark 1s linear both;
}
.CheckSuccess-circle,
.CheckError-circle,
.CheckWarning-circle {
-webkit-animation: checkmarkCircleShimmer 1s linear both, drawCircle 1s linear both;
animation: checkmarkCircleShimmer 1s linear both, drawCircle 1s linear both;
}
@-webkit-keyframes drawCircle { @-webkit-keyframes drawCircle {
0% { 0% {
stroke-dashoffset: 145px; stroke-dashoffset: 145px;

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import CircleLoading from './CircleLoading.svelte';
import CircleCheckmark from './CircleCheckmark.svelte';
import CircleWarning from './CircleWarning.svelte';
import CircleError from './CircleError.svelte';
export let promise: Promise<any>;
</script>
{#await promise}
<CircleLoading />
{:then}
<CircleCheckmark />
{:catch type}
{#if type === 'warning'}
<CircleWarning />
{:else if type === 'error'}
<CircleError />
{:else}
<CircleError />
{/if}
{/await}

View File

@@ -1,85 +1,89 @@
<script lang="ts"> <script lang="ts">
function getApplePaySession(validationURL: string) { // function getApplePaySession(validationURL: string) {
const options = { // const options = {
method: 'POST', // method: 'POST',
headers: { // headers: {
'Content-Type': 'application/json' // 'Content-Type': 'application/json'
}, // },
body: JSON.stringify({ validationURL }) // body: JSON.stringify({ validationURL })
}; // };
return fetch('/api/applepay/validateSession', options).then((resp) => resp.json()); // return fetch('/api/applepay/validateSession', options).then((resp) => resp.json());
} // }
function makeApplePayPaymentTransaction(payment: object) { // function makeApplePayPaymentTransaction(payment: object) {
const options = { // const options = {
method: 'POST', // method: 'POST',
headers: { // headers: {
'Content-Type': 'application/json' // 'Content-Type': 'application/json'
}, // },
body: JSON.stringify({ payment }) // body: JSON.stringify({ payment })
}; // };
return fetch('/api/applepay/pay', options).then((resp) => resp.json()); // return fetch('/api/applepay/pay', options).then((resp) => resp.json());
} // }
function createPaymentRequest() { function createPaymentRequest() {
const paymentRequest = { return true;
countryCode: 'NO',
currencyCode: 'NOK',
shippingMethods: [
{
label: 'Free Shipping',
amount: 0.0,
identifier: 'free',
detail: 'Delivers in five business days'
},
{
label: 'Express Shipping',
amount: 5.0,
identifier: 'express',
detail: 'Delivers in two business days'
}
],
lineItems: [
{
label: 'Shipping',
amount: 0.0
}
],
total: {
label: 'Apple Pay Example',
amount: 8.99
},
supportedNetworks: ['amex', 'discover', 'masterCard', 'visa'],
merchantCapabilities: ['supports3DS'],
requiredShippingContactFields: ['postalAddress', 'email']
};
const session = new ApplePaySession(6, paymentRequest);
session.onvalidatemerchant = (event) => {
console.log('Validate merchante');
console.log('event: ', event);
const validationURL = event.validationURL;
this.getApplePaySession(validationURL).then((response) => {
console.log('response from getApplePaySession:', response);
session.completeMerchantValidation(response);
});
};
session.onpaymentauthorized = (event) => {
this.makeApplePayPaymentTransaction(event.payment).then((response) => {
console.log('response from pay:', response);
if (response.approved) session.completePayment(ApplePaySession.STATUS_SUCCESS);
else session.completePayment(ApplePaySession.STATUS_FAILURE);
});
};
session.begin();
} }
// function createPaymentRequest() {
// const paymentRequest = {
// countryCode: 'NO',
// currencyCode: 'NOK',
// shippingMethods: [
// {
// label: 'Free Shipping',
// amount: 0.0,
// identifier: 'free',
// detail: 'Delivers in five business days'
// },
// {
// label: 'Express Shipping',
// amount: 5.0,
// identifier: 'express',
// detail: 'Delivers in two business days'
// }
// ],
// lineItems: [
// {
// label: 'Shipping',
// amount: 0.0
// }
// ],
// total: {
// label: 'Apple Pay Example',
// amount: 8.99
// },
// supportedNetworks: ['amex', 'discover', 'masterCard', 'visa'],
// merchantCapabilities: ['supports3DS'],
// requiredShippingContactFields: ['postalAddress', 'email']
// };
// const session = new ApplePaySession(6, paymentRequest);
// session.onvalidatemerchant = (event) => {
// console.log('Validate merchante');
// console.log('event: ', event);
// const validationURL = event.validationURL;
// this.getApplePaySession(validationURL).then((response) => {
// console.log('response from getApplePaySession:', response);
// session.completeMerchantValidation(response);
// });
// };
// session.onpaymentauthorized = (event) => {
// this.makeApplePayPaymentTransaction(event.payment).then((response) => {
// console.log('response from pay:', response);
// if (response.approved) session.completePayment(ApplePaySession.STATUS_SUCCESS);
// else session.completePayment(ApplePaySession.STATUS_FAILURE);
// });
// };
// session.begin();
// }
</script> </script>
<div <div

View File

@@ -0,0 +1,15 @@
import type IOrderValidationError from '../interfaces/IOrderValidationError';
import type { IOrderCreateUnsuccessfullResponse } from '../interfaces/ApiResponse';
export class OrderSubmitUnsuccessfullError extends Error {
success: boolean;
validationErrors: IOrderValidationError[];
constructor(orderResponse: IOrderCreateUnsuccessfullResponse) {
const message = 'Error from submitting order!';
super(message);
this.success = orderResponse.success;
this.validationErrors = orderResponse.validationErrors;
}
}

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { goto } from '$app/navigation';
export let pointLeft = false;
let defaultRoute = '/shop';
function navigateBack() {
window.history.length > 1 ? window.history.back() : goto(defaultRoute);
}
</script>
<svg
tabindex="0"
role="button"
aria-roledescription="navigate back"
on:click="{navigateBack}"
on:keypress="{(e) => e.code === 'Enter' && navigateBack()}"
style="{pointLeft ? 'transform: rotateZ(180deg)' : ''}"
class="navigate-back"
width="30"
height="25.2"
viewBox="0 0 20 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.5437 16.5L9.88125 14.8284L14.9875 9.69403H0.5L0.5 7.30597L14.9875 7.30597L9.88125 2.17164L11.5437 0.5L19.5 8.5L11.5437 16.5Z"
fill="var(--color-theme-1)"></path>
</svg>
<style lang="scss">
.navigate-back {
cursor: pointer;
transition: all 0.3s ease;
scale: 0.95;
&:hover {
scale: 1;
}
}
</style>

View File

@@ -1,27 +1,52 @@
import type { IProduct } from './IProduct'; import type { IProduct } from './IProduct';
import type { IOrder, IOrderSummary } from './IOrder'; import type { IOrder, IOrderSummary } from './IOrder';
import type ICustomer from './ICustomer';
import type ICart from './ICart';
import type IOrderValidationError from './IOrderValidationError';
export interface IProductResponse { export interface IProductResponse {
success: boolean; success: boolean;
products: Array<IProduct>; products: Array<IProduct>;
} }
export interface IOrderResponse { export interface IOrderDTO {
success: boolean; success: boolean;
order: IOrder; order: IOrder;
} }
export interface IOrderCreateDTO {
customer: ICustomer;
cart: ICart[];
}
export interface IOrderSummaryResponse { export interface IOrderSummaryResponse {
success: boolean; success: boolean;
order: IOrderSummary; order: IOrderSummary;
} }
export interface IProductResponse { export interface IProductDTO {
success: boolean; success: boolean;
product: IProduct; product: IProduct;
} }
export interface IProductsResponse { export interface IProductsDTO {
success: boolean; success: boolean;
products: Array<IProduct>; products: Array<IProduct>;
} }
export interface ICartDTO {
cart: ICart[];
success: boolean;
}
export interface IOrderCreateUnsuccessfullResponse {
success: boolean;
validationErrors: IOrderValidationError[];
}
export interface IOrderCreateResponse {
success: boolean;
customer_no: string;
order_id: string;
validationErrors?: IOrderValidationError[];
}

View File

@@ -1,9 +1,12 @@
enum BadgeType { enum BadgeType {
SUCCESS = 'success', SUCCESS = 'SUCCESS',
WARNING = 'warning', WARNING = 'WARNING',
ERROR = 'error', REFUNDED = 'REFUNDED',
PENDING = 'pending', ERROR = 'ERROR',
INFO = 'info' PENDING = 'PENDING',
INFO = 'INFO',
INITIATED = 'INITIATED',
NOT_FOUND = 'NOT_FOUND'
} }
export default BadgeType; export default BadgeType;

View File

@@ -0,0 +1,15 @@
export default interface ICart {
planet_id: string;
cart_id: number;
lineitem_id: number;
quantity: number;
sku_id: number;
size: string;
price: number;
product_no: number;
name: string;
description: string;
subtext: string;
image: string;
primary_color: string;
}

View File

@@ -0,0 +1,9 @@
export default interface ICustomer {
city: string;
customer_no?: string;
email: string;
first_name: string;
last_name: string;
street_address: string;
zip_code: number;
}

View File

@@ -1,10 +1,6 @@
// import type IProduct from './IProduct'; // import type IProduct from './IProduct';
// import type BadgeType from './BadgeType'; // import type BadgeType from './BadgeType';
import type ICustomer from './ICustomer';
export interface IStripePayment {
amount: number;
currency: string;
}
export interface IOrderSummary { export interface IOrderSummary {
created: Date; created: Date;
@@ -21,22 +17,14 @@ export interface IOrder {
lineItems: ILineItem[]; lineItems: ILineItem[];
orderid: string; orderid: string;
shipping: IShipping; shipping: IShipping;
payment: IStripePayment;
status: string; status: string;
updated?: Date; updated?: Date;
created?: Date; created?: Date;
} }
export interface ICustomer {
city: string;
customer_no: string;
email: string;
firstname: string;
lastname: string;
streetaddress: string;
zipcode: number;
}
export interface ILineItem { export interface ILineItem {
sku_id: number;
image: string; image: string;
name: string; name: string;
price: number; price: number;
@@ -49,6 +37,7 @@ export interface IShipping {
tracking_code: string; tracking_code: string;
tracking_link: string; tracking_link: string;
user_notified: null; user_notified: null;
has_api: boolean;
} }
export interface IOrdersLineitem { export interface IOrdersLineitem {
@@ -67,3 +56,15 @@ export interface ITracking {
trackingCompany: string; trackingCompany: string;
trackingLink: string; trackingLink: string;
} }
export interface IStripePayment {
amount: number;
amount_captured: number;
amount_received: number;
amount_refunded: number;
created: Date;
stripe_transaction_id: string;
stripe_status: string;
type: string;
updated: Date;
}

View File

@@ -0,0 +1,5 @@
export default interface IOrderValidationError {
type: string;
field: number | string;
message: string;
}

View File

@@ -3,7 +3,8 @@ export interface IProduct {
name: string; name: string;
subtext?: string; subtext?: string;
description?: string; description?: string;
image: string; images?: IImage[];
image?: string;
primary_color?: string; primary_color?: string;
variation_count?: string; variation_count?: string;
@@ -23,3 +24,9 @@ export interface IVariation {
updated?: Date; updated?: Date;
created?: Date; created?: Date;
} }
export interface IImage {
image_id: number;
url: string;
default_image: boolean;
}

View File

@@ -0,0 +1,42 @@
export interface IShippingCourier {
id: string;
name: string;
website: string;
has_api: boolean;
}
export interface IShipment {
id: string;
courier_id: string;
tracking_code: string;
tracking_link: string;
}
export interface IShipmentEvent {
event_id: string;
shipment_id: string;
description: string;
status: string;
location: string;
event_time: Date;
updated: Date;
created: Date;
}
export interface IShipmentResponse {
shipment_id: string;
order_id: string;
courier: string;
has_api: boolean;
courier_id: number;
tracking_code: string;
tracking_link: string;
user_notified: boolean;
}
export interface ICourier {
courier_id: number;
name: string;
website: string;
has_api: boolean;
}

View File

@@ -6,7 +6,7 @@ function structureProduct(product: IProduct) {
'@context': 'https://schema.org/', '@context': 'https://schema.org/',
'@type': 'Product', '@type': 'Product',
name: `${product.name} - ${variation.size}`, name: `${product.name} - ${variation.size}`,
image: [product.image], image: product.images?.map((image) => image.url),
description: product.description, description: product.description,
sku: `${product.product_no}-${variation.sku_id}`, sku: `${product.product_no}-${variation.sku_id}`,
productID: product.product_no, productID: product.product_no,

100
src/lib/stripe/index.ts Normal file
View File

@@ -0,0 +1,100 @@
import { buildApiUrl } from '$lib/utils/apiUrl';
import { loadStripe } from '@stripe/stripe-js/pure';
import type {
ConfirmCardPaymentData,
PaymentIntentResult,
Stripe
// StripeError
} from '@stripe/stripe-js';
let stripeInstance: Stripe;
class StripeUnableToAccepPaymentsError extends Error {
constructor() {
const message = 'Card failed to load or never got a client secret. Unable to accept payments';
super(message);
}
}
// class StripePaymentError extends Error {
// code: string | undefined
// type: string
// constructor(error: StripeError) {
// const message = error.message || 'Unexptected error from stripe';
// super(message);
// console.log('stripe error:', error);
// this.code = error.code;
// this.type = error.type;
// }
// }
class StripeMissingPaymentIntent extends Error {
constructor() {
const message = 'Stripe responded without payment intent';
super(message);
}
}
// Calls backend for a client secret. order_id & customer_no
// is sent to attach to stripe payment intent
function clientSecret(order_id: string, customer_no: string): Promise<string> {
const url = buildApiUrl('/api/v1/payment/stripe');
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ order_id, customer_no })
};
return fetch(url, options)
.then((resp) => resp.json())
.then((data) => data?.clientSecret);
}
// Creates stripe instance from package: @stripe/stripe-js
async function load(apiKey: string): Promise<Stripe | undefined> {
if (stripeInstance) return stripeInstance;
loadStripe.setLoadParameters({ advancedFraudSignals: false });
const stripe: Stripe | null = await loadStripe(apiKey);
if (!stripe) {
console.error('Unable to load stripe payment');
return;
}
stripeInstance = stripe;
return stripe;
}
// Uses @stripe/stripe-js to make payment request from
// stripe Elements directly to Stripe. Server receives
// async webhook updates from Stripe.
function pay(secret: string, data: ConfirmCardPaymentData) {
if (!clientSecret || !stripeInstance) {
throw new StripeUnableToAccepPaymentsError();
}
return stripeInstance
.confirmCardPayment(secret, data)
.then((stripePaymentResponse: PaymentIntentResult) => {
const { error, paymentIntent } = stripePaymentResponse;
if (error) {
throw error;
} else if (paymentIntent) {
console.log('payment intent from stripe:', paymentIntent);
return paymentIntent.status === 'succeeded';
}
throw new StripeMissingPaymentIntent();
});
}
export default {
clientSecret,
load,
pay
};

15
src/lib/utils/apiUrl.ts Normal file
View File

@@ -0,0 +1,15 @@
import { dev, browser } from '$app/environment';
const LOCALHOST_API = 'http://localhost:30010';
export function buildApiUrl(path: string) {
let localhostApi = false;
if (dev) {
localhostApi = true;
} else if (browser && window?.location?.href.includes('localhost')) {
localhostApi = true;
}
return localhostApi ? LOCALHOST_API.concat(path) : path;
}

View File

@@ -1,87 +0,0 @@
import generateUUID from './uuid';
import type IProduct from '../interfaces/IProduct';
import type { IOrder, ICustomer } from '../interfaces/IOrders';
import BadgeType from '../interfaces/BadgeType';
const productNames = ["Who's Who", 'Lullaby', 'The Buried Life', 'The Illegitimate'];
const images = [
'https://cdn-fsly.yottaa.net/551561a7312e580499000a44/www.joann.com/v~4b.100/dw/image/v2/AAMM_PRD/on/demandware.static/-/Sites-joann-product-catalog/default/dw4a83425c/images/hi-res/18/18163006.jpg?sw=556&sh=680&sm=fit&yocs=7x_7C_7D_',
'https://cdn-fsly.yottaa.net/55d09df20b53443653002f02/www.joann.com/v~4b.ed/dw/image/v2/AAMM_PRD/on/demandware.static/-/Sites-joann-product-catalog/default/dwc10a651e/images/hi-res/alt/17767534Alt1.jpg?sw=350&sh=350&sm=fit&yocs=f_',
'https://cdn-fsly.yottaa.net/55d09df20b53443653002f02/www.joann.com/v~4b.ed/dw/image/v2/AAMM_PRD/on/demandware.static/-/Sites-joann-product-catalog/default/dw3f43e4d8/images/hi-res/alt/18995779ALT1.jpg?sw=350&sh=350&sm=fit&yocs=f_',
'https://cdn-fsly.yottaa.net/551561a7312e580499000a44/www.joann.com/v~4b.100/dw/image/v2/AAMM_PRD/on/demandware.static/-/Sites-joann-product-catalog/default/dw029904bd/images/hi-res/alt/18162834alt1.jpg?sw=350&sh=350&sm=fit&yocs=7x_7C_7D_',
'https://adrianbrinkerhoff.imgix.net/AdrianBrinkerhoff-MatthewThompson-103.jpg?auto=compress%2Cformat&bg=%23FFFFFF&crop=focalpoint&fit=crop&fp-x=0.5&fp-y=0.5&h=431&q=90&w=310&s=018ae410aa6b64e6c9c5ca6bb18a1137',
'https://adrianbrinkerhoff.imgix.net/AdrianBrinkerhoff-MatthewThompson-166.jpg?auto=compress%2Cformat&bg=%23FFFFFF&crop=focalpoint&fit=crop&fp-x=0.5&fp-y=0.5&h=431&q=90&w=310&s=50a1f0fb259452fb84453ee4216dd4f1',
'https://adrianbrinkerhoff.imgix.net/AdrianBrinkerhoff-MatthewThompson-108.jpg?auto=compress%2Cformat&bg=%23FFFFFF&crop=focalpoint&fit=crop&fp-x=0.5&fp-y=0.5&h=431&q=90&w=310&s=b4a75bdea66974a4f766ded52bfe9ba0',
'https://adrianbrinkerhoff.imgix.net/AdrianBrinkerhoff-MatthewThompson-32.jpg?auto=compress%2Cformat&bg=%23FFFFFF&crop=focalpoint&fit=crop&fp-x=0.5&fp-y=0.5&h=431&q=90&w=310&s=9199c53ea58a923373f7bcce1145193e'
];
const statusText = {
[BadgeType.INFO]: 'Pending',
[BadgeType.SUCCESS]: 'Succeeded',
[BadgeType.WARNING]: 'Warning',
[BadgeType.PENDING]: 'In transit',
[BadgeType.ERROR]: 'Error'
};
const statusTypes = [
BadgeType.INFO,
BadgeType.SUCCESS,
BadgeType.WARNING,
BadgeType.PENDING,
BadgeType.ERROR
];
function mockCustomer(): ICustomer {
const customer: ICustomer = {
email: 'kevin.midboe@gmail.com',
firstName: 'kevin',
lastName: 'midbøe',
streetAddress: 'Schleppegrells gate 18',
zipCode: '0556',
city: 'Oslo'
};
customer.fullName = `${customer.firstName} ${customer.lastName}`;
return customer;
}
export function mockOrder(id: string | null = null): IOrder {
const products = mockProducts(4);
const status = statusTypes[Math.floor(Math.random() * statusTypes.length)];
return {
uuid: id || generateUUID(),
products,
customer: mockCustomer(),
payment: {
amount: Math.round(Math.random() * 800),
currency: 'NOK'
},
createdDate: new Date(),
updatedDate: new Date(),
status: {
type: status,
text: statusText[status]
}
};
}
export function mockOrders(count: number): Array<IOrder> {
return Array.from(Array(count)).map(() => mockOrder());
}
export function mockProduct(): IProduct {
return {
uuid: generateUUID(),
name: productNames[Math.floor(Math.random() * productNames.length)],
price: Math.floor(Math.random() * 999),
quantity: Math.floor(Math.random() * 4) + 1,
currency: 'NOK',
image: images[Math.floor(Math.random() * images.length)]
};
}
export function mockProducts(count: number): Array<IProduct> {
return Array.from(Array(count)).map(() => mockProduct());
}

View File

@@ -1,11 +1,7 @@
import { dev } from '$app/environment'; import { buildApiUrl } from './apiUrl';
export default async function requestSessionCookie() { export default async function requestSessionCookie() {
let url = '/api'; const url = buildApiUrl('/api/v1');
if (dev) {
url = 'http://localhost:30010'.concat(url);
}
await fetch(url); await fetch(url);
return true; return true;
} }

View File

@@ -1,5 +1,6 @@
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { cart as cartStore } from './cartStore'; import { cart as cartStore } from './cartStore';
import type { ICartDTO } from './interfaces/ApiResponse';
const WS_HOST = '127.0.0.1'; const WS_HOST = '127.0.0.1';
const WS_PORT = 30010; const WS_PORT = 30010;
@@ -46,6 +47,19 @@ function sendPayload(payload: object) {
ws.send(JSON.stringify(payload)); ws.send(JSON.stringify(payload));
} }
// websocket.onmessage
function receivePayload(event: MessageEvent) {
try {
const json = JSON.parse(event?.data || {});
const { success, cart } = json as ICartDTO;
if (success && cart) cartStore.set(cart);
} catch {
console.debug('Non parsable message from server: ', event?.data);
}
}
// Called by routes/+layout.svelte on every navigation,
// if ws is closed we try reconnect
export function reconnectIfCartWSClosed() { export function reconnectIfCartWSClosed() {
const closed = ws?.readyState === 3; const closed = ws?.readyState === 3;
if (!closed) return; if (!closed) return;
@@ -71,12 +85,16 @@ export function connectToCart(attempts = 0, maxAttempts = 6) {
// TODO user feedback when max retries to reconnect, should refresh // TODO user feedback when max retries to reconnect, should refresh
// increasing timeout by a factor // increasing timeout by a factor
const planetId = getCookie('planetId'); const planet_id = getCookie('planet_id');
if (!planetId) return console.log('no cookie'); if (!planet_id) {
const seconds = attempts ** 2;
console.debug(`no cookie. Reconnect will be attempted in ${seconds} seconds.`);
wsReconnectTimeout = setTimeout(() => connectToCart(attempts, maxAttempts), seconds * 1000);
}
let url = `wss://${window.location.hostname}/ws/cart`; let url = `wss://${window.location.hostname}/ws/cart`;
if (dev) { if (dev) {
url = `ws://${WS_HOST}:${WS_PORT}/ws/cart?planetId=${planetId}`; url = `ws://${WS_HOST}:${WS_PORT}/ws/cart?planet_id=${planet_id}`;
} }
ws = new WebSocket(url); ws = new WebSocket(url);
@@ -87,15 +105,7 @@ export function connectToCart(attempts = 0, maxAttempts = 6) {
heartbeat(); heartbeat();
}; };
ws.onmessage = (event: MessageEvent) => { ws.onmessage = (event) => receivePayload(event);
try {
const json = JSON.parse(event?.data || {});
const { success, cart } = json;
if (success && cart) cartStore.set(cart);
} catch {
console.debug('Non parsable message from server: ', event?.data);
}
};
ws.onclose = () => { ws.onclose = () => {
const seconds = attempts ** 2; const seconds = attempts ** 2;

View File

@@ -3,40 +3,39 @@
import FrontText from '$lib/components/FrontText.svelte'; import FrontText from '$lib/components/FrontText.svelte';
import FrontTextImage from '$lib/components/FrontTextImage.svelte'; import FrontTextImage from '$lib/components/FrontTextImage.svelte';
import FrontTextImageBubble from '$lib/components/FrontTextImageBubble.svelte'; import FrontTextImageBubble from '$lib/components/FrontTextImageBubble.svelte';
// import FrontProductGallery from '$lib/components/FrontProductGallery.svelte';
import type IFrontTextImage from '$lib/interfaces/IFrontTextImage'; import type IFrontTextImage from '$lib/interfaces/IFrontTextImage';
// import type { IProduct } from '$lib/interfaces/IProduct';
import type IFrontText from '$lib/interfaces/IFrontText'; import type IFrontText from '$lib/interfaces/IFrontText';
const textImages: Array<IFrontTextImage> = [ const textImages: Array<IFrontTextImage> = [
{ {
title: 'Our story', title: 'Vårt oppdrag',
text: 'The new fabulous museum at Kistefos, designed by world renowned architect Bjarke Ingels Group, BIG, opened Wednesday September 18th, 2019. The building has been named the top architectural museum project in the world to open in 2019, by both the Daily Telegraph and Bloomberg.', text: 'The new fabulous museum at Kistefos, designed by world renowned architect Bjarke Ingels Group, BIG, opened Wednesday September 18th, 2019. The building has been named the top architectural museum project in the world to open in 2019, by both the Daily Telegraph and Bloomberg.',
image: image: 'https://storage.googleapis.com/planetposen-images/front-kf-1.jpg'
'https://kistefos.imgix.net/copyright_laurianghinitoiu_kistefosmuseum-4-of-17-7898.jpg?auto=compress%2Cformat&bg=%23FFFFFF&crop=focalpoint&fit=crop&fm=jpg&fp-x=0.5&fp-y=0.5&h=894&q=90&w=1300&s=6d142e2c43fff947bb5335cd53a566d6'
}, },
{ {
title: 'Paper waste and the planet', title: 'Paper waste and the planet',
text: "As the 50th artwork to be included in the park, a site-specific new commission by French artist Pierre Huyghe (b. 1962, Paris) was opened on the 12th of June. The vast permanent work will be the artist's largest site-specific work to date and the most ambitious to ever be conceived for Kistefos.", text: "As the 50th artwork to be included in the park, a site-specific new commission by French artist Pierre Huyghe (b. 1962, Paris) was opened on the 12th of June. The vast permanent work will be the artist's largest site-specific work to date and the most ambitious to ever be conceived for Kistefos.",
imageRight: true, imageRight: true,
image: image: 'https://storage.googleapis.com/planetposen-images/front-kf-2.jpg'
'https://kistefos.imgix.net/Pierre-kistefos-5_0717.jpg?auto=compress%2Cformat&bg=%23FFFFFF&crop=focalpoint&fit=crop&fm=jpg&fp-x=0.5&fp-y=0.5&h=894&q=90&w=1300&s=dc5feb98f29a39ea145a739397c42b29'
}, },
{ {
title: 'Our goal', title: 'Our goal',
text: 'The scenic sculpture park has an impressive collection of works by internationally renowned contemporary artists including Anish Kapoor, Jeppe Hein, Tony Cragg, Olafur Eliasson, Fernando Bottero and Elmgreen & Dragset. The sculpture park focus is sight specific and international contemporary works of art and is available all year.', text: 'The scenic sculpture park has an impressive collection of works by internationally renowned contemporary artists including Anish Kapoor, Jeppe Hein, Tony Cragg, Olafur Eliasson, Fernando Bottero and Elmgreen & Dragset. The sculpture park focus is sight specific and international contemporary works of art and is available all year.',
image: image: 'https://storage.googleapis.com/planetposen-images/front-kf-3.jpg'
'https://kistefos.imgix.net/Kistefos-EA-Marc-Quinn-All-of-Nature-Flows-Through-Us-0003.jpg?auto=compress%2Cformat&bg=%23FFFFFF&crop=focalpoint&fit=crop&fm=jpg&fp-x=0.5&fp-y=0.5&h=1333&q=90&w=1000&s=c65dada7d2f21cca29f3c758ddf5f81d'
}, },
{ {
title: 'About us', title: 'About us',
text: 'The scenic sculpture park has an impressive collection of works by internationally renowned contemporary artists including Anish Kapoor, Jeppe Hein, Tony Cragg, Olafur Eliasson, Fernando Bottero and Elmgreen & Dragset. The sculpture park focus is sight specific and international contemporary works of art and is available all year.', text: 'The scenic sculpture park has an impressive collection of works by internationally renowned contemporary artists including Anish Kapoor, Jeppe Hein, Tony Cragg, Olafur Eliasson, Fernando Bottero and Elmgreen & Dragset. The sculpture park focus is sight specific and international contemporary works of art and is available all year.',
imageRight: true, imageRight: true,
image: 'https://i.imgur.com/WWbfhiZ.jpg' image: 'https://storage.googleapis.com/planetposen-images/front-bee-1.jpg'
}, },
{ {
title: 'Sculpture park of international standing', title: 'Sculpture park of international standing',
text: 'The scenic sculpture park has an impressive collection of works by internationally renowned contemporary artists including Anish Kapoor, Jeppe Hein, Tony Cragg, Olafur Eliasson, Fernando Bottero and Elmgreen & Dragset. The sculpture park focus is sight specific and international contemporary works of art and is available all year.', text: 'The scenic sculpture park has an impressive collection of works by internationally renowned contemporary artists including Anish Kapoor, Jeppe Hein, Tony Cragg, Olafur Eliasson, Fernando Bottero and Elmgreen & Dragset. The sculpture park focus is sight specific and international contemporary works of art and is available all year.',
imageRight: false, imageRight: false,
image: 'https://i.imgur.com/ISCKKAq.jpg' image: 'https://storage.googleapis.com/planetposen-images/front-bee-2.jpg'
} }
]; ];
@@ -52,15 +51,53 @@
color: 'orange' color: 'orange'
} }
]; ];
// const galleryProducts: IProduct[] = [
// {
// description:
// " - 44'' Fabric by the Yard\n - 100% Cottton\n - Fabric Care: Machine Wash Normal, No Bleach, Tumble Dry Low\n - Printed in the USA from Imported Material.",
// image:
// 'https://storage.googleapis.com/planetposen-images/2c47ed96b5e061d85f688849b998aa5e76c55c2a.jpg',
// name: 'Oversized Blue And Yellow Swirls',
// primary_color: '#E6E0DC',
// product_no: 11,
// subtext: ''
// },
// {
// description:
// "The power of cotton is an almighty force for kids attire. Our cotton spandex interlock is a thicker fabric that's durable and naturally comfortable. Plus with breathability and slight stretch, children's tops and dresses are free-moving during play time. Choose from various designs like this dinosaur patch pattern, and you have a loving sewing project for awesome practicality and personality.\n\n - Width: 57 Inches\n - Content: 98% Cotton 2% Spandex\n - Care: Machine wash gentle cold, nonchlorine bleach, line dry, cool iron.\n - Imported",
// image:
// 'https://storage.googleapis.com/planetposen-images/838074447f08f03c4b75ac2030dcd01201c0656c.jpg',
// name: 'White Dinosaur Patches',
// primary_color: '#B1E0DC',
// product_no: 10,
// subtext: ''
// },
// {
// description:
// ' - Width: 43 inches\n - Content: 100% Cotton\n - Care: Machine wash normal cold, no bleach, tumble dry, do not iron\n - Imported',
// image:
// 'https://storage.googleapis.com/planetposen-images/aef3eb500a6b12b896b7c567b85eded6301d5c4a.jpg',
// name: 'Dino Camping',
// primary_color: '#EBB4BB',
// product_no: 9,
// subtext: 'Snuggle Flannel Fabric'
// }
// ];
</script> </script>
<PageMeta title="Planetposen" description="Planetposen hjemmeside" /> <PageMeta title="Hjem" description="Planetposen hjemmeside" />
<section class="frontpage"> <section class="frontpage">
<!-- {#each textImages as data} <!-- {#each textImages as data}
<TextImageParralax {data} /> <TextImageParralax {data} />
{/each} --> {/each} -->
<FrontTextImage data="{textImages[0]}" /> <FrontTextImage data="{textImages[0]}" />
<FrontTextImage data="{textImages[1]}" /> <FrontTextImage data="{textImages[1]}" />
<!-- <FrontProductGallery
products="{galleryProducts}"
title="Nye julevarer tilgjengelig!"
backgroundColor="#BCDEB7"
/> -->
<FrontTextImageBubble /> <FrontTextImageBubble />
<FrontTextImage data="{textImages[2]}" /> <FrontTextImage data="{textImages[2]}" />
@@ -74,9 +111,6 @@
<style lang="scss" module="scoped"> <style lang="scss" module="scoped">
section { section {
// position: absolute;
// left: 0;
// width: 100vw;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;

View File

@@ -1,14 +1,11 @@
<script lang="ts"> <script lang="ts">
import LinkArrow from '$lib/components/LinkArrow.svelte'; import LinkArrow from '$lib/components/LinkArrow.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
export let isAdmin = false; export let isAdmin = false;
function logout() { function logout() {
let url = '/api/logout'; const url = buildApiUrl('/api/v1/logout');
if (window?.location?.href.includes('localhost')) {
url = 'http://localhost:30010'.concat(url);
}
fetch(url, { method: 'POST' }).then((resp) => { fetch(url, { method: 'POST' }).then((resp) => {
resp.status === 200 && window.location.reload(); resp.status === 200 && window.location.reload();
}); });
@@ -40,7 +37,13 @@
<li>Org nummer: 994 749 765</li> <li>Org nummer: 994 749 765</li>
<li>Kode:&nbsp;<a class="link" href="https://github.com/kevinmidboe">kevinmidboe</a></li> <li>
Kode:&nbsp;<a
class="link"
href="https://github.com/search?q=user%3Akevinmidboe+planetposen&type=repositories"
>github.com</a
>
</li>
</ul> </ul>
</section> </section>
</footer> </footer>

View File

@@ -97,11 +97,11 @@
font-size: 1.8rem; font-size: 1.8rem;
} }
.corner img { // .corner img {
width: 2em; // width: 2em;
height: 2em; // height: 2em;
object-fit: contain; // object-fit: contain;
} // }
nav { nav {
display: flex; display: flex;

View File

@@ -0,0 +1,8 @@
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
return {
stripeApiKey: env.STRIPE_API_KEY || ''
};
};

View File

@@ -1,36 +1,139 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import OrderSection from './OrderSection.svelte'; import OrderSection from './OrderSection.svelte';
import DeliverySection from './DeliverySection.svelte'; import DeliverySection from './DeliverySection.svelte';
import PageMeta from '$lib/components/PageMeta.svelte'; import PageMeta from '$lib/components/PageMeta.svelte';
import CheckoutButton from '$lib/components/Button.svelte'; import CheckoutButton from '$lib/components/Button.svelte';
import StripeCard from '$lib/components/StripeCard.svelte'; import StripeCard from '$lib/components/StripeCard.svelte';
import ApplePayButton from '$lib/components/ApplePayButton.svelte'; import ErrorStack from '$lib/components/ErrorStack.svelte';
import VippsHurtigkasse from '$lib/components/VippsHurtigkasse.svelte';
import { cart } from '$lib/cartStore'; import { cart } from '$lib/cartStore';
import stripeApi from '$lib/stripe/index';
import { OrderSubmitUnsuccessfullError } from '$lib/errors/OrderErrors';
import Loading from '$lib/components/loading/index.svelte';
import { addBadElement, removeBadElements } from './validation';
import { buildApiUrl } from '$lib/utils/apiUrl';
import type { StripeCardElement } from '@stripe/stripe-js/types';
import type ICustomer from '$lib/interfaces/ICustomer';
import type {
IOrderCreateDTO,
IOrderCreateResponse,
IOrderCreateUnsuccessfullResponse
} from '$lib/interfaces/ApiResponse';
import type { PageData } from './$types';
function postOrder(event: any) { export let data: PageData;
const formData = new FormData(event.target); export let stripeApiKey: string = data.stripeApiKey;
const customerJson = {}; let card: StripeCardElement;
formData.forEach((value, key) => (customerJson[key] = value)); let form: HTMLFormElement;
let errors: string[] = [];
const options = { /* eslint-disable @typescript-eslint/no-explicit-any */
method: 'POST', let resolvePaymentPromise: (value: any) => void;
body: JSON.stringify({ let rejectPaymentPromise: (resason: any | null) => void;
customer: customerJson, let paymentPromise: Promise<any>;
cart: $cart /* eslint-enable @typescript-eslint/no-explicit-any */
}),
headers: {
'Content-Type': 'application/json'
}
};
let url = '/api/order'; function startPaymentLoader() {
if (window?.location?.href.includes('localhost')) { paymentPromise = new Promise((resolve, reject) => {
url = 'http://localhost:30010'.concat(url); resolvePaymentPromise = resolve;
rejectPaymentPromise = reject;
});
}
function handleSubmitOrderError(error: IOrderCreateUnsuccessfullResponse) {
console.log('got error from order api!', error, error?.validationErrors);
const { validationErrors } = error;
if (!validationErrors || validationErrors?.length == 0) {
errors.push('Ukjent feil ved plassering av ordre.');
return;
} }
fetch(url, options); validationErrors.forEach((verror) => {
errors.push(verror.message);
const l = document.getElementById(String(verror.field));
if (l) addBadElement(l, verror);
});
}
function handleStripePaymentError(error: Error) {
rejectPaymentPromise('error');
errors = [...errors, 'Betalingsfeil fra stripe: ' + error.message];
}
function getCustomerFromFormData(formData: FormData): ICustomer {
let zip_code: string | number = formData.get('zip_code') as string;
if (!isNaN(parseInt(zip_code))) {
zip_code = parseInt(zip_code);
} else {
zip_code = 0;
}
return {
email: formData.get('email') as string,
first_name: formData.get('first_name') as string,
last_name: formData.get('last_name') as string,
street_address: formData.get('street_address') as string,
zip_code,
city: formData.get('city') as string
};
}
async function postOrder(event: SubmitEvent) {
const formData = new FormData(event.target as HTMLFormElement);
const customer = getCustomerFromFormData(formData);
const orderData: IOrderCreateDTO = { customer, cart: $cart };
const options = {
method: 'POST',
body: JSON.stringify(orderData),
headers: { 'Content-Type': 'application/json' }
};
const url = buildApiUrl('/api/v1/order');
// TODO catch error
let orderResponse: IOrderCreateResponse;
try {
orderResponse = await fetch(url, options).then((resp) => resp.json());
errors = [];
removeBadElements();
if (orderResponse?.success !== true) {
throw new OrderSubmitUnsuccessfullError(orderResponse as IOrderCreateUnsuccessfullResponse);
}
} catch (error: any) {
handleSubmitOrderError(error);
return;
}
// TODO catch error
startPaymentLoader();
let stripePaymentResponse;
try {
const stripeClientSecret = await stripeApi.clientSecret(
orderResponse?.order_id,
orderResponse?.customer_no
);
stripePaymentResponse = await stripeApi.pay(stripeClientSecret, {
payment_method: {
card,
billing_details: {
name: `${customer.first_name} ${customer.last_name}`
}
}
});
if (stripePaymentResponse) {
resolvePaymentPromise(true);
goto(`/receipt/${orderResponse?.order_id}`);
}
} catch (error: any) {
return handleStripePaymentError(error);
}
} }
</script> </script>
@@ -39,8 +142,8 @@
description="Kasse for bestilling og betaling av produkter i handlekurven" description="Kasse for bestilling og betaling av produkter i handlekurven"
/> />
<h1>Checkout</h1> <h1>Kassen</h1>
<form class="checkout" on:submit|preventDefault="{postOrder}"> <form class="checkout" bind:this="{form}" on:submit|preventDefault="{postOrder}">
<section id="delivery"> <section id="delivery">
<h2>Leveringsaddresse</h2> <h2>Leveringsaddresse</h2>
<DeliverySection /> <DeliverySection />
@@ -48,31 +151,30 @@
<section id="order"> <section id="order">
<h2>Din ordre</h2> <h2>Din ordre</h2>
<OrderSection> <OrderSection />
<div class="navigation-buttons" slot="button">
<ApplePayButton />
<VippsHurtigkasse />
</div>
</OrderSection>
</section> </section>
<section id="payment"> <section id="payment">
<h2>Betalingsinformasjon</h2> <h2>Betalingsinformasjon</h2>
<StripeCard /> <StripeCard bind:card="{card}" stripeApiKey="{stripeApiKey}" />
<div class="pay"> <div class="pay">
<CheckoutButton type="submit" text="Betal" /> <CheckoutButton type="submit" text="Betal" />
<div class="payment-state-animation">
{#if paymentPromise}
<Loading promise="{paymentPromise}" />
{/if}
</div>
</div> </div>
</section> </section>
</form> </form>
<ErrorStack bind:errors="{errors}" />
<style lang="scss" module="scoped"> <style lang="scss" module="scoped">
@import '../../styles/media-queries.scss'; @import '../../styles/media-queries.scss';
form.checkout { form.checkout {
// display: flex;
// flex-wrap: wrap;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
'delivery order' 'delivery order'
@@ -80,7 +182,6 @@
grid-gap: 2rem; grid-gap: 2rem;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
// grid-auto-flow: column;
@include mobile { @include mobile {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -93,18 +194,12 @@
.pay { .pay {
margin: 2rem 0; margin: 2rem 0;
}
.navigation-buttons {
display: flex; display: flex;
justify-content: flex-start;
margin-top: 2rem;
flex-wrap: wrap;
} }
:global(.navigation-buttons > *) { :global(.pay .payment-state-animation svg) {
margin-right: 1rem; margin-left: 1.5rem;
margin-bottom: 1rem; width: 34px;
} }
#delivery { #delivery {
@@ -126,10 +221,30 @@
padding-left: 4px; padding-left: 4px;
text-transform: none; text-transform: none;
font-size: 2.3rem; font-size: 2.3rem;
padding: 12px 10px 12px 12px !important; padding: 12px 10px 12px 0;
font-weight: 500; font-weight: 500;
color: #231f20; color: #231f20;
line-height: 1.1; line-height: 1.1;
} }
} }
:global(.bad-msg) {
position: absolute;
left: 0;
margin-top: -2.3rem;
padding: 0.5rem;
background-color: #ff3333;
color: white;
}
:global(.bad-form) {
// border: 2px solid var(--color-theme-1);
background-color: rgba(255, 0, 0, 0.1);
color: var(--color-theme-1);
font-size: 1.1rem;
padding: 0.5rem;
margin: -0.5rem;
position: relative;
border-color: rgba(255, 0, 0, 0.1);
}
</style> </style>

View File

@@ -11,11 +11,16 @@
<div class="personalia"> <div class="personalia">
<Input id="email" bind:value="{email}" label="E-postaddresse" autocomplete="email" /> <Input id="email" bind:value="{email}" label="E-postaddresse" autocomplete="email" />
<Input id="firstName" bind:value="{firstName}" label="Fornavn" autocomplete="given-name" /> <Input id="first_name" bind:value="{firstName}" label="Fornavn" autocomplete="given-name" />
<Input id="lastName" bind:value="{lastName}" label="Etternavn" autocomplete="family-name" /> <Input id="last_name" bind:value="{lastName}" label="Etternavn" autocomplete="family-name" />
<Input id="address" bind:value="{address}" label="Gateadresse" autocomplete="address-line1" /> <Input
<Input id="zipCode" bind:value="{zipCode}" label="Postnummer" autocomplete="postal-code" /> id="street_address"
bind:value="{address}"
label="Gateadresse"
autocomplete="address-line1"
/>
<Input id="zip_code" bind:value="{zipCode}" label="Postnummer" autocomplete="postal-code" />
<Input id="city" bind:value="{city}" label="By" autocomplete="address-level2" /> <Input id="city" bind:value="{city}" label="By" autocomplete="address-level2" />
<div id="personalia-submit-button"> <div id="personalia-submit-button">

View File

@@ -4,76 +4,78 @@
import { cart, subTotal } from '$lib/cartStore'; import { cart, subTotal } from '$lib/cartStore';
import { decrementProductInCart, incrementProductInCart } from '$lib/websocketCart'; import { decrementProductInCart, incrementProductInCart } from '$lib/websocketCart';
// $: totalPrice = $cart
// .map((order: IOrderLineItem) => order?.price * order.quantity)
// .reduce((a, b) => a + b);
const shippingPrice = 75; const shippingPrice = 75;
$: totalPrice = $subTotal + shippingPrice;
function lineItemClass(id: number) {
return `lineitem-${id}`;
}
</script> </script>
<table class="checkout"> <div style="border: 2px solid black">
<thead> <table class="order-summary">
<tr> <thead style="border-bottom: 2px solid black;">
<th>Varenavn</th> <tr>
<th>Antall</th> <th>Varenavn</th>
<th>Pris</th> <th>Antall</th>
</tr> <th>Pris</th>
</thead>
<tbody>
{#if $cart.length}
{#each $cart as order}
<tr>
<td>
<div class="line-order">
<a href="/shop/{order.product_no}"><span>{order.name}</span></a>
<span class="subtext">Størrelse: {order.size}</span>
</div>
</td>
<td>
<QuantitySelect
bind:value="{order.quantity}"
hideButtons="{true}"
on:decrement="{() => decrementProductInCart(order.lineitem_id)}"
on:increment="{() => incrementProductInCart(order.lineitem_id)}"
/>
</td>
<td>Nok {order.quantity * order.price}</td>
</tr>
{/each}
{:else}
<tr class="no-products">
<td>(ingen produkter)</td>
<td>0</td>
<td>Nok 0</td>
</tr> </tr>
{/if} </thead>
<tr> <tbody>
<td>Totalpris:</td> {#if $cart.length}
<td></td> {#each $cart as cartItem}
<td>Nok {$subTotal}</td> <tr id="{lineItemClass(cartItem.lineitem_id)}">
</tr> <td>
<div class="line-order">
<a href="/shop/{cartItem.product_no}"><span>{cartItem.name}</span></a>
<span class="subtext">Størrelse: {cartItem.size}</span>
</div>
</td>
<td>
<QuantitySelect
bind:value="{cartItem.quantity}"
hideButtons="{true}"
on:decrement="{() => decrementProductInCart(cartItem.lineitem_id)}"
on:increment="{() => incrementProductInCart(cartItem.lineitem_id)}"
/>
</td>
<td>Nok {cartItem.quantity * cartItem.price}</td>
</tr>
{/each}
{:else}
<tr class="no-products">
<td>(ingen produkter)</td>
<td>0</td>
<td>Nok 0</td>
</tr>
{/if}
<tr> <tr style="border-bottom-color: rgba(0,0,0,0.15)">
<td>Frakt:</td> <td>Totalsum:</td>
<td></td> <td></td>
<td>Nok {shippingPrice}</td> <td>Nok {$subTotal}</td>
</tr> </tr>
<tr style="font-weight: 600"> <tr style="border-bottom-color: rgba(0,0,0,0.15)">
<td>Totalsum:</td> <td>Frakt:</td>
<td></td> <td></td>
<td>Nok {$subTotal}</td> <td>Nok {shippingPrice}</td>
</tr> </tr>
</tbody>
</table>
<slot name="button" /> <tr style="font-weight: 600">
<td>Pris:</td>
<td></td>
<td>Nok {totalPrice}</td>
</tr>
</tbody>
</table>
</div>
<!-- <slot name="express-checkout-buttons" /> -->
<style lang="scss" module="scoped"> <style lang="scss" module="scoped">
table.checkout { table.order-summary {
width: 100%; width: 100%;
border: 2px solid #dbd9d5;
border-collapse: collapse; border-collapse: collapse;
thead { thead {
@@ -101,8 +103,11 @@
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.5);
} }
tr:not(:last-of-type) {
border-bottom: 2px solid black;
}
td { td {
border: 2px solid #dbd9d5;
padding: 1rem 0.5rem; padding: 1rem 0.5rem;
min-width: 50px; min-width: 50px;
font-size: 1.1rem; font-size: 1.1rem;

View File

@@ -0,0 +1,27 @@
let badElements: HTMLElement[] = [];
interface IOrderValidationError {
type: string;
field: number | string;
message: string;
}
export function removeBadElements() {
badElements.forEach((element) => element.classList.remove('bad-form'));
badElements.forEach((element) => {
const msgErrorElement = element?.getElementsByClassName('bad-msg');
if (!msgErrorElement || msgErrorElement?.length === 0) return;
msgErrorElement[0].remove();
});
badElements = [];
}
export function addBadElement(element: HTMLElement, vError: IOrderValidationError) {
element.classList.add('bad-form');
badElements.push(element);
const msgElement = document.createElement('div');
msgElement.classList.add('bad-msg');
msgElement.innerText = vError.message;
element.appendChild(msgElement);
}

View File

@@ -26,6 +26,18 @@
er nødvendige for at nettstedet vårt skal fungere normalt. er nødvendige for at nettstedet vårt skal fungere normalt.
</p> </p>
<p>
<strong>Hvordan bruker vi informasjonskaplser?</strong>
Denne nettbutikken er open-source så all kode du intragerer med kan søkes igjennom på github.com.
Om du lurer på hvordan vi bruker informasjonskapsler kan du ta en titt på koden her:
<a
class="link"
href="https://github.com/search?q=planet_id+user%3Akevinmidboe&type=Code"
target="_blank"
rel="noreferrer">https://github.com/search?q=planet_id+user%3Akevinmidboe&type=Code</a
>
</p>
<p> <p>
<strong>Tredjepart informasjonskapsler</strong> <strong>Tredjepart informasjonskapsler</strong>
Planetposen.no lagrer ingen informasjonskapsler tilknyttet noen tredjepart. Planetposen.no lagrer ingen informasjonskapsler tilknyttet noen tredjepart.

View File

@@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Input from '$lib/components/Input.svelte'; import Input from '$lib/components/Input.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
let username: string; let username: string;
let password: string; let password: string;
let displayMessage: string | null; let displayMessage: string | null;
function postLogin(event: any) { function postLogin(event: SubmitEvent) {
displayMessage = null; displayMessage = null;
const formData = new FormData(event.target); const formData = new FormData(event.target as HTMLFormElement);
const data = {}; const data = {};
formData.forEach((value, key) => (data[key] = value)); formData.forEach((value, key) => (data[key] = value));
@@ -20,11 +21,7 @@
} }
}; };
let url = '/api/login'; const url = buildApiUrl('/api/v1/login');
if (window?.location?.href.includes('localhost')) {
url = 'http://localhost:30010'.concat(url);
}
fetch(url, options) fetch(url, options)
.then((resp) => { .then((resp) => {
const { status } = resp; const { status } = resp;

View File

@@ -1,16 +1,8 @@
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
let url = '/api/orders'; const res = await fetch('/api/v1/orders');
if (dev || env.API_HOST) {
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url);
const response = await res.json(); const response = await res.json();
console.log('orders length:', response?.orders);
return { return {
orders: response?.orders || [] orders: response?.orders || []

View File

@@ -1,13 +1,15 @@
<script lang="ts"> <script lang="ts">
import OrdersTable from './OrdersTable.svelte'; import OrdersTable from './OrdersTable.svelte';
import BadgeType from '$lib/interfaces/BadgeType'; import BadgeType from '$lib/interfaces/BadgeType';
import PageMeta from '$lib/components/PageMeta.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { IOrder } from '$lib/interfaces/IOrder'; import type { IOrderSummary } from '$lib/interfaces/IOrder';
export let data: PageData; export let data: PageData;
const orders = data.orders as Array<IOrder>; const orders = data.orders as Array<IOrderSummary>;
const pendingOrders = orders.filter( const successfulOrders = orders.filter((el) => el.status === 'CONFIRMED');
const incompleteOrders = orders.filter(
(el) => el.status === BadgeType.INFO || el.status === 'INITIATED' (el) => el.status === BadgeType.INFO || el.status === 'INITIATED'
); );
const inTransitOrders = orders.filter((el) => el.status === BadgeType.PENDING); const inTransitOrders = orders.filter((el) => el.status === BadgeType.PENDING);
@@ -17,38 +19,25 @@
el.status !== BadgeType.PENDING && el.status !== BadgeType.PENDING &&
el.status !== BadgeType.INFO && el.status !== BadgeType.INFO &&
el.status !== 'INITIATED' && el.status !== 'INITIATED' &&
el.status !== 'CONFIRMED' &&
el.status !== BadgeType.WARNING el.status !== BadgeType.WARNING
); );
const deliveredOrders: Array<IOrder> = []; const deliveredOrders: Array<IOrderSummary> = [];
</script> </script>
<PageMeta title="Orders" description="View all webshop orders" />
<div class="page"> <div class="page">
<h1>Orders</h1> <h1>Orders</h1>
<section class="content"> <section class="content">
{#if attentionOrders?.length} {#if attentionOrders?.length}
<h2>⚠️ orders needing attention</h2> <OrdersTable title="⚠️ orders needing attention" orders="{attentionOrders}" />
<OrdersTable orders="{attentionOrders}" />
{/if} {/if}
<h2>📬 pending orders</h2> <OrdersTable title="📬 purchased orders" orders="{successfulOrders}" />
<OrdersTable orders="{pendingOrders}" /> <OrdersTable title="📦 in transit" orders="{inTransitOrders}" />
<OrdersTable title="🙅‍♀️ cancelled/returns" orders="{otherOrders}" />
<h2>📦 in transit</h2> <OrdersTable title="💤 incomplete orders" orders="{incompleteOrders}" />
<OrdersTable orders="{inTransitOrders}" /> <OrdersTable title="🎁🏠 delivered orders" orders="{deliveredOrders}" />
<h2>🙅‍♀️ cancelled/returns</h2>
<OrdersTable orders="{otherOrders}" />
<h2>🏠🎁 delivered orders</h2>
<OrdersTable orders="{deliveredOrders}" />
</section> </section>
</div> </div>
<style lang="scss" module="scoped">
section.content h2 {
// text-decoration: underline;
font-size: 1.2rem;
}
</style>

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Badge from '$lib/components/Badge.svelte'; import Badge from '$lib/components/Badge.svelte';
import Time from '$lib/components/Time.svelte';
import type { IOrderSummary } from '$lib/interfaces/IOrder'; import type { IOrderSummary } from '$lib/interfaces/IOrder';
export let title: string;
export let orders: Array<IOrderSummary>; export let orders: Array<IOrderSummary>;
function navigate(order: IOrderSummary) { function navigate(order: IOrderSummary) {
@@ -10,15 +12,16 @@
} }
</script> </script>
<h2>{title} <span class="section-count">{orders?.length || 0}</span></h2>
{#if orders?.length} {#if orders?.length}
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Amount</th> <th>Amount</th>
<th>Status</th> <th>Status</th>
<th>Order ID</th>
<th>Customer</th> <th>Customer</th>
<th>Date</th> <th>Date</th>
<th>Order ID</th>
<th>Receipt</th> <th>Receipt</th>
</tr> </tr>
</thead> </thead>
@@ -29,18 +32,12 @@
<td>NOK {order.order_sum}</td> <td>NOK {order.order_sum}</td>
<td> <td>
<Badge title="{order.status}" type="{order?.status?.type}" /> <Badge title="{order.status}" />
</td> </td>
<td>{order.order_id}</td>
<td>{order.first_name} {order.last_name}</td> <td>{order.first_name} {order.last_name}</td>
<td <td><Time time="{order?.created}" /></td>
>{order?.created <td>{order.order_id}</td>
? new Intl.DateTimeFormat('nb-NO', { dateStyle: 'short', timeStyle: 'short' }).format(
new Date(order.created)
)
: ''}</td
>
<td> <td>
<a href="receipt/{order.order_id}?email={order.email}">🧾</a> <a href="receipt/{order.order_id}?email={order.email}">🧾</a>
</td> </td>
@@ -55,6 +52,18 @@
<style lang="scss" module="scoped"> <style lang="scss" module="scoped">
@import '../../styles/media-queries.scss'; @import '../../styles/media-queries.scss';
h2 {
font-size: 1.2rem;
.section-count {
background-color: rgba(0, 0, 0, 0.15);
padding: 0.3rem 0.4rem;
margin-left: 0.5rem;
border-radius: 0.5rem;
font-size: 1rem;
}
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -92,8 +101,14 @@
} }
} }
th:last-of-type,
td:last-of-type {
text-align: center;
}
@include mobile { @include mobile {
tr > *:first-child { tr > *:nth-child(4),
tr > *:nth-child(5) {
display: none; display: none;
} }
} }

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { page } from '$app/stores';
console.log('page data:', $page.error);
$: parsedApiResponse = JSON.stringify($page.error?.apiResponse || {}, null, 4);
</script>
<h1>Oisann! Klarte ikke hente order</h1>
<p>
Det du søkte etter fantes ikke her. Om du tror dette er en feil, ta kontakt <a
class="link"
href="mailto:support@planetposen.no">support@planetposen.no</a
> for spørsmål.
</p>
<div class="error">
<span>Internal error message:</span>
<pre>
<code>{parsedApiResponse}</code>
</pre>
</div>
<style lang="scss">
.error {
margin-top: 4rem;
span {
display: inline-block;
font-size: 1.4rem;
text-decoration: none;
transition: all 0.3s ease;
border-bottom: 2px solid var(--color-theme-1);
}
code {
display: block;
font-size: 1rem;
}
}
</style>

View File

@@ -1,22 +1,21 @@
import { dev } from '$app/environment'; import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private'; import type { IOrderDTO } from '$lib/interfaces/ApiResponse';
import type { IOrderResponse } from '$lib/interfaces/ApiResponse';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params }) => { export const load: PageServerLoad = async ({ fetch, params }) => {
const { id } = params; const { id } = params;
let url = `/api/order/${id}`; const res = await fetch(`/api/v1/order/${id}`);
if (dev || env.API_HOST) { const orderResponse = await res.json();
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url);
const orderResponse: IOrderResponse = await res.json();
if (orderResponse?.success == false || orderResponse?.order === undefined) { if (orderResponse?.success == false || orderResponse?.order === undefined) {
throw Error(':('); console.log('throwing error', orderResponse);
throw error(404, {
apiResponse: orderResponse,
message: 'Something went wrong! Unable to get order'
});
} }
return { order: orderResponse.order }; return { order: orderResponse?.order };
}; };

View File

@@ -9,10 +9,9 @@
export let data: PageServerData; export let data: PageServerData;
let order = data.order as IOrder; let order = data.order as IOrder;
console.log('order:', order);
function orderSubTotal() { function orderSubTotal() {
if (!order || order.lineItems?.length === 0) return; if (!order || order?.lineItems?.length === 0) return;
let sum = 0; let sum = 0;
order.lineItems.forEach((lineItem) => (sum = sum + lineItem.quantity * lineItem.price)); order.lineItems.forEach((lineItem) => (sum = sum + lineItem.quantity * lineItem.price));
@@ -21,20 +20,21 @@
} }
</script> </script>
<h1>Order: {order.orderid}</h1> <h1>Order id: {order?.orderid}</h1>
<div class="order"> <div class="order">
<!-- <p>Order: {JSON.stringify(order)}</p> -->
<h2 class="price"><span class="amount">{orderSubTotal()}.00</span> Nok</h2> <h2 class="price"><span class="amount">{orderSubTotal()}.00</span> Nok</h2>
<OrderSummary order="{order}" /> <OrderSummary order="{order}" />
<OrderProducts lineItems="{order?.lineItems}" /> <OrderProducts lineItems="{order?.lineItems}" />
<PaymentDetails order="{order}" /> <PaymentDetails payment="{order?.payment}" />
<CustomerDetails customer="{order?.customer}" /> <CustomerDetails customer="{order?.customer}" />
<TrackingDetails shipping="{order?.shipping}" /> <TrackingDetails shipping="{order?.shipping}" orderId="{order?.orderid}" />
</div> </div>
<style lang="scss"> <style lang="scss">
@import '../../../styles/media-queries.scss';
h2.price { h2.price {
font-size: 1.5rem; font-size: 1.5rem;
color: grey; color: grey;

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { ICustomer } from '$lib/interfaces/IOrder'; import type ICustomer from '$lib/interfaces/ICustomer';
export let customer: ICustomer; export let customer: ICustomer;
@@ -16,17 +16,17 @@
<li> <li>
<span class="label">Name</span> <span class="label">Name</span>
<span class="name">{customer.firstname} {customer.lastname}</span> <span class="name">{customer.first_name} {customer.last_name}</span>
</li> </li>
<li> <li>
<span class="label">Street address</span> <span class="label">Street address</span>
<span>{customer.streetaddress}</span> <span>{customer.street_address}</span>
</li> </li>
<li> <li>
<span class="label">Zip & City</span> <span class="label">Zip & City</span>
<span>{zeroPadZip(customer.zipcode)}, {customer.city}</span> <span>{zeroPadZip(customer.zip_code)}, {customer.city}</span>
</li> </li>
</ul> </ul>
</section> </section>

View File

@@ -39,9 +39,13 @@
</table> </table>
<style lang="scss"> <style lang="scss">
// @import "../styles/global.scss";
@import '../../../styles/media-queries.scss'; @import '../../../styles/media-queries.scss';
@import './styles-order-page.scss';
h2 {
width: 100%;
font-size: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.3);
}
table { table {
width: 100%; width: 100%;
@@ -63,23 +67,20 @@
.image-column { .image-column {
width: 4rem; width: 4rem;
max-width: 4rem; max-width: 4rem;
margin: 0 0.5rem;
@include desktop {
margin: 0 0.5rem;
}
} }
td, td,
th { th {
white-space: nowrap;
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
} }
tbody { tbody {
a {
font-size: inherit;
}
img { img {
width: 4rem; width: 4rem;
height: 4rem;
border-radius: 0.4rem; border-radius: 0.4rem;
} }
@@ -102,11 +103,5 @@
display: none; display: none;
} }
} }
// @include mobile {
// tr > *:last-child, tr > :nth-child(4) {
// display: none;
// }
// }
} }
</style> </style>

View File

@@ -1,43 +1,65 @@
<script lang="ts"> <script lang="ts">
import Badge from '$lib/components/Badge.svelte';
import Time from '$lib/components/Time.svelte';
import type { IOrder } from '$lib/interfaces/IOrder'; import type { IOrder } from '$lib/interfaces/IOrder';
export let order: IOrder; export let order: IOrder;
let paymentMethod: string = Math.random() > 0.5 ? 'Stripe' : 'ApplePay';
</script> </script>
<ul class="summary-list"> <ul class="summary-list">
<li>
<span class="label">Status</span>
<Badge title="{order.status}" />
</li>
<li> <li>
<span class="label">Last update</span> <span class="label">Last update</span>
<span>{order.updated}</span> <Time time="{order.updated}" />
</li>
<li>
<span class="label">Created</span>
<Time time="{order.created}" />
</li> </li>
<li> <li>
<span class="label">Customer</span> <span class="label">Customer</span>
<span class="name">{order.customer.firstname} {order.customer.lastname}</span> <span class="name">{order.customer.first_name} {order.customer.last_name}</span>
</li> </li>
<li> <li>
<span class="label">Receipt</span> <span class="label">Receipt</span>
<span><a href="/receipt/{order.orderid}">{order.orderid}</a></span> <a href="/receipt/{order.orderid}" class="link">{order.orderid}</a>
</li> </li>
<li> <li>
<span class="label">Payment method</span> <span class="label">Payment method</span>
<span>{paymentMethod}</span> <span style="text-transform: capitalize;">{order?.payment?.type}</span>
</li> </li>
</ul> </ul>
<style lang="scss"> <style lang="scss">
@import './styles-order-page.scss'; .label {
color: grey;
}
span.name {
text-transform: capitalize;
}
ul { ul {
width: 100%; width: 100%;
list-style: none;
padding-left: 0;
margin-top: 0;
} }
li { li {
padding: 0.4rem 0;
display: inline-flex; display: inline-flex;
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
white-space: pre;
&:not(:last-of-type) { &:not(:last-of-type) {
margin-right: 3rem; margin-right: 3rem;

View File

@@ -1,36 +1,94 @@
<script lang="ts"> <script lang="ts">
import Badge from '$lib/components/Badge.svelte'; import Badge from '$lib/components/Badge.svelte';
// import type BadgeType from '$lib/interfaces/BadgeType'; import Time from '$lib/components/Time.svelte';
import type { IOrder } from '$lib/interfaces/IOrder'; import type { IStripePayment } from '$lib/interfaces/IOrder';
export let order: IOrder; export let payment: IStripePayment;
const initiatedAmount = payment?.amount ? payment.amount / 100 : 0;
const capturedAmount = payment?.amount_captured ? payment.amount_captured / 100 : 0;
const refundedAmount = payment?.amount_refunded ? payment.amount_refunded / 100 : 0;
function calculateApproximateFee() {
if (!payment?.amount_captured) return null;
const percentCut = 0.024;
const fixedCut = 2;
return capturedAmount * percentCut + fixedCut;
}
function round(num: number) {
return Math.round(num * 100) / 100;
}
let stripeDashboardUrl = `https://dashboard.stripe.com/test/payments/${payment?.stripe_transaction_id}`;
const fee = calculateApproximateFee();
const net = fee ? capturedAmount - fee - refundedAmount : null;
</script> </script>
<section> <section>
<h2>Payment details</h2> <h2>Payment details</h2>
<ul class="property-list"> <ul class="property-list">
<li> <li>
<span class="label">Amount</span> <span class="label">Stripe link</span>
<span>{order?.payment?.amount}.10 kr</span> {#if payment?.stripe_transaction_id}
<a href="{stripeDashboardUrl}" class="link" target="_blank" rel="noreferrer">
<span>{payment?.stripe_transaction_id}</span>
</a>
{:else}
<span>(No payment found)</span>
{/if}
</li> </li>
<li> <li>
<span class="label">Fee</span> <span class="label">Amount requested</span>
<span>2.25 kr</span> <span>{initiatedAmount}.00 kr</span>
</li>
<li>
<span class="label">Amount charged</span>
<span>{capturedAmount}.00 kr</span>
</li>
{#if payment?.amount_refunded > 0}
<li>
<span class="label">Refunded</span>
<span>{payment?.amount_refunded / 100}.00 kr</span>
</li>
{/if}
<li>
<span class="label">Fee (approx)</span>
<span>{fee === null ? '-' : round(fee) + ' kr'}</span>
</li> </li>
<li> <li>
<span class="label">Net</span> <span class="label">Net</span>
<span>7.85 kr</span> <span>{net === null ? '-' : round(net) + ' kr'}</span>
</li> </li>
<li> <li>
<span class="label">Status</span> <span class="label">Status</span>
<Badge title="{order?.status?.text || order.status}" type="{order.status.type}" /> <Badge title="{payment?.stripe_status}" />
</li>
<li>
<span class="label">Created</span>
<Time time="{payment?.created}" />
</li>
<li>
<span class="label">Updated</span>
<Time time="{payment?.updated}" />
</li> </li>
</ul> </ul>
</section> </section>
<style lang="scss"> <style lang="scss">
@import './styles-order-page.scss'; @import './styles-order-page.scss';
a.link {
max-width: 60%;
overflow-x: hidden;
}
</style> </style>

View File

@@ -1,35 +1,138 @@
<script lang="ts"> <script lang="ts">
import type { IShipping } from '$lib/interfaces/IOrder'; import Button from '$lib/components/Button.svelte';
import ShipmentProgress from '$lib/components/ShipmentProgress.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
import type { IShipmentResponse, ICourier } from '$lib/interfaces/IShipping';
export let shipping: IShipping; export let shipping: IShipmentResponse;
export let orderId: string;
function fetchCouriers() {
if (couriers?.length > 0) return couriers;
const url = buildApiUrl('/api/v1/shipment/couriers');
fetch(url)
.then((resp) => resp.json())
.then((response) => (couriers = response?.couriers || []));
}
function updateShipment() {
const url = buildApiUrl(`/api/v1/shipment/${shipping.shipment_id}`);
const options = {
method: 'PUT',
body: JSON.stringify({
tracking_code: trackingCode,
tracking_link: trackingLink,
courier_id: selectedCourier
}),
headers: { 'Content-Type': 'application/json' }
};
fetch(url, options)
.then((resp) => resp.json())
.then((response) => (shipping = response?.shipment || shipping));
}
function addShipment() {
const url = buildApiUrl(`/api/v1/shipment/${orderId}`);
fetch(url, { method: 'POST' })
.then((resp) => resp.json())
.then((response) => {
shipping = response?.shipment;
toggleEdit();
});
}
function toggleEdit() {
edit = !edit;
if (edit) fetchCouriers();
else updateShipment();
}
let edit = false;
let trackingCode: string = shipping?.tracking_code;
let trackingLink: string = shipping?.tracking_link;
let selectedCourier: any = shipping?.courier_id;
let couriers: ICourier[];
</script> </script>
{#if shipping} <section>
<section> <h2>Tracking</h2>
<h2>Tracking</h2>
{#if shipping}
<ul class="property-list"> <ul class="property-list">
<li> <li>
<span class="label">Tracking code</span> <label class="label" for="courier">Tracking company</label>
<span>{shipping.tracking_code}</span> {#if !edit}
<span>{shipping.courier}</span>
{:else if couriers?.length > 0}
<select bind:value="{selectedCourier}" name="couriers" id="courier">
<option value="">--Please choose an option--</option>
{#each couriers as courier}
<option value="{courier.courier_id}">{courier.name}</option>
{/each}
</select>
{/if}
</li> </li>
<li> <li>
<span class="label">Tracking company</span> <span class="label">Tracking code</span>
<span>{shipping.company}</span> {#if !edit}
<span>{shipping.tracking_code}</span>
{:else}
<input bind:value="{trackingCode}" />
{/if}
</li> </li>
<li> <li>
<span class="label">Link</span> <span class="label">Link</span>
<span {#if !edit}
><a href="{shipping.tracking_link}" target="_blank" rel="noopener noreferrer" <a href="{shipping.tracking_link}" class="link" target="_blank" rel="noopener noreferrer">
>{shipping.tracking_link}</a {shipping.tracking_link}
></span </a>
> {:else}
<input style="margin-bottom: 0" class="wide" bind:value="{trackingLink}" />
{/if}
</li> </li>
</ul> </ul>
</section>
{/if} <div class="button-container">
<Button text="{!edit ? 'Edit' : 'Save'}" on:click="{toggleEdit}" />
</div>
{#if shipping?.tracking_code && shipping?.has_api}
{#key shipping.tracking_code}
<ShipmentProgress shipment="{shipping}" />
{/key}
{/if}
{/if}
{#if !shipping}
<div class="button-container">
<Button text="Add" on:click="{addShipment}" />
</div>
{/if}
</section>
<style lang="scss"> <style lang="scss">
@import './styles-order-page.scss'; @import './styles-order-page.scss';
a {
max-width: 60%;
white-space: pre-wrap;
word-wrap: break-word;
}
select {
margin: -1px 0;
}
input {
margin: -2px 0 -2px -2px;
padding: 0;
&.wide {
width: -webkit-fill-available;
}
}
</style> </style>

View File

@@ -16,8 +16,7 @@ section {
} }
} }
.label, .label {
.empty {
color: grey; color: grey;
} }
@@ -51,7 +50,6 @@ ul.property-list {
li span:last-of-type { li span:last-of-type {
@include mobile { @include mobile {
min-width: 60%;
white-space: normal; white-space: normal;
overflow-wrap: break-word; overflow-wrap: break-word;
} }

View File

@@ -33,7 +33,7 @@
<p> <p>
Kontaktperson: Leigh Midbøe<br /> Kontaktperson: Leigh Midbøe<br />
E-post: <a href="mailto:contact@planetposen.no">contact@planetposen.no</a> E-post: <a class="link" href="mailto:contact@planetposen.no">contact@planetposen.no</a>
</p> </p>
<p> <p>

View File

@@ -1,10 +1,33 @@
import validOrderId from '$lib/utils/validOrderId';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ params }) => { export const load: PageServerLoad = async ({ fetch, params, url }) => {
const { id } = params; const { id } = params;
const email = url.searchParams.get('email');
let order = null;
try {
const res = await fetch(`/api/v1/order/${id}`);
if (res?.status === 404) {
return {
id,
email,
order: null
};
}
const orderResponse = await res.json();
if (orderResponse?.order && orderResponse?.order?.lineItems?.length > 0) {
order = orderResponse?.order;
}
} catch (error) {
console.error('unable to parse order response');
throw error;
}
return { return {
id, id,
isValidReceipt: validOrderId(id) email,
order
}; };
}; };

View File

@@ -1,14 +1,27 @@
<script lang="ts"> <script lang="ts">
import PoolInitiatedOrder from './PoolInitiatedOrder.svelte';
import ReceiptNotFound from './ReceiptNotFound.svelte'; import ReceiptNotFound from './ReceiptNotFound.svelte';
import BadgeType from '$lib/interfaces/BadgeType';
import type { PageData } from './$types'; import type { PageData } from './$types';
import PageMeta from '$lib/components/PageMeta.svelte';
export let data: PageData; export let data: PageData;
const isValidReceipt = data.isValidReceipt as boolean; let orderStatus: BadgeType = BadgeType.NOT_FOUND;
const id = data.id as string;
if (data?.order) {
orderStatus = data.order?.status;
}
const id = data?.id as string;
</script> </script>
{#if isValidReceipt} <PageMeta title="Kvittering" description="Søk og finn din ordre kvittering" />
<slot />
{:else} {#if orderStatus === BadgeType.NOT_FOUND}
<ReceiptNotFound id="{id}" /> <ReceiptNotFound id="{id}" />
{:else if orderStatus === BadgeType.INITIATED}
<PoolInitiatedOrder />
{:else}
<slot />
{/if} {/if}

View File

@@ -1,17 +1,6 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import validOrderId from '$lib/utils/validOrderId';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = ({ params, url }) => {
const { id } = params;
const email = url.searchParams.get('email');
return {
id,
email
};
};
export const actions: Actions = { export const actions: Actions = {
default: async ({ request }) => { default: async ({ request }) => {
const data = await request.formData(); const data = await request.formData();
@@ -19,12 +8,8 @@ export const actions: Actions = {
const orderId = data.get('order-id'); const orderId = data.get('order-id');
const email = data.get('order-email'); const email = data.get('order-email');
// TODO replace with posting form (json) to backend to check?? const receiptUrl = `/receipt/${orderId}?email=${email}`;
// also return statusCode from the backend directly. throw redirect(303, receiptUrl);
if (validOrderId(String(orderId)) && email) {
const receiptUrl = `/receipt/${orderId}?email=${email}`;
throw redirect(303, receiptUrl);
}
return { success: false }; return { success: false };
} }

View File

@@ -1,54 +1,68 @@
<script lang="ts"> <script lang="ts">
import CircleCheckmark from '$lib/icons/CircleCheckmark.svelte'; import { page } from '$app/stores';
import CircleCheckmark from '$lib/components/loading/CircleCheckmark.svelte';
import CircleError from '$lib/components/loading/CircleError.svelte';
import { mockProducts } from '$lib/utils/mock';
import type { PageServerData } from './$types'; import type { PageServerData } from './$types';
import type { IProduct } from '$lib/interfaces/IProduct'; import type { ILineItem, IOrder } from '$lib/interfaces/IOrder';
import CircleWarning from '$lib/components/loading/CircleWarning.svelte';
function subTotal(products: Array<IProduct>) { function subTotal(lineItems: Array<ILineItem> = []) {
let total = 0; let total = 0;
products.forEach((product) => (total = total + product.price * product.quantity)); lineItems.forEach((lineItem) => (total = total + lineItem.price * lineItem.quantity));
return total; return total;
} }
export let data: PageServerData; let id: string;
const id = data.id as string; let email: string;
const email = data.email as string; let order: IOrder;
// export let currentRoute;
// const id = currentRoute?.namedParams?.id;
// const email = currentRoute?.queryParams?.email;
const products = mockProducts(Math.floor(Math.random() * 8) + 1); const { data } = $page;
if (data) {
id = data.id as string;
email = data.email || (data?.order?.customer?.email as string);
order = data.order as IOrder;
}
</script> </script>
<section class="order-confirmation"> <section class="order-confirmation">
<CircleCheckmark /> {#if order.status === 'SUCCESS' || order.status === 'CONFIRMED'}
<CircleCheckmark />
{:else if order.status === 'CANCELLED' || order.status === 'REJECTED'}
<CircleError />
{:else}
<CircleWarning />
{/if}
<h1>Takk for din bestilling!</h1> {#if order.status === 'SUCCESS' || order.status === 'CONFIRMED'}
<h1>Takk for din bestilling!</h1>
{:else}
<h1>Bestilling ikke gjennomført!</h1>
{/if}
<div class="order-description"> <div class="order-description">
<p> <p>
A payment to PLANETPOSEN, AS will appear on your statement with order number: A payment to PLANETPOSEN, AS will appear on your statement with order number:
<span class="underline">{id}</span>. <span class="underline">{id}</span>.
</p> </p>
<p>Order receipt has been email to: <span class="underline">{email}</span></p> <p>En ordrebekreftelse er sent til: <span class="underline">{email}</span></p>
</div> </div>
<div class="order-receipt"> <div class="order-receipt">
{#each products as product} {#each order?.lineItems as lineItem}
<p> <p>
<code>{product.name} x{product.quantity}</code> <code>{lineItem.name} x{lineItem.quantity}</code>
<code>{product.currency} {product.price * product.quantity}</code> <code>NOK {lineItem.price * lineItem.quantity}</code>
</p> </p>
{/each} {/each}
<p> <p>
<code>Shipping</code> <code>Shipping</code>
<code>NOK 79</code> <code>NOK 75</code>
</p> </p>
<p> <p>
<code>Total</code> <code>Total</code>
<code>NOK {subTotal(products)}</code> <code>NOK {subTotal(order?.lineItems)}</code>
</p> </p>
</div> </div>
</section> </section>
@@ -56,6 +70,10 @@
<style lang="scss"> <style lang="scss">
@import './styles-receipt-page.scss'; @import './styles-receipt-page.scss';
.order-description .underline {
text-decoration: underline;
}
.order-receipt { .order-receipt {
background-color: #f7f7f7; background-color: #f7f7f7;
max-width: 500px; max-width: 500px;

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import CircleLoading from '$lib/components/loading/CircleLoading.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
import type { PageServerData } from './$types';
const { data } = $page;
const id = data?.id as string;
let maxPoolTime: Date = new Date();
maxPoolTime.setSeconds(maxPoolTime.getSeconds() + 15);
if (!id) {
console.log('no id found after all:(');
}
function redirect() {
const url = `/receipt/${id}`;
window.location.href = url;
goto(url);
}
function checkOrder() {
const url = buildApiUrl(`/api/v1/order/${id}`);
return fetch(url)
.then((resp) => resp.json())
.then((response) => {
response?.order?.status !== 'INITIATED' ? redirect() : null;
});
}
function pool() {
if (new Date() < maxPoolTime) {
setTimeout(() => checkOrder().then(() => pool()), 1500);
return;
}
}
onMount(pool);
</script>
<section class="order-confirmation">
<CircleLoading />
<h1>Bestillingen din behandles</h1>
<div class="order-description">
<p>Vent noen sekunder mens betalingen din blir godkjent</p>
</div>
</section>
<style lang="scss">
@import './styles-receipt-page.scss';
h1 {
text-align: center;
}
</style>

View File

@@ -1,33 +1,16 @@
<script lang="ts"> <script lang="ts">
import Input from '$lib/components/Input.svelte'; import Input from '$lib/components/Input.svelte';
import PlanetButton from '$lib/components/Button.svelte'; import PlanetButton from '$lib/components/Button.svelte';
import CircleError from '$lib/icons/CircleError.svelte'; import CircleWarning from '$lib/components/loading/CircleWarning.svelte';
import CircleWarning from '$lib/icons/CircleWarning.svelte';
const CircleComponent = Math.random() > 0.5 ? CircleWarning : CircleError;
export let id: string; export let id: string;
// async function handleSubmit(event) {
// const data = new FormData(this);
// console.log('formdata::', data);
// const orderId = data.get('order-id');
// const orderEmail = data.get('order-email');
// console.log('orderId:', orderId)
// console.log('orderEmail:', orderEmail)
// const url = `/receipt/${orderId}?email=${orderEmail}`;
// goto(url);
// }
let searchOrderNumber: string; let searchOrderNumber: string;
let searchOrderEmail: string; let searchOrderEmail: string;
</script> </script>
<section class="order-confirmation"> <section class="order-confirmation">
<svelte:component this="{CircleComponent}" /> <CircleWarning />
<h1>Fant ikke din bestilling!</h1> <h1>Fant ikke din bestilling!</h1>
@@ -40,7 +23,8 @@
<form class="order-search" method="POST"> <form class="order-search" method="POST">
<span>Du kan forsøke søke opp din ordre her:</span> <span>Du kan forsøke søke opp din ordre her:</span>
<Input name="order-id" label="Ordre id (hex)" bind:value="{searchOrderNumber}" /> <Input name="order-id" label="Ordre id " bind:value="{searchOrderNumber}" />
<br />
<Input name="order-email" label="Epost adresse" bind:value="{searchOrderEmail}" /> <Input name="order-email" label="Epost adresse" bind:value="{searchOrderEmail}" />
<PlanetButton text="Søk" /> <PlanetButton text="Søk" />
@@ -62,6 +46,10 @@
} }
} }
.underline {
text-decoration: underline;
}
:global(.order-search button) { :global(.order-search button) {
margin-top: 1rem; margin-top: 1rem;
} }

View File

@@ -1,7 +1,7 @@
@import '../../../styles/media-queries.scss'; @import '../../../styles/media-queries.scss';
.order-confirmation { .order-confirmation {
margin: 6rem auto 0; margin: 10rem auto 0;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 0 0.5rem; padding: 0 0.5rem;
@@ -10,8 +10,6 @@
margin-top: 3rem; margin-top: 3rem;
} }
// @include pageMargin;
@include tablet { @include tablet {
padding: 0 1rem; padding: 0 1rem;
} }
@@ -31,8 +29,4 @@
padding: 1rem; padding: 1rem;
margin: 1rem 0; margin: 1rem 0;
text-align: center; text-align: center;
.underline {
text-decoration: underline;
}
} }

View File

@@ -1,16 +1,9 @@
import { dev } from '$app/environment'; import type { IProductsDTO } from '$lib/interfaces/ApiResponse';
import { env } from '$env/dynamic/private';
import type { IProductsResponse } from '$lib/interfaces/ApiResponse';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
let url = '/api/products'; const res = await fetch('/api/v1/products');
if (dev || env.API_HOST) { const products: IProductsDTO = await res.json();
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url);
const products: IProductsResponse = await res.json();
return products; return products;
}; };

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import ProductTile from './ProductTile.svelte'; import ProductTile from '$lib/components/ProductTile.svelte';
import PageMeta from '$lib/components/PageMeta.svelte'; import PageMeta from '$lib/components/PageMeta.svelte';
import type { IProduct } from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';

View File

@@ -1,19 +1,12 @@
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import generateProductJsonLd from '$lib/jsonld/product'; import generateProductJsonLd from '$lib/jsonld/product';
import type { IProductResponse } from '$lib/interfaces/ApiResponse'; import type { IProductDTO } from '$lib/interfaces/ApiResponse';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params }) => { export const load: PageServerLoad = async ({ fetch, params }) => {
const { id } = params; const { id } = params;
let url = `/api/product/${id}`; const res = await fetch(`/api/v1/product/${id}`);
if (dev || env.API_HOST) { const productResponse: IProductDTO = await res.json();
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url);
const productResponse: IProductResponse = await res.json();
const jsonld = generateProductJsonLd(productResponse?.product); const jsonld = generateProductJsonLd(productResponse?.product);
return { return {

View File

@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import ProductTile from '../ProductTile.svelte'; import ProductTile from '$lib/components/ProductTile.svelte';
import ProductVariationSelect from '$lib/components/ProductVariationSelect.svelte'; import ProductVariationSelect from '$lib/components/ProductVariationSelect.svelte';
import QuantitySelect from '$lib/components/QuantitySelect.svelte'; import QuantitySelect from '$lib/components/QuantitySelect.svelte';
import SizesSection from './SizesSection.svelte'; import SizesSection from './SizesSection.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import PageMeta from '$lib/components/PageMeta.svelte'; import PageMeta from '$lib/components/PageMeta.svelte';
import ImageCarousel from '$lib/components/ImageCarousel.svelte';
import IconArrow from '$lib/icons/IconArrow.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { IProduct, IVariation } from '$lib/interfaces/IProduct'; import type { IProduct, IVariation } from '$lib/interfaces/IProduct';
@@ -42,13 +44,15 @@
</script> </script>
<PageMeta title="{pageTitle}" description="{product.description}" /> <PageMeta title="{pageTitle}" description="{product.description}" />
<IconArrow pointLeft="{true}" />
<div class="product-container"> <div class="product-container">
<ProductTile product="{product}" large="{true}" /> <ImageCarousel product="{product}" />
<div class="details"> <div class="details">
<h2 class="name">{product.name}</h2> <h2 class="name">{product.name}</h2>
<p class="subtext">{product.subtext}</p> <p>{product.subtext}</p>
<p class="subtext">{product.description}</p> <p class="description">{product.description}</p>
<p class="price">NOK {selectedVariation?.price} (Ink. Moms)</p> <p class="price">NOK {selectedVariation?.price} (Ink. Moms)</p>
<ProductVariationSelect <ProductVariationSelect
@@ -97,6 +101,10 @@
font-size: 2rem; font-size: 2rem;
} }
.description {
white-space: pre-line;
}
.price { .price {
margin-top: 3rem; margin-top: 3rem;
font-size: 1.5rem; font-size: 1.5rem;

View File

@@ -1,6 +1,4 @@
import { dev } from '$app/environment'; import type { IProductsDTO } from '$lib/interfaces/ApiResponse';
import { env } from '$env/dynamic/private';
import type { IProductResponse } from '$lib/interfaces/ApiResponse';
const domain = 'planet.schleppe.cloud'; const domain = 'planet.schleppe.cloud';
const pages: Array<ISitemapPage> = [ const pages: Array<ISitemapPage> = [
@@ -54,15 +52,10 @@ function sitemapPages(): string {
} }
async function sitemapShopPages(): Promise<string> { async function sitemapShopPages(): Promise<string> {
let url = `/api/products`; const res = await fetch('/api/v1/products');
if (dev || env.API_HOST) { const productResponse: IProductsDTO = await res.json();
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url); return productResponse?.products
const products: IProductResponse = await res.json();
return products?.products
?.map((product) => ?.map((product) =>
buildSitemapUrl(`/shop/${product.product_no}`, String(product.updated), 'daily') buildSitemapUrl(`/shop/${product.product_no}`, String(product.updated), 'daily')
) )

View File

@@ -35,9 +35,7 @@ a {
text-decoration: none; text-decoration: none;
-webkit-transition: -webkit-transform 0.15s linear; -webkit-transition: -webkit-transform 0.15s linear;
transition: -webkit-transform 0.15s linear;
transition: transform 0.15s linear; transition: transform 0.15s linear;
transition: transform 0.15s linear, -webkit-transform 0.15s linear;
-webkit-transform-origin: 50% 80%; -webkit-transform-origin: 50% 80%;
transform-origin: 50% 80%; transform-origin: 50% 80%;
} }
@@ -49,6 +47,8 @@ a:hover {
a.link { a.link {
border-bottom: 2px solid var(--color-text) !important; border-bottom: 2px solid var(--color-text) !important;
text-decoration: none;
display: inline-block;
} }
h1 { h1 {

View File

@@ -1,14 +1,7 @@
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
let url = '/api/warehouse'; const res = await fetch('/api/v1/warehouse');
if (dev || env.API_HOST) {
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url);
const warehouse = await res.json(); const warehouse = await res.json();
return { return {

View File

@@ -2,6 +2,8 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import ProductList from './WarehouseProductList.svelte'; import ProductList from './WarehouseProductList.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import PageMeta from '$lib/components/PageMeta.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
import type { IProduct } from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -9,22 +11,18 @@
const products = data.products as Array<IProduct>; const products = data.products as Array<IProduct>;
async function createProduct() { async function createProduct() {
let url = '/api/product'; const url = buildApiUrl('/api/v1/product');
if (window.location.href.includes('localhost')) {
url = 'http://localhost:30010'.concat(url);
}
fetch(url, { method: 'POST' }) fetch(url, { method: 'POST' })
.then((resp) => resp.json()) .then((resp) => resp.json())
.then((response) => { .then((response) => {
console.log('response::', response);
const { product } = response; const { product } = response;
goto(`/warehouse/${product.product_no} `); goto(`/warehouse/${product?.product_no} `);
}); });
} }
console.log('warehouse:', products); console.log('warehouse:', products);
</script> </script>
<PageMeta title="Warehouse" description="View and edit products in warehouse/stock" />
<div class="warehouse-page"> <div class="warehouse-page">
<h1>Warehouse</h1> <h1>Warehouse</h1>

View File

@@ -51,7 +51,6 @@
</table> </table>
<style lang="scss"> <style lang="scss">
// @import "../styles/global.scss";
@import '../../styles/media-queries.scss'; @import '../../styles/media-queries.scss';
table { table {
@@ -85,7 +84,10 @@
.image-column { .image-column {
width: 4rem; width: 4rem;
max-width: 4rem; max-width: 4rem;
margin: 0 0.5rem;
@include desktop {
margin: 0 0.5rem;
}
} }
.name-and-price > p { .name-and-price > p {
@@ -98,7 +100,6 @@
td, td,
th { th {
white-space: nowrap;
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
} }
@@ -109,15 +110,9 @@
img { img {
width: 4rem; width: 4rem;
height: 4rem;
border-radius: 0.4rem; border-radius: 0.4rem;
} }
.image-column {
display: grid;
place-items: center;
}
tr { tr {
border-bottom: 1px solid rgba(0, 0, 0, 0.05); border-bottom: 1px solid rgba(0, 0, 0, 0.05);
cursor: pointer; cursor: pointer;
@@ -128,12 +123,6 @@
} }
} }
// @include mobile {
// tr > *:first-child {
// display: none;
// }
// }
@include mobile { @include mobile {
tr > *:last-child, tr > *:last-child,
tr > :nth-child(4) { tr > :nth-child(4) {

View File

@@ -1,18 +1,16 @@
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params }) => { export const load: PageServerLoad = async ({ fetch, params }) => {
const { id } = params; const { id } = params;
// let url = `/api/warehouse/product/${id}`; const productRes = await fetch(`/api/v1/warehouse/${id}`);
let url = `/api/warehouse/${id}`; const { product } = await productRes.json();
if (dev || env.API_HOST) {
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url); const auditRes = await fetch(`/api/v1/warehouse/${id}/audit`);
const product = await res.json(); const { logs } = await auditRes.json();
console.log('product::', product);
return product; return {
product,
logs
};
}; };

View File

@@ -2,41 +2,76 @@
import PricingSection from './PricingSection.svelte'; import PricingSection from './PricingSection.svelte';
import DetailsSection from './DetailsSection.svelte'; import DetailsSection from './DetailsSection.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
import type { PageServerData } from './$types'; import type { PageServerData } from './$types';
import type { IProduct } from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
import type IProductVariation from '$lib/interfaces/IProductVariation';
export let data: PageServerData; export let data: PageServerData;
const product = data.product as IProduct; let product = data.product as IProduct;
let logs = data.logs;
let form: HTMLFormElement;
let edit = false; let edit = false;
function save() { function saveProduct(event: SubmitEvent) {
console.log('savvvving'); const formData = new FormData(event.target as HTMLFormElement);
const productUpdate = {
name: formData.get('name'),
subtext: formData.get('subtext'),
description: formData.get('description'),
primary_color: formData.get('primary_color')
} as IProduct;
const url = buildApiUrl(`/api/v1/product/${product.product_no}`);
const options = {
method: 'PUT',
body: JSON.stringify(productUpdate),
headers: { 'Content-Type': 'application/json' }
};
fetch(url, options)
.then((resp) => resp.json())
.then((response) => (product = response?.product || product));
edit = false; edit = false;
} }
function sumVariations(variations: IProductVariation[] | undefined) {
if (!variations) return 0;
let sum = 0;
variations.forEach((variation: IProductVariation) => (sum += variation.stock));
return sum;
}
$: totalVariations = sumVariations(product.variations);
$: displayImage =
product?.images?.find((image) => image?.default_image)?.url || product?.images?.[0]?.url || '';
</script> </script>
<h1>Product details</h1> <h1>Product details</h1>
<div class="info row"> <form bind:this="{form}" on:submit|preventDefault="{saveProduct}">
<img src="{product.image}" alt="Product" /> <div class="info row">
<div class="name-and-price"> <img src="{displayImage}" alt="Product" />
<p>{product.name}</p> <div class="name-and-price">
<p>NOK {product?.price}</p> <p>{product.name}</p>
<p>Left in stock: {totalVariations}</p>
</div>
</div> </div>
<div class="edit-button"> <div class="row edit-button">
{#if !edit} {#if !edit}
<Button text="Edit" on:click="{() => (edit = !edit)}" /> <Button text="Edit" on:click="{() => (edit = !edit)}" />
{:else} {:else}
<Button text="Save" on:click="{save}" /> <Button type="submit" text="Save" />
{/if} {/if}
</div> </div>
</div>
<h2>Details</h2> <h2>Details</h2>
<DetailsSection product="{product}" edit="{edit}" /> <DetailsSection bind:product="{product}" edit="{edit}" />
</form>
<h2>Variations</h2> <h2>Variations</h2>
<PricingSection product="{product}" /> <PricingSection bind:product="{product}" />
<h2>Metadata</h2> <h2>Metadata</h2>
<div> <div>
@@ -45,12 +80,24 @@
<h2>Audit log</h2> <h2>Audit log</h2>
<div> <div>
<p class="empty">No logs</p> {#if logs?.length > 0}
<pre class="audit-logs">
{#each logs as log}
<code>{log?.changed_fields}</code>
{/each}
</pre>
{:else}
<p class="empty">No logs</p>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@import '../../../styles/media-queries.scss'; @import '../../../styles/media-queries.scss';
h1 {
word-break: break-all;
}
.row { .row {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -65,25 +112,38 @@
.info { .info {
align-items: center; align-items: center;
.edit-button {
margin-left: auto;
}
img { img {
width: 64px; width: 4rem;
height: 64px;
border-radius: 6px; border-radius: 6px;
} }
.name-and-price > p { .name-and-price > p {
margin: 0.5rem 0 0.5rem 2rem; margin: 0.5rem 0 0.5rem 2rem;
&:first-of-type {
font-size: 1.5rem;
}
&:nth-of-type(2) { &:nth-of-type(2) {
opacity: 0.6; opacity: 0.6;
} }
} }
} }
.edit-button {
margin-top: 1rem;
@include tablet {
justify-content: end;
margin-top: -3rem;
}
}
pre.audit-logs {
display: flex;
flex-direction: column;
}
.label, .label,
.empty { .empty {
color: grey; color: grey;

View File

@@ -1,8 +1,48 @@
<script lang="ts"> <script lang="ts">
import Time from '$lib/components/Time.svelte';
import ImageUpload from '$lib/components/ImageUpload.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
import type { IProduct } from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
export let product: IProduct; export let product: IProduct;
export let edit: boolean; export let edit: boolean;
async function addImage(event: CustomEvent) {
const imageUrl = event.detail;
const url = buildApiUrl(`/api/v1/product/${product.product_no}/image`);
const options = {
method: 'POST',
body: JSON.stringify({
url: imageUrl
}),
headers: { 'Content-Type': 'application/json' }
};
const resp = await fetch(url, options);
const { images } = await resp.json();
product.images = images;
}
async function deleteImage(image_id: number) {
const url = buildApiUrl(`/api/v1/product/${product.product_no}/image/${image_id}`);
const resp = await fetch(url, { method: 'DELETE' });
const { images } = await resp.json();
console.log('received images from DELETE:', images);
product.images = images;
console.log('product.images:', product.images);
}
function setDefault(image_id: number) {
if (!product.images) return;
const url = buildApiUrl(`/api/v1/product/${product.product_no}/image/${image_id}/default`);
fetch(url, { method: 'POST' })
.then((resp) => resp.json())
.then((response) => (product.images = response?.images));
}
</script> </script>
<div class="details row"> <div class="details row">
@@ -13,39 +53,54 @@
{#if !edit} {#if !edit}
<span>{product.name}</span> <span>{product.name}</span>
{:else} {:else}
<input bind:value="{product.name}" /> <input name="name" class="wide" id="name" bind:value="{product.name}" />
{/if} {/if}
</li> </li>
<li> <li>
<span class="label">Created</span> <span class="label">Created</span>
<span>{product.created}</span> <Time time="{product.created}" />
</li> </li>
<li> <li>
<span class="label">Subtext</span> <span class="label">Updated</span>
<Time time="{product.updated}" />
</li>
<li>
<label for="subtext" class="label">Subtext</label>
{#if !edit} {#if !edit}
<span>{product.subtext || '(empty)'}</span> <span>{product.subtext || '(empty)'}</span>
{:else} {:else}
<input bind:value="{product.subtext}" /> <input name="subtext" id="subtext" bind:value="{product.subtext}" />
{/if} {/if}
</li> </li>
<li> <li>
<span class="label">Description</span> <label for="description" class="label">Description</label>
{#if !edit} {#if !edit}
<span>{product.description || '(empty)'}</span> <span class="description">{product.description || '(empty)'}</span>
{:else} {:else}
<input class="wide" bind:value="{product.description}" /> <textarea
rows="6"
class="wide description"
name="description"
id="description"
bind:value="{product.description}"></textarea>
{/if} {/if}
</li> </li>
<li> <li>
<span class="label">Primary color</span> <label for="primary_color" class="label">Primary color</label>
{#if !edit} {#if !edit}
<span>{product.primary_color || '(empty)'}</span> <span>{product.primary_color || '(empty)'}</span>
{:else} {:else}
<input bind:value="{product.primary_color}" /> <input
type="color"
name="primary_color"
id="primary_color"
bind:value="{product.primary_color}"
/>
{/if} {/if}
{#if product.primary_color} {#if product.primary_color}
<div class="color-box" style="{`--color: ${product.primary_color}`}"></div> <div class="color-box" style="{`--color: ${product.primary_color}`}"></div>
@@ -60,8 +115,22 @@
</div> </div>
<div class="right"> <div class="right">
<span class="label">Image</span> <span class="label">Images</span>
<img src="{product.image}" alt="Product" /> <div class="images-and-upload">
<div class="images">
{#each product?.images || [] as { url, default_image, image_id } (image_id)}
<div class="img" on:drop="{() => setDefault(image_id)}" on:dragover|preventDefault>
<img src="{url}" alt="Product" />
<button class="delete" on:click="{() => deleteImage(image_id)}"></button>
{#if default_image}
<button class="default_image" draggable="true">🟣</button>
{/if}
</div>
{/each}
</div>
<ImageUpload on:added="{addImage}" />
</div>
</div> </div>
</div> </div>
@@ -78,28 +147,69 @@
} }
input { input {
margin: 0; margin: -2px 0 -2px -2px;
padding: 0; padding: 0;
}
&.wide { .wide {
width: -webkit-fill-available; width: 100%;
} width: -webkit-fill-available;
margin-right: 0.5rem;
}
span.description {
white-space: pre-line;
}
textarea.description {
height: auto;
} }
.right { .right {
width: 50%; width: 50%;
display: flex; display: flex;
@include mobile {
flex-direction: column;
}
span { span {
padding: 0.4rem 0; padding: 0.4rem 0;
margin-right: 1.5rem; margin-right: 1.5rem;
} }
img { .images-and-upload {
display: flex;
flex-direction: column;
}
.images .img {
position: relative;
display: inline-block;
width: 110px; width: 110px;
height: 110px;
border-radius: 4px; border-radius: 4px;
margin-top: 0.4rem; margin: 0 0.4rem 0.4rem 0;
img {
width: inherit;
}
}
button {
position: absolute;
top: 0;
border: none;
background-color: transparent;
cursor: pointer;
margin: 0;
padding: 0.25rem;
}
.delete {
right: 0;
}
.default_image {
cursor: move;
left: 0;
} }
} }
@@ -127,7 +237,8 @@
align-items: center; align-items: center;
} }
li span:first-of-type { li span:first-of-type,
li label:first-of-type {
display: inline-block; display: inline-block;
margin-bottom: auto; margin-bottom: auto;
min-width: 30%; min-width: 30%;

View File

@@ -3,6 +3,7 @@
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Badge from '$lib/components/Badge.svelte'; import Badge from '$lib/components/Badge.svelte';
import BadgeType from '$lib/interfaces/BadgeType'; import BadgeType from '$lib/interfaces/BadgeType';
import { buildApiUrl } from '$lib/utils/apiUrl';
export let product: IProduct; export let product: IProduct;
let table: HTMLElement; let table: HTMLElement;
@@ -13,61 +14,44 @@
success: boolean; success: boolean;
} }
const setEditIndex = (index: number, event: MouseEvent) => {
if (!table.contains(event.target as Node)) {
console.log('outside?', index);
// editingVariationIndex = -1;
} else {
console.log('inside');
}
editingVariationIndex = index;
};
const resetEditingIndex = () => (editingVariationIndex = -1); const resetEditingIndex = () => (editingVariationIndex = -1);
const updateProductsVariations = (response: ISkuResponse) => { const updateProductsVariations = (response: ISkuResponse) => {
product.variations = response?.skus; product.variations = response?.skus || [];
editingVariationIndex = product.variations.length - 1;
}; };
function setDefault(variation: IVariation) { function setDefault(variation: IVariation) {
if (!product.variations) return; if (!product.variations) return;
let url = `/api/product/${product.product_no}/sku/${variation.sku_id}/default_price`; const url = buildApiUrl(
if (window?.location?.href.includes('localhost')) { `/api/v1/product/${product.product_no}/sku/${variation.sku_id}/default`
url = 'http://localhost:30010'.concat(url); );
} fetch(url, { method: 'POST' })
.then((resp) => resp.json())
.then((response) => (product.variations = response?.skus));
}
function addSkuVariation() {
const url = buildApiUrl(`/api/v1/product/${product.product_no}/sku`);
fetch(url, { method: 'POST' }) fetch(url, { method: 'POST' })
.then((resp) => resp.json()) .then((resp) => resp.json())
.then(updateProductsVariations); .then(updateProductsVariations);
} }
function addSkuVariation() {
// ADD OVER API - update product.variations with result --> set edit
// const newSku: IProductVariation = {
// sku_id: null,
// stock: 0,
// size: null,
// price: 0,
// default_price: false
// }
// if (!product.variations || product.variations.length === 0) {
// product.variations = []
// }
// product.variations.push(newSku)
// editingVariationIndex = product.variations.length - 1
let url = `/api/product/${product.product_no}/sku`;
if (window?.location?.href.includes('localhost')) {
url = 'http://localhost:30010'.concat(url);
}
fetch(url, { method: 'POST' })
.then((resp) => resp.json())
.then(updateProductsVariations)
.then(() => (editingVariationIndex = product.variations.length - 1));
}
function saveSkuVariation(variation: IVariation) { function saveSkuVariation(variation: IVariation) {
let url = `/api/product/${product.product_no}/sku/${variation?.sku_id}`; const url = buildApiUrl(`/api/v1/product/${product.product_no}/sku/${variation?.sku_id}`);
if (window?.location?.href.includes('localhost')) {
url = 'http://localhost:30010'.concat(url);
}
const { stock, size, price } = variation; const { stock, size, price } = variation;
const options = { const options = {
method: 'PATCH', method: 'PUT',
body: JSON.stringify({ stock, price, size }), body: JSON.stringify({ stock, price, size }),
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -81,29 +65,14 @@
} }
function deleteVariation(variation: IVariation) { function deleteVariation(variation: IVariation) {
console.log('delete it using api', variation); const url = buildApiUrl(`/api/v1/product/${product.product_no}/sku/${variation?.sku_id}`);
let url = `/api/product/${product.product_no}/sku/${variation?.sku_id}`;
if (window?.location?.href.includes('localhost')) {
url = 'http://localhost:30010'.concat(url);
}
fetch(url, { method: 'DELETE' }) fetch(url, { method: 'DELETE' })
.then((resp) => resp.json()) .then((resp) => resp.json())
.then(updateProductsVariations) .then(updateProductsVariations)
.then(() => resetEditingIndex()); .then(() => resetEditingIndex());
} }
function disableEditIfClickOutsideTable(event: MouseEvent) {
console.log('target:', event.target);
if (!table.contains(event.target as Node)) {
console.log('outside?');
} else {
console.log('inside');
}
}
</script> </script>
<svelte:window on:click="{disableEditIfClickOutsideTable}" />
<table class="pricing" bind:this="{table}"> <table class="pricing" bind:this="{table}">
<thead> <thead>
<tr> <tr>
@@ -123,36 +92,31 @@
<tbody> <tbody>
{#if product?.variations?.length} {#if product?.variations?.length}
{#each product?.variations as variation, index} {#each product?.variations as variation, index}
{#if editingVariationIndex !== index} <tr
<tr class="edit"
on:click="{() => (editingVariationIndex = index)}" on:click="{(event) => setEditIndex(index, event)}"
on:drop="{() => setDefault(variation)}" on:drop="{() => setDefault(variation)}"
on:dragover|preventDefault on:dragover|preventDefault
> >
<td> <td>
{#if editingVariationIndex !== index}
<span>Nok {variation.price}</span> <span>Nok {variation.price}</span>
{#if variation.default_price} {:else}
<div draggable="true"> <span>Nok <input type="number" bind:value="{variation.price}" /></span>
<Badge title="Default price" type="{BadgeType.PENDING}" icon="{''}" /> {/if}
</div>
{/if} {#if variation.default_price}
</td> <div draggable="true">
<Badge title="Default price" type="{BadgeType.PENDING}" icon="" />
</div>
{/if}
</td>
{#if editingVariationIndex !== index}
<td>{variation.stock}</td> <td>{variation.stock}</td>
<td>{variation.size}</td> <td>{variation.size}</td>
<td></td> <td></td>
<td></td> <td></td>
</tr> {:else}
{:else}
<tr class="edit" on:drop="{() => setDefault(variation)}" on:dragover|preventDefault>
<td>
<span>Nok <input type="number" bind:value="{variation.price}" /></span>
{#if variation.default_price}
<div draggable="true">
<Badge title="Default price" type="{BadgeType.PENDING}" icon="{''}" />
</div>
{/if}
</td>
<td><input type="number" bind:value="{variation.stock}" /></td> <td><input type="number" bind:value="{variation.stock}" /></td>
<td><input bind:value="{variation.size}" /></td> <td><input bind:value="{variation.size}" /></td>
<td class="cta"> <td class="cta">
@@ -161,8 +125,8 @@
<td class="cta"> <td class="cta">
<button on:click="{() => deleteVariation(variation)}">🗑️</button> <button on:click="{() => deleteVariation(variation)}">🗑️</button>
</td> </td>
</tr> {/if}
{/if} </tr>
{/each} {/each}
{/if} {/if}
</tbody> </tbody>

View File

@@ -1,16 +1,9 @@
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params }) => { export const load: PageServerLoad = async ({ fetch, params }) => {
const { id } = params; const { id } = params;
let url = `/api/warehouse/${id}`; const res = await fetch(`/api/v1/warehouse/${id}`);
if (dev || env.API_HOST) {
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url);
const product = await res.json(); const product = await res.json();
return product; return product;
}; };

View File

@@ -19,7 +19,7 @@
<div> <div>
<h2>Images</h2> <h2>Images</h2>
<img src="{product.image}" /> <img src="{product.image}" alt="Default product" />
</div> </div>
<div class="variations"> <div class="variations">
@@ -48,9 +48,9 @@
<option>Wine</option> <option>Wine</option>
</select> </select>
<Input label="Price" type="number" /> <Input label="Price" type="number" value="" />
<Input label="Stock" type="number" /> <Input label="Stock" type="number" value="" />
<input type="checkbox" checked /> <input type="checkbox" checked value="" />
</div> </div>
</div> </div>

31
src/styles/effects.scss Normal file
View File

@@ -0,0 +1,31 @@
@keyframes pulse-live {
0% {
box-shadow: 0 0 0 0 rgba(214, 0, 0, 0.7);
box-shadow: 0 0 0 0 rgba(227, 45, 34, 0.7);
}
70% {
box-shadow: 0 0 0 12px rgba(214, 0, 0, 0);
box-shadow: 0 0 0 12px rgba(227, 45, 34, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(214, 0, 0, 0);
box-shadow: 0 0 0 0 rgba(227, 45, 34, 0);
}
}
@mixin pulse-dot {
&::after {
content: '';
top: 50%;
position: absolute;
display: block;
border-radius: 50%;
background-color: #d60000;
background-color: rgb(227, 45, 34);
border-radius: 50%;
transform: translate(-50%, -50%);
animation: pulse-live 2s infinite;
height: 16px;
width: 16px;
}
}

View File

@@ -35,7 +35,3 @@ p.last-edit {
display: inline-block; display: inline-block;
} }
} }
a {
text-decoration: underline;
}

View File

@@ -1,8 +1,7 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": false,
"checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,