Invoices, Klaviyo: Refactor to shared components (#989)

This commit is contained in:
Lukasz Ostrowski 2023-09-06 14:22:54 +02:00 committed by GitHub
parent 86bc946b3e
commit 4aee4e11f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 220 additions and 359 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-klaviyo": patch
---
Fixed error where config couldn't be saved

View file

@ -0,0 +1,5 @@
---
"saleor-app-invoices": minor
---
Replace text "loading" messages with skeletons

View file

@ -0,0 +1,5 @@
---
"saleor-app-invoices": minor
---
Redesigned app layout. Now app uses shared sections as other apps.

View file

@ -0,0 +1,5 @@
---
"saleor-app-klaviyo": minor
---
Improved app layout to match modern style.

View file

@ -1,13 +1,18 @@
const { withSentryConfig } = require("@sentry/nextjs"); const { withSentryConfig } = require("@sentry/nextjs");
const isSentryPropertiesInEnvironment = Boolean( const isSentryPropertiesInEnvironment = Boolean(
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG,
); );
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"], transpilePackages: [
"@saleor/apps-shared",
"@saleor/apps-ui",
"@saleor/react-hook-form-macaw",
"@saleor/trpc",
],
}; };
const configWithSentry = withSentryConfig( const configWithSentry = withSentryConfig(
@ -23,9 +28,7 @@ const configWithSentry = withSentryConfig(
tunnelRoute: "/monitoring", tunnelRoute: "/monitoring",
hideSourceMaps: true, hideSourceMaps: true,
disableLogger: true, disableLogger: true,
} },
); );
module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig; module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig;

View file

@ -15,7 +15,9 @@
"@hookform/resolvers": "^3.1.0", "@hookform/resolvers": "^3.1.0",
"@saleor/app-sdk": "0.41.1", "@saleor/app-sdk": "0.41.1",
"@saleor/apps-shared": "workspace:*", "@saleor/apps-shared": "workspace:*",
"@saleor/apps-ui": "workspace:*",
"@saleor/macaw-ui": "0.8.0-pre.127", "@saleor/macaw-ui": "0.8.0-pre.127",
"@saleor/trpc": "workspace:*",
"@sentry/nextjs": "7.67.0", "@sentry/nextjs": "7.67.0",
"@tanstack/react-query": "4.29.19", "@tanstack/react-query": "4.29.19",
"@trpc/client": "10.34.0", "@trpc/client": "10.34.0",

View file

@ -1,25 +0,0 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui/next";
import { memo, useEffect } from "react";
// todo move to shared
export function ThemeSynchronizer() {
const { appBridgeState } = useAppBridge();
const { setTheme } = useTheme();
useEffect(() => {
if (!setTheme || !appBridgeState?.theme) {
return;
}
if (appBridgeState.theme === "light") {
setTheme("defaultLight");
}
if (appBridgeState.theme === "dark") {
setTheme("defaultDark");
}
}, [appBridgeState?.theme, setTheme]);
return null;
}

View file

@ -53,6 +53,8 @@ describe("appConfigurationRouter", function () {
token: "TOKEN", token: "TOKEN",
saleorApiUrl: "http://localhost:8000/graphql/", saleorApiUrl: "http://localhost:8000/graphql/",
appId: "app", appId: "app",
ssr: true,
baseUrl: "localhost:3000",
}) })
.upsertChannelOverride({ .upsertChannelOverride({
channelSlug: "test", channelSlug: "test",

View file

@ -1,13 +1,14 @@
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import React, { useCallback, useEffect } from "react";
import { Box, Button, Input, Text } from "@saleor/macaw-ui/next";
import { SellerAddress } from "../address";
import { trpcClient } from "../../trpc/trpc-client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useDashboardNotification } from "@saleor/apps-shared"; import { useDashboardNotification } from "@saleor/apps-shared";
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
import { Box, Button, Input, Text } from "@saleor/macaw-ui/next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useCallback } from "react";
import { z } from "zod";
import { trpcClient } from "../../trpc/trpc-client";
import { SellerAddress } from "../address";
import { AddressV2Schema, AddressV2Shape } from "../schema-v2/app-config-schema.v2"; import { AddressV2Schema, AddressV2Shape } from "../schema-v2/app-config-schema.v2";
type Props = { type Props = {
@ -57,12 +58,29 @@ export const AddressForm = (props: Props & InnerFormProps) => {
}); });
return ( return (
<form <Layout.AppSectionCard
as="form"
footer={
<ButtonsBox>
<Button
variant="tertiary"
onClick={(e) => {
e.stopPropagation();
props.onCancel();
}}
>
<Text color={"textNeutralSubdued"}>Cancel</Text>
</Button>
<Button type="submit" variant="primary">
Save
</Button>
</ButtonsBox>
}
onSubmit={handleSubmit((data, event) => { onSubmit={handleSubmit((data, event) => {
return props.onSubmit(data); return props.onSubmit(data);
})} })}
> >
<Box display={"grid"} gap={3} marginBottom={9}> <Box display={"grid"} gap={3}>
{fieldsBlock1.map((fieldName) => ( {fieldsBlock1.map((fieldName) => (
<Controller <Controller
key={fieldName} key={fieldName}
@ -128,21 +146,7 @@ export const AddressForm = (props: Props & InnerFormProps) => {
/> />
))} ))}
</Box> </Box>
<Box display={"grid"} justifyContent={"flex-end"} gap={1.5} gridAutoFlow={"column"}> </Layout.AppSectionCard>
<Button
variant="tertiary"
onClick={(e) => {
e.stopPropagation();
props.onCancel();
}}
>
<Text color={"textNeutralSubdued"}>Cancel</Text>
</Button>
<Button type="submit" variant="primary">
Save
</Button>
</Box>
</form>
); );
}; };
@ -164,9 +168,6 @@ export const ConnectedAddressForm = (props: Props) => {
const { push } = useRouter(); const { push } = useRouter();
const addressData =
channelOverrideConfigQuery.data && channelOverrideConfigQuery.data[props.channelSlug];
const submitHandler = useCallback( const submitHandler = useCallback(
async (data: AddressV2Shape) => { async (data: AddressV2Shape) => {
return upsertConfigMutation.mutate({ return upsertConfigMutation.mutate({
@ -174,7 +175,7 @@ export const ConnectedAddressForm = (props: Props) => {
channelSlug: props.channelSlug, channelSlug: props.channelSlug,
}); });
}, },
[props.channelSlug, upsertConfigMutation] [props.channelSlug, upsertConfigMutation],
); );
const onCancelHandler = useCallback(() => { const onCancelHandler = useCallback(() => {
@ -182,7 +183,7 @@ export const ConnectedAddressForm = (props: Props) => {
}, [push]); }, [push]);
if (channelOverrideConfigQuery.isLoading) { if (channelOverrideConfigQuery.isLoading) {
return <Text color={"textNeutralSubdued"}>Loading</Text>; return <SkeletonLayout.Section />;
} }
return ( return (

View file

@ -1,21 +1,13 @@
import { Box, Text } from "@saleor/macaw-ui/next"; import { Box, Text } from "@saleor/macaw-ui/next";
import { DefaultShopAddress } from "../../shop-info/ui/default-shop-address"; import { DefaultShopAddress } from "../../shop-info/ui/default-shop-address";
import { AppSection } from "../../ui/AppSection";
import { PerChannelConfigList } from "../../channels/ui/per-channel-config-list"; import { PerChannelConfigList } from "../../channels/ui/per-channel-config-list";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Layout } from "@saleor/apps-ui";
export const AppConfigView = () => { export const AppConfigView = () => {
const { appBridge } = useAppBridge(); const { appBridge } = useAppBridge();
return ( return (
<Box>
<Box
display={"grid"}
justifyContent={"space-between"}
__gridTemplateColumns={"400px 400px"}
gap={10}
__marginBottom={"200px"}
>
<Box> <Box>
<Text as={"h1"} variant={"hero"} marginBottom={5}> <Text as={"h1"} variant={"hero"} marginBottom={5}>
Configuration Configuration
@ -24,6 +16,10 @@ export const AppConfigView = () => {
The Invoices App will generate invoices for each order, for which{" "} The Invoices App will generate invoices for each order, for which{" "}
<code>INVOICE_REQUESTED</code> event will be triggered <code>INVOICE_REQUESTED</code> event will be triggered
</Text> </Text>
<Layout.AppSection
marginTop={10}
heading={"Default address of the shop"}
sideContent={
<Text as={"p"} marginBottom={1.5}> <Text as={"p"} marginBottom={1.5}>
By default it will use{" "} By default it will use{" "}
<a <a
@ -32,7 +28,7 @@ export const AppConfigView = () => {
appBridge?.dispatch( appBridge?.dispatch(
actions.Redirect({ actions.Redirect({
to: "/site-settings", to: "/site-settings",
}) }),
); );
}} }}
> >
@ -40,22 +36,25 @@ export const AppConfigView = () => {
</a>{" "} </a>{" "}
address, but each channel can be configured separately address, but each channel can be configured separately
</Text> </Text>
</Box> }
<Box> >
<DefaultShopAddress /> <DefaultShopAddress />
</Box> </Layout.AppSection>
</Box>
<AppSection <Layout.AppSection
includePadding={true} marginTop={10}
heading={"Shop address per channel"} heading={"Shop address per channel"}
mainContent={<PerChannelConfigList />}
sideContent={ sideContent={
<Text> <Text>
Configure custom billing address for each channel. If not set, default shop address will Configure custom billing address for each channel. If not set, default shop address will
be used be used
</Text> </Text>
} }
/> >
<Layout.AppSectionCard>
<PerChannelConfigList />
</Layout.AppSectionCard>
</Layout.AppSection>
</Box> </Box>
); );
}; };

View file

@ -1,9 +1,9 @@
import { Box, ChevronRightIcon, Text, Button } from "@saleor/macaw-ui/next"; import { Box, ChevronRightIcon, Text, Button } from "@saleor/macaw-ui/next";
import { AppSection } from "../../ui/AppSection";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ConnectedAddressForm } from "../ui/address-form"; import { ConnectedAddressForm } from "../ui/address-form";
import { trpcClient } from "../../trpc/trpc-client"; import { trpcClient } from "../../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared"; import { useDashboardNotification } from "@saleor/apps-shared";
import { Layout } from "@saleor/apps-ui";
export const ChannelConfigView = () => { export const ChannelConfigView = () => {
const { const {
@ -15,7 +15,7 @@ export const ChannelConfigView = () => {
const { notifySuccess } = useDashboardNotification(); const { notifySuccess } = useDashboardNotification();
if (!channel) { if (!channel) {
return null; return null; // TODO: error
} }
return ( return (
@ -29,10 +29,9 @@ export const ChannelConfigView = () => {
<Text>{channel}</Text> <Text>{channel}</Text>
</Box> </Box>
</Box> </Box>
<AppSection <Layout.AppSection
includePadding={true} includePadding={true}
heading={"Shop address per channel"} heading={"Shop address per channel"}
mainContent={<ConnectedAddressForm channelSlug={channel as string} />}
sideContent={ sideContent={
<Box> <Box>
<Text marginBottom={5} as={"p"}> <Text marginBottom={5} as={"p"}>
@ -51,7 +50,9 @@ export const ChannelConfigView = () => {
</Button> </Button>
</Box> </Box>
} }
/> >
<ConnectedAddressForm channelSlug={channel as string} />
</Layout.AppSection>
</Box> </Box>
); );
}; };

View file

@ -1,6 +1,7 @@
import { Box, Text, Chip, Button } from "@saleor/macaw-ui/next"; import { Box, Text, Chip, Button } from "@saleor/macaw-ui/next";
import { trpcClient } from "../../trpc/trpc-client"; import { trpcClient } from "../../trpc/trpc-client";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { SkeletonLayout } from "@saleor/apps-ui";
const defaultAddressChip = ( const defaultAddressChip = (
<Chip __display={"inline-block"} size={"large"}> <Chip __display={"inline-block"} size={"large"}>
@ -17,7 +18,7 @@ export const PerChannelConfigList = () => {
const { push } = useRouter(); const { push } = useRouter();
if (shopChannelsQuery.isLoading || channelsOverridesQuery.isLoading) { if (shopChannelsQuery.isLoading || channelsOverridesQuery.isLoading) {
return <Text color={"textNeutralSubdued"}>Loading...</Text>; return <SkeletonLayout.Section />;
} }
const renderChannelAddress = (slug: string) => { const renderChannelAddress = (slug: string) => {

View file

@ -2,30 +2,31 @@ import { Box, Text, Button } from "@saleor/macaw-ui/next";
import { trpcClient } from "../../trpc/trpc-client"; import { trpcClient } from "../../trpc/trpc-client";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
const Wrapper = ({ children }: PropsWithChildren<{}>) => { const Wrapper = ({ children }: PropsWithChildren<{}>) => {
const { appBridge } = useAppBridge(); const { appBridge } = useAppBridge();
return ( return (
<Box> <Layout.AppSectionCard
<Box display={"flex"} justifyContent={"space-between"} marginBottom={5}> footer={
<Text variant={"heading"}>Default address of the shop</Text> <ButtonsBox>
<Button <Button
size={"small"}
variant={"tertiary"}
onClick={() => { onClick={() => {
appBridge?.dispatch( appBridge?.dispatch(
actions.Redirect({ actions.Redirect({
to: "/site-settings", to: "/site-settings",
}) }),
); );
}} }}
> >
<Text color={"textNeutralSubdued"}>Edit</Text> Edit
</Button> </Button>
</Box> </ButtonsBox>
}
>
<Box>{children}</Box> <Box>{children}</Box>
</Box> </Layout.AppSectionCard>
); );
}; };
@ -46,7 +47,7 @@ export const DefaultShopAddress = () => {
if (isLoading) { if (isLoading) {
return ( return (
<Wrapper> <Wrapper>
<Text color={"textNeutralSubdued"}>Loading...</Text> <SkeletonLayout.Section />
</Wrapper> </Wrapper>
); );
} }
@ -70,6 +71,9 @@ export const DefaultShopAddress = () => {
if (data && data.companyAddress) { if (data && data.companyAddress) {
return ( return (
<Wrapper> <Wrapper>
<Text as="p" marginBottom={4} variant="caption">
This address will be used if custom address is not set for channel
</Text>
<Text size={"small"} as={"p"}> <Text size={"small"} as={"p"}>
{data.companyAddress.companyName} {data.companyAddress.companyName}
</Text> </Text>

View file

@ -1,34 +1,13 @@
import { httpBatchLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next"; import { createTRPCNext } from "@trpc/next";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const"; import { createHttpBatchLink } from "@saleor/trpc";
import { appBridgeInstance } from "../../pages/_app"; import { appBridgeInstance } from "../../pages/_app";
import { AppRouter } from "./trpc-app-router"; import { AppRouter } from "./trpc-app-router";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export const trpcClient = createTRPCNext<AppRouter>({ export const trpcClient = createTRPCNext<AppRouter>({
config({ ctx }) { config() {
return { return {
links: [ links: [createHttpBatchLink(appBridgeInstance)],
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {
return {
/**
* Attach headers from app to client requests, so tRPC can add them to context
*/
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token,
[SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl,
};
},
}),
],
// queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } }, // queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } },
}; };
}, },

View file

@ -1,13 +0,0 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
import { inferAsyncReturnType } from "@trpc/server";
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
return {
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,
saleorApiUrl: req.headers[SALEOR_API_URL_HEADER] as string | undefined,
appId: undefined as undefined | string,
};
};
export type TrpcContext = inferAsyncReturnType<typeof createTrpcContext>;

View file

@ -1,6 +1,7 @@
import { initTRPC } from "@trpc/server"; import { initTRPC } from "@trpc/server";
import { TrpcContext } from "./trpc-context";
import { Permission } from "@saleor/app-sdk/types"; import { Permission } from "@saleor/app-sdk/types";
import { TrpcContext } from "@saleor/trpc";
interface Meta { interface Meta {
requiredClientPermissions?: Permission[]; requiredClientPermissions?: Permission[];

View file

@ -1,36 +0,0 @@
import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next";
import { ReactNode } from "react";
// todo move to shared
export const AppSection = ({
heading,
sideContent,
mainContent,
includePadding = false,
...props
}: PropsWithBox<{
heading: string;
sideContent?: ReactNode;
mainContent: ReactNode;
includePadding?: boolean;
}>) => {
return (
<Box as="section" __gridTemplateColumns={"400px auto"} display={"grid"} gap={10} {...props}>
<Box>
<Text as="h2" variant={"heading"} size={"large"} marginBottom={1.5}>
{heading}
</Text>
{sideContent}
</Box>
<Box
borderStyle={"solid"}
borderColor={"neutralPlain"}
borderWidth={1}
padding={includePadding ? 5 : 0}
borderRadius={4}
>
{mainContent}
</Box>
</Box>
);
};

View file

@ -6,12 +6,11 @@ import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import { AppProps } from "next/app"; import { AppProps } from "next/app";
import { NoSSRWrapper } from "@saleor/apps-shared"; import { NoSSRWrapper, ThemeSynchronizer } from "@saleor/apps-shared";
import { trpcClient } from "../modules/trpc/trpc-client"; import { trpcClient } from "../modules/trpc/trpc-client";
import { Box, ThemeProvider } from "@saleor/macaw-ui/next"; import { Box, ThemeProvider } from "@saleor/macaw-ui/next";
import { NextPage } from "next"; import { NextPage } from "next";
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
/** /**
* Ensure instance is a singleton. * Ensure instance is a singleton.

View file

@ -1,7 +1,7 @@
import * as trpcNext from "@trpc/server/adapters/next"; import * as trpcNext from "@trpc/server/adapters/next";
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
import { appRouter } from "../../../modules/trpc/trpc-app-router"; import { appRouter } from "../../../modules/trpc/trpc-app-router";
import { createLogger } from "@saleor/apps-shared"; import { createLogger } from "@saleor/apps-shared";
import { createTrpcContext } from "@saleor/trpc";
const logger = createLogger({ name: "tRPC error" }); const logger = createLogger({ name: "tRPC error" });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,4 +0,0 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,8 +1,3 @@
body {
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
a { a {
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;

View file

@ -16,6 +16,7 @@
"@material-ui/lab": "4.0.0-alpha.61", "@material-ui/lab": "4.0.0-alpha.61",
"@saleor/app-sdk": "0.41.1", "@saleor/app-sdk": "0.41.1",
"@saleor/apps-shared": "workspace:*", "@saleor/apps-shared": "workspace:*",
"@saleor/apps-ui": "workspace:*",
"@saleor/macaw-ui": "0.8.0-pre.127", "@saleor/macaw-ui": "0.8.0-pre.127",
"@sentry/nextjs": "7.67.0", "@sentry/nextjs": "7.67.0",
"@urql/exchange-auth": "^2.1.4", "@urql/exchange-auth": "^2.1.4",

View file

@ -1,25 +0,0 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui/next";
import { useEffect } from "react";
// todo move to shared
export function ThemeSynchronizer() {
const { appBridgeState } = useAppBridge();
const { setTheme } = useTheme();
useEffect(() => {
if (!setTheme || !appBridgeState?.theme) {
return;
}
if (appBridgeState.theme === "light") {
setTheme("defaultLight");
}
if (appBridgeState.theme === "dark") {
setTheme("defaultDark");
}
}, [appBridgeState?.theme, setTheme]);
return null;
}

View file

@ -1,17 +0,0 @@
import { Box } from "@saleor/macaw-ui/next";
import { PropsWithChildren } from "react";
export function AppColumnsLayout({ children }: PropsWithChildren<{}>) {
return (
<Box
display={"grid"}
__gridTemplateColumns={"280px auto 280px"}
gap={4}
__maxWidth={"1180px"}
marginX={"auto"}
marginY={0}
>
{children}
</Box>
);
}

View file

@ -1,11 +1,8 @@
import "@saleor/macaw-ui/next/style";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge"; import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import React from "react"; import { NoSSRWrapper, ThemeSynchronizer } from "@saleor/apps-shared";
import { AppProps } from "next/app";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import { Box, ThemeProvider } from "@saleor/macaw-ui/next"; import { Box, ThemeProvider } from "@saleor/macaw-ui/next";
import { NoSSRWrapper } from "@saleor/apps-shared"; import "@saleor/macaw-ui/next/style";
import { ThemeSynchronizer } from "../hooks/theme-synchronizer"; import { AppProps } from "next/app";
/** /**
* Ensure instance is a singleton. * Ensure instance is a singleton.
@ -18,7 +15,9 @@ function SaleorApp({ Component, pageProps }: AppProps) {
<AppBridgeProvider appBridgeInstance={appBridgeInstance}> <AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<ThemeProvider> <ThemeProvider>
<ThemeSynchronizer /> <ThemeSynchronizer />
<Box padding={10}>
<Component {...pageProps} /> <Component {...pageProps} />
</Box>
</ThemeProvider> </ThemeProvider>
</AppBridgeProvider> </AppBridgeProvider>
</NoSSRWrapper> </NoSSRWrapper>

View file

@ -3,13 +3,8 @@ import { Head, Html, Main, NextScript } from "next/document";
export default function Document() { export default function Document() {
return ( return (
<Html> <Html>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<Head /> <Head />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<body> <body>
<Main /> <Main />
<NextScript /> <NextScript />

View file

@ -61,12 +61,20 @@ const handler: NextProtectedApiHandler = async (request, res, ctx) => {
data: await getAppSettings(settings), data: await getAppSettings(settings),
}); });
case "POST": { case "POST": {
await settings.set((request.body as PostRequestBody).data); try {
await settings.set((JSON.parse(request.body) as PostRequestBody).data);
return res.json({ return res.json({
success: true, success: true,
data: await getAppSettings(settings), data: await getAppSettings(settings),
}); });
} catch (e) {
console.error(e);
return res.json({
success: false,
});
}
} }
default: default:
return res.status(405).end(); return res.status(405).end();

View file

@ -8,17 +8,17 @@ import { orderCreatedWebhook } from "./webhooks/order-created";
import { orderFullyPaidWebhook } from "./webhooks/order-fully-paid"; import { orderFullyPaidWebhook } from "./webhooks/order-fully-paid";
const handler = createManifestHandler({ const handler = createManifestHandler({
async manifestFactory(context): Promise<AppManifest> { async manifestFactory({ appBaseUrl }): Promise<AppManifest> {
const { appBaseUrl } = context; const iframeBaseUrl = process.env.APP_IFRAME_BASE_URL ?? appBaseUrl;
const apiBaseURL = process.env.APP_API_BASE_URL ?? appBaseUrl;
return { return {
about: about: "Klaviyo integration allows sending Klaviyo notifications on Saleor events.",
"Klaviyo integration allows sending Klaviyo notifications on Saleor events.", appUrl: iframeBaseUrl,
appUrl: appBaseUrl,
author: "Saleor Commerce", author: "Saleor Commerce",
brand: { brand: {
logo: { logo: {
default: `${context.appBaseUrl}/logo.png`, default: `${apiBaseURL}/logo.png`,
}, },
}, },
dataPrivacyUrl: "https://saleor.io/legal/privacy/", dataPrivacyUrl: "https://saleor.io/legal/privacy/",
@ -27,7 +27,7 @@ const handler = createManifestHandler({
name: "Klaviyo", name: "Klaviyo",
permissions: ["MANAGE_USERS", "MANAGE_ORDERS"], permissions: ["MANAGE_USERS", "MANAGE_ORDERS"],
supportUrl: "https://github.com/saleor/apps/discussions", supportUrl: "https://github.com/saleor/apps/discussions",
tokenTargetUrl: `${appBaseUrl}/api/register`, tokenTargetUrl: `${apiBaseURL}/api/register`,
version: pkg.version, version: pkg.version,
webhooks: [ webhooks: [
customerCreatedWebhook.getWebhookManifest(appBaseUrl), customerCreatedWebhook.getWebhookManifest(appBaseUrl),

View file

@ -1,47 +1,31 @@
import { useAppBridge, withAuthorization } from "@saleor/app-sdk/app-bridge"; import { useAppBridge, useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const"; import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { ChangeEvent, SyntheticEvent, useEffect, useState } from "react"; import { ChangeEvent, SyntheticEvent, useEffect, useState } from "react";
import { useAppApi } from "../hooks/useAppApi";
import { AppColumnsLayout } from "../lib/ui/app-columns-layout";
import { useDashboardNotification } from "@saleor/apps-shared"; import { useDashboardNotification } from "@saleor/apps-shared";
import { Box, BoxProps, Text, Input, Button } from "@saleor/macaw-ui/next"; import { Breadcrumbs, ButtonsBox, Layout, SkeletonLayout, TextLink } from "@saleor/apps-ui";
import { Box, Button, Input, Text } from "@saleor/macaw-ui/next";
import { useAppApi } from "../hooks/useAppApi";
interface ConfigurationField { interface ConfigurationField {
key: string; key: string;
value: string; value: string;
} }
function Section(props: BoxProps) {
return <Box padding={4} {...props} />;
}
function Instructions() { function Instructions() {
const { appBridge } = useAppBridge();
const openExternalUrl = (url: string) => {
// eslint-disable-next-line
appBridge?.dispatch({
type: "redirect",
payload: {
newContext: true,
actionId: "redirect_from_klaviyo_app",
to: url,
},
});
};
return ( return (
<Section> <Box>
<Text as={"h3"} variant="heading"> <Text as={"h3"} variant="heading" marginY={4}>
How to set up How to set up
</Text> </Text>
<Text as="p">App will send events as Klaviyo metrics each time Saleor Event occurs.</Text> <Text as="p" marginBottom={2}>
<Text as="p"> App will send events as Klaviyo metrics each time Saleor Event occurs.
</Text>
<Text as="p" marginBottom={2}>
When first metric is sent, it should be available in Klaviyo to build on top of. When first metric is sent, it should be available in Klaviyo to build on top of.
</Text> </Text>
<Text as="p"> <Text as="p" marginBottom={4}>
Metric name can be customized, PUBLIC_TOKEN must be provided to enable the app. Metric name can be customized, PUBLIC_TOKEN must be provided to enable the app.
</Text> </Text>
<Text as={"h3"} variant="heading"> <Text as={"h3"} variant="heading">
@ -49,16 +33,9 @@ function Instructions() {
</Text> </Text>
<ul> <ul>
<li> <li>
<a <TextLink href="https://github.com/saleor/saleor-app-klaviyo" newTab>
onClick={(e) => {
e.preventDefault();
openExternalUrl("https://github.com/saleor/saleor-app-klaviyo");
}}
href="https://github.com/saleor/saleor-app-klaviyo"
>
Visit repository & readme Visit repository & readme
</a> </TextLink>
</li> </li>
</ul> </ul>
<Text as={"h3"} variant="heading"> <Text as={"h3"} variant="heading">
@ -66,47 +43,28 @@ function Instructions() {
</Text> </Text>
<ul> <ul>
<li> <li>
<a <TextLink
onClick={(e) => {
e.preventDefault();
openExternalUrl(
"https://help.klaviyo.com/hc/en-us/articles/115005062267-How-to-Manage-Your-Account-s-API-Keys"
);
}}
href="https://help.klaviyo.com/hc/en-us/articles/115005062267-How-to-Manage-Your-Account-s-API-Keys" href="https://help.klaviyo.com/hc/en-us/articles/115005062267-How-to-Manage-Your-Account-s-API-Keys"
newTab
> >
Read about public tokens Read about public tokens
</a> </TextLink>
</li> </li>
<li> <li>
<a <TextLink href="https://www.klaviyo.com/account#api-keys-tab" newTab>
onClick={(e) => {
e.preventDefault();
openExternalUrl("https://www.klaviyo.com/account#api-keys-tab");
}}
href="https://www.klaviyo.com/account#api-keys-tab"
>
Get public token here Get public token here
</a> </TextLink>
</li> </li>
<li> <li>
<a <TextLink
onClick={(e) => {
e.preventDefault();
openExternalUrl(
"https://help.klaviyo.com/hc/en-us/articles/115005076787-Guide-to-Managing-Your-Metrics"
);
}}
href="https://help.klaviyo.com/hc/en-us/articles/115005076787-Guide-to-Managing-Your-Metrics" href="https://help.klaviyo.com/hc/en-us/articles/115005076787-Guide-to-Managing-Your-Metrics"
newTab
> >
Read about metrics Read about metrics
</a> </TextLink>
</li> </li>
</ul> </ul>
</Section> </Box>
); );
} }
@ -114,6 +72,7 @@ function Configuration() {
const { appBridgeState } = useAppBridge(); const { appBridgeState } = useAppBridge();
const { notifySuccess, notifyError } = useDashboardNotification(); const { notifySuccess, notifyError } = useDashboardNotification();
const [configuration, setConfiguration] = useState<ConfigurationField[]>(); const [configuration, setConfiguration] = useState<ConfigurationField[]>();
const authenticatedFetch = useAuthenticatedFetch() as typeof window.fetch;
const { data: configurationData, error } = useAppApi({ const { data: configurationData, error } = useAppApi({
url: "/api/configuration", url: "/api/configuration",
@ -131,13 +90,8 @@ function Configuration() {
const handleSubmit = (event: SyntheticEvent) => { const handleSubmit = (event: SyntheticEvent) => {
event.preventDefault(); event.preventDefault();
fetch("/api/configuration", { authenticatedFetch("/api/configuration", {
method: "POST", method: "POST",
headers: [
["content-type", "application/json"],
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
],
body: JSON.stringify({ data: configuration }), body: JSON.stringify({ data: configuration }),
}) })
.then(async (response) => { .then(async (response) => {
@ -149,7 +103,7 @@ function Configuration() {
}) })
.catch(async () => { .catch(async () => {
await notifyError( await notifyError(
"Configuration update failed. Ensure fields are filled correctly and you have MANAGE_APPS permission" "Configuration update failed. Ensure fields are filled correctly and you have MANAGE_APPS permission",
); );
}); });
}; };
@ -158,7 +112,7 @@ function Configuration() {
const { name, value } = event.target as HTMLInputElement; const { name, value } = event.target as HTMLInputElement;
setConfiguration((prev) => setConfiguration((prev) =>
prev!.map((prevField) => (prevField.key === name ? { ...prevField, value } : prevField)) prev!.map((prevField) => (prevField.key === name ? { ...prevField, value } : prevField)),
); );
}; };
@ -192,31 +146,36 @@ function Configuration() {
} }
if (configuration === undefined) { if (configuration === undefined) {
return <p>Loading...</p>; return <SkeletonLayout.Section />;
} }
return ( return (
<AppColumnsLayout> <Box>
<div /> <Breadcrumbs marginBottom={10}>
<Section> <Breadcrumbs.Item>Configuration</Breadcrumbs.Item>
<Text variant={"heading"} marginBottom={4} as={"h2"}> </Breadcrumbs>
Klaviyo configuration <Layout.AppSection heading="Set up integration" sideContent={<Instructions />}>
</Text> <Layout.AppSectionCard
<Box as={"form"} display={"grid"} gap={4} gridAutoFlow={"row"} onSubmit={handleSubmit}> as={"form"}
onSubmit={handleSubmit}
footer={
<ButtonsBox>
<Button type="submit" variant="primary">
Save
</Button>
</ButtonsBox>
}
>
<Box display={"grid"} gap={4} gridAutoFlow={"row"}>
{configuration!.map(({ key, value }) => ( {configuration!.map(({ key, value }) => (
<div key={key}> <div key={key}>
<Input label={key} name={key} onChange={onChange} value={value} /> <Input label={key} name={key} onChange={onChange} value={value} />
</div> </div>
))} ))}
<div>
<Button type="submit" variant="primary">
Save
</Button>
</div>
</Box> </Box>
</Section> </Layout.AppSectionCard>
<Instructions /> </Layout.AppSection>
</AppColumnsLayout> </Box>
); );
} }

View file

@ -18,7 +18,9 @@
"SENTRY_PROJECT", "SENTRY_PROJECT",
"SENTRY_AUTH_TOKEN", "SENTRY_AUTH_TOKEN",
"NEXT_PUBLIC_VERCEL_ENV", "NEXT_PUBLIC_VERCEL_ENV",
"SENTRY_ENVIRONMENT" "SENTRY_ENVIRONMENT",
"APP_API_BASE_URL",
"APP_IFRAME_BASE_URL"
] ]
} }
} }

View file

@ -67,7 +67,8 @@
"vals", "vals",
"urql", "urql",
"Protos", "Protos",
"pino" "pino",
"IFRAME"
], ],
"ignorePaths": [ "ignorePaths": [
"node_modules", "node_modules",

View file

@ -18,6 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.26.2", "@changesets/cli": "^2.26.2",
"@sentry/cli": "2.20.6",
"@types/node": "18.15.3", "@types/node": "18.15.3",
"cspell": "^7.2.0", "cspell": "^7.2.0",
"eslint": "8.46.0", "eslint": "8.46.0",
@ -27,8 +28,7 @@
"next": "13.4.8", "next": "13.4.8",
"prettier": "3.0.3", "prettier": "3.0.3",
"syncpack": "10.9.3", "syncpack": "10.9.3",
"turbo": "1.10.12", "turbo": "1.10.12"
"@sentry/cli": "2.20.6"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"

View file

@ -642,9 +642,15 @@ importers:
'@saleor/apps-shared': '@saleor/apps-shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
'@saleor/apps-ui':
specifier: workspace:*
version: link:../../packages/ui
'@saleor/macaw-ui': '@saleor/macaw-ui':
specifier: 0.8.0-pre.127 specifier: 0.8.0-pre.127
version: 0.8.0-pre.127(@types/react-dom@18.2.5)(@types/react@18.2.5)(react-dom@18.2.0)(react@18.2.0) version: 0.8.0-pre.127(@types/react-dom@18.2.5)(@types/react@18.2.5)(react-dom@18.2.0)(react@18.2.0)
'@saleor/trpc':
specifier: workspace:*
version: link:../../packages/trpc
'@sentry/nextjs': '@sentry/nextjs':
specifier: 7.67.0 specifier: 7.67.0
version: 7.67.0(next@13.4.8)(react@18.2.0) version: 7.67.0(next@13.4.8)(react@18.2.0)
@ -787,6 +793,9 @@ importers:
'@saleor/apps-shared': '@saleor/apps-shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
'@saleor/apps-ui':
specifier: workspace:*
version: link:../../packages/ui
'@saleor/macaw-ui': '@saleor/macaw-ui':
specifier: 0.8.0-pre.127 specifier: 0.8.0-pre.127
version: 0.8.0-pre.127(@types/react-dom@18.2.5)(@types/react@18.2.5)(react-dom@18.2.0)(react@18.2.0) version: 0.8.0-pre.127(@types/react-dom@18.2.5)(@types/react@18.2.5)(react-dom@18.2.0)(react@18.2.0)