diff --git a/.env.example b/.env.example index 75d14dc..77a0d7e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/components/index.ts b/app/components/index.ts index ecfd34b..25e367e 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -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' diff --git a/app/components/payment-form/index.ts b/app/components/payment-form/index.ts new file mode 100644 index 0000000..e876eff --- /dev/null +++ b/app/components/payment-form/index.ts @@ -0,0 +1 @@ +export { default } from './payment-form' diff --git a/app/components/payment-form/payment-form.tsx b/app/components/payment-form/payment-form.tsx new file mode 100644 index 0000000..f919b98 --- /dev/null +++ b/app/components/payment-form/payment-form.tsx @@ -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(undefined); + const [isLoading, setIsLoading] = useState(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 = 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 ( +
+ + + + { message &&
{ message }
} + + ); +} + +export default PaymentForm diff --git a/app/components/payment-form/types.ts b/app/components/payment-form/types.ts new file mode 100644 index 0000000..3203fe0 --- /dev/null +++ b/app/components/payment-form/types.ts @@ -0,0 +1,3 @@ +export interface PaymentFormProps { + redirectUrl: string +} diff --git a/app/components/purchase-cart-list/purchase-cart-list.tsx b/app/components/purchase-cart-list/purchase-cart-list.tsx index fac6c64..ee2cc73 100644 --- a/app/components/purchase-cart-list/purchase-cart-list.tsx +++ b/app/components/purchase-cart-list/purchase-cart-list.tsx @@ -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) @@ -48,7 +48,7 @@ const PurchaseCartList = ({ cart, onClose, onCartRemoval }: PurchaseCartListProp - + Checkout diff --git a/app/icons/check/check.tsx b/app/icons/check/check.tsx new file mode 100644 index 0000000..286d6ec --- /dev/null +++ b/app/icons/check/check.tsx @@ -0,0 +1,11 @@ +import type { CheckProps } from './types' + +const Check = ({ className }: CheckProps) => { + return ( + + + + ) +} + +export default Check diff --git a/app/icons/check/index.ts b/app/icons/check/index.ts new file mode 100644 index 0000000..d6c351f --- /dev/null +++ b/app/icons/check/index.ts @@ -0,0 +1 @@ +export { default } from './check' diff --git a/app/icons/check/types.ts b/app/icons/check/types.ts new file mode 100644 index 0000000..618d804 --- /dev/null +++ b/app/icons/check/types.ts @@ -0,0 +1,3 @@ +export interface CheckProps { + className?: string +} diff --git a/app/icons/index.ts b/app/icons/index.ts index 6d4c06b..1242c68 100644 --- a/app/icons/index.ts +++ b/app/icons/index.ts @@ -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' diff --git a/app/models/payment/index.ts b/app/models/payment/index.ts new file mode 100644 index 0000000..ee13103 --- /dev/null +++ b/app/models/payment/index.ts @@ -0,0 +1,2 @@ +export * from './payment.server' +export * from './schema' diff --git a/app/models/payment/payment.server.ts b/app/models/payment/payment.server.ts new file mode 100644 index 0000000..a681801 --- /dev/null +++ b/app/models/payment/payment.server.ts @@ -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 +} diff --git a/app/models/payment/schema.ts b/app/models/payment/schema.ts new file mode 100644 index 0000000..3ee3646 --- /dev/null +++ b/app/models/payment/schema.ts @@ -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 diff --git a/app/routes/checkout.tsx b/app/routes/checkout.tsx index 2c104b9..0761ce0 100644 --- a/app/routes/checkout.tsx +++ b/app/routes/checkout.tsx @@ -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' @@ -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() + const { cart, progress } = useLoaderData() + + const handleGoBack = () => { + if (progress === "100%") return window.location.href = "/" + + goBack(); + } return (
-
@@ -65,7 +88,7 @@ export default () => {
- +
diff --git a/app/routes/checkout/billing-details/confirmation-code.tsx b/app/routes/checkout/billing-details/confirmation-code.tsx index daa52b8..848c2c3 100644 --- a/app/routes/checkout/billing-details/confirmation-code.tsx +++ b/app/routes/checkout/billing-details/confirmation-code.tsx @@ -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 diff --git a/app/routes/checkout/billing-details/index.tsx b/app/routes/checkout/billing-details/index.tsx index 35622f1..df425b2 100644 --- a/app/routes/checkout/billing-details/index.tsx +++ b/app/routes/checkout/billing-details/index.tsx @@ -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' @@ -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) => { @@ -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(); const result = useActionData() const errors: ValidationErrors = result ? result.errors : {} @@ -86,7 +94,7 @@ export default () => { )} - Already a user? Login here + Already a user? Login here