This commit is contained in:
jackbeeby
2024-12-05 18:20:27 +11:00
parent b8de0556ec
commit dc5eea1ad0
288 changed files with 101937 additions and 0 deletions

66
src/pages/_app.tsx Normal file
View File

@@ -0,0 +1,66 @@
import "../styles/global.css";
import "@saleor/macaw-ui/next/style";
import { AppBridgeProvider, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import { ThemeProvider } from "@saleor/macaw-ui/next";
import { type AppProps } from "next/app";
import { Provider } from "urql";
import { type ReactNode } from "react";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; // note: it's imported only in dev mode
import Head from "next/head";
import { ThemeSynchronizer } from "../modules/ui/theme-synchronizer";
import { NoSSRWrapper } from "../modules/ui/no-ssr-wrapper";
import { appBridgeInstance } from "@/app-bridge-instance";
import { ErrorModal } from "@/modules/ui/organisms/GlobalErrorModal/modal";
import { trpcClient } from "@/modules/trpc/trpc-client";
import { createClient } from "@/lib/create-graphq-client";
const UrqlProvider = ({ children }: { children: ReactNode }) => {
const { appBridgeState } = useAppBridge();
if (!appBridgeState?.saleorApiUrl) {
return <>{children}</>;
}
const client = createClient(appBridgeState?.saleorApiUrl, async () =>
appBridgeState?.token ? { token: appBridgeState.token } : null,
);
return <Provider value={client}>{children}</Provider>;
};
function NextApp({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>Saleor App Payment Stripe</title>
<link rel="icon" href="/favicon-32x32.png" type="image/png" />
<meta name="theme-color" content="#635BFF" />
<link rel="apple-touch-icon" sizes="48x48" href="/icons/icon-48x48.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.png" />
<link rel="apple-touch-icon" sizes="96x96" href="/icons/icon-96x96.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/icons/icon-144x144.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192x192.png" />
<link rel="apple-touch-icon" sizes="256x256" href="/icons/icon-256x256.png" />
<link rel="apple-touch-icon" sizes="384x384" href="/icons/icon-384x384.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512x512.png" />
</Head>
<NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<UrqlProvider>
<ThemeProvider>
<ThemeSynchronizer />
<RoutePropagator />
<Component {...pageProps} />
<ErrorModal />
<ReactQueryDevtools position="top-right" />
</ThemeProvider>
</UrqlProvider>
</AppBridgeProvider>
</NoSSRWrapper>
</>
);
}
export default trpcClient.withTRPC(NextApp);

39
src/pages/_error.js Normal file
View File

@@ -0,0 +1,39 @@
/**
* NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
*
* NOTE: If using this with `next` version 12.2.0 or lower, uncomment the
* penultimate line in `CustomErrorComponent`.
*
* This page is loaded by Nextjs:
* - on the server, when data-fetching methods throw or reject
* - on the client, when `getInitialProps` throws or rejects
* - on the client, when a React lifecycle method throws or rejects, and it's
* caught by the built-in Nextjs error boundary
*
* See:
* - https://nextjs.org/docs/basic-features/data-fetching/overview
* - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
* - https://reactjs.org/docs/error-boundaries.html
*/
import * as Sentry from "@sentry/nextjs";
import NextErrorComponent from "next/error";
const CustomErrorComponent = (props) => {
// If you're using a Nextjs version prior to 12.2.1, uncomment this to
// compensate for https://github.com/vercel/next.js/issues/8592
// Sentry.captureUnderscoreErrorException(props);
return <NextErrorComponent statusCode={props.statusCode} />;
};
CustomErrorComponent.getInitialProps = async (contextData) => {
// In case this is running in a serverless function, await this in order to give Sentry
// time to send the error before the lambda exits
await Sentry.captureUnderscoreErrorException(contextData);
// This will contain the status code of the response
return NextErrorComponent.getInitialProps(contextData);
};
export default CustomErrorComponent;

43
src/pages/api/manifest.ts Normal file
View File

@@ -0,0 +1,43 @@
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { type AppManifest } from "@saleor/app-sdk/types";
import packageJson from "../../../package.json";
import { paymentGatewayInitializeSessionSyncWebhook } from "./webhooks/saleor/payment-gateway-initialize-session";
import { transactionInitializeSessionSyncWebhook } from "./webhooks/saleor/transaction-initialize-session";
import { transactionCancelationRequestedSyncWebhook } from "./webhooks/saleor/transaction-cancelation-requested";
import { transactionChargeRequestedSyncWebhook } from "./webhooks/saleor/transaction-charge-requested";
import { transactionProcessSessionSyncWebhook } from "./webhooks/saleor/transaction-process-session";
import { transactionRefundRequestedSyncWebhook } from "./webhooks/saleor/transaction-refund-requested";
export default createManifestHandler({
async manifestFactory(context) {
const manifest: AppManifest = {
id: "app.saleor.stripe",
name: "Stripe",
about: packageJson.description,
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
appUrl: `${context.appBaseUrl}`,
permissions: ["HANDLE_PAYMENTS"],
version: packageJson.version,
requiredSaleorVersion: ">=3.14.0",
homepageUrl: "https://github.com/saleor/saleor-app-payment-stripe",
supportUrl: "https://github.com/saleor/saleor-app-payment-stripe/issues",
brand: {
logo: {
default: `${context.appBaseUrl}/logo.png`,
},
},
webhooks: [
paymentGatewayInitializeSessionSyncWebhook.getWebhookManifest(context.appBaseUrl),
transactionInitializeSessionSyncWebhook.getWebhookManifest(context.appBaseUrl),
transactionProcessSessionSyncWebhook.getWebhookManifest(context.appBaseUrl),
transactionCancelationRequestedSyncWebhook.getWebhookManifest(context.appBaseUrl),
transactionChargeRequestedSyncWebhook.getWebhookManifest(context.appBaseUrl),
transactionRefundRequestedSyncWebhook.getWebhookManifest(context.appBaseUrl),
],
extensions: [],
};
return manifest;
},
});

25
src/pages/api/register.ts Normal file
View File

@@ -0,0 +1,25 @@
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../saleor-app";
import { env } from "@/lib/env.mjs";
const allowedUrlsPattern = env.ALLOWED_DOMAIN_PATTERN;
/**
* Required endpoint, called by Saleor to install app.
* It will exchange tokens with app, so saleorApp.apl will contain token
*/
export default createAppRegisterHandler({
apl: saleorApp.apl,
allowedSaleorUrls: [
(url) => {
if (allowedUrlsPattern) {
const regex = new RegExp(allowedUrlsPattern);
return regex.test(url);
}
return true;
},
],
});

View File

@@ -0,0 +1,26 @@
import * as trpcNext from "@trpc/server/adapters/next";
import * as Sentry from "@sentry/nextjs";
import { appRouter } from "../../../modules/trpc/trpc-app-router";
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
import { logger, redactError } from "@/lib/logger";
import { BaseTrpcError, FieldError } from "@/errors";
import { isDevelopment } from "@/lib/isEnv";
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: createTrpcContext,
onError: ({ path, error, type, ctx, input }) => {
const cause = error?.cause;
if (cause instanceof BaseTrpcError || cause instanceof FieldError) {
// don't log expected errors
return;
}
if (isDevelopment()) {
// eslint-disable-next-line @saleor/saleor-app/logger-leak
return logger.error({ input, path, error, type, ctx }, "TRPC failed");
}
logger.error({ path, error: redactError(error), type }, "TRPC failed");
Sentry.captureException(error);
},
});

View File

@@ -0,0 +1,44 @@
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
import { type PageConfig } from "next";
import { saleorApp } from "@/saleor-app";
import {
UntypedPaymentGatewayInitializeSessionDocument,
type PaymentGatewayInitializeSessionEventFragment,
} from "generated/graphql";
import { PaymentGatewayInitializeSessionWebhookHandler } from "@/modules/webhooks/payment-gateway-initialize-session";
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
import ValidatePaymentGatewayInitializeSessionResponse from "@/schemas/PaymentGatewayInitializeSession/PaymentGatewayInitializeSessionResponse.mjs";
export const config: PageConfig = {
api: {
bodyParser: false,
},
};
export const paymentGatewayInitializeSessionSyncWebhook =
new SaleorSyncWebhook<PaymentGatewayInitializeSessionEventFragment>({
name: "PaymentGatewayInitializeSession",
apl: saleorApp.apl,
event: "PAYMENT_GATEWAY_INITIALIZE_SESSION",
query: UntypedPaymentGatewayInitializeSessionDocument,
webhookPath: "/api/webhooks/saleor/payment-gateway-initialize-session",
});
export default paymentGatewayInitializeSessionSyncWebhook.createHandler(
getSyncWebhookHandler(
"paymentGatewayInitializeSessionSyncWebhook",
PaymentGatewayInitializeSessionWebhookHandler,
ValidatePaymentGatewayInitializeSessionResponse,
(payload, errorResponse) => {
return {
message: errorResponse.message,
data: {
errors: errorResponse.errors,
paymentMethodsResponse: {},
publishableKey: "",
environment: "TEST",
},
} as const;
},
),
);

View File

@@ -0,0 +1,44 @@
import { uuidv7 } from "uuidv7";
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
import { type PageConfig } from "next";
import { saleorApp } from "@/saleor-app";
import {
UntypedTransactionCancelationRequestedDocument,
type TransactionCancelationRequestedEventFragment,
TransactionEventTypeEnum,
} from "generated/graphql";
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
import { TransactionCancelationRequestedWebhookHandler } from "@/modules/webhooks/transaction-cancelation-requested";
import ValidateTransactionCancelationRequestedResponse from "@/schemas/TransactionCancelationRequested/TransactionCancelationRequestedResponse.mjs";
export const config: PageConfig = {
api: {
bodyParser: false,
},
};
export const transactionCancelationRequestedSyncWebhook =
new SaleorSyncWebhook<TransactionCancelationRequestedEventFragment>({
name: "TransactionCancelationRequested",
apl: saleorApp.apl,
event: "TRANSACTION_CANCELATION_REQUESTED",
query: UntypedTransactionCancelationRequestedDocument,
webhookPath: "/api/webhooks/saleor/transaction-cancelation-requested",
});
export default transactionCancelationRequestedSyncWebhook.createHandler(
getSyncWebhookHandler(
"transactionCancelationRequestedSyncWebhook",
TransactionCancelationRequestedWebhookHandler,
ValidateTransactionCancelationRequestedResponse,
(_payload, errorResponse) => {
return {
message: errorResponse.message,
result: TransactionEventTypeEnum.CancelFailure,
// @todo consider making pspReference optional https://github.com/saleor/saleor/issues/12490
pspReference: uuidv7(),
} as const;
},
),
);

View File

@@ -0,0 +1,43 @@
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
import { type PageConfig } from "next";
import { uuidv7 } from "uuidv7";
import { saleorApp } from "@/saleor-app";
import {
UntypedTransactionChargeRequestedDocument,
type TransactionChargeRequestedEventFragment,
TransactionEventTypeEnum,
} from "generated/graphql";
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
import { TransactionChargeRequestedWebhookHandler } from "@/modules/webhooks/transaction-charge-requested";
import ValidateTransactionChargeRequestedResponse from "@/schemas/TransactionChargeRequested/TransactionChargeRequestedResponse.mjs";
export const config: PageConfig = {
api: {
bodyParser: false,
},
};
export const transactionChargeRequestedSyncWebhook =
new SaleorSyncWebhook<TransactionChargeRequestedEventFragment>({
name: "TransactionChargeRequested",
apl: saleorApp.apl,
event: "TRANSACTION_CHARGE_REQUESTED",
query: UntypedTransactionChargeRequestedDocument,
webhookPath: "/api/webhooks/saleor/transaction-charge-requested",
});
export default transactionChargeRequestedSyncWebhook.createHandler(
getSyncWebhookHandler(
"transactionChargeRequestedSyncWebhook",
TransactionChargeRequestedWebhookHandler,
ValidateTransactionChargeRequestedResponse,
(_payload, errorResponse) => {
return {
message: errorResponse.message,
result: TransactionEventTypeEnum.ChargeFailure,
// @todo consider making pspReference optional https://github.com/saleor/saleor/issues/12490
pspReference: uuidv7(),
} as const;
},
),
);

View File

@@ -0,0 +1,49 @@
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
import { type PageConfig } from "next";
import { uuidv7 } from "uuidv7";
import { saleorApp } from "@/saleor-app";
import {
UntypedTransactionInitializeSessionDocument,
type TransactionInitializeSessionEventFragment,
TransactionFlowStrategyEnum,
TransactionEventTypeEnum,
} from "generated/graphql";
import { TransactionInitializeSessionWebhookHandler } from "@/modules/webhooks/transaction-initialize-session";
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
import ValidateTransactionInitializeSessionResponse from "@/schemas/TransactionInitializeSession/TransactionInitializeSessionResponse.mjs";
export const config: PageConfig = {
api: {
bodyParser: false,
},
};
export const transactionInitializeSessionSyncWebhook =
new SaleorSyncWebhook<TransactionInitializeSessionEventFragment>({
name: "TransactionInitializeSession",
apl: saleorApp.apl,
event: "TRANSACTION_INITIALIZE_SESSION",
query: UntypedTransactionInitializeSessionDocument,
webhookPath: "/api/webhooks/saleor/transaction-initialize-session",
});
export default transactionInitializeSessionSyncWebhook.createHandler(
getSyncWebhookHandler(
"transactionInitializeSessionSyncWebhook",
TransactionInitializeSessionWebhookHandler,
ValidateTransactionInitializeSessionResponse,
(payload, errorResponse) => {
return {
amount: 0,
result:
payload.action.actionType === TransactionFlowStrategyEnum.Authorization
? TransactionEventTypeEnum.AuthorizationFailure
: TransactionEventTypeEnum.ChargeFailure,
message: errorResponse.message,
data: { errors: errorResponse.errors, paymentIntent: {}, publishableKey: "" },
// @todo consider making pspReference optional https://github.com/saleor/saleor/issues/12490
pspReference: uuidv7(),
} as const;
},
),
);

View File

@@ -0,0 +1,49 @@
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
import { type PageConfig } from "next";
import { uuidv7 } from "uuidv7";
import { saleorApp } from "@/saleor-app";
import {
UntypedTransactionProcessSessionDocument,
type TransactionProcessSessionEventFragment,
TransactionFlowStrategyEnum,
TransactionEventTypeEnum,
} from "generated/graphql";
import { TransactionProcessSessionWebhookHandler } from "@/modules/webhooks/transaction-process-session";
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
import ValidateTransactionProcessSessionResponse from "@/schemas/TransactionProcessSession/TransactionProcessSessionResponse.mjs";
export const config: PageConfig = {
api: {
bodyParser: false,
},
};
export const transactionProcessSessionSyncWebhook =
new SaleorSyncWebhook<TransactionProcessSessionEventFragment>({
name: "TransactionProcessSession",
apl: saleorApp.apl,
event: "TRANSACTION_PROCESS_SESSION",
query: UntypedTransactionProcessSessionDocument,
webhookPath: "/api/webhooks/saleor/transaction-process-session",
});
export default transactionProcessSessionSyncWebhook.createHandler(
getSyncWebhookHandler(
"transactionProcessSessionSyncWebhook",
TransactionProcessSessionWebhookHandler,
ValidateTransactionProcessSessionResponse,
(payload, errorResponse) => {
return {
amount: 0,
result:
payload.action.actionType === TransactionFlowStrategyEnum.Authorization
? TransactionEventTypeEnum.AuthorizationFailure
: TransactionEventTypeEnum.ChargeFailure,
message: errorResponse.message,
data: { errors: errorResponse.errors, paymentIntent: {}, publishableKey: "" },
// @todo consider making pspReference optional https://github.com/saleor/saleor/issues/12490
pspReference: uuidv7(),
} as const;
},
),
);

View File

@@ -0,0 +1,43 @@
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
import { type PageConfig } from "next";
import { uuidv7 } from "uuidv7";
import {
UntypedTransactionRefundRequestedDocument,
type TransactionRefundRequestedEventFragment,
TransactionEventTypeEnum,
} from "generated/graphql";
import { saleorApp } from "@/saleor-app";
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
import { TransactionRefundRequestedWebhookHandler } from "@/modules/webhooks/transaction-refund-requested";
import ValidateTransactionRefundRequestedResponse from "@/schemas/TransactionRefundRequesed/TransactionRefundRequestedResponse.mjs";
export const config: PageConfig = {
api: {
bodyParser: false,
},
};
export const transactionRefundRequestedSyncWebhook =
new SaleorSyncWebhook<TransactionRefundRequestedEventFragment>({
name: "TransactionRefundRequested",
apl: saleorApp.apl,
event: "TRANSACTION_REFUND_REQUESTED",
query: UntypedTransactionRefundRequestedDocument,
webhookPath: "/api/webhooks/saleor/transaction-refund-requested",
});
export default transactionRefundRequestedSyncWebhook.createHandler(
getSyncWebhookHandler(
"transactionRefundRequested",
TransactionRefundRequestedWebhookHandler,
ValidateTransactionRefundRequestedResponse,
(_payload, errorResponse) => {
return {
message: errorResponse.message,
result: TransactionEventTypeEnum.RefundFailure,
// @todo consider making pspReference optional https://github.com/saleor/saleor/issues/12490
pspReference: uuidv7(),
} as const;
},
),
);

View File

@@ -0,0 +1,58 @@
import { type NextApiRequest, type NextApiResponse } from "next";
import * as Sentry from "@sentry/nextjs";
import { createLogger, redactError } from "@/lib/logger";
import { BaseError, MissingSaleorApiUrlError, MissingAuthDataError } from "@/errors";
import {
MissingSignatureError,
UnexpectedTransactionEventReportError,
} from "@/modules/webhooks/stripe-webhook.errors";
import { stripeWebhookHandler } from "@/modules/webhooks/stripe-webhook";
export const config = {
api: {
bodyParser: false,
},
};
export default async function StripeWebhookHandler(
req: NextApiRequest,
res: NextApiResponse,
): Promise<void> {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
res.status(405).end("Method Not Allowed");
return;
}
const logger = createLogger({}, { msgPrefix: "[StripeWebhookHandler] " });
logger.info("Handler was called");
try {
await stripeWebhookHandler(req);
} catch (err) {
if (err instanceof BaseError) {
Sentry.captureException(err, { extra: { errors: err.errors } });
} else {
Sentry.captureException(err);
}
logger.error(redactError(err), "stripeWebhookHandler failed");
if (err instanceof MissingSaleorApiUrlError) {
return res.status(400).json(MissingSaleorApiUrlError.serialize(err));
}
if (err instanceof MissingAuthDataError) {
return res.status(412).json(MissingAuthDataError.serialize(err));
}
if (err instanceof MissingSignatureError) {
return res.status(400).json(MissingSignatureError.serialize(err));
}
if (err instanceof UnexpectedTransactionEventReportError) {
return res.status(500).json(UnexpectedTransactionEventReportError.serialize(err));
}
return res.status(500).json(BaseError.serialize(err));
}
logger.info("StripeWebhookHandler finished OK");
res.status(204).end();
return;
}

138
src/pages/config.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { useAppBridge, withAuthorization } from "@saleor/app-sdk/app-bridge";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { type NextPage } from "next";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { checkTokenPermissions } from "../modules/jwt/check-token-offline";
import {
type PaymentAppFormConfigEntry,
paymentAppCombinedFormSchema,
paymentAppConfigEntrySchema,
} from "../modules/payment-app-configuration/config-entry";
import { FetchError, useFetch, usePost } from "../lib/use-fetch";
import { AppLayout } from "@/modules/ui/templates/AppLayout";
import { FormInput } from "@/modules/ui/atoms/macaw-ui/FormInput";
const actionId = "payment-form";
const ConfigPage: NextPage = () => {
const { appBridgeState, appBridge } = useAppBridge();
const { token } = appBridgeState ?? {};
const hasPermissions = checkTokenPermissions(token, ["MANAGE_APPS", "MANAGE_SETTINGS"]);
const [isLoading, setIsLoading] = useState(true);
const formMethods = useForm<PaymentAppFormConfigEntry>({
resolver: zodResolver(paymentAppCombinedFormSchema),
defaultValues: {
secretKey: "",
configurationName: "",
},
});
const {
handleSubmit,
control,
reset,
setError,
formState: { isSubmitting },
resetField,
} = formMethods;
useFetch("/api/config", {
schema: paymentAppConfigEntrySchema,
onFinished: () => setIsLoading(false),
onSuccess: (data) => {
reset(data);
},
onError: async (err) => {
const message = err instanceof FetchError ? err.body : err.message;
await appBridge?.dispatch({
type: "notification",
payload: {
title: "Form error",
text: "Error while fetching initial form data",
status: "error",
actionId,
apiMessage: message,
},
});
},
});
const postForm = usePost("/api/config", {
schema: z.unknown(),
onSuccess: async () => {
await appBridge?.dispatch({
type: "notification",
payload: {
title: "Form saved",
text: "App configuration was saved successfully",
status: "success",
actionId: "payment-form",
},
});
},
onError: async (err) => {
const apiMessage = err instanceof FetchError ? err.body : err.name;
await appBridge?.dispatch({
type: "notification",
payload: {
title: "Form error",
text: err.message,
status: "error",
actionId,
apiMessage,
},
});
setError("root", { message: err.message });
},
});
if (!hasPermissions) {
return (
<AppLayout title="">
<Text variant="hero">{"You don't have permissions to configure this app"}</Text>
</AppLayout>
);
}
return (
<AppLayout title="">
<Box display="flex" flexDirection="column" gap={8}>
<FormProvider {...formMethods}>
<form
method="POST"
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={handleSubmit((data) => postForm(data))}
>
<Text variant="heading">Payment Provider settings</Text>
<Box display="flex" gap={6} alignItems="flex-end">
<FormInput control={control} label="API_KEY" name="secretKey" disabled={isLoading} />
<Button
variant="secondary"
size="small"
type="button"
onClick={() => resetField("secretKey")}
>
Reset
</Button>
</Box>
<div>
<Button type="submit" disabled={isLoading || isSubmitting}>
{isLoading ? "Loading" : isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
</form>
</FormProvider>
</Box>
</AppLayout>
);
};
export default withAuthorization()(ConfigPage);

View File

@@ -0,0 +1,26 @@
import { Text } from "@saleor/macaw-ui/next";
import { withAuthorization } from "@saleor/app-sdk/app-bridge";
import { AppLayout } from "@/modules/ui/templates/AppLayout";
import { StripeConfigurationForm } from "@/modules/ui/organisms/AddStripeConfigurationForm/AddStripeConfigurationForm";
const AddConfigurationPage = () => {
return (
<AppLayout
title="Stripe > Add configuration"
description={
<>
<Text as="p" variant="body" size="medium">
Create new Stripe configuration.
</Text>
<Text as="p" variant="body" size="medium">
Stripe Webhooks will be created automatically.
</Text>
</>
}
>
<StripeConfigurationForm configurationId={undefined} />
</AppLayout>
);
};
export default withAuthorization()(AddConfigurationPage);

View File

@@ -0,0 +1,33 @@
import { Text } from "@saleor/macaw-ui/next";
import { withAuthorization } from "@saleor/app-sdk/app-bridge";
import { useRouter } from "next/router";
import { AppLayout } from "@/modules/ui/templates/AppLayout";
import { StripeConfigurationForm } from "@/modules/ui/organisms/AddStripeConfigurationForm/AddStripeConfigurationForm";
const EditConfigurationPage = () => {
const router = useRouter();
if (typeof router.query.configurationId !== "string" || !router.query.configurationId) {
// TODO: Add loading
return <div />;
}
return (
<AppLayout
title="Stripe > Edit configuration"
description={
<>
<Text as="p" variant="body" size="medium">
Edit Stripe configuration.
</Text>
<Text as="p" variant="body" size="medium">
Note: Stripe Webhooks will be created automatically.
</Text>
</>
}
>
<StripeConfigurationForm configurationId={router.query.configurationId} />
</AppLayout>
);
};
export default withAuthorization()(EditConfigurationPage);

View File

@@ -0,0 +1,86 @@
import { useAppBridge, withAuthorization } from "@saleor/app-sdk/app-bridge";
import { Box, Text } from "@saleor/macaw-ui/next";
import { AppLayout, AppLayoutRow } from "@/modules/ui/templates/AppLayout";
import { trpcClient } from "@/modules/trpc/trpc-client";
import { getErrorHandler } from "@/modules/trpc/utils";
import { useFetchChannelsQuery } from "generated/graphql";
import { StripeConfigurationsList } from "@/modules/ui/organisms/StripeConfigurationList/StripeConfigurationList";
import { ChannelToConfigurationList } from "@/modules/ui/organisms/ChannelToConfigurationList/ChannelToConfigurationList";
import { Skeleton } from "@/modules/ui/atoms/Skeleton/Skeleton";
function ListConfigurationPage() {
const { appBridge } = useAppBridge();
const [allConfigurations, channelMappings] = trpcClient.useQueries((t) => [
t.paymentAppConfigurationRouter.paymentConfig.getAll(undefined, {
onError: getErrorHandler({
appBridge,
actionId: "list-all-configurations",
message: "Error while fetching the list of configurations",
title: "API Error",
}),
}),
t.paymentAppConfigurationRouter.mapping.getAll(undefined, {
onError: getErrorHandler({
appBridge,
actionId: "channel-mappings-get-all",
message: "Error while fetching the channel mappings",
title: "API Error",
}),
}),
]);
const [channels] = useFetchChannelsQuery();
const hasAnyConfigs = allConfigurations.data && allConfigurations.data.length > 0;
const hasAnyMappings = Object.values(channelMappings.data || {}).filter(Boolean).length > 0;
return (
<AppLayout title="Stripe">
<AppLayoutRow
title="Stripe Configurations"
description="Create Stripe configurations that can be later assigned to Saleor channels."
disabled={channelMappings.isLoading}
>
{allConfigurations.isLoading ? (
<Skeleton height={40} />
) : (
<StripeConfigurationsList configurations={allConfigurations.data || []} />
)}
</AppLayoutRow>
<AppLayoutRow
disabled={!hasAnyConfigs || channelMappings.isLoading}
title="Saleor channel mappings"
description={
<Box>
<Text as="p" variant="body" size="medium">
Assign Stripe configurations to Saleor channels.
</Text>
{!channelMappings.isLoading && !hasAnyMappings && (
<Box marginTop={6}>
<Text as="p" variant="body" size="medium" color="textCriticalDefault">
No channels have configurations assigned.
</Text>
<Text as="p" variant="body" size="medium" color="textCriticalDefault">
This means payments are not processed by Stripe.
</Text>
</Box>
)}
</Box>
}
>
{channelMappings.isLoading ? (
<Skeleton height={40} />
) : (
<ChannelToConfigurationList
disabled={!hasAnyConfigs || channelMappings.isLoading}
configurations={allConfigurations.data || []}
channelMappings={channelMappings.data || {}}
channels={channels.data?.channels || []}
/>
)}
</AppLayoutRow>
</AppLayout>
);
}
export default withAuthorization()(ListConfigurationPage);

136
src/pages/index.tsx Normal file
View File

@@ -0,0 +1,136 @@
import { type NextPage } from "next";
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useRouter } from "next/router";
import { FormInput } from "@/modules/ui/atoms/macaw-ui/FormInput";
const schema = z
.object({
saleorUrl: z.string().url(),
})
.required();
type FormValues = z.infer<typeof schema>;
const AddToSaleorForm = () => {
const formMethods = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
saleorUrl: "",
},
});
const {
handleSubmit,
control,
formState: { isSubmitting, errors },
} = formMethods;
return (
<FormProvider {...formMethods}>
<form
method="post"
noValidate
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={handleSubmit((values) => {
const manifestUrl = new URL("/api/manifest", window.location.origin).toString();
const redirectUrl = new URL(
`/dashboard/apps/install?manifestUrl=${manifestUrl}`,
values.saleorUrl,
).toString();
window.open(redirectUrl, "_blank");
})}
>
<Box display="flex" flexDirection="column" gap={2} marginTop={10}>
<FormInput
inputMode="url"
label="Saleor URL"
required
name="saleorUrl"
size="medium"
placeholder="https://…"
error={!!errors.saleorUrl}
helperText={errors.saleorUrl?.message || " "}
control={control}
/>
<Button type="submit" size="large" disabled={isSubmitting}>
Add to Saleor
</Button>
</Box>
</form>
</FormProvider>
);
};
const CopyManifest = () => {
const [copied, setCopied] = useState(false);
useEffect(() => {
const unsetCopied = () => {
setCopied(false);
};
if (copied) {
setTimeout(unsetCopied, 1750);
}
}, [copied]);
const handleClick = async () => {
await navigator.clipboard.writeText(window.location.origin + "/api/manifest");
setCopied(true);
};
return (
<Button variant="secondary" onClick={() => void handleClick()}>
{copied ? "Copied" : "Copy app manifest URL"}
</Button>
);
};
const IndexPage: NextPage = () => {
const { appBridgeState } = useAppBridge();
const [mounted, setMounted] = useState(false);
const router = useRouter();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
if (appBridgeState?.ready && mounted) {
void router.replace("/configurations/list");
return null;
}
return (
<Box display="flex" flexDirection="column" gap={2} __maxWidth="45rem">
<Text as="h1" size="large" variant="hero">
Welcome to Payment App Stripe 💰
</Text>
<Text as="p" size="large" variant="title">
Simplify your payment process and offer a seamless online shopping experience with Stripe
payment integration for Saleor.
</Text>
{!appBridgeState?.ready && (
<div>
<Text as="p" size="large" variant="bodyStrong">
Install this app in your Saleor Dashboard to proceed!
</Text>
{mounted && <AddToSaleorForm />}
</div>
)}
<CopyManifest />
</Box>
);
};
export default IndexPage;