Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(checkout): build payment section #67

Merged
merged 5 commits into from
Jan 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ AUDIOPHILE_API_BASE_URL=http://0.0.0.0:3001
AUDIOPHILE_API_KEY=audiophile
AUDIOPHILE_API_VERSION=v1
MAPBOX_GL_TOKEN=token123
STRIPE_PUBLIC_API_KEY=key123
1 change: 1 addition & 0 deletions app/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ export { default as PurchaseCartSummaryItem } from './purchase-cart-summary-item
export { default as PurchaseCartSummaryFee } from './purchase-cart-summary-fee'
export { default as LocationInfo } from './location-info'
export { default as ProgressBar } from './progress-bar'
export { default as PaymentForm } from './payment-form'
1 change: 1 addition & 0 deletions app/components/payment-form/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './payment-form'
94 changes: 94 additions & 0 deletions app/components/payment-form/payment-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import {
PaymentElement,
LinkAuthenticationElement,
useStripe,
useElements
} from "@stripe/react-stripe-js";
import invariant from 'tiny-invariant'

import type { FormEventHandler } from 'react'
import type { PaymentIntentResult } from '@stripe/stripe-js'
import type { PaymentFormProps } from "./types";

const PaymentForm = ({ redirectUrl }: PaymentFormProps) => {
const stripe = useStripe();
const elements = useElements();

const [message, setMessage] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState<boolean>(false);

useEffect(() => {
if (!stripe) return

const clientSecret = new URLSearchParams(window.location.search).get("payment_intent_client_secret")
if (!clientSecret) return

stripe
.retrievePaymentIntent(clientSecret)
.then(({ paymentIntent }: PaymentIntentResult) => {
invariant(paymentIntent, 'payment intent must exist')

switch (paymentIntent.status) {
case "succeeded":
setMessage("Payment succeeded!");
break;
case "processing":
setMessage("Your payment is processing.");
break;
case "requires_payment_method":
setMessage("Your payment was not successful, please try again.");
break;
default:
setMessage("Something went wrong.");
break;
}
});
}, [stripe]);

const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
if (!stripe || !elements) return

setIsLoading(true)

const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: redirectUrl
},
});

if (error.type === "card_error" || error.type === "validation_error") {
setMessage(error.message);
} else {
setMessage("An unexpected error occurred.");
}

setIsLoading(false);
};

return (
<form id="payment-form" onSubmit={handleSubmit}>
<LinkAuthenticationElement
id="link-authentication-element"
className="!font-bold"
/>
<PaymentElement
id="payment-element"
options={{ layout: "tabs" }}
/>
<button
disabled={isLoading || !stripe || !elements}
id="submit"
>
<span id="button-text">
{ isLoading ? <div className="spinner" id="spinner" /> : "Pay now" }
</span>
</button>
{ message && <div id="payment-message">{ message }</div> }
</form>
);
}

export default PaymentForm
3 changes: 3 additions & 0 deletions app/components/payment-form/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface PaymentFormProps {
redirectUrl: string
}
4 changes: 2 additions & 2 deletions app/components/purchase-cart-list/purchase-cart-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import formatCurrency from '~/utils/format-currency'
import type { PurchaseCartListProps } from './types'

const PurchaseCartList = ({ cart, onClose, onCartRemoval }: PurchaseCartListProps) => {
const { items } = cart
const { items, uuid } = cart

const subtotal = items.reduce((cumPrice, item) => item.price + cumPrice, 0)

Expand Down Expand Up @@ -48,7 +48,7 @@ const PurchaseCartList = ({ cart, onClose, onCartRemoval }: PurchaseCartListProp
</Text>
</div>

<ButtonLink variant="primary" to="/checkout" className="text-center" onClick={onClose}>
<ButtonLink variant="primary" to={`/checkout?cart_uuid=${uuid}`} className="text-center" onClick={onClose}>
Checkout
</ButtonLink>
</div>
Expand Down
11 changes: 11 additions & 0 deletions app/icons/check/check.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { CheckProps } from './types'

const Check = ({ className }: CheckProps) => {
return (
<svg viewBox="0 0 26 21" fill="none" className={className ? className : ''}>
<path d="M1.75391 11.3328L8.50542 18.0843L24.3085 2.28125" strokeWidth="4"/>
</svg>
)
}

export default Check
1 change: 1 addition & 0 deletions app/icons/check/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './check'
3 changes: 3 additions & 0 deletions app/icons/check/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface CheckProps {
className?: string
}
1 change: 1 addition & 0 deletions app/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { default as BurgerMenu } from './burger-menu'
export { default as PurchaseCart } from './purchase-cart'
export { default as Cross } from './cross'
export { default as TrashCan } from './trash-can'
export { default as Check } from './check'
2 changes: 2 additions & 0 deletions app/models/payment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './payment.server'
export * from './schema'
19 changes: 19 additions & 0 deletions app/models/payment/payment.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as AudiophileClient from '~/utils/audiophile-client'
import { PaymentSchema } from './schema'

export const startPayment = async (authToken: string, cartUuid: string) => {
const response = await AudiophileClient.sendRequest('post', 'payments', {
authToken,
body: { purchase_cart_uuid: cartUuid }
})

const payment = PaymentSchema.parse(response)
return payment
}

export const getPayment = async (authToken: string, uuid: string) => {
const response = await AudiophileClient.sendRequest('get', `payments/${uuid}`, { authToken })

const payment = PaymentSchema.parse(response)
return payment
}
13 changes: 13 additions & 0 deletions app/models/payment/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import z from 'zod'

export const PaymentSchema = z.object({
uuid: z.string(),
status: z.string(),
amount: z.number(),
purchase_cart: z.object({
uuid: z.string()
}),
client_secret: z.string().optional()
})

export type Payment = z.infer<typeof PaymentSchema>
53 changes: 38 additions & 15 deletions app/routes/checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Outlet, useLoaderData } from '@remix-run/react'
import { json, redirect } from '@remix-run/node'
import invariant from 'tiny-invariant'
import { Text, PurchaseCartSummary, ProgressBar } from '~/components'
import { getLastStartedCart } from '~/models/purchase-cart'
import { getCartDetails } from '~/models/purchase-cart'
import { getSessionId } from '~/utils/session-storage'
import trackPageView from '~/utils/track-page-view'
import { getAccessToken } from '~/utils/auth-storage'
Expand All @@ -16,35 +16,58 @@ export const loader = async ({ request }: LoaderArgs) => {
const sessionId = await getSessionId(request)
invariant(sessionId, 'sessionId must exist')

const activeCart = await getLastStartedCart(sessionId)
if(!activeCart) return redirect('/')
const url = new URL(request.url)
const cartUuid = url.searchParams.get('cart_uuid')
invariant(cartUuid, 'cart_uuid param must be present')

const cart = await getCartDetails(sessionId, cartUuid)

const paymentIntent = url.searchParams.get('payment_intent')
const paymentUuid = url.searchParams.get('payment_uuid')

// checkout flow navigation
let progress = "0";

const url = new URL(request.url)
const accessToken = await getAccessToken(request)
if(!accessToken) {
progress = "33%"
if (url.pathname === '/checkout') return redirect('/checkout/billing-details')
} else if (!activeCart.user_location_uuid) {
progress = "66%"
if (url.pathname === '/checkout') return redirect('/checkout/shipping-info')
progress = "25%"
if (url.pathname === '/checkout') return redirect(`/checkout/billing-details?cart_uuid=${cartUuid}`)
} else if (!cart.user_location_uuid) {
progress = "50%"
if (url.pathname === '/checkout') return redirect(`/checkout/shipping-info?cart_uuid=${cartUuid}`)
} else if (!paymentIntent || !paymentUuid) {
progress = "75%"
if (url.pathname === '/checkout') return redirect(`/checkout/payment?cart_uuid=${cartUuid}`)
} else {
progress = "100%"
progress = '100%'

if (url.pathname === '/checkout') {
const redirectStatus = url.searchParams.get('redirect_status')

if (redirectStatus === 'succeeded') {
return redirect(`/checkout/thank-you?cart_uuid=${cartUuid}&payment_uuid=${paymentUuid}&payment_intent=${paymentIntent}`)
} else {
return redirect(`/checkout/payment-failed?cart_uuid=${cartUuid}&payment_uuid=${paymentUuid}&payment_intent=${paymentIntent}`)
}
}
}

return json({ activeCart, progress })
return json({ cart, progress })
}

export default () => {
const { activeCart, progress } = useLoaderData<typeof loader>()
const { cart, progress } = useLoaderData<typeof loader>()

const handleGoBack = () => {
if (progress === "100%") return window.location.href = "/"

goBack();
}

return (
<div className="bg-gray">
<div className="pt-4 pb-6 px-6 sm:pt-12 sm:px-10">
<div className="max-w-6xl mx-auto">
<button onClick={goBack} className="text-base text-black opacity-50 hover:opacity-100 transition">
<button onClick={handleGoBack} className="text-base text-black opacity-50 hover:opacity-100 transition">
Go Back
</button>
</div>
Expand All @@ -65,7 +88,7 @@ export default () => {
</div>

<div className="lg:flex-0.5">
<PurchaseCartSummary cart={activeCart} />
<PurchaseCartSummary cart={cart} />
</div>
</div>
</div>
Expand Down
4 changes: 3 additions & 1 deletion app/routes/checkout/billing-details/confirmation-code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export const action = async ({ request }: ActionArgs) => {
accessToken: tokenData.access_token
})

return redirect(`/checkout`, { headers })
const cartUuid = url.searchParams.get('cart_uuid')

return redirect(`/checkout?cart_uuid=${cartUuid}`, { headers })
} catch (error) {
if (!(error instanceof RequestError)) throw error

Expand Down
16 changes: 12 additions & 4 deletions app/routes/checkout/billing-details/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Link, Form, useActionData } from '@remix-run/react'
import { Link, Form, useActionData, useLoaderData } from '@remix-run/react'
import { json, redirect } from '@remix-run/node'
import { Text, TextInput, Button } from '~/components'
import useMaskedInput from '~/hooks/use-masked-input'
Expand Down Expand Up @@ -31,7 +31,11 @@ const validateForm = (userInfo: UserInfoPayload) => {

export const loader = async ({ request }: LoaderArgs) => {
trackPageView(request)
return null;

const url = new URL(request.url)
const cartUuid = url.searchParams.get('cart_uuid')

return json({ cartUuid });
}

export const action = async ({ request }: ActionArgs) => {
Expand All @@ -51,10 +55,14 @@ export const action = async ({ request }: ActionArgs) => {

if (Object.keys(errors).length) return json({ errors })

return redirect(`/checkout/billing-details/confirmation-code?email=${encodeURIComponent(userInfo.email)}`)
const url = new URL(request.url)
const cartUuid = url.searchParams.get('cart_uuid')

return redirect(`/checkout/billing-details/confirmation-code?email=${encodeURIComponent(userInfo.email)}&cart_uuid=${cartUuid}`)
}

export default () => {
const { cartUuid } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>()
const errors: ValidationErrors = result ? result.errors : {}

Expand Down Expand Up @@ -86,7 +94,7 @@ export default () => {
)}

<Text variant="body" as="p">
Already a user? <Link to="/checkout/billing-details/login" className="text-orange underline hover:cursor-pointer">Login here</Link>
Already a user? <Link to={`/checkout/billing-details/login?cart_uuid=${cartUuid}`} className="text-orange underline hover:cursor-pointer">Login here</Link>
</Text>

<Button type="submit" variant="primary">
Expand Down
16 changes: 12 additions & 4 deletions app/routes/checkout/billing-details/login.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Form, Link, useActionData } from '@remix-run/react'
import { Form, Link, useActionData, useLoaderData } from '@remix-run/react'
import { json, redirect } from '@remix-run/node'
import { Button, Text, TextInput } from '~/components'
import formDataToObject from '~/utils/form-data-to-object'
Expand All @@ -24,7 +24,11 @@ const validateForm = (userInfo: UserInfoPayload) => {

export const loader = async ({ request }: LoaderArgs) => {
trackPageView(request)
return null;

const url = new URL(request.url)
const cartUuid = url.searchParams.get('cart_uuid')

return json({ cartUuid });
}

export const action = async ({ request }: ActionArgs) => {
Expand All @@ -44,11 +48,15 @@ export const action = async ({ request }: ActionArgs) => {

if (Object.keys(errors).length) return json({ errors })

return redirect(`/checkout/billing-details/confirmation-code?email=${encodeURIComponent(userInfo.email)}`)
const url = new URL(request.url)
const cartUuid = url.searchParams.get('cart_uuid')

return redirect(`/checkout/billing-details/confirmation-code?email=${encodeURIComponent(userInfo.email)}&cart_uuid=${cartUuid}`)
}


export default () => {
const { cartUuid } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>()
const errors: ValidationErrors = result ? result.errors : {}

Expand All @@ -67,7 +75,7 @@ export default () => {
)}

<Text variant="body" as="p">
Not a user yet? <Link to="/checkout/billing-details" className="text-orange underline hover:cursor-pointer">Register here</Link>
Not a user yet? <Link to={`/checkout/billing-details?cart_uuid=${cartUuid}`} className="text-orange underline hover:cursor-pointer">Register here</Link>
</Text>

<Button type="submit" variant="primary">
Expand Down
Loading