Skip to content

Commit

Permalink
Stripe integration (#990)
Browse files Browse the repository at this point in the history
  • Loading branch information
typeofweb authored Oct 30, 2023
1 parent b80fc8e commit 97fafd6
Show file tree
Hide file tree
Showing 36 changed files with 635 additions and 302 deletions.
9 changes: 5 additions & 4 deletions __tests__/STF_01.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ test("STF_01: Add items to the basket", async ({ page }) => {

await openCart({ page });

await expect(page.getByTestId("CartProductList").getByRole("listitem")).toHaveCount(1);
await expect(page.getByTestId("CartProductList").getByRole("listitem")).toContainText(product.name);
await expect(page.getByTestId("CartProductList").getByRole("listitem")).toContainText(`Qty: 1`);
await expect(page.getByTestId("CartProductList").getByRole("listitem")).toContainText(price.toFixed(2));
const productInCart = page.getByTestId("CartProductList").getByRole("listitem");
await expect(productInCart).toHaveCount(1);
await expect(productInCart).toContainText(product.name);
await expect(productInCart).toContainText(`Qty: 1`);
await expect(productInCart).toContainText(price.toFixed(2));
});
9 changes: 5 additions & 4 deletions __tests__/STF_03.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ test("STF_03: Check if price are calculating correctly", async ({ page }) => {
await openCart({ page });

const totalPrice = (price * 2).toFixed(2);
await expect(page.getByTestId("CartProductList").getByRole("listitem")).toHaveCount(1);
await expect(page.getByTestId("CartProductList").getByRole("listitem")).toContainText(product.name);
await expect(page.getByTestId("CartProductList").getByRole("listitem")).toContainText(`Qty: 2`);
await expect(page.getByTestId("CartProductList").getByRole("listitem")).toContainText(totalPrice);
const productInCart = page.getByTestId("CartProductList").getByRole("listitem");
await expect(productInCart).toHaveCount(1);
await expect(productInCart).toContainText(product.name);
await expect(productInCart).toContainText(`Qty: 2`);
await expect(productInCart).toContainText(totalPrice);
});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"@adyen/adyen-web": "5.53.2",
"@adyen/api-library": "14.3.0",
"@headlessui/react": "1.7.17",
"@stripe/react-stripe-js": "2.3.1",
"@stripe/stripe-js": "2.1.10",
"@saleor/auth-sdk": "0.14.0",
"clsx": "2.0.0",
"editorjs-html": "3.4.3",
Expand Down
24 changes: 23 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions src/app/(main)/cart/CheckoutLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
type Props = {
disabled?: boolean;
checkoutId?: string;
className?: string;
};

export const CheckoutLink = ({ disabled, checkoutId }: Props) => {
export const CheckoutLink = ({ disabled, checkoutId, className = "" }: Props) => {
return (
<a
aria-disabled={disabled}
onClick={(e) => disabled && e.preventDefault()}
href={`/checkout?checkout=${checkoutId}`}
className="w-full rounded border border-transparent bg-neutral-900 px-6 py-3 text-center font-medium text-neutral-50 hover:bg-neutral-800 aria-disabled:cursor-not-allowed aria-disabled:bg-neutral-500 sm:col-start-2"
className={`inline-block max-w-full rounded border border-transparent bg-neutral-900 px-6 py-3 text-center font-medium text-neutral-50 hover:bg-neutral-800 aria-disabled:cursor-not-allowed aria-disabled:bg-neutral-500 sm:px-16 ${className}`}
>
Checkout
</a>
Expand Down
106 changes: 60 additions & 46 deletions src/app/(main)/cart/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,55 +14,70 @@ export default async function Page() {
const checkoutId = cookies().get("checkoutId")?.value || "";

const checkout = await Checkout.find(checkoutId);
const lines = checkout ? checkout.lines : [];

if (!checkout) {
return (
<section className="mx-auto max-w-7xl p-8">
<h1 className="mt-8 text-3xl font-bold text-neutral-900">Your Shopping Cart is empty</h1>
<p className="my-12 text-sm text-neutral-500">
Looks like you haven’t added any items to the cart yet.
</p>
<Link
href={"/"}
className="inline-block max-w-full rounded border border-transparent bg-neutral-900 px-6 py-3 text-center font-medium text-neutral-50 hover:bg-neutral-800 aria-disabled:cursor-not-allowed aria-disabled:bg-neutral-500 sm:px-16"
>
Go back
</Link>
</section>
);
}

return (
<section className="mx-auto max-w-7xl p-8">
<h1 className="mt-8 text-3xl font-bold text-neutral-900">Your Shopping Cart</h1>
<form className="mt-12">
<div>
<ul
data-testid="CartProductList"
role="list"
className="divide-y divide-neutral-200 border-b border-t border-neutral-200"
>
{lines.map((item) => (
<li key={item.id} className="flex py-4">
<div className="aspect-square h-24 w-24 flex-shrink-0 overflow-hidden rounded-md border bg-neutral-50 sm:h-32 sm:w-32">
{item.variant?.product?.thumbnail?.url && (
<Image
src={item.variant.product.thumbnail.url}
alt={item.variant.product.thumbnail.alt ?? ""}
width={200}
height={200}
className="h-full w-full object-contain object-center"
/>
)}
</div>
<div className="relative flex flex-1 flex-col justify-between p-4 py-2">
<div className="flex justify-between justify-items-start gap-4">
<div className="">
<Link href={`/products/${item.variant.product.slug}?variant=${item.variant.id}`}>
<h2 className="font-medium text-neutral-700">{item.variant?.product?.name}</h2>
</Link>
<p className="mt-1 text-sm text-neutral-500">{item.variant?.product?.category?.name}</p>
{item.variant.name !== item.variant.id && Boolean(item.variant.name) && (
<p className="mt-1 text-sm text-neutral-500">Variant: {item.variant.name}</p>
)}
</div>
<p className="text-right font-semibold text-neutral-900">
{formatMoney(item.totalPrice.gross.amount, item.totalPrice.gross.currency)}
</p>
</div>
<div className="flex justify-between">
<div className="text-sm font-bold">Qty: {item.quantity}</div>
<DeleteLineButton checkoutId={checkoutId} lineId={item.id} />
<ul
data-testid="CartProductList"
role="list"
className="divide-y divide-neutral-200 border-b border-t border-neutral-200"
>
{checkout.lines.map((item) => (
<li key={item.id} className="flex py-4">
<div className="aspect-square h-24 w-24 flex-shrink-0 overflow-hidden rounded-md border bg-neutral-50 sm:h-32 sm:w-32">
{item.variant?.product?.thumbnail?.url && (
<Image
src={item.variant.product.thumbnail.url}
alt={item.variant.product.thumbnail.alt ?? ""}
width={200}
height={200}
className="h-full w-full object-contain object-center"
/>
)}
</div>
<div className="relative flex flex-1 flex-col justify-between p-4 py-2">
<div className="flex justify-between justify-items-start gap-4">
<div className="">
<Link href={`/products/${item.variant.product.slug}?variant=${item.variant.id}`}>
<h2 className="font-medium text-neutral-700">{item.variant?.product?.name}</h2>
</Link>
<p className="mt-1 text-sm text-neutral-500">{item.variant?.product?.category?.name}</p>
{item.variant.name !== item.variant.id && Boolean(item.variant.name) && (
<p className="mt-1 text-sm text-neutral-500">Variant: {item.variant.name}</p>
)}
</div>
<p className="text-right font-semibold text-neutral-900">
{formatMoney(item.totalPrice.gross.amount, item.totalPrice.gross.currency)}
</p>
</div>
</li>
))}
</ul>
</div>
<div className="flex justify-between">
<div className="text-sm font-bold">Qty: {item.quantity}</div>
<DeleteLineButton checkoutId={checkoutId} lineId={item.id} />
</div>
</div>
</li>
))}
</ul>

<div className="mt-12">
<div className="rounded border bg-neutral-50 px-4 py-2">
<div className="flex items-center justify-between py-2">
Expand All @@ -71,13 +86,12 @@ export default async function Page() {
<p className="mt-1 text-sm text-neutral-500">Shipping will be calculated in the next step</p>
</div>
<div className="font-medium text-neutral-900">
{checkout &&
formatMoney(checkout.totalPrice.gross.amount, checkout.totalPrice.gross.currency)}
{formatMoney(checkout.totalPrice.gross.amount, checkout.totalPrice.gross.currency)}
</div>
</div>
</div>
<div className="mt-10 grid sm:grid-cols-3">
<CheckoutLink checkoutId={checkoutId} disabled={lines.length < 1} />
<div className="mt-10 text-center">
<CheckoutLink checkoutId={checkoutId} disabled={!checkout.lines.length} className="w-1/3" />
</div>
</div>
</form>
Expand Down
6 changes: 4 additions & 2 deletions src/app/(main)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ export default function RootLayout(props: { children: ReactNode }) {
return (
<>
<Header />
<main className="min-h-[calc(100vh-106px)] flex-grow">{props.children}</main>
<Footer />
<div className="flex h-[calc(100%-64px)] flex-col">
<main className="flex-1">{props.children}</main>
<Footer />
</div>
</>
);
}
52 changes: 19 additions & 33 deletions src/app/(main)/products/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { type Metadata } from "next";
import xss from "xss";
import invariant from "ts-invariant";
import { AddButton } from "./AddButton";
import { VariantSelector } from "@/ui/components/VariantSelector";
import { ProductImageWrapper } from "@/ui/atoms/ProductImageWrapper";
Expand Down Expand Up @@ -85,44 +86,29 @@ export default async function Page(props: { params: { slug: string }; searchPara
async function addItem() {
"use server";

let checkoutId = cookies().get("checkoutId")?.value;
const checkout = await Checkout.findOrCreate(cookies().get("checkoutId")?.value);
invariant(checkout, "This should never happen");

if (!checkoutId) {
const { checkoutCreate } = await Checkout.create();
cookies().set("checkoutId", checkout.id, {
secure: shouldUseHttps,
sameSite: "lax",
httpOnly: true,
});

if (checkoutCreate && checkoutCreate?.checkout?.id) {
cookies().set("checkoutId", checkoutCreate.checkout?.id, {
secure: shouldUseHttps,
sameSite: "lax",
httpOnly: true,
});

checkoutId = checkoutCreate.checkout.id;
}
if (!selectedVariantID) {
return;
}

checkoutId = cookies().get("checkoutId")?.value;

if (checkoutId && selectedVariantID) {
const checkout = await Checkout.find(checkoutId);

if (!checkout) {
cookies().delete("checkoutId");
}
// TODO: error handling
await executeGraphQL(CheckoutAddLineDocument, {
variables: {
id: checkout.id,
productVariantId: decodeURIComponent(selectedVariantID),
},
cache: "no-cache",
});

// TODO: error handling
await executeGraphQL(CheckoutAddLineDocument, {
variables: {
id: checkoutId,
productVariantId: decodeURIComponent(selectedVariantID),
},
cache: "no-cache",
});

revalidatePath("/cart");
} else {
throw new Error("Cart not found");
}
revalidatePath("/cart");
}

const isAvailable = variants?.some((variant) => variant.quantityAvailable) ?? false;
Expand Down
8 changes: 6 additions & 2 deletions src/app/checkout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ export const metadata = {
title: "Shopping Cart · Saleor Storefront example",
};

export default function CheckoutPage({ searchParams }: { searchParams: { checkout?: string } }) {
export default function CheckoutPage({
searchParams,
}: {
searchParams: { checkout?: string; order?: string };
}) {
invariant(process.env.NEXT_PUBLIC_SALEOR_API_URL, "Missing NEXT_PUBLIC_SALEOR_API_URL env variable");

if (!searchParams.checkout) {
if (!searchParams.checkout && !searchParams.order) {
return null;
}

Expand Down
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export const metadata: Metadata = {

export default function RootLayout(props: { children: ReactNode }) {
return (
<html lang="en">
<body className={`${inter.className} flex min-h-screen flex-col`}>
<html lang="en" className="h-full">
<body className={`${inter.className} h-full`}>
<AuthProvider>{props.children}</AuthProvider>
<DraftModeNotification />
</body>
Expand Down
11 changes: 8 additions & 3 deletions src/checkout/components/AddressForm/AddressForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ export const AddressForm: FC<PropsWithChildren<AddressFormProps>> = ({
const isValidPhoneNumber = usePhoneNumberValidator(values.countryCode);
const previousValues = useRef(values);

const { orderedAddressFields, getFieldLabel, isRequiredField, countryAreaChoices, allowedFields } =
useAddressFormUtils(values.countryCode);
const {
orderedAddressFields,
getFieldLabel,
isRequiredField,
countryAreaChoices,
allowedFields = [],
} = useAddressFormUtils(values.countryCode);

const allowedFieldsRef = useRef(allowedFields || []);
const allowedFieldsRef = useRef(allowedFields);

const customValidators: Partial<Record<AddressField, FieldValidator>> = {
phone: isValidPhoneNumber,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const useAddressFormSchema = (initialCountryCode?: CountryCode) => {

const validationSchema = useMemo(
() =>
allowedFields.reduce(
allowedFields?.reduce(
(schema, field) => schema.concat(object().shape({ [field]: getFieldValidator(field) })),
object().shape({}),
),
Expand Down
2 changes: 1 addition & 1 deletion src/checkout/components/AddressForm/useAddressFormUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,6 @@ export const useAddressFormUtils = (countryCode: CountryCode = defaultCountry) =
hasAllRequiredFields,
getMissingFieldsFromAddress,
...validationRules,
allowedFields: validationRules?.allowedFields as AddressField[],
allowedFields: validationRules?.allowedFields as AddressField[] | undefined,
};
};
2 changes: 1 addition & 1 deletion src/checkout/hooks/useAlerts/useAlerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { type ErrorCode } from "@/checkout/lib/globalTypes";
import { type ApiErrors } from "@/checkout/hooks/useGetParsedErrors/types";
import { useGetParsedErrors } from "@/checkout/hooks/useGetParsedErrors";
import { apiErrorMessages } from "@/checkout/sections/PaymentSection/AdyenDropIn/errorMessages";
import { apiErrorMessages } from "@/checkout/sections/PaymentSection/errorMessages";

function useAlerts(scope: CheckoutScope): {
showErrors: (errors: ApiErrors<any>) => void;
Expand Down
Loading

0 comments on commit 97fafd6

Please sign in to comment.