mirror of
https://github.com/KevinMidboe/planetposen-frontend.git
synced 2025-10-29 13:10:12 +00:00
Checkout validates, payment response spinner, error msgs & typed resp
- When stripe responds with success we forward to receipt page which waits for stripe webhook to updated order status. - Moved stripe logic out of card component and into stripeApi.ts. - Get stripe api token from +page.server.ts environment variable. - Spinner for stripe payment for feedback on payment until stripe verifies and responds. - Error stack component trying to create card stack animation.
This commit is contained in:
105
src/lib/components/ErrorStack.svelte
Normal file
105
src/lib/components/ErrorStack.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly, slide } from 'svelte/transition';
|
||||
import type IOrderValidationError from '$lib/interfaces/IOrderValidationError';
|
||||
|
||||
export let errors: string[] = [];
|
||||
let currentCard = 0;
|
||||
let debounce = false;
|
||||
const flyoutDuration = 425;
|
||||
|
||||
function offsetTop(index: number) {
|
||||
const randomVal = Math.round(Math.random() * 20 * Math.random() > 0.5 ? 1 : -1);
|
||||
return `top: ${
|
||||
-7 * index
|
||||
}px; transform: translate(${randomVal}px,${randomVal}px) rotate(${randomVal}deg)`;
|
||||
}
|
||||
|
||||
function dismiss(index: number) {
|
||||
debounce = true;
|
||||
errors = errors?.filter((m, i) => i !== index);
|
||||
setTimeout(() => (debounce = false), flyoutDuration);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if errors && errors?.length > 0}
|
||||
<section style="position: relative;">
|
||||
{#each errors as error, index (`${index}-${error}`)}
|
||||
<p
|
||||
class="error-card"
|
||||
data-status="{index === currentCard ? 'current' : 'waiting'}"
|
||||
style="{offsetTop(index)}"
|
||||
in:slide out:fly="{{ y: 100, duration: flyoutDuration }}"
|
||||
>
|
||||
<button
|
||||
class="dismiss"
|
||||
on:click="{() => dismiss(index)}"
|
||||
on:keyup="{(e) => e.code === 'Enter' && debounce === false && dismiss(index)}">X</button
|
||||
>
|
||||
|
||||
<span class="card_content">{error}</span>
|
||||
<span class="help"
|
||||
>Prøv igjen eller gi en lyd på <a class="link" href="mailto:kontakt@planetposen.no"
|
||||
>kontakt@planetposen.no</a
|
||||
> så tar vi kontakt med deg.</span
|
||||
>
|
||||
</p>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.error-card {
|
||||
position: absolute;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
touch-action: none;
|
||||
background-color: rgba(255, 0, 0, 0.6);
|
||||
color: var(--color-theme-1);
|
||||
font-size: 1.1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 0.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
max-width: 550px;
|
||||
transition: all 0.6s ease;
|
||||
border: 1px solid firebrick;
|
||||
|
||||
&[data-status='current'] {
|
||||
z-index: 2;
|
||||
// background-color: orange;
|
||||
background-color: #ff3333;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
padding: 0.4rem 0 0.4rem;
|
||||
}
|
||||
|
||||
button.dismiss {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
font-size: 1.3rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.help {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
font-size: 0.95rem;
|
||||
// margin-top: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { loadStripe } from '@stripe/stripe-js/pure';
|
||||
import type { Stripe } from '@stripe/stripe-js';
|
||||
import { cart } from '../cartStore';
|
||||
import { onMount } from 'svelte';
|
||||
import stripeApi from '$lib/stripe/index';
|
||||
import type { StripeCardElement } from '@stripe/stripe-js/types';
|
||||
|
||||
function mountCard() {
|
||||
const elements = stripe.elements();
|
||||
export let card: StripeCardElement;
|
||||
export let stripeApiKey: string;
|
||||
|
||||
async function mountCard() {
|
||||
let stripe = await stripeApi.load(stripeApiKey);
|
||||
const elements = stripe?.elements();
|
||||
if (!elements) return;
|
||||
|
||||
const options = {
|
||||
hidePostalCode: true,
|
||||
@@ -27,61 +31,9 @@
|
||||
card.mount(cardElement);
|
||||
}
|
||||
|
||||
// function makeIntent() {
|
||||
// let url = "/api/payment/stripe";
|
||||
// if (window.location.href.includes("localhost"))
|
||||
// url = "http://localhost:30010".concat(url);
|
||||
onMount(() => mountCard());
|
||||
|
||||
// fetch(url, {
|
||||
// method: "POST",
|
||||
// headers: {
|
||||
// "Content-Type": "application/json",
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// cart: $cart
|
||||
// })
|
||||
// })
|
||||
// .then((resp) => resp.json())
|
||||
// .then((data) => (clientSecret = data.paymentIntent.clientSecret));
|
||||
// }
|
||||
|
||||
function pay() {
|
||||
stripe
|
||||
.confirmCardPayment(clientSecret, {
|
||||
payment_method: {
|
||||
card,
|
||||
billing_details: {
|
||||
name: 'Kevin Testost'
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.error) {
|
||||
confirmDiag.innerText = result.error.message || 'Unexpected payment ERROR!';
|
||||
} else {
|
||||
if (result.paymentIntent.status === 'succeeded') {
|
||||
confirmDiag.innerText = 'Confirmed transaction!';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initStripe() {
|
||||
window.addEventListener('submit-stripe-payment', pay, false);
|
||||
|
||||
loadStripe.setLoadParameters({ advancedFraudSignals: false });
|
||||
stripe = await loadStripe('pk_test_YiU5HewgBoClZCwHdhXhTxUn');
|
||||
mountCard();
|
||||
// makeIntent();
|
||||
}
|
||||
|
||||
onMount(() => initStripe());
|
||||
// onDestroy(() => window.removeEventListener('submit-stripe-payment', null))
|
||||
|
||||
let stripe: Stripe;
|
||||
let card;
|
||||
let cardElement: HTMLElement;
|
||||
let clientSecret: string;
|
||||
let confirmDiag: HTMLElement;
|
||||
</script>
|
||||
|
||||
@@ -96,7 +48,6 @@
|
||||
|
||||
.card {
|
||||
// padding: 1rem;
|
||||
margin: 0 0.5rem;
|
||||
border: 2px solid black;
|
||||
|
||||
@include desktop {
|
||||
|
||||
5
src/lib/interfaces/IOrderValidationError.ts
Normal file
5
src/lib/interfaces/IOrderValidationError.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default interface IOrderValidationError {
|
||||
type: string;
|
||||
field: number | string;
|
||||
message: string;
|
||||
}
|
||||
100
src/lib/stripe/index.ts
Normal file
100
src/lib/stripe/index.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { buildApiUrl } from '$lib/utils/apiUrl';
|
||||
import { loadStripe } from '@stripe/stripe-js/pure';
|
||||
import type {
|
||||
ConfirmCardPaymentData,
|
||||
PaymentIntentResult,
|
||||
Stripe,
|
||||
StripeError
|
||||
} from '@stripe/stripe-js';
|
||||
|
||||
let stripeInstance: Stripe;
|
||||
|
||||
class StripeUnableToAccepPaymentsError extends Error {
|
||||
constructor() {
|
||||
const message = 'Card failed to load or never got a client secret. Unable to accept payments';
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// class StripePaymentError extends Error {
|
||||
// code: string | undefined
|
||||
// type: string
|
||||
|
||||
// constructor(error: StripeError) {
|
||||
// const message = error.message || 'Unexptected error from stripe';
|
||||
// super(message);
|
||||
|
||||
// console.log('stripe error:', error);
|
||||
// this.code = error.code;
|
||||
// this.type = error.type;
|
||||
// }
|
||||
// }
|
||||
|
||||
class StripeMissingPaymentIntent extends Error {
|
||||
constructor() {
|
||||
const message = 'Stripe responded without payment intent';
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Calls backend for a client secret. order_id & customer_no
|
||||
// is sent to attach to stripe payment intent
|
||||
function clientSecret(order_id: string, customer_no: string): Promise<string> {
|
||||
const url = buildApiUrl('/api/v1/payment/stripe');
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ order_id, customer_no })
|
||||
};
|
||||
|
||||
return fetch(url, options)
|
||||
.then((resp) => resp.json())
|
||||
.then((data) => data?.clientSecret);
|
||||
}
|
||||
|
||||
// Creates stripe instance from package: @stripe/stripe-js
|
||||
async function load(apiKey: string): Promise<Stripe | undefined> {
|
||||
if (stripeInstance) return stripeInstance;
|
||||
|
||||
loadStripe.setLoadParameters({ advancedFraudSignals: false });
|
||||
const stripe: Stripe | null = await loadStripe(apiKey);
|
||||
if (!stripe) {
|
||||
console.error('Unable to load stripe payment');
|
||||
return;
|
||||
}
|
||||
|
||||
stripeInstance = stripe;
|
||||
return stripe;
|
||||
}
|
||||
|
||||
// Uses @stripe/stripe-js to make payment request from
|
||||
// stripe Elements directly to Stripe. Server receives
|
||||
// async webhook updates from Stripe.
|
||||
function pay(secret: string, data: ConfirmCardPaymentData) {
|
||||
if (!clientSecret || !stripeInstance) {
|
||||
throw new StripeUnableToAccepPaymentsError();
|
||||
}
|
||||
|
||||
return stripeInstance
|
||||
.confirmCardPayment(secret, data)
|
||||
.then((stripePaymentResponse: PaymentIntentResult) => {
|
||||
const { error, paymentIntent } = stripePaymentResponse;
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
} else if (paymentIntent) {
|
||||
console.log('payment intent from stripe:', paymentIntent);
|
||||
return paymentIntent.status === 'succeeded';
|
||||
}
|
||||
|
||||
throw new StripeMissingPaymentIntent();
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
clientSecret,
|
||||
load,
|
||||
pay
|
||||
};
|
||||
14
src/lib/utils/apiUrl.ts
Normal file
14
src/lib/utils/apiUrl.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { dev, browser } from '$app/environment';
|
||||
|
||||
const LOCALHOST_API = 'http://localhost:30010';
|
||||
|
||||
export function buildApiUrl(path: string) {
|
||||
let localhostApi = false;
|
||||
|
||||
if (dev) localhostApi = true;
|
||||
if (browser && window?.location?.href.includes('localhost')) {
|
||||
localhostApi = true;
|
||||
}
|
||||
|
||||
return localhostApi ? LOCALHOST_API.concat(path) : path;
|
||||
}
|
||||
8
src/routes/checkout/+page.server.ts
Normal file
8
src/routes/checkout/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return {
|
||||
stripeApiKey: env.STRIPE_API_KEY || ''
|
||||
};
|
||||
};
|
||||
@@ -1,36 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import OrderSection from './OrderSection.svelte';
|
||||
import DeliverySection from './DeliverySection.svelte';
|
||||
import PageMeta from '$lib/components/PageMeta.svelte';
|
||||
import CheckoutButton from '$lib/components/Button.svelte';
|
||||
import StripeCard from '$lib/components/StripeCard.svelte';
|
||||
import ApplePayButton from '$lib/components/ApplePayButton.svelte';
|
||||
import VippsHurtigkasse from '$lib/components/VippsHurtigkasse.svelte';
|
||||
import ErrorStack from '$lib/components/ErrorStack.svelte';
|
||||
import { cart } from '$lib/cartStore';
|
||||
import stripeApi from '$lib/stripe/index';
|
||||
import { OrderSubmitUnsuccessfullError } from '$lib/errors/OrderErrors';
|
||||
import Loading from '$lib/components/loading/index.svelte';
|
||||
import { addBadElement, removeBadElements } from './validation';
|
||||
import { buildApiUrl } from '$lib/utils/apiUrl';
|
||||
import type { StripeCardElement } from '@stripe/stripe-js/types';
|
||||
import type ICustomer from '$lib/interfaces/ICustomer';
|
||||
import type IOrderValidationError from '$lib/interfaces/IOrderValidationError';
|
||||
import type {
|
||||
IOrderCreateDTO,
|
||||
IOrderCreateResponse,
|
||||
IOrderCreateUnsuccessfullResponse
|
||||
} from '$lib/interfaces/ApiResponse';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
function postOrder(event: any) {
|
||||
const formData = new FormData(event.target);
|
||||
export let data: PageData;
|
||||
export let stripeApiKey: string = data.stripeApiKey;
|
||||
|
||||
const customerJson = {};
|
||||
formData.forEach((value, key) => (customerJson[key] = value));
|
||||
let card: StripeCardElement;
|
||||
let form: HTMLFormElement;
|
||||
let errors: string[] = [];
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
customer: customerJson,
|
||||
cart: $cart
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
let resolvePaymentPromise: (value: any) => void;
|
||||
let rejectPaymentPromise: (resason: any | null) => void;
|
||||
let paymentPromise: Promise<any>;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
let url = '/api/order';
|
||||
if (window?.location?.href.includes('localhost')) {
|
||||
url = 'http://localhost:30010'.concat(url);
|
||||
function startPaymentLoader() {
|
||||
paymentPromise = new Promise((resolve, reject) => {
|
||||
resolvePaymentPromise = resolve;
|
||||
rejectPaymentPromise = reject;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmitOrderError(error: IOrderCreateUnsuccessfullResponse) {
|
||||
console.log('got error from order api!', error, error?.validationErrors);
|
||||
const { success, validationErrors } = error;
|
||||
|
||||
if (!validationErrors || validationErrors?.length == 0) {
|
||||
errors.push('Ukjent feil ved plassering av ordre.');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(url, options);
|
||||
validationErrors.forEach((verror) => {
|
||||
errors.push(verror.message);
|
||||
|
||||
const l = document.getElementById(String(verror.field));
|
||||
if (l) addBadElement(l, verror);
|
||||
});
|
||||
}
|
||||
|
||||
function handleStripePaymentError(error: Error) {
|
||||
rejectPaymentPromise('error');
|
||||
errors = [...errors, 'Betalingsfeil fra stripe: ' + error.message];
|
||||
}
|
||||
|
||||
function getCustomerFromFormData(formData: FormData): ICustomer {
|
||||
let zip_code: string | number = formData.get('zip_code') as string;
|
||||
if (!isNaN(parseInt(zip_code))) {
|
||||
zip_code = parseInt(zip_code);
|
||||
} else {
|
||||
zip_code = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
email: formData.get('email') as string,
|
||||
first_name: formData.get('first_name') as string,
|
||||
last_name: formData.get('last_name') as string,
|
||||
street_address: formData.get('street_address') as string,
|
||||
zip_code,
|
||||
city: formData.get('city') as string
|
||||
};
|
||||
}
|
||||
|
||||
async function postOrder(event: SubmitEvent) {
|
||||
const formData = new FormData(event.target as HTMLFormElement);
|
||||
const customer = getCustomerFromFormData(formData);
|
||||
|
||||
const orderData: IOrderCreateDTO = { customer, cart: $cart };
|
||||
const options = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(orderData),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
};
|
||||
|
||||
const url = buildApiUrl('/api/v1/order');
|
||||
|
||||
// TODO catch error
|
||||
let orderResponse: IOrderCreateResponse;
|
||||
try {
|
||||
orderResponse = await fetch(url, options).then((resp) => resp.json());
|
||||
|
||||
errors = [];
|
||||
removeBadElements();
|
||||
if (orderResponse?.success !== true) {
|
||||
throw new OrderSubmitUnsuccessfullError(orderResponse as IOrderCreateUnsuccessfullResponse);
|
||||
}
|
||||
} catch (error: any) {
|
||||
handleSubmitOrderError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO catch error
|
||||
startPaymentLoader();
|
||||
let stripePaymentResponse;
|
||||
try {
|
||||
const stripeClientSecret = await stripeApi.clientSecret(
|
||||
orderResponse?.order_id,
|
||||
orderResponse?.customer_no
|
||||
);
|
||||
|
||||
stripePaymentResponse = await stripeApi.pay(stripeClientSecret, {
|
||||
payment_method: {
|
||||
card,
|
||||
billing_details: {
|
||||
name: `${customer.first_name} ${customer.last_name}`
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (stripePaymentResponse) {
|
||||
resolvePaymentPromise(true);
|
||||
goto(`/receipt/${orderResponse?.order_id}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
return handleStripePaymentError(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,8 +143,8 @@
|
||||
description="Kasse for bestilling og betaling av produkter i handlekurven"
|
||||
/>
|
||||
|
||||
<h1>Checkout</h1>
|
||||
<form class="checkout" on:submit|preventDefault="{postOrder}">
|
||||
<h1>Kassen</h1>
|
||||
<form class="checkout" bind:this="{form}" on:submit|preventDefault="{postOrder}">
|
||||
<section id="delivery">
|
||||
<h2>Leveringsaddresse</h2>
|
||||
<DeliverySection />
|
||||
@@ -48,31 +152,30 @@
|
||||
|
||||
<section id="order">
|
||||
<h2>Din ordre</h2>
|
||||
<OrderSection>
|
||||
<div class="navigation-buttons" slot="button">
|
||||
<ApplePayButton />
|
||||
<VippsHurtigkasse />
|
||||
</div>
|
||||
</OrderSection>
|
||||
<OrderSection />
|
||||
</section>
|
||||
|
||||
<section id="payment">
|
||||
<h2>Betalingsinformasjon</h2>
|
||||
<StripeCard />
|
||||
<StripeCard bind:card="{card}" stripeApiKey="{stripeApiKey}" />
|
||||
|
||||
<div class="pay">
|
||||
<CheckoutButton type="submit" text="Betal" />
|
||||
<div class="payment-state-animation">
|
||||
{#if paymentPromise}
|
||||
<Loading promise="{paymentPromise}" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<ErrorStack bind:errors="{errors}" />
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
form.checkout {
|
||||
// display: flex;
|
||||
// flex-wrap: wrap;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'delivery order'
|
||||
@@ -80,7 +183,6 @@
|
||||
|
||||
grid-gap: 2rem;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
// grid-auto-flow: column;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -93,18 +195,12 @@
|
||||
|
||||
.pay {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.navigation-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
:global(.navigation-buttons > *) {
|
||||
margin-right: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
:global(.pay .payment-state-animation svg) {
|
||||
margin-left: 1.5rem;
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
#delivery {
|
||||
@@ -126,10 +222,30 @@
|
||||
padding-left: 4px;
|
||||
text-transform: none;
|
||||
font-size: 2.3rem;
|
||||
padding: 12px 10px 12px 12px !important;
|
||||
padding: 12px 10px 12px 0;
|
||||
font-weight: 500;
|
||||
color: #231f20;
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.bad-msg) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
margin-top: -2.3rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #ff3333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
:global(.bad-form) {
|
||||
// border: 2px solid var(--color-theme-1);
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
color: var(--color-theme-1);
|
||||
font-size: 1.1rem;
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem;
|
||||
position: relative;
|
||||
border-color: rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,76 +4,78 @@
|
||||
import { cart, subTotal } from '$lib/cartStore';
|
||||
import { decrementProductInCart, incrementProductInCart } from '$lib/websocketCart';
|
||||
|
||||
// $: totalPrice = $cart
|
||||
// .map((order: IOrderLineItem) => order?.price * order.quantity)
|
||||
// .reduce((a, b) => a + b);
|
||||
const shippingPrice = 75;
|
||||
$: totalPrice = $subTotal + shippingPrice;
|
||||
|
||||
function lineItemClass(id: number) {
|
||||
return `lineitem-${id}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<table class="checkout">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Varenavn</th>
|
||||
<th>Antall</th>
|
||||
<th>Pris</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#if $cart.length}
|
||||
{#each $cart as cartItem}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="line-order">
|
||||
<a href="/shop/{cartItem.product_no}"><span>{cartItem.name}</span></a>
|
||||
<span class="subtext">Størrelse: {cartItem.size}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<QuantitySelect
|
||||
bind:value="{cartItem.quantity}"
|
||||
hideButtons="{true}"
|
||||
on:decrement="{() => decrementProductInCart(cartItem.lineitem_id)}"
|
||||
on:increment="{() => incrementProductInCart(cartItem.lineitem_id)}"
|
||||
/>
|
||||
</td>
|
||||
<td>Nok {cartItem.quantity * cartItem.price}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else}
|
||||
<tr class="no-products">
|
||||
<td>(ingen produkter)</td>
|
||||
<td>0</td>
|
||||
<td>Nok 0</td>
|
||||
<div style="border: 2px solid black">
|
||||
<table class="order-summary">
|
||||
<thead style="border-bottom: 2px solid black;">
|
||||
<tr>
|
||||
<th>Varenavn</th>
|
||||
<th>Antall</th>
|
||||
<th>Pris</th>
|
||||
</tr>
|
||||
{/if}
|
||||
</thead>
|
||||
|
||||
<tr>
|
||||
<td>Totalpris:</td>
|
||||
<td></td>
|
||||
<td>Nok {$subTotal}</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
{#if $cart.length}
|
||||
{#each $cart as cartItem}
|
||||
<tr id="{lineItemClass(cartItem.lineitem_id)}">
|
||||
<td>
|
||||
<div class="line-order">
|
||||
<a href="/shop/{cartItem.product_no}"><span>{cartItem.name}</span></a>
|
||||
<span class="subtext">Størrelse: {cartItem.size}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<QuantitySelect
|
||||
bind:value="{cartItem.quantity}"
|
||||
hideButtons="{true}"
|
||||
on:decrement="{() => decrementProductInCart(cartItem.lineitem_id)}"
|
||||
on:increment="{() => incrementProductInCart(cartItem.lineitem_id)}"
|
||||
/>
|
||||
</td>
|
||||
<td>Nok {cartItem.quantity * cartItem.price}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else}
|
||||
<tr class="no-products">
|
||||
<td>(ingen produkter)</td>
|
||||
<td>0</td>
|
||||
<td>Nok 0</td>
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<tr>
|
||||
<td>Frakt:</td>
|
||||
<td></td>
|
||||
<td>Nok {shippingPrice}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom-color: rgba(0,0,0,0.15)">
|
||||
<td>Totalsum:</td>
|
||||
<td></td>
|
||||
<td>Nok {$subTotal}</td>
|
||||
</tr>
|
||||
|
||||
<tr style="font-weight: 600">
|
||||
<td>Totalsum:</td>
|
||||
<td></td>
|
||||
<td>Nok {$subTotal}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<tr style="border-bottom-color: rgba(0,0,0,0.15)">
|
||||
<td>Frakt:</td>
|
||||
<td></td>
|
||||
<td>Nok {shippingPrice}</td>
|
||||
</tr>
|
||||
|
||||
<tr style="font-weight: 600">
|
||||
<td>Pris:</td>
|
||||
<td></td>
|
||||
<td>Nok {totalPrice}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- <slot name="express-checkout-buttons" /> -->
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
table.checkout {
|
||||
table.order-summary {
|
||||
width: 100%;
|
||||
border: 2px solid #dbd9d5;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead {
|
||||
@@ -101,8 +103,11 @@
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
tr:not(:last-of-type) {
|
||||
border-bottom: 2px solid black;
|
||||
}
|
||||
|
||||
td {
|
||||
border: 2px solid #dbd9d5;
|
||||
padding: 1rem 0.5rem;
|
||||
min-width: 50px;
|
||||
font-size: 1.1rem;
|
||||
|
||||
27
src/routes/checkout/validation.ts
Normal file
27
src/routes/checkout/validation.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
let badElements: HTMLElement[] = [];
|
||||
|
||||
interface IOrderValidationError {
|
||||
type: string;
|
||||
field: number | string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function removeBadElements() {
|
||||
badElements.forEach((element) => element.classList.remove('bad-form'));
|
||||
badElements.forEach((element) => {
|
||||
const msgErrorElement = element?.getElementsByClassName('bad-msg');
|
||||
if (!msgErrorElement || msgErrorElement?.length === 0) return;
|
||||
msgErrorElement[0].remove();
|
||||
});
|
||||
badElements = [];
|
||||
}
|
||||
|
||||
export function addBadElement(element: HTMLElement, vError: IOrderValidationError) {
|
||||
element.classList.add('bad-form');
|
||||
badElements.push(element);
|
||||
|
||||
const msgElement = document.createElement('div');
|
||||
msgElement.classList.add('bad-msg');
|
||||
msgElement.innerText = vError.message;
|
||||
element.appendChild(msgElement);
|
||||
}
|
||||
Reference in New Issue
Block a user