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"> | <script lang="ts"> | ||||||
|   import { onDestroy, onMount } from 'svelte'; |   import { onMount } from 'svelte'; | ||||||
|   import { loadStripe } from '@stripe/stripe-js/pure'; |   import stripeApi from '$lib/stripe/index'; | ||||||
|   import type { Stripe } from '@stripe/stripe-js'; |   import type { StripeCardElement } from '@stripe/stripe-js/types'; | ||||||
|   import { cart } from '../cartStore'; |  | ||||||
|  |  | ||||||
|   function mountCard() { |   export let card: StripeCardElement; | ||||||
|     const elements = stripe.elements(); |   export let stripeApiKey: string; | ||||||
|  |  | ||||||
|  |   async function mountCard() { | ||||||
|  |     let stripe = await stripeApi.load(stripeApiKey); | ||||||
|  |     const elements = stripe?.elements(); | ||||||
|  |     if (!elements) return; | ||||||
|  |  | ||||||
|     const options = { |     const options = { | ||||||
|       hidePostalCode: true, |       hidePostalCode: true, | ||||||
| @@ -27,61 +31,9 @@ | |||||||
|     card.mount(cardElement); |     card.mount(cardElement); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // function makeIntent() { |   onMount(() => mountCard()); | ||||||
|   //   let url = "/api/payment/stripe"; |  | ||||||
|   //   if (window.location.href.includes("localhost")) |  | ||||||
|   //     url = "http://localhost:30010".concat(url); |  | ||||||
|  |  | ||||||
|   //   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 cardElement: HTMLElement; | ||||||
|   let clientSecret: string; |  | ||||||
|   let confirmDiag: HTMLElement; |   let confirmDiag: HTMLElement; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| @@ -96,7 +48,6 @@ | |||||||
|  |  | ||||||
|   .card { |   .card { | ||||||
|     // padding: 1rem; |     // padding: 1rem; | ||||||
|     margin: 0 0.5rem; |  | ||||||
|     border: 2px solid black; |     border: 2px solid black; | ||||||
|  |  | ||||||
|     @include desktop { |     @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"> | <script lang="ts"> | ||||||
|  |   import { goto } from '$app/navigation'; | ||||||
|   import OrderSection from './OrderSection.svelte'; |   import OrderSection from './OrderSection.svelte'; | ||||||
|   import DeliverySection from './DeliverySection.svelte'; |   import DeliverySection from './DeliverySection.svelte'; | ||||||
|   import PageMeta from '$lib/components/PageMeta.svelte'; |   import PageMeta from '$lib/components/PageMeta.svelte'; | ||||||
|   import CheckoutButton from '$lib/components/Button.svelte'; |   import CheckoutButton from '$lib/components/Button.svelte'; | ||||||
|   import StripeCard from '$lib/components/StripeCard.svelte'; |   import StripeCard from '$lib/components/StripeCard.svelte'; | ||||||
|   import ApplePayButton from '$lib/components/ApplePayButton.svelte'; |   import ErrorStack from '$lib/components/ErrorStack.svelte'; | ||||||
|   import VippsHurtigkasse from '$lib/components/VippsHurtigkasse.svelte'; |  | ||||||
|   import { cart } from '$lib/cartStore'; |   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) { |   export let data: PageData; | ||||||
|     const formData = new FormData(event.target); |   export let stripeApiKey: string = data.stripeApiKey; | ||||||
|  |  | ||||||
|     const customerJson = {}; |   let card: StripeCardElement; | ||||||
|     formData.forEach((value, key) => (customerJson[key] = value)); |   let form: HTMLFormElement; | ||||||
|  |   let errors: string[] = []; | ||||||
|  |  | ||||||
|  |   /* 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 */ | ||||||
|  |  | ||||||
|  |   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; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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 = { |     const options = { | ||||||
|       method: 'POST', |       method: 'POST', | ||||||
|       body: JSON.stringify({ |       body: JSON.stringify(orderData), | ||||||
|         customer: customerJson, |       headers: { 'Content-Type': 'application/json' } | ||||||
|         cart: $cart |  | ||||||
|       }), |  | ||||||
|       headers: { |  | ||||||
|         'Content-Type': 'application/json' |  | ||||||
|       } |  | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     let url = '/api/order'; |     const url = buildApiUrl('/api/v1/order'); | ||||||
|     if (window?.location?.href.includes('localhost')) { |  | ||||||
|       url = 'http://localhost:30010'.concat(url); |     // 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; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fetch(url, options); |     // 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> | </script> | ||||||
|  |  | ||||||
| @@ -39,8 +143,8 @@ | |||||||
|   description="Kasse for bestilling og betaling av produkter i handlekurven" |   description="Kasse for bestilling og betaling av produkter i handlekurven" | ||||||
| /> | /> | ||||||
|  |  | ||||||
| <h1>Checkout</h1> | <h1>Kassen</h1> | ||||||
| <form class="checkout" on:submit|preventDefault="{postOrder}"> | <form class="checkout" bind:this="{form}" on:submit|preventDefault="{postOrder}"> | ||||||
|   <section id="delivery"> |   <section id="delivery"> | ||||||
|     <h2>Leveringsaddresse</h2> |     <h2>Leveringsaddresse</h2> | ||||||
|     <DeliverySection /> |     <DeliverySection /> | ||||||
| @@ -48,31 +152,30 @@ | |||||||
|  |  | ||||||
|   <section id="order"> |   <section id="order"> | ||||||
|     <h2>Din ordre</h2> |     <h2>Din ordre</h2> | ||||||
|     <OrderSection> |     <OrderSection /> | ||||||
|       <div class="navigation-buttons" slot="button"> |  | ||||||
|         <ApplePayButton /> |  | ||||||
|         <VippsHurtigkasse /> |  | ||||||
|       </div> |  | ||||||
|     </OrderSection> |  | ||||||
|   </section> |   </section> | ||||||
|  |  | ||||||
|   <section id="payment"> |   <section id="payment"> | ||||||
|     <h2>Betalingsinformasjon</h2> |     <h2>Betalingsinformasjon</h2> | ||||||
|     <StripeCard /> |     <StripeCard bind:card="{card}" stripeApiKey="{stripeApiKey}" /> | ||||||
|  |  | ||||||
|     <div class="pay"> |     <div class="pay"> | ||||||
|       <CheckoutButton type="submit" text="Betal" /> |       <CheckoutButton type="submit" text="Betal" /> | ||||||
|  |       <div class="payment-state-animation"> | ||||||
|  |         {#if paymentPromise} | ||||||
|  |           <Loading promise="{paymentPromise}" /> | ||||||
|  |         {/if} | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </section> |   </section> | ||||||
| </form> | </form> | ||||||
|  |  | ||||||
|  | <ErrorStack bind:errors="{errors}" /> | ||||||
|  |  | ||||||
| <style lang="scss" module="scoped"> | <style lang="scss" module="scoped"> | ||||||
|   @import '../../styles/media-queries.scss'; |   @import '../../styles/media-queries.scss'; | ||||||
|  |  | ||||||
|   form.checkout { |   form.checkout { | ||||||
|     // display: flex; |  | ||||||
|     // flex-wrap: wrap; |  | ||||||
|  |  | ||||||
|     display: grid; |     display: grid; | ||||||
|     grid-template-areas: |     grid-template-areas: | ||||||
|       'delivery order' |       'delivery order' | ||||||
| @@ -80,7 +183,6 @@ | |||||||
|  |  | ||||||
|     grid-gap: 2rem; |     grid-gap: 2rem; | ||||||
|     grid-template-columns: 1fr 1fr; |     grid-template-columns: 1fr 1fr; | ||||||
|     // grid-auto-flow: column; |  | ||||||
|  |  | ||||||
|     @include mobile { |     @include mobile { | ||||||
|       grid-template-columns: minmax(0, 1fr); |       grid-template-columns: minmax(0, 1fr); | ||||||
| @@ -93,18 +195,12 @@ | |||||||
|  |  | ||||||
|   .pay { |   .pay { | ||||||
|     margin: 2rem 0; |     margin: 2rem 0; | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .navigation-buttons { |  | ||||||
|     display: flex; |     display: flex; | ||||||
|     justify-content: flex-start; |  | ||||||
|     margin-top: 2rem; |  | ||||||
|     flex-wrap: wrap; |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   :global(.navigation-buttons > *) { |   :global(.pay .payment-state-animation svg) { | ||||||
|     margin-right: 1rem; |     margin-left: 1.5rem; | ||||||
|     margin-bottom: 1rem; |     width: 34px; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   #delivery { |   #delivery { | ||||||
| @@ -126,10 +222,30 @@ | |||||||
|       padding-left: 4px; |       padding-left: 4px; | ||||||
|       text-transform: none; |       text-transform: none; | ||||||
|       font-size: 2.3rem; |       font-size: 2.3rem; | ||||||
|       padding: 12px 10px 12px 12px !important; |       padding: 12px 10px 12px 0; | ||||||
|       font-weight: 500; |       font-weight: 500; | ||||||
|       color: #231f20; |       color: #231f20; | ||||||
|       line-height: 1.1; |       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> | </style> | ||||||
|   | |||||||
| @@ -4,14 +4,17 @@ | |||||||
|   import { cart, subTotal } from '$lib/cartStore'; |   import { cart, subTotal } from '$lib/cartStore'; | ||||||
|   import { decrementProductInCart, incrementProductInCart } from '$lib/websocketCart'; |   import { decrementProductInCart, incrementProductInCart } from '$lib/websocketCart'; | ||||||
|  |  | ||||||
|   // $: totalPrice = $cart |  | ||||||
|   //   .map((order: IOrderLineItem) => order?.price * order.quantity) |  | ||||||
|   //   .reduce((a, b) => a + b); |  | ||||||
|   const shippingPrice = 75; |   const shippingPrice = 75; | ||||||
|  |   $: totalPrice = $subTotal + shippingPrice; | ||||||
|  |  | ||||||
|  |   function lineItemClass(id: number) { | ||||||
|  |     return `lineitem-${id}`; | ||||||
|  |   } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <table class="checkout"> | <div style="border: 2px solid black"> | ||||||
|   <thead> |   <table class="order-summary"> | ||||||
|  |     <thead style="border-bottom: 2px solid black;"> | ||||||
|       <tr> |       <tr> | ||||||
|         <th>Varenavn</th> |         <th>Varenavn</th> | ||||||
|         <th>Antall</th> |         <th>Antall</th> | ||||||
| @@ -22,7 +25,7 @@ | |||||||
|     <tbody> |     <tbody> | ||||||
|       {#if $cart.length} |       {#if $cart.length} | ||||||
|         {#each $cart as cartItem} |         {#each $cart as cartItem} | ||||||
|         <tr> |           <tr id="{lineItemClass(cartItem.lineitem_id)}"> | ||||||
|             <td> |             <td> | ||||||
|               <div class="line-order"> |               <div class="line-order"> | ||||||
|                 <a href="/shop/{cartItem.product_no}"><span>{cartItem.name}</span></a> |                 <a href="/shop/{cartItem.product_no}"><span>{cartItem.name}</span></a> | ||||||
| @@ -48,32 +51,31 @@ | |||||||
|         </tr> |         </tr> | ||||||
|       {/if} |       {/if} | ||||||
|  |  | ||||||
|     <tr> |       <tr style="border-bottom-color: rgba(0,0,0,0.15)"> | ||||||
|       <td>Totalpris:</td> |         <td>Totalsum:</td> | ||||||
|         <td></td> |         <td></td> | ||||||
|         <td>Nok {$subTotal}</td> |         <td>Nok {$subTotal}</td> | ||||||
|       </tr> |       </tr> | ||||||
|  |  | ||||||
|     <tr> |       <tr style="border-bottom-color: rgba(0,0,0,0.15)"> | ||||||
|         <td>Frakt:</td> |         <td>Frakt:</td> | ||||||
|         <td></td> |         <td></td> | ||||||
|         <td>Nok {shippingPrice}</td> |         <td>Nok {shippingPrice}</td> | ||||||
|       </tr> |       </tr> | ||||||
|  |  | ||||||
|       <tr style="font-weight: 600"> |       <tr style="font-weight: 600"> | ||||||
|       <td>Totalsum:</td> |         <td>Pris:</td> | ||||||
|         <td></td> |         <td></td> | ||||||
|       <td>Nok {$subTotal}</td> |         <td>Nok {totalPrice}</td> | ||||||
|       </tr> |       </tr> | ||||||
|     </tbody> |     </tbody> | ||||||
|   </table> |   </table> | ||||||
|  | </div> | ||||||
|  |  | ||||||
| <!-- <slot name="express-checkout-buttons" /> --> | <!-- <slot name="express-checkout-buttons" /> --> | ||||||
|  |  | ||||||
| <style lang="scss" module="scoped"> | <style lang="scss" module="scoped"> | ||||||
|   table.checkout { |   table.order-summary { | ||||||
|     width: 100%; |     width: 100%; | ||||||
|     border: 2px solid #dbd9d5; |  | ||||||
|     border-collapse: collapse; |     border-collapse: collapse; | ||||||
|  |  | ||||||
|     thead { |     thead { | ||||||
| @@ -101,8 +103,11 @@ | |||||||
|       color: rgba(0, 0, 0, 0.5); |       color: rgba(0, 0, 0, 0.5); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     tr:not(:last-of-type) { | ||||||
|  |       border-bottom: 2px solid black; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     td { |     td { | ||||||
|       border: 2px solid #dbd9d5; |  | ||||||
|       padding: 1rem 0.5rem; |       padding: 1rem 0.5rem; | ||||||
|       min-width: 50px; |       min-width: 50px; | ||||||
|       font-size: 1.1rem; |       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