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:
2022-12-29 22:58:41 +01:00
parent 1e7cd2c3c5
commit 1fb3fdd502
9 changed files with 494 additions and 163 deletions

View File

@@ -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>