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"
},
"devDependencies": {
"@sveltejs/adapter-static": "next",
"@sveltejs/kit": "next",
"@types/cookie": "^0.5.1",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
"eslint": "^8.16.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"svelte": "^3.46.0",
"svelte-check": "^2.7.1",
"svelte-preprocess": "^4.10.6",
"tslib": "^2.3.1",
"typescript": "^4.7.4",
"vite": "^3.1.0"
"@sveltejs/adapter-static": "1.0.0",
"@sveltejs/kit": "1.0.1",
"@types/cookie": "0.5.1",
"@typescript-eslint/eslint-plugin": "5.27.0",
"@typescript-eslint/parser": "5.27.0",
"eslint": "8.16.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-svelte3": "4.0.0",
"prettier": "2.8.0",
"prettier-plugin-svelte": "2.8.1",
"sass": "1.57.0",
"svelte": "3.55.0",
"svelte-check": "3.0.1",
"svelte-preprocess": "5.0.0",
"tslib": "2.4.1",
"typescript": "4.9.4",
"vite": "4.0.3"
},
"type": "module",
"dependencies": {
"@stripe/stripe-js": "^1.42.0",
"sass": "^1.55.0"
"@stripe/stripe-js": "1.46.0"
}
}
}

View File

@@ -2,7 +2,7 @@
<html lang="no-NO">
<head>
<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="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 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 count: Readable<number> = derived(cart, ($cart) => $cart.length || 0);
export const subTotal: Readable<number> = derived(cart, ($cart) => {
let total = 0;
$cart.forEach((cartItem) => (total += cartItem.price * cartItem.quantity));
$cart.forEach((cartItem: ICart) => (total += cartItem.price * cartItem.quantity));
return total;
});

View File

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

View File

@@ -1,9 +1,17 @@
<script lang="ts">
import { count, toggleCart, isOpen } from '../cartStore';
import IconCart from '../icons/IconCart.svelte';
$: openClass = $isOpen ? 'open' : '';
</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}
<span>{$count}</span>
{/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>
<svelte:head>
<title>{title}</title>
<title>{title} | planetposen</title>
<meta name="description" content="{description}" />
</svelte:head>

View File

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

View File

@@ -24,7 +24,7 @@
});
</script>
<ul>
<ul class="product-variations">
{#each variations as variation}
<li
class="{`variation ${variation.sku_id === selectedVariation?.sku_id && 'selected'}`}"
@@ -37,7 +37,7 @@
</ul>
<style lang="scss">
ul {
ul.product-variations {
list-style-type: none;
padding-left: 0;
@@ -59,11 +59,6 @@
&.selected {
border-color: black;
}
p {
padding: 0;
margin: 0;
}
}
}
</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">
import { onDestroy, onMount } from 'svelte';
import { loadStripe } from '@stripe/stripe-js/pure';
import type { Stripe } from '@stripe/stripe-js';
import { cart } from '../cartStore';
import { onMount } from 'svelte';
import stripeApi from '$lib/stripe/index';
import type { StripeCardElement } from '@stripe/stripe-js/types';
function mountCard() {
const elements = stripe.elements();
export let card: StripeCardElement;
export let stripeApiKey: string;
async function mountCard() {
let stripe = await stripeApi.load(stripeApiKey);
const elements = stripe?.elements();
if (!elements) return;
const options = {
hidePostalCode: true,
@@ -27,61 +31,9 @@
card.mount(cardElement);
}
// function makeIntent() {
// let url = "/api/payment/stripe";
// if (window.location.href.includes("localhost"))
// url = "http://localhost:30010".concat(url);
onMount(() => mountCard());
// 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 clientSecret: string;
let confirmDiag: HTMLElement;
</script>
@@ -96,7 +48,6 @@
.card {
// padding: 1rem;
margin: 0 0.5rem;
border: 2px solid black;
@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">
<g transform="translate(24,24)">
<g class="CheckSuccess-checkGroup" opacity="0" style="animation-delay: 700ms">
<g class="loading-group CheckSuccess" opacity="0">
<path
class="CheckSuccess-check"
class="loading-icon"
fill="none"
d="M-10 1.5c0 0 6.5 6 6.5 6c0 0 13.5-13 13.5-13"
stroke="#24B47E"
@@ -10,12 +10,11 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="28 28"
stroke-dashoffset="28"
style="animation-delay: 700ms"></path>
stroke-dashoffset="28"></path>
</g>
</g>
<path
class="CheckSuccess-circle"
class="loading-circle"
fill="none"
stroke="#24B47E"
stroke-width="2"
@@ -25,8 +24,7 @@
stroke-dasharray="145 145"
stroke-linejoin="round"
stroke-miterlimit="1"
transform="translate(24,24) rotate(-35)"
style="animation-delay: 700ms"></path>
transform="translate(24,24) rotate(-35)"></path>
</svg>
<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">
<g transform="translate(24,24)">
<g class="CheckError-checkGroup" opacity="0" style="animation-delay: 700ms">
<g class="loading-group CheckError" opacity="0">
<path
class="CheckError-cross"
class="loading-icon"
fill="none"
d="M -10 -10 l 20 20 M -10 10 l 20 -20"
stroke="#FF6245"
@@ -10,12 +10,11 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="28 28"
stroke-dashoffset="28"
style="animation-delay: 700ms"></path>
stroke-dashoffset="28"></path>
</g>
</g>
<path
class="CheckError-circle"
class="loading-circle"
fill="none"
stroke="#FF6245"
stroke-width="2"
@@ -25,8 +24,7 @@
stroke-dasharray="145 145"
stroke-linejoin="round"
stroke-miterlimit="1"
transform="translate(24,24) rotate(-35)"
style="animation-delay: 700ms"></path>
transform="translate(24,24) rotate(-35)"></path>
</svg>
<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">
<g transform="translate(24,24)">
<g class="CheckWarning-checkGroup" opacity="0" style="animation-delay: 700ms">
<g class="loading-group CheckWarning" opacity="0">
<path
class="CheckWarning-exclamation"
class="loading-icon"
fill="none"
d="M 0 -12 v 15 m 0 6 v 3"
stroke="#FFC107"
@@ -10,12 +10,11 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="28 28"
stroke-dashoffset="28"
style="animation-delay: 700ms"></path>
stroke-dashoffset="28"></path>
</g>
</g>
<path
class="CheckWarning-circle"
class="loading-circle"
fill="none"
stroke="#FFC107"
stroke-width="2"
@@ -25,8 +24,7 @@
stroke-dasharray="145 145"
stroke-linejoin="round"
stroke-miterlimit="1"
transform="translate(24,24) rotate(-35)"
style="animation-delay: 700ms"></path>
transform="translate(24,24) rotate(-35)"></path>
</svg>
<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 {
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 {
0% {
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">
function getApplePaySession(validationURL: string) {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ validationURL })
};
// function getApplePaySession(validationURL: string) {
// const options = {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json'
// },
// 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) {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ payment })
};
// function makeApplePayPaymentTransaction(payment: object) {
// const options = {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json'
// },
// 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() {
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();
return true;
}
// 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>
<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 { IOrder, IOrderSummary } from './IOrder';
import type ICustomer from './ICustomer';
import type ICart from './ICart';
import type IOrderValidationError from './IOrderValidationError';
export interface IProductResponse {
success: boolean;
products: Array<IProduct>;
}
export interface IOrderResponse {
export interface IOrderDTO {
success: boolean;
order: IOrder;
}
export interface IOrderCreateDTO {
customer: ICustomer;
cart: ICart[];
}
export interface IOrderSummaryResponse {
success: boolean;
order: IOrderSummary;
}
export interface IProductResponse {
export interface IProductDTO {
success: boolean;
product: IProduct;
}
export interface IProductsResponse {
export interface IProductsDTO {
success: boolean;
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 {
SUCCESS = 'success',
WARNING = 'warning',
ERROR = 'error',
PENDING = 'pending',
INFO = 'info'
SUCCESS = 'SUCCESS',
WARNING = 'WARNING',
REFUNDED = 'REFUNDED',
ERROR = 'ERROR',
PENDING = 'PENDING',
INFO = 'INFO',
INITIATED = 'INITIATED',
NOT_FOUND = 'NOT_FOUND'
}
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 BadgeType from './BadgeType';
export interface IStripePayment {
amount: number;
currency: string;
}
import type ICustomer from './ICustomer';
export interface IOrderSummary {
created: Date;
@@ -21,22 +17,14 @@ export interface IOrder {
lineItems: ILineItem[];
orderid: string;
shipping: IShipping;
payment: IStripePayment;
status: string;
updated?: Date;
created?: Date;
}
export interface ICustomer {
city: string;
customer_no: string;
email: string;
firstname: string;
lastname: string;
streetaddress: string;
zipcode: number;
}
export interface ILineItem {
sku_id: number;
image: string;
name: string;
price: number;
@@ -49,6 +37,7 @@ export interface IShipping {
tracking_code: string;
tracking_link: string;
user_notified: null;
has_api: boolean;
}
export interface IOrdersLineitem {
@@ -67,3 +56,15 @@ export interface ITracking {
trackingCompany: 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;
subtext?: string;
description?: string;
image: string;
images?: IImage[];
image?: string;
primary_color?: string;
variation_count?: string;
@@ -23,3 +24,9 @@ export interface IVariation {
updated?: 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/',
'@type': 'Product',
name: `${product.name} - ${variation.size}`,
image: [product.image],
image: product.images?.map((image) => image.url),
description: product.description,
sku: `${product.product_no}-${variation.sku_id}`,
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() {
let url = '/api';
if (dev) {
url = 'http://localhost:30010'.concat(url);
}
const url = buildApiUrl('/api/v1');
await fetch(url);
return true;
}

View File

@@ -1,5 +1,6 @@
import { dev } from '$app/environment';
import { cart as cartStore } from './cartStore';
import type { ICartDTO } from './interfaces/ApiResponse';
const WS_HOST = '127.0.0.1';
const WS_PORT = 30010;
@@ -46,6 +47,19 @@ function sendPayload(payload: object) {
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() {
const closed = ws?.readyState === 3;
if (!closed) return;
@@ -71,12 +85,16 @@ export function connectToCart(attempts = 0, maxAttempts = 6) {
// TODO user feedback when max retries to reconnect, should refresh
// increasing timeout by a factor
const planetId = getCookie('planetId');
if (!planetId) return console.log('no cookie');
const planet_id = getCookie('planet_id');
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`;
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);
@@ -87,15 +105,7 @@ export function connectToCart(attempts = 0, maxAttempts = 6) {
heartbeat();
};
ws.onmessage = (event: MessageEvent) => {
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.onmessage = (event) => receivePayload(event);
ws.onclose = () => {
const seconds = attempts ** 2;

View File

@@ -3,40 +3,39 @@
import FrontText from '$lib/components/FrontText.svelte';
import FrontTextImage from '$lib/components/FrontTextImage.svelte';
import FrontTextImageBubble from '$lib/components/FrontTextImageBubble.svelte';
// import FrontProductGallery from '$lib/components/FrontProductGallery.svelte';
import type IFrontTextImage from '$lib/interfaces/IFrontTextImage';
// import type { IProduct } from '$lib/interfaces/IProduct';
import type IFrontText from '$lib/interfaces/IFrontText';
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.',
image:
'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'
image: 'https://storage.googleapis.com/planetposen-images/front-kf-1.jpg'
},
{
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.",
imageRight: true,
image:
'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'
image: 'https://storage.googleapis.com/planetposen-images/front-kf-2.jpg'
},
{
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.',
image:
'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'
image: 'https://storage.googleapis.com/planetposen-images/front-kf-3.jpg'
},
{
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.',
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',
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,
image: 'https://i.imgur.com/ISCKKAq.jpg'
image: 'https://storage.googleapis.com/planetposen-images/front-bee-2.jpg'
}
];
@@ -52,15 +51,53 @@
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>
<PageMeta title="Planetposen" description="Planetposen hjemmeside" />
<PageMeta title="Hjem" description="Planetposen hjemmeside" />
<section class="frontpage">
<!-- {#each textImages as data}
<TextImageParralax {data} />
{/each} -->
<FrontTextImage data="{textImages[0]}" />
<FrontTextImage data="{textImages[1]}" />
<!-- <FrontProductGallery
products="{galleryProducts}"
title="Nye julevarer tilgjengelig!"
backgroundColor="#BCDEB7"
/> -->
<FrontTextImageBubble />
<FrontTextImage data="{textImages[2]}" />
@@ -74,9 +111,6 @@
<style lang="scss" module="scoped">
section {
// position: absolute;
// left: 0;
// width: 100vw;
display: flex;
flex-direction: column;
justify-content: center;

View File

@@ -1,14 +1,11 @@
<script lang="ts">
import LinkArrow from '$lib/components/LinkArrow.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
export let isAdmin = false;
function logout() {
let url = '/api/logout';
if (window?.location?.href.includes('localhost')) {
url = 'http://localhost:30010'.concat(url);
}
const url = buildApiUrl('/api/v1/logout');
fetch(url, { method: 'POST' }).then((resp) => {
resp.status === 200 && window.location.reload();
});
@@ -40,7 +37,13 @@
<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>
</section>
</footer>

View File

@@ -97,11 +97,11 @@
font-size: 1.8rem;
}
.corner img {
width: 2em;
height: 2em;
object-fit: contain;
}
// .corner img {
// width: 2em;
// height: 2em;
// object-fit: contain;
// }
nav {
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">
import { goto } from '$app/navigation';
import OrderSection from './OrderSection.svelte';
import DeliverySection from './DeliverySection.svelte';
import PageMeta from '$lib/components/PageMeta.svelte';
import CheckoutButton from '$lib/components/Button.svelte';
import StripeCard from '$lib/components/StripeCard.svelte';
import ApplePayButton from '$lib/components/ApplePayButton.svelte';
import VippsHurtigkasse from '$lib/components/VippsHurtigkasse.svelte';
import ErrorStack from '$lib/components/ErrorStack.svelte';
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) {
const formData = new FormData(event.target);
export let data: PageData;
export let stripeApiKey: string = data.stripeApiKey;
const customerJson = {};
formData.forEach((value, key) => (customerJson[key] = value));
let card: StripeCardElement;
let form: HTMLFormElement;
let errors: string[] = [];
const options = {
method: 'POST',
body: JSON.stringify({
customer: customerJson,
cart: $cart
}),
headers: {
'Content-Type': 'application/json'
}
};
/* eslint-disable @typescript-eslint/no-explicit-any */
let resolvePaymentPromise: (value: any) => void;
let rejectPaymentPromise: (resason: any | null) => void;
let paymentPromise: Promise<any>;
/* eslint-enable @typescript-eslint/no-explicit-any */
let url = '/api/order';
if (window?.location?.href.includes('localhost')) {
url = 'http://localhost:30010'.concat(url);
function startPaymentLoader() {
paymentPromise = new Promise((resolve, reject) => {
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>
@@ -39,8 +142,8 @@
description="Kasse for bestilling og betaling av produkter i handlekurven"
/>
<h1>Checkout</h1>
<form class="checkout" on:submit|preventDefault="{postOrder}">
<h1>Kassen</h1>
<form class="checkout" bind:this="{form}" on:submit|preventDefault="{postOrder}">
<section id="delivery">
<h2>Leveringsaddresse</h2>
<DeliverySection />
@@ -48,31 +151,30 @@
<section id="order">
<h2>Din ordre</h2>
<OrderSection>
<div class="navigation-buttons" slot="button">
<ApplePayButton />
<VippsHurtigkasse />
</div>
</OrderSection>
<OrderSection />
</section>
<section id="payment">
<h2>Betalingsinformasjon</h2>
<StripeCard />
<StripeCard bind:card="{card}" stripeApiKey="{stripeApiKey}" />
<div class="pay">
<CheckoutButton type="submit" text="Betal" />
<div class="payment-state-animation">
{#if paymentPromise}
<Loading promise="{paymentPromise}" />
{/if}
</div>
</div>
</section>
</form>
<ErrorStack bind:errors="{errors}" />
<style lang="scss" module="scoped">
@import '../../styles/media-queries.scss';
form.checkout {
// display: flex;
// flex-wrap: wrap;
display: grid;
grid-template-areas:
'delivery order'
@@ -80,7 +182,6 @@
grid-gap: 2rem;
grid-template-columns: 1fr 1fr;
// grid-auto-flow: column;
@include mobile {
grid-template-columns: minmax(0, 1fr);
@@ -93,18 +194,12 @@
.pay {
margin: 2rem 0;
}
.navigation-buttons {
display: flex;
justify-content: flex-start;
margin-top: 2rem;
flex-wrap: wrap;
}
:global(.navigation-buttons > *) {
margin-right: 1rem;
margin-bottom: 1rem;
:global(.pay .payment-state-animation svg) {
margin-left: 1.5rem;
width: 34px;
}
#delivery {
@@ -126,10 +221,30 @@
padding-left: 4px;
text-transform: none;
font-size: 2.3rem;
padding: 12px 10px 12px 12px !important;
padding: 12px 10px 12px 0;
font-weight: 500;
color: #231f20;
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>

View File

@@ -11,11 +11,16 @@
<div class="personalia">
<Input id="email" bind:value="{email}" label="E-postaddresse" autocomplete="email" />
<Input id="firstName" bind:value="{firstName}" label="Fornavn" autocomplete="given-name" />
<Input id="lastName" bind:value="{lastName}" label="Etternavn" autocomplete="family-name" />
<Input id="first_name" bind:value="{firstName}" label="Fornavn" autocomplete="given-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 id="zipCode" bind:value="{zipCode}" label="Postnummer" autocomplete="postal-code" />
<Input
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" />
<div id="personalia-submit-button">

View File

@@ -4,76 +4,78 @@
import { cart, subTotal } from '$lib/cartStore';
import { decrementProductInCart, incrementProductInCart } from '$lib/websocketCart';
// $: totalPrice = $cart
// .map((order: IOrderLineItem) => order?.price * order.quantity)
// .reduce((a, b) => a + b);
const shippingPrice = 75;
$: totalPrice = $subTotal + shippingPrice;
function lineItemClass(id: number) {
return `lineitem-${id}`;
}
</script>
<table class="checkout">
<thead>
<tr>
<th>Varenavn</th>
<th>Antall</th>
<th>Pris</th>
</tr>
</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>
<div style="border: 2px solid black">
<table class="order-summary">
<thead style="border-bottom: 2px solid black;">
<tr>
<th>Varenavn</th>
<th>Antall</th>
<th>Pris</th>
</tr>
{/if}
</thead>
<tr>
<td>Totalpris:</td>
<td></td>
<td>Nok {$subTotal}</td>
</tr>
<tbody>
{#if $cart.length}
{#each $cart as cartItem}
<tr id="{lineItemClass(cartItem.lineitem_id)}">
<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>
<td>Frakt:</td>
<td></td>
<td>Nok {shippingPrice}</td>
</tr>
<tr style="border-bottom-color: rgba(0,0,0,0.15)">
<td>Totalsum:</td>
<td></td>
<td>Nok {$subTotal}</td>
</tr>
<tr style="font-weight: 600">
<td>Totalsum:</td>
<td></td>
<td>Nok {$subTotal}</td>
</tr>
</tbody>
</table>
<tr style="border-bottom-color: rgba(0,0,0,0.15)">
<td>Frakt:</td>
<td></td>
<td>Nok {shippingPrice}</td>
</tr>
<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">
table.checkout {
table.order-summary {
width: 100%;
border: 2px solid #dbd9d5;
border-collapse: collapse;
thead {
@@ -101,8 +103,11 @@
color: rgba(0, 0, 0, 0.5);
}
tr:not(:last-of-type) {
border-bottom: 2px solid black;
}
td {
border: 2px solid #dbd9d5;
padding: 1rem 0.5rem;
min-width: 50px;
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.
</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>
<strong>Tredjepart informasjonskapsler</strong>
Planetposen.no lagrer ingen informasjonskapsler tilknyttet noen tredjepart.

View File

@@ -1,14 +1,15 @@
<script lang="ts">
import Button from '$lib/components/Button.svelte';
import Input from '$lib/components/Input.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
let username: string;
let password: string;
let displayMessage: string | null;
function postLogin(event: any) {
function postLogin(event: SubmitEvent) {
displayMessage = null;
const formData = new FormData(event.target);
const formData = new FormData(event.target as HTMLFormElement);
const data = {};
formData.forEach((value, key) => (data[key] = value));
@@ -20,11 +21,7 @@
}
};
let url = '/api/login';
if (window?.location?.href.includes('localhost')) {
url = 'http://localhost:30010'.concat(url);
}
const url = buildApiUrl('/api/v1/login');
fetch(url, options)
.then((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';
export const load: PageServerLoad = async ({ fetch }) => {
let url = '/api/orders';
if (dev || env.API_HOST) {
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url);
const res = await fetch('/api/v1/orders');
const response = await res.json();
console.log('orders length:', response?.orders);
return {
orders: response?.orders || []

View File

@@ -1,13 +1,15 @@
<script lang="ts">
import OrdersTable from './OrdersTable.svelte';
import BadgeType from '$lib/interfaces/BadgeType';
import PageMeta from '$lib/components/PageMeta.svelte';
import type { PageData } from './$types';
import type { IOrder } from '$lib/interfaces/IOrder';
import type { IOrderSummary } from '$lib/interfaces/IOrder';
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'
);
const inTransitOrders = orders.filter((el) => el.status === BadgeType.PENDING);
@@ -17,38 +19,25 @@
el.status !== BadgeType.PENDING &&
el.status !== BadgeType.INFO &&
el.status !== 'INITIATED' &&
el.status !== 'CONFIRMED' &&
el.status !== BadgeType.WARNING
);
const deliveredOrders: Array<IOrder> = [];
const deliveredOrders: Array<IOrderSummary> = [];
</script>
<PageMeta title="Orders" description="View all webshop orders" />
<div class="page">
<h1>Orders</h1>
<section class="content">
{#if attentionOrders?.length}
<h2>⚠️ orders needing attention</h2>
<OrdersTable orders="{attentionOrders}" />
<OrdersTable title="⚠️ orders needing attention" orders="{attentionOrders}" />
{/if}
<h2>📬 pending orders</h2>
<OrdersTable orders="{pendingOrders}" />
<h2>📦 in transit</h2>
<OrdersTable orders="{inTransitOrders}" />
<h2>🙅‍♀️ cancelled/returns</h2>
<OrdersTable orders="{otherOrders}" />
<h2>🏠🎁 delivered orders</h2>
<OrdersTable orders="{deliveredOrders}" />
<OrdersTable title="📬 purchased orders" orders="{successfulOrders}" />
<OrdersTable title="📦 in transit" orders="{inTransitOrders}" />
<OrdersTable title="🙅‍♀️ cancelled/returns" orders="{otherOrders}" />
<OrdersTable title="💤 incomplete orders" orders="{incompleteOrders}" />
<OrdersTable title="🎁🏠 delivered orders" orders="{deliveredOrders}" />
</section>
</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">
import { goto } from '$app/navigation';
import Badge from '$lib/components/Badge.svelte';
import Time from '$lib/components/Time.svelte';
import type { IOrderSummary } from '$lib/interfaces/IOrder';
export let title: string;
export let orders: Array<IOrderSummary>;
function navigate(order: IOrderSummary) {
@@ -10,15 +12,16 @@
}
</script>
<h2>{title} <span class="section-count">{orders?.length || 0}</span></h2>
{#if orders?.length}
<table>
<thead>
<tr>
<th>Amount</th>
<th>Status</th>
<th>Order ID</th>
<th>Customer</th>
<th>Date</th>
<th>Order ID</th>
<th>Receipt</th>
</tr>
</thead>
@@ -29,18 +32,12 @@
<td>NOK {order.order_sum}</td>
<td>
<Badge title="{order.status}" type="{order?.status?.type}" />
<Badge title="{order.status}" />
</td>
<td>{order.order_id}</td>
<td>{order.first_name} {order.last_name}</td>
<td
>{order?.created
? new Intl.DateTimeFormat('nb-NO', { dateStyle: 'short', timeStyle: 'short' }).format(
new Date(order.created)
)
: ''}</td
>
<td><Time time="{order?.created}" /></td>
<td>{order.order_id}</td>
<td>
<a href="receipt/{order.order_id}?email={order.email}">🧾</a>
</td>
@@ -55,6 +52,18 @@
<style lang="scss" module="scoped">
@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 {
width: 100%;
border-collapse: collapse;
@@ -92,8 +101,14 @@
}
}
th:last-of-type,
td:last-of-type {
text-align: center;
}
@include mobile {
tr > *:first-child {
tr > *:nth-child(4),
tr > *:nth-child(5) {
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 { env } from '$env/dynamic/private';
import type { IOrderResponse } from '$lib/interfaces/ApiResponse';
import { error } from '@sveltejs/kit';
import type { IOrderDTO } from '$lib/interfaces/ApiResponse';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params }) => {
const { id } = params;
let url = `/api/order/${id}`;
if (dev || env.API_HOST) {
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url);
const orderResponse: IOrderResponse = await res.json();
const res = await fetch(`/api/v1/order/${id}`);
const orderResponse = await res.json();
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;
let order = data.order as IOrder;
console.log('order:', order);
function orderSubTotal() {
if (!order || order.lineItems?.length === 0) return;
if (!order || order?.lineItems?.length === 0) return;
let sum = 0;
order.lineItems.forEach((lineItem) => (sum = sum + lineItem.quantity * lineItem.price));
@@ -21,20 +20,21 @@
}
</script>
<h1>Order: {order.orderid}</h1>
<h1>Order id: {order?.orderid}</h1>
<div class="order">
<!-- <p>Order: {JSON.stringify(order)}</p> -->
<h2 class="price"><span class="amount">{orderSubTotal()}.00</span> Nok</h2>
<OrderSummary order="{order}" />
<OrderProducts lineItems="{order?.lineItems}" />
<PaymentDetails order="{order}" />
<PaymentDetails payment="{order?.payment}" />
<CustomerDetails customer="{order?.customer}" />
<TrackingDetails shipping="{order?.shipping}" />
<TrackingDetails shipping="{order?.shipping}" orderId="{order?.orderid}" />
</div>
<style lang="scss">
@import '../../../styles/media-queries.scss';
h2.price {
font-size: 1.5rem;
color: grey;

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,94 @@
<script lang="ts">
import Badge from '$lib/components/Badge.svelte';
// import type BadgeType from '$lib/interfaces/BadgeType';
import type { IOrder } from '$lib/interfaces/IOrder';
import Time from '$lib/components/Time.svelte';
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>
<section>
<h2>Payment details</h2>
<ul class="property-list">
<li>
<span class="label">Amount</span>
<span>{order?.payment?.amount}.10 kr</span>
<span class="label">Stripe link</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>
<span class="label">Fee</span>
<span>2.25 kr</span>
<span class="label">Amount requested</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>
<span class="label">Net</span>
<span>7.85 kr</span>
<span>{net === null ? '-' : round(net) + ' kr'}</span>
</li>
<li>
<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>
</ul>
</section>
<style lang="scss">
@import './styles-order-page.scss';
a.link {
max-width: 60%;
overflow-x: hidden;
}
</style>

View File

@@ -1,35 +1,138 @@
<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>
{#if shipping}
<section>
<h2>Tracking</h2>
<section>
<h2>Tracking</h2>
{#if shipping}
<ul class="property-list">
<li>
<span class="label">Tracking code</span>
<span>{shipping.tracking_code}</span>
<label class="label" for="courier">Tracking company</label>
{#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>
<span class="label">Tracking company</span>
<span>{shipping.company}</span>
<span class="label">Tracking code</span>
{#if !edit}
<span>{shipping.tracking_code}</span>
{:else}
<input bind:value="{trackingCode}" />
{/if}
</li>
<li>
<span class="label">Link</span>
<span
><a href="{shipping.tracking_link}" target="_blank" rel="noopener noreferrer"
>{shipping.tracking_link}</a
></span
>
{#if !edit}
<a href="{shipping.tracking_link}" class="link" target="_blank" rel="noopener noreferrer">
{shipping.tracking_link}
</a>
{:else}
<input style="margin-bottom: 0" class="wide" bind:value="{trackingLink}" />
{/if}
</li>
</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">
@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>

View File

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

View File

@@ -33,7 +33,7 @@
<p>
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>

View File

@@ -1,10 +1,33 @@
import validOrderId from '$lib/utils/validOrderId';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ params }) => {
export const load: PageServerLoad = async ({ fetch, params, url }) => {
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 {
id,
isValidReceipt: validOrderId(id)
email,
order
};
};

View File

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

View File

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

View File

@@ -1,54 +1,68 @@
<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 { 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;
products.forEach((product) => (total = total + product.price * product.quantity));
lineItems.forEach((lineItem) => (total = total + lineItem.price * lineItem.quantity));
return total;
}
export let data: PageServerData;
const id = data.id as string;
const email = data.email as string;
// export let currentRoute;
// const id = currentRoute?.namedParams?.id;
// const email = currentRoute?.queryParams?.email;
let id: string;
let email: string;
let order: IOrder;
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>
<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">
<p>
A payment to PLANETPOSEN, AS will appear on your statement with order number:
<span class="underline">{id}</span>.
</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 class="order-receipt">
{#each products as product}
{#each order?.lineItems as lineItem}
<p>
<code>{product.name} x{product.quantity}</code>
<code>{product.currency} {product.price * product.quantity}</code>
<code>{lineItem.name} x{lineItem.quantity}</code>
<code>NOK {lineItem.price * lineItem.quantity}</code>
</p>
{/each}
<p>
<code>Shipping</code>
<code>NOK 79</code>
<code>NOK 75</code>
</p>
<p>
<code>Total</code>
<code>NOK {subTotal(products)}</code>
<code>NOK {subTotal(order?.lineItems)}</code>
</p>
</div>
</section>
@@ -56,6 +70,10 @@
<style lang="scss">
@import './styles-receipt-page.scss';
.order-description .underline {
text-decoration: underline;
}
.order-receipt {
background-color: #f7f7f7;
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">
import Input from '$lib/components/Input.svelte';
import PlanetButton from '$lib/components/Button.svelte';
import CircleError from '$lib/icons/CircleError.svelte';
import CircleWarning from '$lib/icons/CircleWarning.svelte';
const CircleComponent = Math.random() > 0.5 ? CircleWarning : CircleError;
import CircleWarning from '$lib/components/loading/CircleWarning.svelte';
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 searchOrderEmail: string;
</script>
<section class="order-confirmation">
<svelte:component this="{CircleComponent}" />
<CircleWarning />
<h1>Fant ikke din bestilling!</h1>
@@ -40,7 +23,8 @@
<form class="order-search" method="POST">
<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}" />
<PlanetButton text="Søk" />
@@ -62,6 +46,10 @@
}
}
.underline {
text-decoration: underline;
}
:global(.order-search button) {
margin-top: 1rem;
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<script lang="ts">
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 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 type { IProductResponse } from '$lib/interfaces/ApiResponse';
import type { IProductDTO } from '$lib/interfaces/ApiResponse';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params }) => {
const { id } = params;
let url = `/api/product/${id}`;
if (dev || env.API_HOST) {
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const res = await fetch(url);
const productResponse: IProductResponse = await res.json();
const res = await fetch(`/api/v1/product/${id}`);
const productResponse: IProductDTO = await res.json();
const jsonld = generateProductJsonLd(productResponse?.product);
return {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,7 +51,6 @@
</table>
<style lang="scss">
// @import "../styles/global.scss";
@import '../../styles/media-queries.scss';
table {
@@ -85,7 +84,10 @@
.image-column {
width: 4rem;
max-width: 4rem;
margin: 0 0.5rem;
@include desktop {
margin: 0 0.5rem;
}
}
.name-and-price > p {
@@ -98,7 +100,6 @@
td,
th {
white-space: nowrap;
padding: 0.4rem 0.6rem;
}
@@ -109,15 +110,9 @@
img {
width: 4rem;
height: 4rem;
border-radius: 0.4rem;
}
.image-column {
display: grid;
place-items: center;
}
tr {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
cursor: pointer;
@@ -128,12 +123,6 @@
}
}
// @include mobile {
// tr > *:first-child {
// display: none;
// }
// }
@include mobile {
tr > *:last-child,
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';
export const load: PageServerLoad = async ({ fetch, params }) => {
const { id } = params;
// let url = `/api/warehouse/product/${id}`;
let url = `/api/warehouse/${id}`;
if (dev || env.API_HOST) {
url = (env.API_HOST || 'http://localhost:30010').concat(url);
}
const productRes = await fetch(`/api/v1/warehouse/${id}`);
const { product } = await productRes.json();
const res = await fetch(url);
const product = await res.json();
console.log('product::', product);
return product;
const auditRes = await fetch(`/api/v1/warehouse/${id}/audit`);
const { logs } = await auditRes.json();
return {
product,
logs
};
};

View File

@@ -2,41 +2,76 @@
import PricingSection from './PricingSection.svelte';
import DetailsSection from './DetailsSection.svelte';
import Button from '$lib/components/Button.svelte';
import { buildApiUrl } from '$lib/utils/apiUrl';
import type { PageServerData } from './$types';
import type { IProduct } from '$lib/interfaces/IProduct';
import type IProductVariation from '$lib/interfaces/IProductVariation';
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;
function save() {
console.log('savvvving');
function saveProduct(event: SubmitEvent) {
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;
}
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>
<h1>Product details</h1>
<div class="info row">
<img src="{product.image}" alt="Product" />
<div class="name-and-price">
<p>{product.name}</p>
<p>NOK {product?.price}</p>
<form bind:this="{form}" on:submit|preventDefault="{saveProduct}">
<div class="info row">
<img src="{displayImage}" alt="Product" />
<div class="name-and-price">
<p>{product.name}</p>
<p>Left in stock: {totalVariations}</p>
</div>
</div>
<div class="edit-button">
<div class="row edit-button">
{#if !edit}
<Button text="Edit" on:click="{() => (edit = !edit)}" />
{:else}
<Button text="Save" on:click="{save}" />
<Button type="submit" text="Save" />
{/if}
</div>
</div>
<h2>Details</h2>
<DetailsSection product="{product}" edit="{edit}" />
<h2>Details</h2>
<DetailsSection bind:product="{product}" edit="{edit}" />
</form>
<h2>Variations</h2>
<PricingSection product="{product}" />
<PricingSection bind:product="{product}" />
<h2>Metadata</h2>
<div>
@@ -45,12 +80,24 @@
<h2>Audit log</h2>
<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>
<style lang="scss">
@import '../../../styles/media-queries.scss';
h1 {
word-break: break-all;
}
.row {
display: flex;
width: 100%;
@@ -65,25 +112,38 @@
.info {
align-items: center;
.edit-button {
margin-left: auto;
}
img {
width: 64px;
height: 64px;
width: 4rem;
border-radius: 6px;
}
.name-and-price > p {
margin: 0.5rem 0 0.5rem 2rem;
&:first-of-type {
font-size: 1.5rem;
}
&:nth-of-type(2) {
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,
.empty {
color: grey;

View File

@@ -1,8 +1,48 @@
<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';
export let product: IProduct;
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>
<div class="details row">
@@ -13,39 +53,54 @@
{#if !edit}
<span>{product.name}</span>
{:else}
<input bind:value="{product.name}" />
<input name="name" class="wide" id="name" bind:value="{product.name}" />
{/if}
</li>
<li>
<span class="label">Created</span>
<span>{product.created}</span>
<Time time="{product.created}" />
</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}
<span>{product.subtext || '(empty)'}</span>
{:else}
<input bind:value="{product.subtext}" />
<input name="subtext" id="subtext" bind:value="{product.subtext}" />
{/if}
</li>
<li>
<span class="label">Description</span>
<label for="description" class="label">Description</label>
{#if !edit}
<span>{product.description || '(empty)'}</span>
<span class="description">{product.description || '(empty)'}</span>
{:else}
<input class="wide" bind:value="{product.description}" />
<textarea
rows="6"
class="wide description"
name="description"
id="description"
bind:value="{product.description}"></textarea>
{/if}
</li>
<li>
<span class="label">Primary color</span>
<label for="primary_color" class="label">Primary color</label>
{#if !edit}
<span>{product.primary_color || '(empty)'}</span>
{:else}
<input bind:value="{product.primary_color}" />
<input
type="color"
name="primary_color"
id="primary_color"
bind:value="{product.primary_color}"
/>
{/if}
{#if product.primary_color}
<div class="color-box" style="{`--color: ${product.primary_color}`}"></div>
@@ -60,8 +115,22 @@
</div>
<div class="right">
<span class="label">Image</span>
<img src="{product.image}" alt="Product" />
<span class="label">Images</span>
<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>
@@ -78,28 +147,69 @@
}
input {
margin: 0;
margin: -2px 0 -2px -2px;
padding: 0;
}
&.wide {
width: -webkit-fill-available;
}
.wide {
width: 100%;
width: -webkit-fill-available;
margin-right: 0.5rem;
}
span.description {
white-space: pre-line;
}
textarea.description {
height: auto;
}
.right {
width: 50%;
display: flex;
@include mobile {
flex-direction: column;
}
span {
padding: 0.4rem 0;
margin-right: 1.5rem;
}
img {
.images-and-upload {
display: flex;
flex-direction: column;
}
.images .img {
position: relative;
display: inline-block;
width: 110px;
height: 110px;
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;
}
li span:first-of-type {
li span:first-of-type,
li label:first-of-type {
display: inline-block;
margin-bottom: auto;
min-width: 30%;

View File

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

View File

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

View File

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

View File

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