mirror of
https://github.com/KevinMidboe/planetposen-frontend.git
synced 2025-10-29 05:10:11 +00:00
All planetposen routes at version 0.1
This commit is contained in:
68
src/routes/+layout.svelte
Normal file
68
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import Header from './Header.svelte';
|
||||
import Footer from './Footer.svelte';
|
||||
import CartModal from './CartModal.svelte';
|
||||
import { closeCart } from '$lib/cartStore';
|
||||
import requestSessionCookie from '$lib/utils/requestSessionCookie';
|
||||
import { getCookie } from '$lib/utils/cookie';
|
||||
import { connectToCart, reconnectIfCartWSClosed } from '$lib/websocketCart';
|
||||
import './styles.css';
|
||||
|
||||
let isAdmin = false;
|
||||
|
||||
beforeNavigate(() => {
|
||||
closeCart();
|
||||
reconnectIfCartWSClosed();
|
||||
isAdmin = getCookie('admin') === 'true';
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
isAdmin = getCookie('admin') === 'true';
|
||||
requestSessionCookie();
|
||||
connectToCart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
<Header isAdmin="{isAdmin}" />
|
||||
|
||||
<CartModal />
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<Footer isAdmin="{isAdmin}" />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/media-queries.scss';
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
:global(main:has(.frontpage)) {
|
||||
max-width: unset !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
min-height: 95vh;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
|
||||
@include mobile {
|
||||
min-height: 90vh;
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
src/routes/+page.svelte
Normal file
89
src/routes/+page.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import FrontText from '$lib/components/FrontText.svelte';
|
||||
import FrontTextImage from '$lib/components/FrontTextImage.svelte';
|
||||
import FrontTextImageBubble from '$lib/components/FrontTextImageBubble.svelte';
|
||||
import type IFrontTextImage from '$lib/interfaces/IFrontTextImage';
|
||||
import type IFrontText from '$lib/interfaces/IFrontText';
|
||||
|
||||
const textImages: Array<IFrontTextImage> = [
|
||||
{
|
||||
title: 'Our story',
|
||||
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'
|
||||
},
|
||||
{
|
||||
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'
|
||||
},
|
||||
{
|
||||
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'
|
||||
},
|
||||
{
|
||||
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'
|
||||
},
|
||||
{
|
||||
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'
|
||||
}
|
||||
];
|
||||
|
||||
const textTitle: Array<IFrontText> = [
|
||||
{
|
||||
title: 'Katy Vandekerckhove:',
|
||||
text: 'Kistefos was really a jewel on earth with high level art in fantastic surroundings',
|
||||
color: '#27615d'
|
||||
},
|
||||
{
|
||||
title: 'Katy Vandekerckhove:',
|
||||
text: 'Kistefos was really a jewel on earth with high level art in fantastic surroundings',
|
||||
color: 'orange'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Home</title>
|
||||
<meta name="description" content="Svelte demo app" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="frontpage">
|
||||
<!-- {#each textImages as data}
|
||||
<TextImageParralax {data} />
|
||||
{/each} -->
|
||||
<FrontTextImage data="{textImages[0]}" />
|
||||
<FrontTextImage data="{textImages[1]}" />
|
||||
<FrontTextImageBubble />
|
||||
<FrontTextImage data="{textImages[2]}" />
|
||||
|
||||
<FrontText data="{textTitle[0]}" />
|
||||
|
||||
<FrontTextImage data="{textImages[3]}" />
|
||||
<FrontTextImage data="{textImages[4]}" />
|
||||
|
||||
<FrontText data="{textTitle[1]}" />
|
||||
</section>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
section {
|
||||
// position: absolute;
|
||||
// left: 0;
|
||||
// width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0.6;
|
||||
}
|
||||
</style>
|
||||
3
src/routes/+page.ts
Normal file
3
src/routes/+page.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// since there's no dynamic data here, we can prerender
|
||||
// it so that it gets served as a static asset in production
|
||||
export const prerender = true;
|
||||
287
src/routes/CartModal.svelte
Normal file
287
src/routes/CartModal.svelte
Normal file
@@ -0,0 +1,287 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { fly } from 'svelte/transition';
|
||||
import {
|
||||
removeProductFromCart,
|
||||
decrementProductInCart,
|
||||
incrementProductInCart
|
||||
} from '$lib/websocketCart';
|
||||
import { cart, isOpen, subTotal } from '$lib/cartStore';
|
||||
import IconTrashcan from '$lib/icons/IconTrashcan.svelte';
|
||||
import QuantitySelect from '$lib/components/QuantitySelect.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Unsubscriber } from 'svelte/store';
|
||||
|
||||
let cartHeight = 0;
|
||||
let isOpenSubscription: Unsubscriber;
|
||||
|
||||
if (browser) {
|
||||
cartHeight = -1 * (window?.visualViewport?.height || 0);
|
||||
}
|
||||
|
||||
function updateQuantityLineItem(id: number, event: Event) {
|
||||
console.log('todo update quantity by input', id, event);
|
||||
}
|
||||
|
||||
function toggleNoScrollBody() {
|
||||
if (!browser) return;
|
||||
|
||||
if ($isOpen === true && !document.body.classList.contains('no-scroll')) {
|
||||
document.body.classList.add('no-scroll');
|
||||
} else if ($isOpen === false && document.body.classList.contains('no-scroll')) {
|
||||
document.body.classList.remove('no-scroll');
|
||||
}
|
||||
}
|
||||
|
||||
isOpenSubscription = isOpen.subscribe(() => toggleNoScrollBody());
|
||||
onDestroy(() => isOpenSubscription());
|
||||
</script>
|
||||
|
||||
{#if $isOpen}
|
||||
<section class="{`cart ${isOpen && 'show'}`}" transition:fly="{{ y: cartHeight, duration: 800 }}">
|
||||
<div class="cart-container">
|
||||
<div class="products">
|
||||
<h2>Cart</h2>
|
||||
|
||||
{#if $cart?.length > 0}
|
||||
<ul>
|
||||
{#each $cart as cartItem}
|
||||
<li class="cart-item">
|
||||
<div class="cart-item--left">
|
||||
<img src="{cartItem.image}" alt="Product" />
|
||||
</div>
|
||||
|
||||
<div class="cart-item--right">
|
||||
<div class="cart-item--text">
|
||||
{cartItem.name}, {cartItem.size}
|
||||
<div class="subtotal">Nok {cartItem.price * cartItem.quantity}.00</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<QuantitySelect
|
||||
on:decrement="{() => decrementProductInCart(cartItem.lineitem_id)}"
|
||||
on:increment="{() => incrementProductInCart(cartItem.lineitem_id)}"
|
||||
on:change="{(event) =>
|
||||
updateQuantityLineItem(cartItem.lineitem_id, event?.detail)}"
|
||||
value="{cartItem.quantity}"
|
||||
small="{true}"
|
||||
/>
|
||||
|
||||
<div class="cart-item--trash">
|
||||
<IconTrashcan
|
||||
on:click="{() => removeProductFromCart(cartItem.lineitem_id)}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<h4>(empty)</h4>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $cart?.length > 0}
|
||||
<div class="cart-summary">
|
||||
<h2>Summary</h2>
|
||||
|
||||
<p>Subtotal: Nok {$subTotal}</p>
|
||||
|
||||
<Button text="Gå til kassen" on:click="{() => goto('/checkout')}" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../styles/media-queries.scss';
|
||||
|
||||
// move inside cart container
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
background-color: var(--background);
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
padding-top: 5rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.products {
|
||||
width: 70%;
|
||||
margin-right: 5%;
|
||||
float: left;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 10%;
|
||||
height: 80%;
|
||||
z-index: 1;
|
||||
width: 1px;
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-summary {
|
||||
width: 25%;
|
||||
float: left;
|
||||
margin-bottom: 3rem;
|
||||
|
||||
p {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.products,
|
||||
.cart-summary {
|
||||
width: 100%;
|
||||
|
||||
&::before {
|
||||
width: 90%;
|
||||
left: 5%;
|
||||
height: 1px;
|
||||
top: unset;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cart {
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 9;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100vw; /* viewport width */
|
||||
width: -webkit-fill-available; /* viewport width */
|
||||
// height: fit-content;
|
||||
height: -webkit-fill-available;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
backface-visibility: hidden;
|
||||
background-color: var(--background);
|
||||
color: #231f20;
|
||||
// margin: 48px 0 30px 0;
|
||||
margin: 0;
|
||||
margin-bottom: 3rem;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.show {
|
||||
visibility: visible;
|
||||
backface-visibility: visible;
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
height: fit-content;
|
||||
|
||||
.cart-container {
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cart-container {
|
||||
display: block;
|
||||
max-width: 2200px;
|
||||
padding: 0 1rem;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
|
||||
@include tablet {
|
||||
padding: 1.5rem 2.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
// display: grid;
|
||||
// grid-template-columns: 1fr 1fr;
|
||||
margin-bottom: 1.5rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@include tablet {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
width: 33%;
|
||||
}
|
||||
|
||||
&--left {
|
||||
min-width: 85px;
|
||||
width: 85px;
|
||||
margin-right: 1.5rem;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
border: 1px solid var(--background);
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--right {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--text {
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 1em;
|
||||
font-size: 1rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
&--trash {
|
||||
display: inline-block;
|
||||
width: 1.75rem;
|
||||
// position: absolute;
|
||||
margin-left: 1rem;
|
||||
// bottom: 6px;
|
||||
|
||||
@include mobile {
|
||||
bottom: 0px;
|
||||
right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
|
||||
@media (min-width: 600px) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.cart-item--trash svg) {
|
||||
fill: grey;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
fill: black;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
109
src/routes/Footer.svelte
Normal file
109
src/routes/Footer.svelte
Normal file
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import LinkArrow from '$lib/components/LinkArrow.svelte';
|
||||
|
||||
export let isAdmin = false;
|
||||
|
||||
function logout() {
|
||||
let url = '/api/logout';
|
||||
if (window?.location?.href.includes('localhost')) {
|
||||
url = 'http://localhost:30010'.concat(url);
|
||||
}
|
||||
|
||||
fetch(url, { method: 'POST' }).then((resp) => {
|
||||
resp.status === 200 && window.location.reload();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<section>
|
||||
<h2>Personvern og vilkår</h2>
|
||||
<ul>
|
||||
<li><LinkArrow /><a href="/terms-and-conditions">Betingelser og vilkår</a></li>
|
||||
<li><LinkArrow /><a href="/privacy-policy">Personvernerklæring</a></li>
|
||||
<li><LinkArrow /><a href="/cookies">Cookies</a></li>
|
||||
<li>
|
||||
<LinkArrow />
|
||||
{#if !isAdmin}
|
||||
<a href="/login">Logg på</a>
|
||||
{:else}
|
||||
<a href="/" on:click="{logout}">Logg ut</a>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Kontakt</h2>
|
||||
<ul>
|
||||
<li>Epost: <a class="link" href="mailto:post@planetposen.no">post@planetposen.no</a></li>
|
||||
|
||||
<li>Org nummer: 994 749 765</li>
|
||||
|
||||
<li>Kode: <a class="link" href="https://github.com/kevinmidboe">kevinmidboe</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
</footer>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../styles/media-queries.scss';
|
||||
|
||||
footer {
|
||||
background-color: var(--color-theme-1);
|
||||
color: white;
|
||||
padding: 4rem;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
|
||||
h2 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
section {
|
||||
width: 30%;
|
||||
padding: 2rem;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 0rem;
|
||||
|
||||
li {
|
||||
font-size: 1.1rem;
|
||||
padding: 0.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
a {
|
||||
--color-text: white;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
border-bottom: 2px solid var(--color-theme-1);
|
||||
|
||||
&:hover {
|
||||
border-color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
padding: 3rem 0.5rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
section {
|
||||
width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(footer ul li svg) {
|
||||
margin-right: 0.6rem;
|
||||
}
|
||||
</style>
|
||||
161
src/routes/Header.svelte
Normal file
161
src/routes/Header.svelte
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
// import logo from '$lib/images/svelte-logo.svg';
|
||||
// import logo from '$lib/assets/planetposen-logo.png';
|
||||
|
||||
import Cart from '$lib/components/Cart.svelte';
|
||||
|
||||
interface IHeaderLinks {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export let isAdmin = false;
|
||||
|
||||
let genPopHeaderLinks: Array<IHeaderLinks> = [
|
||||
{
|
||||
path: '/shop',
|
||||
name: 'Nettbutikk'
|
||||
},
|
||||
{
|
||||
path: '/checkout',
|
||||
name: 'kasse'
|
||||
}
|
||||
];
|
||||
|
||||
const adminHeaderLinks: Array<IHeaderLinks> = genPopHeaderLinks.concat([
|
||||
{
|
||||
path: '/warehouse',
|
||||
name: 'warehouse'
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'Ordre'
|
||||
},
|
||||
{
|
||||
path: '/receipt',
|
||||
name: 'Kvittering'
|
||||
}
|
||||
]);
|
||||
|
||||
$: headerLinks = !isAdmin ? genPopHeaderLinks : adminHeaderLinks;
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<div class="corner">
|
||||
<a href="/">
|
||||
<!-- <img src={logo} alt="Planetposen logo" /> -->
|
||||
<!-- <img src="/planetposen-logo.png" alt="Planetposen logo" /> -->
|
||||
🌍
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<ul>
|
||||
<li aria-current="{$page.url.pathname === '/' ? 'page' : undefined}">
|
||||
<a href="/">Hjem</a>
|
||||
</li>
|
||||
|
||||
{#each headerLinks as link}
|
||||
<li aria-current="{$page.url.pathname.startsWith(link.path) ? 'page' : undefined}">
|
||||
<a href="{link.path}">{link.name}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<Cart />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 99;
|
||||
overflow-x: scroll;
|
||||
// background-color: var(--background);
|
||||
background-color: white;
|
||||
border-bottom: 2px solid rgba(24, 51, 47, 0.4);
|
||||
}
|
||||
|
||||
.corner {
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
}
|
||||
|
||||
.corner a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.corner img {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/*--background: rgba(255, 255, 255, 0.7);*/
|
||||
}
|
||||
|
||||
ul {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 2em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
li[aria-current='page'] a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// li[aria-current='page']::after, li:hover::after {
|
||||
// content: '';
|
||||
// width: 90%;
|
||||
// height: 2px;
|
||||
// position: absolute;
|
||||
// bottom: 0;
|
||||
// margin-left: 5%;
|
||||
// background-color: var(--color-text);
|
||||
// }
|
||||
|
||||
nav a {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
|
||||
// a:hover {
|
||||
// color: var(--color-theme-1);
|
||||
// }
|
||||
</style>
|
||||
131
src/routes/checkout/+page.svelte
Normal file
131
src/routes/checkout/+page.svelte
Normal file
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import OrderSection from './OrderSection.svelte';
|
||||
import DeliverySection from './DeliverySection.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 { cart } from '$lib/cartStore';
|
||||
|
||||
import type IProduct from '$lib/interfaces/IProduct';
|
||||
|
||||
function postOrder(event: any) {
|
||||
const formData = new FormData(event.target);
|
||||
|
||||
const customerJson = {};
|
||||
formData.forEach((value, key) => (customerJson[key] = value));
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
customer: customerJson,
|
||||
cart: $cart
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
let url = '/api/order';
|
||||
if (window?.location?.href.includes('localhost')) {
|
||||
url = 'http://localhost:30010'.concat(url);
|
||||
}
|
||||
|
||||
fetch(url, options);
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Checkout</h1>
|
||||
<form class="checkout" on:submit|preventDefault="{postOrder}">
|
||||
<section id="delivery">
|
||||
<h2>Leveringsaddresse</h2>
|
||||
<DeliverySection />
|
||||
</section>
|
||||
|
||||
<section id="order">
|
||||
<h2>Din ordre</h2>
|
||||
<OrderSection>
|
||||
<div class="navigation-buttons" slot="button">
|
||||
<ApplePayButton />
|
||||
<VippsHurtigkasse />
|
||||
</div>
|
||||
</OrderSection>
|
||||
</section>
|
||||
|
||||
<section id="payment">
|
||||
<h2>Betalingsinformasjon</h2>
|
||||
<StripeCard />
|
||||
|
||||
<div class="pay">
|
||||
<CheckoutButton type="submit" text="Betal" />
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
form.checkout {
|
||||
// display: flex;
|
||||
// flex-wrap: wrap;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'delivery order'
|
||||
'payment order';
|
||||
|
||||
grid-gap: 2rem;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
// grid-auto-flow: column;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'order'
|
||||
'delivery'
|
||||
'payment';
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
#delivery {
|
||||
grid-area: delivery;
|
||||
}
|
||||
#order {
|
||||
grid-area: order;
|
||||
}
|
||||
#payment {
|
||||
grid-area: payment;
|
||||
}
|
||||
|
||||
section {
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
h2 {
|
||||
padding-left: 4px;
|
||||
text-transform: none;
|
||||
font-size: 2.3rem;
|
||||
padding: 12px 10px 12px 12px !important;
|
||||
font-weight: 500;
|
||||
color: #231f20;
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
src/routes/checkout/DeliverySection.svelte
Normal file
42
src/routes/checkout/DeliverySection.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/Input.svelte';
|
||||
|
||||
let email: string;
|
||||
let address: string;
|
||||
let firstName: string;
|
||||
let lastName: string;
|
||||
let zipCode: string;
|
||||
let city: string;
|
||||
</script>
|
||||
|
||||
<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="address" bind:value="{address}" label="Gateadresse" autocomplete="address-line1" />
|
||||
<Input id="zipCode" bind:value="{zipCode}" label="Postnummer" autocomplete="postal-code" />
|
||||
<Input id="city" bind:value="{city}" label="By" autocomplete="address-level2" />
|
||||
|
||||
<div id="personalia-submit-button">
|
||||
<slot name="button" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
div.personalia {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-gap: 1rem;
|
||||
|
||||
:global(#address) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
:global(#email) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
:global(#personalia-submit-button) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
137
src/routes/checkout/OrderSection.svelte
Normal file
137
src/routes/checkout/OrderSection.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import QuantitySelect from '$lib/components/QuantitySelect.svelte';
|
||||
|
||||
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;
|
||||
</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>
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<tr>
|
||||
<td>Totalpris:</td>
|
||||
<td></td>
|
||||
<td>Nok {$subTotal}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Frakt:</td>
|
||||
<td></td>
|
||||
<td>Nok {shippingPrice}</td>
|
||||
</tr>
|
||||
|
||||
<tr style="font-weight: 600">
|
||||
<td>Totalsum:</td>
|
||||
<td></td>
|
||||
<td>Nok {$subTotal}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<slot name="button" />
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
table.checkout {
|
||||
width: 100%;
|
||||
border: 2px solid #dbd9d5;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead {
|
||||
th {
|
||||
padding: 1rem 0.5rem;
|
||||
min-width: 50px;
|
||||
font-size: 1.3rem;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
th:nth-of-type(2),
|
||||
td:nth-of-type(2) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
th:last-of-type,
|
||||
td:last-of-type {
|
||||
width: 30%;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
tr.no-products {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
td {
|
||||
border: 2px solid #dbd9d5;
|
||||
padding: 1rem 0.5rem;
|
||||
min-width: 50px;
|
||||
font-size: 1.1rem;
|
||||
text-align: left;
|
||||
|
||||
&:nth-of-type(2) {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
.line-order {
|
||||
padding: 9px 12px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
|
||||
.subtext {
|
||||
color: #999;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
src/routes/cookies/+page.svelte
Normal file
44
src/routes/cookies/+page.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<article>
|
||||
<h1>Cookies</h1>
|
||||
|
||||
<section>
|
||||
<p>
|
||||
<strong>Generelt</strong>
|
||||
"Cookies" er små tekstfiler som lagres i nettleseren din når du laster opp en nettside. De brukes
|
||||
hovedsakelig til å forbedre brukeropplevelsen og huske hvem du er, slik at varer i din handlekurv
|
||||
forblir tilgjengelig.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Planetposen</strong>
|
||||
Vi samler informasjon om navigasjon, tidsbruk, besøkte sider, nettleserversjon med mer. Førsteparts
|
||||
informasjonskapsler er nødvendige for at nettstedet vårt skal fungere.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Hva slags informasjonskapsler bruker vi?</strong>
|
||||
Vi bruker informasjonskapsler for å beholde varer i handlekurven og sikre betalinger. Disse informasjonskapslene
|
||||
er nødvendige for at nettstedet vårt skal fungere normalt.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Tredjepart informasjonskapsler</strong>
|
||||
Planetposen.no lagrer ingen informasjonskapsler tilknyttet noen tredjepart.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Hvordan unngå informasjonskapsler?</strong>
|
||||
De fleste nettlesere (Google Chrome, Firefox, Safari osv.) er satt til å akseptere informasjonskapsler
|
||||
automatisk, men du kan selv endre disse innstillingene slik at informasjonskapsler ikke aksepteres.
|
||||
Vær oppmerksom på at begrenset tilgang til informasjonskapsler kan påvirke brukeropplevelsen din.
|
||||
Hvordan du administrerer informasjonskapsler finner du vanligvis i "Hjelp"-funksjonen i nettleseren
|
||||
din.
|
||||
</p>
|
||||
|
||||
<p class="last-edit"><strong>Siden ble sist endret:</strong> 16.11.2022</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/generic-article.scss';
|
||||
</style>
|
||||
84
src/routes/login/+page.svelte
Normal file
84
src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Input from '$lib/components/Input.svelte';
|
||||
|
||||
let username: string;
|
||||
let password: string;
|
||||
let displayMessage: string | null;
|
||||
|
||||
function postLogin(event: any) {
|
||||
displayMessage = null;
|
||||
const formData = new FormData(event.target);
|
||||
const data = {};
|
||||
formData.forEach((value, key) => (data[key] = value));
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
let url = '/api/login';
|
||||
if (window?.location?.href.includes('localhost')) {
|
||||
url = 'http://localhost:30010'.concat(url);
|
||||
}
|
||||
|
||||
fetch(url, options)
|
||||
.then((resp) => {
|
||||
const { status } = resp;
|
||||
if (!(status === 403 || status === 200)) throw resp;
|
||||
return resp.json();
|
||||
})
|
||||
.then((response) => {
|
||||
const { message, success } = response;
|
||||
|
||||
if (message) {
|
||||
displayMessage = message;
|
||||
}
|
||||
|
||||
success && setTimeout(() => window.location.reload(), 1000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h1>Logg på</h1>
|
||||
|
||||
<form on:submit|preventDefault="{postLogin}">
|
||||
<Input id="username" bind:value="{username}" label="Brukernavn" autocapitalize="none" />
|
||||
<Input id="password" bind:value="{password}" label="Passord" type="password" />
|
||||
|
||||
<div class="signin-button">
|
||||
<Button type="submit" text="Logg på" />
|
||||
</div>
|
||||
|
||||
{#if displayMessage}
|
||||
<div class="display-message">
|
||||
<p>{displayMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
@include desktop {
|
||||
section {
|
||||
margin: 20% 2rem;
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.signin-button {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.display-message {
|
||||
border: 2px dashed var(----color-theme-1);
|
||||
margin: 1rem 0;
|
||||
padding: 0.2rem 1rem;
|
||||
}
|
||||
</style>
|
||||
18
src/routes/orders/+page.server.ts
Normal file
18
src/routes/orders/+page.server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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 response = await res.json();
|
||||
console.log('orders length:', response?.orders);
|
||||
|
||||
return {
|
||||
orders: response?.orders || []
|
||||
};
|
||||
};
|
||||
54
src/routes/orders/+page.svelte
Normal file
54
src/routes/orders/+page.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import OrdersTable from './OrdersTable.svelte';
|
||||
import BadgeType from '$lib/interfaces/BadgeType';
|
||||
import type { PageData } from './$types';
|
||||
import type { IOrder } from '$lib/interfaces/IOrder';
|
||||
|
||||
export let data: PageData;
|
||||
const orders = data.orders as Array<IOrder>;
|
||||
|
||||
const pendingOrders = orders.filter(
|
||||
(el) => el.status === BadgeType.INFO || el.status === 'INITIATED'
|
||||
);
|
||||
const inTransitOrders = orders.filter((el) => el.status === BadgeType.PENDING);
|
||||
const attentionOrders = orders.filter((el) => el.status === BadgeType.WARNING);
|
||||
const otherOrders = orders.filter(
|
||||
(el) =>
|
||||
el.status !== BadgeType.PENDING &&
|
||||
el.status !== BadgeType.INFO &&
|
||||
el.status !== 'INITIATED' &&
|
||||
el.status !== BadgeType.WARNING
|
||||
);
|
||||
|
||||
const deliveredOrders: Array<IOrder> = [];
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>Orders</h1>
|
||||
<section class="content">
|
||||
{#if attentionOrders?.length}
|
||||
<h2>⚠️ orders needing attention</h2>
|
||||
|
||||
<OrdersTable 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}" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
section.content h2 {
|
||||
// text-decoration: underline;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
101
src/routes/orders/OrdersTable.svelte
Normal file
101
src/routes/orders/OrdersTable.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Badge from '$lib/components/Badge.svelte';
|
||||
import type { IOrderSummary } from '$lib/interfaces/IOrder';
|
||||
|
||||
export let orders: Array<IOrderSummary>;
|
||||
|
||||
function navigate(order: IOrderSummary) {
|
||||
goto(`/orders/${order.order_id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if orders?.length}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Order ID</th>
|
||||
<th>Customer</th>
|
||||
<th>Date</th>
|
||||
<th>Receipt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#each orders as order}
|
||||
<tr on:click="{() => navigate(order)}">
|
||||
<td>NOK {order.order_sum}</td>
|
||||
|
||||
<td>
|
||||
<Badge title="{order.status}" type="{order?.status?.type}" />
|
||||
</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>
|
||||
<a href="receipt/{order.order_id}?email={order.email}">🧾</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<span>no orders</span>
|
||||
{/if}
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead {
|
||||
tr th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody {
|
||||
a {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
|
||||
td {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
tr > *:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
src/routes/orders/[id]/+page.server.ts
Normal file
22
src/routes/orders/[id]/+page.server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { IOrderResponse } 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();
|
||||
|
||||
if (orderResponse?.success == false || orderResponse?.order === undefined) {
|
||||
throw Error(':(');
|
||||
}
|
||||
|
||||
return { order: orderResponse.order };
|
||||
};
|
||||
55
src/routes/orders/[id]/+page.svelte
Normal file
55
src/routes/orders/[id]/+page.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import OrderSummary from './OrderSummary.svelte';
|
||||
import OrderProducts from './OrderProducts.svelte';
|
||||
import PaymentDetails from './PaymentDetails.svelte';
|
||||
import CustomerDetails from './CustomerDetails.svelte';
|
||||
import TrackingDetails from './TrackingDetails.svelte';
|
||||
import type { IOrder } from '$lib/interfaces/IOrder';
|
||||
import type { PageServerData } from './$types';
|
||||
|
||||
export let data: PageServerData;
|
||||
let order = data.order as IOrder;
|
||||
console.log('order:', order);
|
||||
|
||||
function orderSubTotal() {
|
||||
if (!order || order.lineItems?.length === 0) return;
|
||||
|
||||
let sum = 0;
|
||||
order.lineItems.forEach((lineItem) => (sum = sum + lineItem.quantity * lineItem.price));
|
||||
|
||||
return sum;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Order: {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}" />
|
||||
<CustomerDetails customer="{order?.customer}" />
|
||||
<TrackingDetails shipping="{order?.shipping}" />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
h2.price {
|
||||
font-size: 1.5rem;
|
||||
color: grey;
|
||||
border-bottom: unset;
|
||||
|
||||
.amount {
|
||||
font-weight: bold;
|
||||
font-size: 2.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.order {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
36
src/routes/orders/[id]/CustomerDetails.svelte
Normal file
36
src/routes/orders/[id]/CustomerDetails.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { ICustomer } from '$lib/interfaces/IOrder';
|
||||
|
||||
export let customer: ICustomer;
|
||||
|
||||
const zeroPadZip = (zipCode: number) => String(zipCode).padStart(4, '0');
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2>Customer</h2>
|
||||
<ul class="property-list">
|
||||
<li>
|
||||
<span class="label">Email</span>
|
||||
<span>{customer.email}</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Name</span>
|
||||
<span class="name">{customer.firstname} {customer.lastname}</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Street address</span>
|
||||
<span>{customer.streetaddress}</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Zip & City</span>
|
||||
<span>{zeroPadZip(customer.zipcode)}, {customer.city}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles-order-page.scss';
|
||||
</style>
|
||||
112
src/routes/orders/[id]/OrderProducts.svelte
Normal file
112
src/routes/orders/[id]/OrderProducts.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import type { ILineItem } from '$lib/interfaces/IOrder';
|
||||
|
||||
export let lineItems: Array<ILineItem>;
|
||||
</script>
|
||||
|
||||
<h2>Products</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="image-column"></th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Quantity</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#each lineItems as product}
|
||||
<tr>
|
||||
<td class="image-column">
|
||||
<img src="{product.image}" alt="{product.name}" />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<p>{product.name}</p>
|
||||
</td>
|
||||
|
||||
<td>{product.size}</td>
|
||||
|
||||
<td>{product.quantity}</td>
|
||||
|
||||
<td>Nok {product.price * product.quantity}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style lang="scss">
|
||||
// @import "../styles/global.scss";
|
||||
@import '../../../styles/media-queries.scss';
|
||||
@import './styles-order-page.scss';
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead {
|
||||
tr th {
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.image-column {
|
||||
width: 4rem;
|
||||
max-width: 4rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.image-column {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:hover {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
tr > *:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// @include mobile {
|
||||
// tr > *:last-child, tr > :nth-child(4) {
|
||||
// display: none;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
</style>
|
||||
50
src/routes/orders/[id]/OrderSummary.svelte
Normal file
50
src/routes/orders/[id]/OrderSummary.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
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">Last update</span>
|
||||
<span>{order.updated}</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Customer</span>
|
||||
<span class="name">{order.customer.firstname} {order.customer.lastname}</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Receipt</span>
|
||||
<span><a href="/receipt/{order.orderid}">{order.orderid}</a></span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Payment method</span>
|
||||
<span>{paymentMethod}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles-order-page.scss';
|
||||
|
||||
ul {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-right: 3rem;
|
||||
}
|
||||
|
||||
span:first-of-type {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
src/routes/orders/[id]/PaymentDetails.svelte
Normal file
36
src/routes/orders/[id]/PaymentDetails.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/Badge.svelte';
|
||||
import type BadgeType from '$lib/interfaces/BadgeType';
|
||||
import type { IOrder } from '$lib/interfaces/IOrder';
|
||||
|
||||
export let order: IOrder;
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2>Payment details</h2>
|
||||
<ul class="property-list">
|
||||
<li>
|
||||
<span class="label">Amount</span>
|
||||
<span>{order?.payment?.amount}.10 kr</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Fee</span>
|
||||
<span>2.25 kr</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Net</span>
|
||||
<span>7.85 kr</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Status</span>
|
||||
<Badge title="{order?.status?.text || order.status}" type="{order.status.type}" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles-order-page.scss';
|
||||
</style>
|
||||
35
src/routes/orders/[id]/TrackingDetails.svelte
Normal file
35
src/routes/orders/[id]/TrackingDetails.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import type { IShipping } from '$lib/interfaces/IOrder';
|
||||
|
||||
export let shipping: IShipping;
|
||||
</script>
|
||||
|
||||
{#if shipping}
|
||||
<section>
|
||||
<h2>Tracking</h2>
|
||||
<ul class="property-list">
|
||||
<li>
|
||||
<span class="label">Tracking code</span>
|
||||
<span>{shipping.tracking_code}</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Tracking company</span>
|
||||
<span>{shipping.company}</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Link</span>
|
||||
<span
|
||||
><a href="{shipping.tracking_link}" target="_blank" rel="noopener noreferrer"
|
||||
>{shipping.tracking_link}</a
|
||||
></span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles-order-page.scss';
|
||||
</style>
|
||||
59
src/routes/orders/[id]/styles-order-page.scss
Normal file
59
src/routes/orders/[id]/styles-order-page.scss
Normal file
@@ -0,0 +1,59 @@
|
||||
@import '../../../styles/media-queries.scss';
|
||||
|
||||
h2 {
|
||||
width: 100%;
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
@include tablet {
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
.label,
|
||||
.empty {
|
||||
color: grey;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 0.4rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
span.name {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
ul.property-list {
|
||||
li span:first-of-type {
|
||||
display: inline-block;
|
||||
min-width: 30%;
|
||||
margin-bottom: auto;
|
||||
|
||||
@include mobile {
|
||||
min-width: 40%;
|
||||
}
|
||||
}
|
||||
|
||||
li span:last-of-type {
|
||||
@include mobile {
|
||||
min-width: 60%;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
171
src/routes/privacy-policy/+page.svelte
Normal file
171
src/routes/privacy-policy/+page.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<article>
|
||||
<h1>Personvernerklæring</h1>
|
||||
<section>
|
||||
<p>
|
||||
<strong>1. Introduksjon</strong>
|
||||
Planetposen Midbøe (org.nr 994 749 765) ("PLANETPOSEN MIDBØE") er et norsk selskap som produserer
|
||||
og selger håndsyde miljøvennlige gaveposer, som blant annet selges på nettsiden planetposen.no.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Denne personvernerklæringen gir informasjon om personopplysningene Planetposen samler inn,
|
||||
behandler og utleverer, formålene som personopplysningene behandles for og dine rettigheter i
|
||||
denne forbindelse. Planetposen vil behandle dine personopplysninger i samsvar med de til
|
||||
enhver tid gjeldende lover om personvern.
|
||||
</p>
|
||||
<p>
|
||||
Planetposen representert ved daglig leder og nettstedets utvikler er behandlingsansvarlig for
|
||||
behandlingen av dine personopplysninger. Den behandlingsansvarlige bestemmer formål og midler
|
||||
for behandlingen av personopplysninger.
|
||||
</p>
|
||||
<p>
|
||||
Hvis du har spørsmål knyttet til Planetposen behandling av dine personopplysninger eller denne
|
||||
personvernerklæringen, vennligst kontakt oss ved å bruke følgende kontaktinformasjon:
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Kontaktperson: Leigh Midbøe<br />
|
||||
E-post: <a href="mailto:contact@planetposen.no">contact@planetposen.no</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>2. Personopplysningene vi behandler, formål og rettslig grunnlag</strong>
|
||||
Planetposen behandler følgende personlige, formål og juridiske grunnlag:
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Nettbutikksalg, hvor navn, kontaktinformasjon, epost, kredittkort og betalingsinformasjon
|
||||
behandles. Det rettslige grunnlaget for slik behandling er at behandlingen er nødvendig for
|
||||
gjennomføring av en kjøpsavtale som ble inngått med deg da du foretok et kjøp i nettbutikken.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Oppsett og administrasjon av kundekontoer på nettsiden, hvor navn, e-postadresse, ditt valgte
|
||||
passord og kjøpshistorikk behandles. Behandlingen har hjemmel i en interesseavveining, hvor
|
||||
vår berettigede interesse er å yte god kundeservice ved å tilby kundekontoer med oversikt over
|
||||
kjøpshistorikk og tilgang til å oppdatere kontaktinformasjon og andre personopplysninger. Det
|
||||
er frivillig å opprette en profil og profilen kan til enhver tid slettes av kunden. Ved å
|
||||
sende en e-post til slettmeg@planetposen.no
|
||||
<br /><i>Replace with delete me from order history.</i>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
For å få informasjon om bruken av nettsiden vår, og for å forbedre brukeropplevelsen, bruker
|
||||
Planetposen informasjonskapsler og behandler din IP-adresse. Vi anonymiserer informasjonen som
|
||||
samles inn gjennom informasjonskapsler, slik at du ikke kan identifiseres som en individuell
|
||||
person basert på denne informasjonen. Du kan lese mer om vår bruk av informasjonskapsler her.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Svar på henvendelser vi mottar, hvor vi behandler navn, e-postadresse og eventuelle
|
||||
personopplysninger som inngår i henvendelsen. Behandlingen har hjemmel i en
|
||||
interesseavveining, hvor vår berettigede interesse er å følge opp dine henvendelser.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Overholdelse av lovpålagte krav og rettslige forpliktelser, slik som overholdelse av
|
||||
lovpålagte forpliktelser etter norsk regnskapslovgivning og pålegg fra offentlige myndigheter.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Sikre rettighetene til Planetposen, til å etablere, utøve eller forsvare rettskrav vi mener å
|
||||
ha, eller som er rettet mot oss av kunder, leverandører, partnere, andre tredjeparter eller
|
||||
offentlige myndigheter.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>3. Oppbevaringstid</strong>Planetposen vil ikke lagre dine personopplysninger lenger
|
||||
enn nødvendig for å oppfylle formålene som personopplysningene ble samlet inn for. Hvis
|
||||
behandlingen er basert på ditt samtykke, vil behandlingen opphøre når du trekker tilbake
|
||||
samtykket. Du kan når som helst trekke tilbake samtykket ditt.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Personopplysningene vil likevel bli oppbevart så lenge det er nødvendig for at Planetposen
|
||||
skal kunne oppfylle lovpålagte krav og juridiske forpliktelser, herunder lagringskrav etter
|
||||
norsk regnskapslovgivning.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>4. Beskyttelse av personopplysninger</strong>
|
||||
Planetposen verdsetter ditt personvern og har iverksatt hensiktsmessige sikkerhetstiltak for å
|
||||
beskytte dine personopplysninger mot brudd på personopplysninger og uautorisert tilgang og utlevering
|
||||
av personopplysninger.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>5. Utlevering av personopplysninger til tredjeparter</strong>
|
||||
|
||||
Planetposen vil kun utlevere dine personopplysninger til tredjepart dersom vi har juridisk
|
||||
grunnlag for å gjøre det.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Planetposen kan bruke databehandlere til å samle inn, lagre eller på annen måte behandle
|
||||
personopplysninger på våre vegne. Forholdet til slike databehandlere er styrt av
|
||||
databehandleravtaler, som blant annet sikrer konfidensialitet og informasjonssikkerhet for
|
||||
dine personopplysninger. Dine personopplysninger kan bli gjenstand for behandling eller
|
||||
lagring utenfor EU/EØS. I slike tilfeller vil Planetposen sørge for at personopplysningene er
|
||||
underlagt hensiktsmessige sikkerhetstiltak, ved å gå inn i EUs standardkontraktsklausuler for
|
||||
slike overføringer, overføringer til selskaper underlagt Privacy Shield-sertifisering eller
|
||||
overføringer til land godkjent av EU-kommisjonen. Ta kontakt med oss hvis du trenger mer
|
||||
informasjon.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Planetposen kan utlevere personopplysninger til offentlige myndigheter for å etterleve
|
||||
lovpålagte krav eller pålegg fra offentlige myndigheter, f.eks. å overholde forpliktelser
|
||||
etter norsk regnskaps- eller skattelovgivning. De relevante offentlige myndigheter vil være
|
||||
behandlingsansvarlig for personopplysninger som utleveres i slike tilfeller.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>6. Sosiale medieplattformer</strong>
|
||||
Dersom du «liker» eller melder deg inn for å bli medlem av Planetposens Facebook-side eller Planetposens
|
||||
profiler på andre sosiale medieplattformer, vil denne informasjonen bli delt med den aktuelle plattformen.
|
||||
Det samme gjelder andre aktiviteter på Planetposens sosiale medieplattformer, som innhold du legger
|
||||
ut og innlegg du liker. Den aktuelle plattformen er ansvarlig for personopplysningene den samler
|
||||
inn og behandler. Ytterligere informasjon om hvordan disse plattformene behandler personopplysninger
|
||||
finnes i den aktuelle plattformens personvernerklæring.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>7. Dine rettigheter</strong>
|
||||
|
||||
Du har rett til å be om innsyn, retting og sletting av personopplysningene Planetposen
|
||||
behandler om deg, forutsatt at kriteriene for slike forespørsler er oppfylt. Du kan også be om
|
||||
begrensning av behandlingen, protestere mot behandlingen og kreve dataportabilitet (rett til å
|
||||
motta personopplysninger eller få personopplysninger overført i et passende format), der
|
||||
kriteriene er oppfylt. Hvis behandlingen er basert på ditt samtykke, kan du når som helst
|
||||
trekke tilbake samtykket ditt.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Hvis du ønsker å utøve rettighetene dine, vennligst kontakt oss ved å bruke
|
||||
kontaktinformasjonen angitt ovenfor i seksjon 1.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Du har rett til å klage til Datatilsynet på vår behandling av dine personopplysninger. Vi
|
||||
oppfordrer deg imidlertid til å rette eventuelle innsigelser mot Planetposens behandling av
|
||||
personopplysninger til oss først.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>8.Endringer i personvernreglene</strong>
|
||||
Den til enhver tid gjeldende personvernerklæringen vil være tilgjengelig på nettstedet vårt. Personvernerklæringen
|
||||
vil bli oppdatert på nettsiden ved eventuelle endringer i Planetposens behandling av personopplysninger.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Ved endringer i behandlingen av personopplysninger som krever ditt samtykke, vil vi innhente
|
||||
ditt samtykke før vi iverksetter slik behandling.
|
||||
</p>
|
||||
|
||||
<p class="last-edit"><strong>Siden ble sist endret:</strong> 16.11.2022</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/generic-article.scss';
|
||||
</style>
|
||||
10
src/routes/receipt/[[id]]/+layout.server.ts
Normal file
10
src/routes/receipt/[[id]]/+layout.server.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import validOrderId from '$lib/utils/validOrderId';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ params }) => {
|
||||
const { id } = params;
|
||||
return {
|
||||
id,
|
||||
isValidReceipt: validOrderId(id)
|
||||
};
|
||||
};
|
||||
14
src/routes/receipt/[[id]]/+layout.svelte
Normal file
14
src/routes/receipt/[[id]]/+layout.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import ReceiptNotFound from './ReceiptNotFound.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
const isValidReceipt = data.isValidReceipt as boolean;
|
||||
const id = data.id as string;
|
||||
</script>
|
||||
|
||||
{#if isValidReceipt}
|
||||
<slot />
|
||||
{:else}
|
||||
<ReceiptNotFound id="{id}" />
|
||||
{/if}
|
||||
36
src/routes/receipt/[[id]]/+page.server.ts
Normal file
36
src/routes/receipt/[[id]]/+page.server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
// could we have order-id and send email to matching
|
||||
// the order.
|
||||
// user enters their email and return invalid or not,
|
||||
// should have some aggressive rate-limiting
|
||||
87
src/routes/receipt/[[id]]/+page.svelte
Normal file
87
src/routes/receipt/[[id]]/+page.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import CircleCheckmark from '$lib/icons/CircleCheckmark.svelte';
|
||||
|
||||
import { mockProducts } from '$lib/utils/mock';
|
||||
import type { PageServerData } from './$types';
|
||||
import type IProduct from '$lib/interfaces/IProduct';
|
||||
|
||||
function subTotal(products: Array<IProduct>) {
|
||||
let total = 0;
|
||||
products.forEach((product) => (total = total + product.price * product.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;
|
||||
|
||||
const products = mockProducts(Math.floor(Math.random() * 8) + 1);
|
||||
</script>
|
||||
|
||||
<section class="order-confirmation">
|
||||
<CircleCheckmark />
|
||||
|
||||
<h1>Takk for din bestilling!</h1>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="order-receipt">
|
||||
{#each products as product}
|
||||
<p>
|
||||
<code>{product.name} x{product.quantity}</code>
|
||||
<code>{product.currency} {product.price * product.quantity}</code>
|
||||
</p>
|
||||
{/each}
|
||||
<p>
|
||||
<code>Shipping</code>
|
||||
<code>NOK 79</code>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<code>Total</code>
|
||||
<code>NOK {subTotal(products)}</code>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles-receipt-page.scss';
|
||||
|
||||
.order-receipt {
|
||||
background-color: #f7f7f7;
|
||||
max-width: 500px;
|
||||
width: calc(100% - 4rem);
|
||||
padding: 2rem;
|
||||
font-family: monospace;
|
||||
|
||||
p {
|
||||
margin: 0.8rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid lightgrey;
|
||||
|
||||
&:last-of-type {
|
||||
padding-top: 1.5rem;
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
opacity: 0.4;
|
||||
font-size: 1rem;
|
||||
|
||||
&:first-of-type {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
68
src/routes/receipt/[[id]]/ReceiptNotFound.svelte
Normal file
68
src/routes/receipt/[[id]]/ReceiptNotFound.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<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;
|
||||
|
||||
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}" />
|
||||
|
||||
<h1>Fant ikke din bestilling!</h1>
|
||||
|
||||
{#if id?.length}
|
||||
<div class="order-description">
|
||||
<p>Bestilling med id: <span class="underline">{id}</span> er ikke funnet i systemet vårt.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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-email" label="Epost adresse" bind:value="{searchOrderEmail}" />
|
||||
|
||||
<PlanetButton text="Søk" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles-receipt-page.scss';
|
||||
|
||||
.order-search {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-top: 2rem;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
padding-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.order-search button) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
38
src/routes/receipt/[[id]]/styles-receipt-page.scss
Normal file
38
src/routes/receipt/[[id]]/styles-receipt-page.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
@import '../../../styles/media-queries.scss';
|
||||
|
||||
.order-confirmation {
|
||||
margin: 6rem auto 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 0.5rem;
|
||||
|
||||
@include mobile {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
// @include pageMargin;
|
||||
|
||||
@include tablet {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.order-description {
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
|
||||
.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
15
src/routes/shop/+page.server.ts
Normal file
15
src/routes/shop/+page.server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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/products';
|
||||
if (dev || env.API_HOST) {
|
||||
url = (env.API_HOST || 'http://localhost:30010').concat(url);
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
const products = await res.json();
|
||||
|
||||
return products;
|
||||
};
|
||||
41
src/routes/shop/+page.svelte
Normal file
41
src/routes/shop/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ProductTile from './ProductTile.svelte';
|
||||
import type IProduct from '$lib/interfaces/IProduct';
|
||||
|
||||
export let data: PageData;
|
||||
const products = data.products as Array<IProduct>;
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>Nettbutikk</h1>
|
||||
|
||||
<section class="content tiles-container">
|
||||
{#each products as product}
|
||||
<ProductTile product="{product}" />
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
.tiles-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 2000px;
|
||||
margin: 0 auto;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
104
src/routes/shop/ProductTile.svelte
Normal file
104
src/routes/shop/ProductTile.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
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">
|
||||
<div
|
||||
class="product"
|
||||
style="{`background-color: ${product?.primary_color || '#E6E0DC'}; color: ${
|
||||
product?.primary_color === '#231B1D' ? '#f3efeb' : '#37301e'
|
||||
}`}"
|
||||
>
|
||||
{#if !large}<h3>{product?.name}</h3>{/if}
|
||||
|
||||
<div class="{`image-frame ${large ? 'large' : null}`}">
|
||||
<img src="{product?.image}" alt="{product?.name}" />
|
||||
</div>
|
||||
|
||||
{#if !large}
|
||||
<p class="subtext">{product?.subtext}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
:global(a.product-tile) {
|
||||
text-decoration: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.product {
|
||||
margin: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #e6e0dc;
|
||||
color: #37301e;
|
||||
cursor: pointer;
|
||||
transition: all 0.4s ease;
|
||||
|
||||
&:hover {
|
||||
margin: 1rem;
|
||||
padding: 2rem;
|
||||
|
||||
img {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 3px 3px 13px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 22px;
|
||||
max-width: 75%;
|
||||
font-weight: normal;
|
||||
|
||||
margin-bottom: auto;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.subtext {
|
||||
text-transform: uppercase;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.image-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
&.large img {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
img {
|
||||
margin: 3rem 0;
|
||||
width: 66%;
|
||||
transition: all 0.6s ease;
|
||||
|
||||
@include mobile {
|
||||
width: 75%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
16
src/routes/shop/[id]/+page.server.ts
Normal file
16
src/routes/shop/[id]/+page.server.ts
Normal file
@@ -0,0 +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/product/${id}`;
|
||||
if (dev || env.API_HOST) {
|
||||
url = (env.API_HOST || 'http://localhost:30010').concat(url);
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
const product = await res.json();
|
||||
return product;
|
||||
};
|
||||
109
src/routes/shop/[id]/+page.svelte
Normal file
109
src/routes/shop/[id]/+page.svelte
Normal file
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ProductTile from '../ProductTile.svelte';
|
||||
import ProductVariationSelect from '$lib/components/ProductVariationSelect.svelte';
|
||||
import QuantitySelect from '$lib/components/QuantitySelect.svelte';
|
||||
import SizesSection from './SizesSection.svelte';
|
||||
import type IProduct from '$lib/interfaces/IProduct';
|
||||
import type IProductVariation from '$lib/interfaces/IProductVariation';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
const product = data.product as IProduct;
|
||||
import { addProductToCart } from '$lib/websocketCart';
|
||||
console.log('shop product:', product);
|
||||
|
||||
function setSelectedVariation(event: CustomEvent) {
|
||||
selectedVariation = event.detail;
|
||||
}
|
||||
|
||||
function addProductClicked() {
|
||||
cooldownInputs = true;
|
||||
addProductToCart(product.product_no, selectedVariation.sku_id, quantity);
|
||||
setTimeout(() => reset(), 2300);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
quantity = 1;
|
||||
cooldownInputs = false;
|
||||
}
|
||||
|
||||
let cooldownInputs = false;
|
||||
let quantity = 1;
|
||||
let selectedVariation: IProductVariation;
|
||||
$: addProductButtonText = cooldownInputs
|
||||
? `${quantity} produkt${quantity > 1 ? 'er' : ''} lagt til`
|
||||
: `Legg til ${quantity} i handlekurven`;
|
||||
</script>
|
||||
|
||||
<div class="product-container">
|
||||
<ProductTile product="{product}" large="{true}" />
|
||||
|
||||
<div class="details">
|
||||
<h2 class="name">{product.name}</h2>
|
||||
<p class="subtext">{product.subtext}</p>
|
||||
<p class="subtext">{product.description}</p>
|
||||
<p class="price">NOK {selectedVariation?.price} (Ink. Moms)</p>
|
||||
|
||||
<ProductVariationSelect
|
||||
variations="{product.variations}"
|
||||
on:selected="{setSelectedVariation}"
|
||||
/>
|
||||
|
||||
<QuantitySelect bind:value="{quantity}" disabled="{cooldownInputs}" />
|
||||
|
||||
<div class="button">
|
||||
<Button
|
||||
on:click="{() => addProductClicked()}"
|
||||
text="{addProductButtonText}"
|
||||
active="{cooldownInputs}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SizesSection />
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../../styles/media-queries.scss';
|
||||
// @import "../styles/global.scss";
|
||||
|
||||
.product-container {
|
||||
display: grid;
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto 4rem;
|
||||
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@include tablet {
|
||||
margin: 8rem auto 2rem;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
> * {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.details {
|
||||
.name {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.price {
|
||||
margin-top: 3rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@include desktop {
|
||||
margin-left: 4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
26
src/routes/shop/[id]/SizesSection.svelte
Normal file
26
src/routes/shop/[id]/SizesSection.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<section>
|
||||
<h2>Våre størrelser</h2>
|
||||
|
||||
<p>Dette er størrelsene vi har</p>
|
||||
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut
|
||||
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
|
||||
laboris nisi.
|
||||
</p>
|
||||
<p>
|
||||
ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit
|
||||
esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident,
|
||||
sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
section {
|
||||
margin-top: 4rem;
|
||||
|
||||
h2 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
87
src/routes/sitemap.xml/+server.ts
Normal file
87
src/routes/sitemap.xml/+server.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { IProductResponse } from '$lib/interfaces/ApiResponse';
|
||||
|
||||
const domain = 'planet.schleppe.cloud';
|
||||
const pages: Array<ISitemapPage> = [
|
||||
{
|
||||
name: 'home',
|
||||
modified: '2022-11-16T14:00:00.000Z'
|
||||
},
|
||||
{
|
||||
name: 'shop',
|
||||
modified: '2022-11-16T14:00:00.000Z'
|
||||
},
|
||||
{
|
||||
name: 'privacy-policy',
|
||||
modified: '2022-11-16T14:00:00.000Z'
|
||||
},
|
||||
{
|
||||
name: 'cookies',
|
||||
modified: '2022-11-16T14:00:00.000Z'
|
||||
},
|
||||
{
|
||||
name: 'terms-and-condition',
|
||||
modified: '2022-11-16T14:00:00.000Z'
|
||||
}
|
||||
];
|
||||
|
||||
interface ISitemapPage {
|
||||
name: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildSitemap();
|
||||
const headers = {
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': `max-age=0, s-max-age=${3600}`
|
||||
};
|
||||
|
||||
return new Response(body, { headers });
|
||||
}
|
||||
|
||||
function buildSitemapUrl(address: string, modified: string, frequency: string) {
|
||||
return `<url>
|
||||
<loc>https://${address}</loc>
|
||||
<lastmod>${modified}</lastmod>
|
||||
<changefreq>${frequency}</changefreq>
|
||||
</url>`;
|
||||
}
|
||||
|
||||
function sitemapPages(): string {
|
||||
return pages
|
||||
.map((page) => buildSitemapUrl(`https://${domain}/${page.name}`, page.modified, 'yearly'))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
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(url);
|
||||
const products: IProductResponse = await res.json();
|
||||
|
||||
return products?.products
|
||||
?.map((product) =>
|
||||
buildSitemapUrl(
|
||||
`https://${domain}/shop/${product.product_no}`,
|
||||
String(product.updated),
|
||||
'daily'
|
||||
)
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function buildSitemap(): Promise<string> {
|
||||
const generalPages = sitemapPages();
|
||||
const shopPages = await sitemapShopPages();
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${generalPages}
|
||||
${shopPages}
|
||||
</urlset>`.trim();
|
||||
}
|
||||
100
src/routes/styles.css
Normal file
100
src/routes/styles.css
Normal file
@@ -0,0 +1,100 @@
|
||||
:root {
|
||||
--font-body: Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
--font-mono: 'Fira Mono', monospace;
|
||||
--background: #f3efec;
|
||||
--color-bg-1: hsl(209, 36%, 86%);
|
||||
--color-bg-2: hsl(224, 44%, 95%);
|
||||
--color-theme-1: #18332f;
|
||||
--color-theme-2: #40b3ff;
|
||||
--color-text: #231f20;
|
||||
--column-width: 42rem;
|
||||
--column-margin-top: 4rem;
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-theme-1);
|
||||
color: var(--color-text);
|
||||
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%;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
transform: skew(-15deg);
|
||||
}
|
||||
|
||||
a.link {
|
||||
border-bottom: 2px solid var(--color-text) !important;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.no-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 16px;
|
||||
font-family: var(--font-mono);
|
||||
background-color: rgba(255, 255, 255, 0.45);
|
||||
border-radius: 3px;
|
||||
box-shadow: 2px 2px 6px rgb(255 255 255 / 25%);
|
||||
padding: 0.5em;
|
||||
overflow-x: auto;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.text-column {
|
||||
display: flex;
|
||||
max-width: 48rem;
|
||||
flex: 0.6;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
}
|
||||
47
src/routes/terms-and-conditions/+page.svelte
Normal file
47
src/routes/terms-and-conditions/+page.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<article>
|
||||
<h1>Betingelser og vilkår</h1>
|
||||
<section>
|
||||
<p>
|
||||
<strong>Leveranseinformasjon</strong>
|
||||
Vi kommer tilbake til deg innen en uke etter bestilling med endelig leveringsdato. Estimert leveringsdato
|
||||
er 2-5 uker fra bestillingen din ble sendt, avhengig av tilgjengeligheten på produktene i bestillingen
|
||||
din. Vår transportpartner på innenlandsleveringer er Schencker, som leverer på døren på hverdager
|
||||
mellom 15:00 og 21:00 norsk tid.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Bestillings- og kontraktsprosess</strong>
|
||||
Når vi mottar bestillingen din, vil en ordrebekreftelse automatisk bli sendt til din registrerte
|
||||
e-post. Vennligst sjekk at den er i samsvar med bestillingen: Produktene du har bestilt og kostnadene
|
||||
deres.
|
||||
</p>
|
||||
<p>
|
||||
Bestillingen er bindende når den er registrert i vår nettbutikk og betaling er foretatt eller
|
||||
utstedt faktura. Vi er bundet av bestillingen din dersom den ikke avviker fra det vi tilbyr i
|
||||
vår nettbutikk, markedsføring eller annet.
|
||||
</p>
|
||||
<p>
|
||||
Du har fortsatt rett til å angre på kjøpet i henhold til norsk lov; Angrerettloven, Det gir
|
||||
deg rett til å angre på kjøpet ditt hos oss innen 14 dager etter mottak av varen. Du må
|
||||
imidlertid betale returportoen ved å legge ved returskjemaet som er vedlagt
|
||||
e-postbekreftelsen. Les mer om det her.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Informasjon gitt i nettbutikken</strong>
|
||||
Vi streber etter å alltid gi våre kunder korrekt og oppdatert informasjon om alle våre produkter.
|
||||
Vi forbeholder oss imidlertid retten til at det kan oppstå trykkfeil, og at vi på grunn av dette
|
||||
ikke er i stand til å levere i henhold til informasjonen gitt i vår nettbutikk, markedsføring eller
|
||||
på annen måte. Husk at produktene våre i stor grad er laget av naturlige materialer og variasjoner
|
||||
i mønsterfarge og utførelse vil forekomme. Dette gir ikke krav eller angrerett på kjøp. Videre
|
||||
forbeholder vi oss retten til å kansellere din bestilling eller deler av din bestilling, dersom
|
||||
det kjøpte produktet tas ut av produksjon eller endres med tanke på ytelse eller kvalitet.
|
||||
</p>
|
||||
|
||||
<p class="last-edit"><strong>Siden ble sist endret:</strong> 16.11.2022</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/generic-article.scss';
|
||||
</style>
|
||||
17
src/routes/warehouse/+page.server.ts
Normal file
17
src/routes/warehouse/+page.server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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 warehouse = await res.json();
|
||||
|
||||
return {
|
||||
products: warehouse?.warehouse
|
||||
};
|
||||
};
|
||||
45
src/routes/warehouse/+page.svelte
Normal file
45
src/routes/warehouse/+page.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import ProductList from './WarehouseProductList.svelte';
|
||||
import type IProduct from '$lib/interfaces/IProduct';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
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);
|
||||
}
|
||||
|
||||
fetch(url, { method: 'POST' })
|
||||
.then((resp) => resp.json())
|
||||
.then((response) => {
|
||||
console.log('response::', response);
|
||||
const { product } = response;
|
||||
goto(`/warehouse/${product.product_no} `);
|
||||
});
|
||||
}
|
||||
console.log('warehouse:', products);
|
||||
</script>
|
||||
|
||||
<div class="warehouse-page">
|
||||
<h1>Warehouse</h1>
|
||||
|
||||
<section class="content">
|
||||
<h2>Your products</h2>
|
||||
|
||||
<ProductList products="{products}" />
|
||||
</section>
|
||||
|
||||
<Button text="Add" on:click="{createProduct}" />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.warehouse-page button) {
|
||||
margin-top: 2rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
144
src/routes/warehouse/WarehouseProductList.svelte
Normal file
144
src/routes/warehouse/WarehouseProductList.svelte
Normal file
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type IProduct from '$lib/interfaces/IProduct';
|
||||
|
||||
export let products: Array<IProduct>;
|
||||
|
||||
function navigate(product: IProduct) {
|
||||
goto(`/warehouse/${product.product_no}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="image-column"></th>
|
||||
<th>Name</th>
|
||||
<th class="stock-column">In-Stock</th>
|
||||
<th class="date-column">created</th>
|
||||
<th class="date-column">updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#each products as product}
|
||||
<tr on:click="{() => navigate(product)}">
|
||||
<td class="image-column">
|
||||
<img src="{product.image}" alt="{product.name}" />
|
||||
</td>
|
||||
|
||||
<td class="name-and-price">
|
||||
<p><a href="/warehouse/{product.product_no}">{product.name}</a></p>
|
||||
<p>{product.variation_count} variation(s)</p>
|
||||
</td>
|
||||
|
||||
<td class="stock-column">{product.sum_stock}</td>
|
||||
|
||||
<td class="date-column"
|
||||
>{new Intl.DateTimeFormat('nb-NO', { dateStyle: 'short', timeStyle: 'short' }).format(
|
||||
new Date(product.created || 0)
|
||||
)}</td
|
||||
>
|
||||
|
||||
<td class="date-column"
|
||||
>{new Intl.DateTimeFormat('nb-NO', { dateStyle: 'short', timeStyle: 'short' }).format(
|
||||
new Date(product.updated || 0)
|
||||
)}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style lang="scss">
|
||||
// @import "../styles/global.scss";
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead {
|
||||
tr th {
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.stock-column {
|
||||
width: 80px;
|
||||
max-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-column {
|
||||
width: 150px;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.image-column {
|
||||
width: 4rem;
|
||||
max-width: 4rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.name-and-price > p {
|
||||
margin: 0.5rem 0;
|
||||
|
||||
&:not(:nth-of-type(1)) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
white-space: nowrap;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
tbody {
|
||||
a {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
&:hover {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @include mobile {
|
||||
// tr > *:first-child {
|
||||
// display: none;
|
||||
// }
|
||||
// }
|
||||
|
||||
@include mobile {
|
||||
tr > *:last-child,
|
||||
tr > :nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
18
src/routes/warehouse/[id]/+page.server.ts
Normal file
18
src/routes/warehouse/[id]/+page.server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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 res = await fetch(url);
|
||||
const product = await res.json();
|
||||
console.log('product::', product);
|
||||
return product;
|
||||
};
|
||||
91
src/routes/warehouse/[id]/+page.svelte
Normal file
91
src/routes/warehouse/[id]/+page.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import PricingSection from './PricingSection.svelte';
|
||||
import DetailsSection from './DetailsSection.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import type { PageServerData } from './$types';
|
||||
import type IProduct from '$lib/interfaces/IProduct';
|
||||
|
||||
export let data: PageServerData;
|
||||
const product = data.product as IProduct;
|
||||
let edit = false;
|
||||
|
||||
function save() {
|
||||
console.log('savvvving');
|
||||
edit = false;
|
||||
}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="edit-button">
|
||||
{#if !edit}
|
||||
<Button text="Edit" on:click="{() => (edit = !edit)}" />
|
||||
{:else}
|
||||
<Button text="Save" on:click="{save}" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Details</h2>
|
||||
<DetailsSection product="{product}" edit="{edit}" />
|
||||
|
||||
<h2>Variations</h2>
|
||||
<PricingSection product="{product}" />
|
||||
|
||||
<h2>Metadata</h2>
|
||||
<div>
|
||||
<p class="empty">No metadata</p>
|
||||
</div>
|
||||
|
||||
<h2>Audit log</h2>
|
||||
<div>
|
||||
<p class="empty">No logs</p>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../styles/media-queries.scss';
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
width: 100%;
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.info {
|
||||
align-items: center;
|
||||
|
||||
.edit-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.name-and-price > p {
|
||||
margin: 0.5rem 0 0.5rem 2rem;
|
||||
|
||||
&:nth-of-type(2) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label,
|
||||
.empty {
|
||||
color: grey;
|
||||
}
|
||||
</style>
|
||||
146
src/routes/warehouse/[id]/DetailsSection.svelte
Normal file
146
src/routes/warehouse/[id]/DetailsSection.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import type IProduct from '$lib/interfaces/IProduct';
|
||||
|
||||
export let product: IProduct;
|
||||
export let edit: boolean;
|
||||
</script>
|
||||
|
||||
<div class="details row">
|
||||
<div class="left">
|
||||
<ul class="property-list">
|
||||
<li>
|
||||
<span class="label">Name</span>
|
||||
{#if !edit}
|
||||
<span>{product.name}</span>
|
||||
{:else}
|
||||
<input bind:value="{product.name}" />
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Created</span>
|
||||
<span>{product.created}</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Subtext</span>
|
||||
{#if !edit}
|
||||
<span>{product.subtext || '(empty)'}</span>
|
||||
{:else}
|
||||
<input bind:value="{product.subtext}" />
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Description</span>
|
||||
{#if !edit}
|
||||
<span>{product.description || '(empty)'}</span>
|
||||
{:else}
|
||||
<input class="wide" bind:value="{product.description}" />
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Primary color</span>
|
||||
{#if !edit}
|
||||
<span>{product.primary_color || '(empty)'}</span>
|
||||
{:else}
|
||||
<input bind:value="{product.primary_color}" />
|
||||
{/if}
|
||||
{#if product.primary_color}
|
||||
<div class="color-box" style="{`--color: ${product.primary_color}`}"></div>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="label">Feature list</span>
|
||||
<span class="empty">(empty)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<span class="label">Image</span>
|
||||
<img src="{product.image}" alt="Product" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../styles/media-queries.scss';
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
|
||||
.left {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&.wide {
|
||||
width: -webkit-fill-available;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
|
||||
span {
|
||||
padding: 0.4rem 0;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
|
||||
.left,
|
||||
.right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
ul.property-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-top: 0;
|
||||
|
||||
li {
|
||||
padding: 0.4rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
li span:first-of-type {
|
||||
display: inline-block;
|
||||
margin-bottom: auto;
|
||||
min-width: 30%;
|
||||
}
|
||||
|
||||
.color-box {
|
||||
margin-left: 1rem;
|
||||
display: inline-block;
|
||||
background-color: var(--color);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
257
src/routes/warehouse/[id]/PricingSection.svelte
Normal file
257
src/routes/warehouse/[id]/PricingSection.svelte
Normal file
@@ -0,0 +1,257 @@
|
||||
<script lang="ts">
|
||||
import type IProduct from '$lib/interfaces/IProduct';
|
||||
import type IProductVariation from '$lib/interfaces/IProductVariation';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Badge from '$lib/components/Badge.svelte';
|
||||
import BadgeType from '$lib/interfaces/BadgeType';
|
||||
|
||||
export let product: IProduct;
|
||||
let table: HTMLElement;
|
||||
let editingVariationIndex = -1;
|
||||
|
||||
interface ISkuResponse {
|
||||
skus: IProductVariation[];
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const resetEditingIndex = () => (editingVariationIndex = -1);
|
||||
const updateProductsVariations = (response: ISkuResponse) => {
|
||||
product.variations = response?.skus;
|
||||
};
|
||||
|
||||
function setDefault(variation: IProductVariation) {
|
||||
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);
|
||||
}
|
||||
|
||||
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: IProductVariation) {
|
||||
let url = `/api/product/${product.product_no}/sku/${variation?.sku_id}`;
|
||||
if (window?.location?.href.includes('localhost')) {
|
||||
url = 'http://localhost:30010'.concat(url);
|
||||
}
|
||||
|
||||
const { stock, size, price } = variation;
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ stock, price, size }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
fetch(url, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(updateProductsVariations)
|
||||
.then(() => resetEditingIndex());
|
||||
}
|
||||
|
||||
function deleteVariation(variation: IProductVariation) {
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
<th>Price</th>
|
||||
<th>Stock</th>
|
||||
<th>Type/size</th>
|
||||
{#if editingVariationIndex >= 0}
|
||||
<th class="cta">Save</th>
|
||||
<th class="cta">Delete</th>
|
||||
{:else}
|
||||
<th class="cta"></th>
|
||||
<th class="cta"></th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<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>
|
||||
<span>Nok {variation.price}</span>
|
||||
{#if variation.default_price}
|
||||
<div draggable="true">
|
||||
<Badge title="Default price" type="{BadgeType.PENDING}" icon="{''}" />
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<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>
|
||||
<td><input type="number" bind:value="{variation.stock}" /></td>
|
||||
<td><input bind:value="{variation.size}" /></td>
|
||||
<td class="cta">
|
||||
<button on:click="{() => saveSkuVariation(variation)}">💾</button>
|
||||
</td>
|
||||
<td class="cta">
|
||||
<button on:click="{() => deleteVariation(variation)}">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Button text="add new" on:click="{addSkuVariation}" />
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../../styles/media-queries.scss';
|
||||
|
||||
table.pricing {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1.85rem;
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
|
||||
&:not(&:first-of-type) {
|
||||
width: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
.cta {
|
||||
width: 45px !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[draggable='true'] {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
tbody {
|
||||
td {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
cursor: pointer;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
tr:not(.edit) {
|
||||
td {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
&:hover > td:first-of-type {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '✏️';
|
||||
position: absolute;
|
||||
left: -1.5rem;
|
||||
top: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
-webkit-appearance: none;
|
||||
background-color: transparent;
|
||||
border: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
max-width: 4rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
td:first-of-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
16
src/routes/warehouse/[id]/edit/+page.server.ts
Normal file
16
src/routes/warehouse/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +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/${id}`;
|
||||
if (dev || env.API_HOST) {
|
||||
url = (env.API_HOST || 'http://localhost:30010').concat(url);
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
const product = await res.json();
|
||||
return product;
|
||||
};
|
||||
97
src/routes/warehouse/[id]/edit/+page.svelte
Normal file
97
src/routes/warehouse/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/Input.svelte';
|
||||
import type IProduct from '$lib/interfaces/IProduct';
|
||||
import type { PageServerData } from './$types';
|
||||
|
||||
export let data: PageServerData;
|
||||
const product = data.product as IProduct;
|
||||
</script>
|
||||
|
||||
<h1>Attribute edit product</h1>
|
||||
<div class="edit-product-page">
|
||||
<div>
|
||||
<h2>Product attributes</h2>
|
||||
<Input label="Name" value="{product.name}" required="{false}" />
|
||||
<Input label="Description" value="{product.description}" required="{false}" />
|
||||
<Input label="Subtext" value="{product.subtext}" required="{false}" />
|
||||
<Input label="Color" value="{product.primary_color}" required="{false}" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Images</h2>
|
||||
<img src="{product.image}" />
|
||||
</div>
|
||||
|
||||
<div class="variations">
|
||||
<h2>Variations</h2>
|
||||
|
||||
{#if product?.variations?.length}
|
||||
{#each product.variations as variation}
|
||||
<span>{variation.size}</span>
|
||||
<span>{variation.price}</span>
|
||||
<span>{variation.stock}</span>
|
||||
<span>{variation.default_price}</span>
|
||||
<p></p>
|
||||
<hr />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Add variations</h2>
|
||||
<label for="variations">Set/size</label>
|
||||
<select name="variations">
|
||||
<option>Set</option>
|
||||
<option>Large</option>
|
||||
<option>Medium</option>
|
||||
<option>Small</option>
|
||||
<option>Wine</option>
|
||||
</select>
|
||||
|
||||
<Input label="Price" type="number" />
|
||||
<Input label="Stock" type="number" />
|
||||
<input type="checkbox" checked />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../../styles/media-queries.scss';
|
||||
|
||||
:global(.edit-product-page label:not(:first-of-type)) {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.edit-product-page label span) {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
// :global(.edit-product-page label input) {
|
||||
// border-radius: 3.5px !important;
|
||||
// }
|
||||
|
||||
h2 {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.edit-product-page {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 2rem;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 150px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.variations {
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
src/routes/warehouse/add/+page.svelte
Normal file
1
src/routes/warehouse/add/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<h1>add new product</h1>
|
||||
Reference in New Issue
Block a user