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

Add demo app #1100

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsaleor%2Fstorefront&env=NEXT_PUBLIC_SALEOR_API_URL&envDescription=Full%20Saleor%20GraphQL%20endpoint%20URL%2C%20eg%3A%20https%3A%2F%2Fstorefront1.saleor.cloud%2Fgraphql%2F&project-name=my-saleor-storefront&repository-name=my-saleor-storefront&demo-title=Saleor%20Next.js%20Storefront&demo-description=Starter%20pack%20for%20building%20performant%20e-commerce%20experiences%20with%20Saleor.&demo-url=https%3A%2F%2Fstorefront.saleor.io%2F&demo-image=https%3A%2F%2Fstorefront-d5h86wzey-saleorcommerce.vercel.app%2Fopengraph-image.png%3F4db0ee8cf66e90af)
[![Storefront Demo](https://img.shields.io/badge/VIEW%20DEMO-DFDFDF?style=for-the-badge)](https://storefront.saleor.io)

![Nextjs Storefront](./public/screenshot.png)

Expand Down Expand Up @@ -72,6 +71,7 @@
## Quickstart

### 1. Create Saleor backend instance

To quickly get started with the backend, use a free developer account at [Saleor Cloud](https://cloud.saleor.io/?utm_source=storefront&utm_medium=github).

Alternatively you can [run Saleor locally using docker](https://docs.saleor.io/docs/3.x/setup/docker-compose?utm_source=storefront&utm_medium=github).
Expand All @@ -95,6 +95,7 @@ saleor storefront create --url https://{SALEOR_HOSTNAME}/graphql/
#### [Option 2] Manual install

Clone repository:

```bash
git clone https://github.com/saleor/storefront.git
```
Expand All @@ -113,7 +114,6 @@ Then, [install `pnpm`](https://pnpm.io/installation) and run the following comma
pnpm i
```


## Payments

Currently, Saleor Storefront supports payments via the [Saleor Adyen App](https://docs.saleor.io/docs/3.x/developer/app-store/apps/adyen). To install and configure the payment app go to the "Apps" section in the Saleor Dashboard (App Store is only available in Saleor Cloud).
Expand Down
8 changes: 8 additions & 0 deletions src/app/api/demo-payment/gateway-initialize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export async function POST() {
console.log("gateway");
return Response.json({
data: {
some: "init-data",
},
});
}
82 changes: 82 additions & 0 deletions src/app/api/demo-payment/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { DEMO_PAYMENT_GATEWAY } from "@/checkout/sections/PaymentSection/Demo/metadata";

const removeWhiteSpace = (str: string): string => {
return str.replaceAll(/[\t\n]+/g, " ");
};

const url = `https://${process.env.VERCEL_BRANCH_URL}`;

export async function GET() {
return Response.json({
id: DEMO_PAYMENT_GATEWAY,
version: "1.0.0",
requiredSaleorVersion: "^3.20",
name: "Demo Payment App",
author: "Storefront",
about: "Demo for processing payments. Can be used for testing purposes.",

permissions: ["HANDLE_PAYMENTS"],

appUrl: `${url}`,
configurationUrl: `${url}`,
tokenTargetUrl: `${url}/api/demo-payment`,

dataPrivacy: "",
dataPrivacyUrl: `${url}`,
homepageUrl: `${url}`,
supportUrl: `${url}`,
// brand: {
// logo: {
// default: [APP_ICON_URL],
// },
// },
webhooks: [
{
name: "Transaction initialize",
syncEvents: ["TRANSACTION_INITIALIZE_SESSION"],
query: removeWhiteSpace(`
subscription {
event {
... on TransactionInitializeSession {
__typename
data
action {
amount
currency
actionType
}
issuedAt
merchantReference
idempotencyKey
}
}
}`),
targetUrl: `${url}/api/demo-payment/transaction-initialize`,
isActive: true,
},
{
name: "Gateway initialize",
syncEvents: ["PAYMENT_GATEWAY_INITIALIZE_SESSION"],
query: removeWhiteSpace(`
subscription {
event {
... on PaymentGatewayInitializeSession {
__typename
amount
data
}
}
}`),
targetUrl: `${url}/api/demo-payment/gateway-initialize`,
isActive: true,
},
],
});
}

export async function POST(_: Request) {
// During installation this path would be used to save app token that later can be used to authenticate requests. For example to manipulate checkout or orders.
return new Response("Success!", {
status: 200,
});
}
10 changes: 10 additions & 0 deletions src/app/api/demo-payment/transaction-initialize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export async function POST(req: Request) {
const randomPspReference = crypto.randomUUID(); // Generate a random PSP reference
const data = await req.json();
console.log(data);
return Response.json({
result: "CHARGE_SUCCESS",
amount: 10, // `payload` is typed thanks to the generated types
pspReference: randomPspReference,
});
}
58 changes: 58 additions & 0 deletions src/checkout/sections/PaymentSection/Demo/PaymentOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { FormEventHandler } from "react";
import { useCheckoutValidationActions } from "@/checkout/state/checkoutValidationStateStore";
import { useUser } from "@/checkout/hooks/useUser";
import { useCheckoutUpdateStateActions } from "@/checkout/state/updateStateStore";
import { useEvent } from "@/checkout/hooks/useEvent";
import { useCheckoutCompleteMutation, useTransactionInitializeMutation } from "@/checkout/graphql";
import { useCheckout } from "@/checkout/hooks/useCheckout";
import { replaceUrl } from "@/checkout/lib/utils/url";
import { Button } from "@/checkout/components";
import { DEMO_PAYMENT_GATEWAY } from "@/checkout/sections/PaymentSection/Demo/metadata";

export const DemoPayment = () => {
const { checkout } = useCheckout();
const { authenticated } = useUser();
const { validateAllForms } = useCheckoutValidationActions();
const { setSubmitInProgress, setShouldRegisterUser } = useCheckoutUpdateStateActions();
const [__, mutation] = useTransactionInitializeMutation();
const [_, completeMutation] = useCheckoutCompleteMutation();
const onSubmit: FormEventHandler<HTMLFormElement> = useEvent(async (e) => {
e.preventDefault();
validateAllForms(authenticated);
setShouldRegisterUser(true);
setSubmitInProgress(true);
await mutation({
checkoutId: checkout.id,
paymentGateway: {
id: DEMO_PAYMENT_GATEWAY,
data: {
details: "valid-details",
},
},
});
const { data } = await completeMutation({
checkoutId: checkout.id,
});

const order = data?.checkoutComplete?.order;

if (order) {
const newUrl = replaceUrl({
query: {
order: order.id,
},
replaceWholeQuery: true,
});
window.location.href = newUrl;
}
});

return (
<form onSubmit={onSubmit}>
{/* eslint-disable-next-line react/jsx-no-undef */}
<button type={"submit"}>
<Button label={"Pay"} />
</button>
</form>
);
};
1 change: 1 addition & 0 deletions src/checkout/sections/PaymentSection/Demo/metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DEMO_PAYMENT_GATEWAY = `storefront.demo-payment`;
17 changes: 3 additions & 14 deletions src/checkout/sections/PaymentSection/PaymentMethods.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,21 @@
import { paymentMethodToComponent } from "./supportedPaymentApps";
import { PaymentSectionSkeleton } from "@/checkout/sections/PaymentSection/PaymentSectionSkeleton";
import { usePayments } from "@/checkout/sections/PaymentSection/usePayments";
import { useCheckoutUpdateState } from "@/checkout/state/updateStateStore";
import { DemoPayment } from "@/checkout/sections/PaymentSection/Demo/PaymentOptions";

export const PaymentMethods = () => {
const { availablePaymentGateways, fetching } = usePayments();
const {
changingBillingCountry,
updateState: { checkoutDeliveryMethodUpdate },
} = useCheckoutUpdateState();

// delivery methods change total price so we want to wait until the change is done
if (changingBillingCountry || fetching || checkoutDeliveryMethodUpdate === "loading") {
if (changingBillingCountry || checkoutDeliveryMethodUpdate === "loading") {
return <PaymentSectionSkeleton />;
}

return (
<div className="gap-y-8">
{availablePaymentGateways.map((gateway) => {
const Component = paymentMethodToComponent[gateway.id];
return (
<Component
key={gateway.id}
// @ts-expect-error -- gateway matches the id but TypeScript doesn't know that
config={gateway}
/>
);
})}
<DemoPayment />
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useCheckout } from "@/checkout/hooks/useCheckout";
import { useSubmit } from "@/checkout/hooks/useSubmit";
import { type MightNotExist } from "@/checkout/lib/globalTypes";
import { type ParsedPaymentGateways } from "@/checkout/sections/PaymentSection/types";
import { getFilteredPaymentGateways } from "@/checkout/sections/PaymentSection/utils";
import { DEMO_PAYMENT_GATEWAY } from "@/checkout/sections/PaymentSection/Demo/metadata";

export const usePaymentGatewaysInitialize = () => {
const {
Expand All @@ -30,10 +30,12 @@ export const usePaymentGatewaysInitialize = () => {
onSubmit: paymentGatewaysInitialize,
parse: () => ({
checkoutId,
paymentGateways: getFilteredPaymentGateways(availablePaymentGateways).map(({ config, id }) => ({
id,
data: config,
})),
paymentGateways: availablePaymentGateways
.filter((x) => x.id === DEMO_PAYMENT_GATEWAY)
.map(({ config, id }) => ({
id,
data: config,
})),
}),
onSuccess: ({ data }) => {
const parsedConfigs = (data.gatewayConfigs || []) as ParsedPaymentGateways;
Expand Down
30 changes: 0 additions & 30 deletions src/checkout/sections/PaymentSection/utils.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,12 @@
import { compact } from "lodash-es";
import { adyenGatewayId } from "./AdyenDropIn/types";
import { stripeGatewayId } from "./StripeElements/types";
import {
type CheckoutAuthorizeStatusEnum,
type CheckoutChargeStatusEnum,
type OrderAuthorizeStatusEnum,
type OrderChargeStatusEnum,
type PaymentGateway,
} from "@/checkout/graphql";
import { type MightNotExist } from "@/checkout/lib/globalTypes";
import { getUrl } from "@/checkout/lib/utils/url";
import { type PaymentStatus } from "@/checkout/sections/PaymentSection/types";

export const supportedPaymentGateways = [adyenGatewayId, stripeGatewayId] as const;

export const getFilteredPaymentGateways = (
paymentGateways: MightNotExist<PaymentGateway[]>,
): PaymentGateway[] => {
if (!paymentGateways) {
return [];
}

// we want to use only payment apps, not plugins
return compact(paymentGateways).filter(({ id, name }) => {
const shouldBeIncluded = supportedPaymentGateways.includes(id);
const isAPlugin = !id.startsWith("app.");

// app is missing in our codebase but is an app and not a plugin
// hence we'd like to have it handled by default
if (!shouldBeIncluded && !isAPlugin) {
console.warn(`Unhandled payment gateway - name: ${name}, id: ${id}`);
return false;
}

return shouldBeIncluded;
});
};

export const getUrlForTransactionInitialize = () => getUrl({ query: { processingPayment: true } });

export const usePaymentStatus = ({
Expand Down
Loading