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

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

View File

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

View File

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

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

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

14
src/lib/utils/apiUrl.ts Normal file
View 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;
}

View File

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

View File

@@ -1,36 +1,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>

View File

@@ -4,76 +4,78 @@
import { cart, subTotal } from '$lib/cartStore';
import { decrementProductInCart, incrementProductInCart } from '$lib/websocketCart';
// $: totalPrice = $cart
// .map((order: IOrderLineItem) => order?.price * order.quantity)
// .reduce((a, b) => a + b);
const shippingPrice = 75;
$: totalPrice = $subTotal + shippingPrice;
function lineItemClass(id: number) {
return `lineitem-${id}`;
}
</script>
<table class="checkout">
<thead>
<tr>
<th>Varenavn</th>
<th>Antall</th>
<th>Pris</th>
</tr>
</thead>
<tbody>
{#if $cart.length}
{#each $cart as 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;

View File

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