├── .env.example ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .graphqlrc.yml ├── .husky └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc ├── CODEOWNERS ├── LICENSE ├── README.md ├── components ├── AccessWarning │ └── AccessWarning.tsx └── LoadingPage │ ├── LoadingPage.tsx │ └── styles.ts ├── docs ├── connect-with-klaviyo.md └── readme-assets │ ├── app-screen.png │ ├── flow-creation.png │ ├── flow-screen.png │ ├── new-metric.png │ ├── trigger-metric.png │ └── trigger-setup.png ├── graphql ├── fragments │ ├── AddressFragment.graphql │ ├── MetadataFragment.graphql │ ├── MoneyFragment.graphql │ ├── OrderFragment.graphql │ ├── PaymentFragment.graphql │ └── TaxedMoneyFragment.graphql ├── mutations │ └── UpdateAppMetadata.graphql ├── queries │ └── FetchAppDetails.graphql └── schema.graphql ├── hooks ├── theme-synchronizer.tsx └── useAppApi.ts ├── lib ├── graphql.ts ├── klaviyo.ts ├── metadata.ts └── ui │ ├── app-columns-layout.tsx │ ├── app-icon.tsx │ └── main-bar.tsx ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── _error.tsx ├── api │ ├── configuration.ts │ ├── manifest.ts │ ├── register.ts │ └── webhooks │ │ ├── customer-created.ts │ │ ├── fulfillment-created.ts │ │ ├── order-created.ts │ │ └── order-fully-paid.ts └── configuration.tsx ├── pnpm-lock.yaml ├── public ├── favicon.ico └── vercel.svg ├── saleor-app.ts ├── sentry.client.config.js ├── sentry.server.config.js ├── styles └── globals.css ├── tsconfig.json ├── types.ts └── utils └── useDashboardNotifier.ts /.env.example: -------------------------------------------------------------------------------- 1 | APL=file 2 | # Optional 3 | # Regex pattern consumed conditionally to restrcit app installation to specific urls. 4 | # See api/register.tsx 5 | # Leave empty to allow all domains 6 | # Example: "https:\/\/.*.saleor.cloud\/graphql\/" to enable Saleor Cloud APIs 7 | ALLOWED_DOMAIN_PATTERN= 8 | # Encryption key used by the EncryptedSettingsManager. Required by the production builds 9 | SECRET_KEY= -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "tsconfigRootDir": "./", 5 | "project": ["./tsconfig.json"] 6 | }, 7 | "extends": [ 8 | "airbnb", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier" // prettier *has* to be the last one, to avoid conflicting rules 11 | ], 12 | "ignorePatterns": [ 13 | "pnpm-lock.yaml", 14 | "graphql/schema.graphql", 15 | "generated", 16 | "next.config.js", 17 | "tailwind.config.js", 18 | "sentry.client.config.js", 19 | "sentry.server.config.js" 20 | ], 21 | "plugins": ["simple-import-sort", "@typescript-eslint"], 22 | "rules": { 23 | "quotes": ["error", "double"], 24 | "react/react-in-jsx-scope": "off", // next does not require react imports 25 | "import/extensions": "off", // file extension not required when importing 26 | "react/jsx-filename-extension": "off", 27 | "no-restricted-syntax": [ 28 | "error", 29 | { 30 | "selector": "ForInStatement", 31 | "message": "for ... in disallowed, use for ... of instead" 32 | } 33 | ], 34 | 35 | "no-underscore-dangle": "off", 36 | "no-await-in-loop": "off", 37 | "react/jsx-props-no-spreading": "off", 38 | "react/require-default-props": "off", 39 | "simple-import-sort/imports": "warn", 40 | "simple-import-sort/exports": "warn", 41 | "import/first": "warn", 42 | "import/newline-after-import": "warn", 43 | "import/no-duplicates": "warn", 44 | "no-unused-vars": "off", 45 | "@typescript-eslint/no-unused-vars": ["error"], 46 | "@typescript-eslint/ban-types": "off", 47 | "no-console": [ 48 | "error", 49 | { 50 | "allow": ["warn", "error", "debug"] 51 | } 52 | ], 53 | "no-continue": "off", 54 | "operator-linebreak": "off", 55 | "max-len": "off", 56 | "array-callback-return": "off", 57 | "implicit-arrow-linebreak": "off", 58 | "@typescript-eslint/no-non-null-asserted-optional-chain": "off", 59 | "@typescript-eslint/no-non-null-assertion": "off", 60 | "no-restricted-imports": "off", 61 | "no-restricted-exports": "off", 62 | "@typescript-eslint/ban-ts-comment": "off", 63 | "import/prefer-default-export": "off", 64 | "@typescript-eslint/no-misused-promises": ["error"], 65 | "@typescript-eslint/no-floating-promises": ["error"], 66 | // note you must disable the base rule as it can report incorrect errors 67 | "no-shadow": "off", 68 | "@typescript-eslint/no-shadow": ["error"] 69 | }, 70 | "settings": { 71 | "import/parsers": { 72 | "@typescript-eslint/parser": [".ts", ".tsx"] 73 | }, 74 | "import/resolver": { 75 | "typescript": { 76 | "alwaysTryTypes": true // always try to resolve types under `@types` directory even it doesn't contain any source code, like `@types/unist` 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: QA 2 | on: [pull_request] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: pnpm/action-setup@v2.2.1 9 | with: 10 | version: 6.19.1 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: "18" 14 | cache: "pnpm" 15 | - name: Install dependencies 16 | run: pnpm install 17 | - name: Check linters 18 | run: pnpm lint 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | .envfile 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | .auth_token 39 | 40 | #editor 41 | .vscode 42 | .idea 43 | 44 | # GraphQL auto-generated 45 | generated/ 46 | 47 | # Sentry 48 | .sentryclirc 49 | .saleor-app-auth.json 50 | .env -------------------------------------------------------------------------------- /.graphqlrc.yml: -------------------------------------------------------------------------------- 1 | schema: graphql/schema.graphql 2 | documents: [graphql/**/*.graphql, ./**/*.ts, ./**/*.tsx] 3 | extensions: 4 | codegen: 5 | overwrite: true 6 | generates: 7 | generated/graphql.ts: 8 | config: 9 | dedupeFragments: true 10 | plugins: 11 | - typescript 12 | - typescript-operations 13 | - urql-introspection 14 | - typescript-urql: 15 | documentVariablePrefix: "Untyped" 16 | fragmentVariablePrefix: "Untyped" 17 | - typed-document-node 18 | generated/schema.graphql: 19 | plugins: 20 | - schema-ast 21 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | saleor/api.tsx 3 | pnpm-lock.yaml 4 | graphql/schema.graphql 5 | generated 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | @saleor/appstore -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020-2022, Saleor Commerce 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | ------- 32 | 33 | Unless stated otherwise, artwork included in this distribution is licensed 34 | under the Creative Commons Attribution 4.0 International License. 35 | 36 | You can learn more about the permitted use by visiting 37 | https://creativecommons.org/licenses/by/4.0/ 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saleor App Klaviyo 2 | 3 | App has been moved to the [saleor/apps monorepo](https://github.com/saleor/apps) 4 | -------------------------------------------------------------------------------- /components/AccessWarning/AccessWarning.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "@material-ui/core"; 2 | import React from "react"; 3 | 4 | type WarningCause = 5 | | "not_in_iframe" 6 | | "missing_access_token" 7 | | "invalid_access_token" 8 | | "unknown_cause"; 9 | 10 | interface AccessWarningProps { 11 | cause?: WarningCause; 12 | } 13 | 14 | const warnings: Record = { 15 | not_in_iframe: "The view can only be displayed in the iframe.", 16 | missing_access_token: "App doesn't have an access token.", 17 | invalid_access_token: "Access token is invalid.", 18 | unknown_cause: "Something went wrong.", 19 | }; 20 | 21 | function AccessWarning({ cause = "unknown_cause" }: AccessWarningProps) { 22 | return ( 23 |
24 | 25 | App can't be accessed outside of the Saleor Dashboard 26 | 27 | 28 | ❌ {warnings[cause]} 29 | 30 |
31 | ); 32 | } 33 | 34 | export default AccessWarning; 35 | -------------------------------------------------------------------------------- /components/LoadingPage/LoadingPage.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress, Typography } from "@material-ui/core"; 2 | import React from "react"; 3 | 4 | import { useStyles } from "./styles"; 5 | 6 | function LoadingPage() { 7 | const classes = useStyles(); 8 | 9 | return ( 10 |
11 | 12 | 13 | 14 | Attempting connection to Saleor Dashboard 15 | 16 |
17 | ); 18 | } 19 | 20 | export default LoadingPage; 21 | -------------------------------------------------------------------------------- /components/LoadingPage/styles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "@saleor/macaw-ui"; 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | loaderContainer: { 5 | display: "flex", 6 | flexDirection: "column", 7 | alignItems: "center", 8 | }, 9 | message: { 10 | marginTop: theme.spacing(4), 11 | }, 12 | })); 13 | 14 | export { useStyles }; 15 | -------------------------------------------------------------------------------- /docs/connect-with-klaviyo.md: -------------------------------------------------------------------------------- 1 | # How to connect your App with Klavio 2 | 3 | ## Installation 4 | 5 | Follow [readme](../README.md) and deploy app. Then, install it in your Saleor Dashboard 6 | 7 | ## Creating Klaviyo account 8 | 9 | Before we continue, you need a Klaviyo account. You can sign up [here](https://www.klaviyo.com/). 10 | 11 | ## Accessing your public key 12 | 13 | To access your public key, please follow this [Klaviyo document](https://help.klaviyo.com/hc/en-us/articles/115005062267-How-to-Manage-Your-Account-s-API-Keys). 14 | 15 | ## Dashboard configuration 16 | 17 | 1. Open Dashboard and navigate to the "Apps" section. 18 | 2. Find your fresh installed Klaviyo app. 19 | 3. Paste your public key into the input field and save. 20 | 21 | ## Triggering the initial webhook 22 | 23 | First and foremost, you need to perform an initial API call to Klaviyo, which will create a metric (of which the name you can configure in the Klaviyo App configuration screen). 24 | 25 | Let's navigate to "Customers" and create the first, dummy customer. 26 | 27 | Then, open Klavio [Metrics page](https://www.klaviyo.com/analytics/metrics). 28 | 29 | Your Metric should be visible on the list: 30 | 31 | ![](readme-assets/new-metric.png) 32 | 33 | ## Creating a flow 34 | 35 | Now, you can create your first flow 36 | 37 | 1. Open the [flow creation page](https://www.klaviyo.com/flows/create). 38 | 2. Click "Create from scratch" and name your flow. 39 | [](readme-assets/flow-creation.png) 40 | 3. Create a new trigger with "Metric". 41 | [](readme-assets/trigger-setup.png) 42 | 4. Your freshly sent Metric should be available. 43 | [](readme-assets/trigger-metric.png) 44 | 5. Now you can proceed to create your flow. Feel free to welcome your user. 45 | [](readme-assets/flow-screen.png) 46 | -------------------------------------------------------------------------------- /docs/readme-assets/app-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saleor/saleor-app-klaviyo/80deb95e28e12ce1dc5119bf5ba83078b45614e3/docs/readme-assets/app-screen.png -------------------------------------------------------------------------------- /docs/readme-assets/flow-creation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saleor/saleor-app-klaviyo/80deb95e28e12ce1dc5119bf5ba83078b45614e3/docs/readme-assets/flow-creation.png -------------------------------------------------------------------------------- /docs/readme-assets/flow-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saleor/saleor-app-klaviyo/80deb95e28e12ce1dc5119bf5ba83078b45614e3/docs/readme-assets/flow-screen.png -------------------------------------------------------------------------------- /docs/readme-assets/new-metric.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saleor/saleor-app-klaviyo/80deb95e28e12ce1dc5119bf5ba83078b45614e3/docs/readme-assets/new-metric.png -------------------------------------------------------------------------------- /docs/readme-assets/trigger-metric.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saleor/saleor-app-klaviyo/80deb95e28e12ce1dc5119bf5ba83078b45614e3/docs/readme-assets/trigger-metric.png -------------------------------------------------------------------------------- /docs/readme-assets/trigger-setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saleor/saleor-app-klaviyo/80deb95e28e12ce1dc5119bf5ba83078b45614e3/docs/readme-assets/trigger-setup.png -------------------------------------------------------------------------------- /graphql/fragments/AddressFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment AddressFragment on Address { 2 | __typename 3 | id 4 | firstName 5 | lastName 6 | companyName 7 | streetAddress1 8 | streetAddress2 9 | city 10 | cityArea 11 | postalCode 12 | country { 13 | code 14 | } 15 | countryArea 16 | phone 17 | } 18 | -------------------------------------------------------------------------------- /graphql/fragments/MetadataFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment MetadataFragment on MetadataItem { 2 | key 3 | value 4 | } 5 | -------------------------------------------------------------------------------- /graphql/fragments/MoneyFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment MoneyFragment on Money { 2 | amount 3 | currency 4 | } 5 | -------------------------------------------------------------------------------- /graphql/fragments/OrderFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment OrderFragment on Order { 2 | __typename 3 | id 4 | channel { 5 | __typename 6 | id 7 | slug 8 | currencyCode 9 | } 10 | shippingMethod { 11 | type 12 | id 13 | name 14 | } 15 | shippingAddress { 16 | ...AddressFragment 17 | } 18 | billingAddress { 19 | ...AddressFragment 20 | } 21 | discounts { 22 | id 23 | } 24 | token 25 | userEmail 26 | created 27 | original 28 | lines { 29 | __typename 30 | id 31 | productVariantId 32 | totalPrice { 33 | ...TaxedMoneyFragment 34 | } 35 | allocations { 36 | quantity 37 | warehouse { 38 | id 39 | } 40 | } 41 | productName 42 | variantName 43 | translatedProductName 44 | translatedVariantName 45 | productSku 46 | quantity 47 | unitDiscountValue 48 | unitDiscountType 49 | unitDiscountReason 50 | unitPrice { 51 | ...TaxedMoneyFragment 52 | } 53 | undiscountedUnitPrice { 54 | ...TaxedMoneyFragment 55 | } 56 | taxRate 57 | } 58 | fulfillments { 59 | id 60 | } 61 | payments { 62 | ...PaymentFragment 63 | } 64 | privateMetadata { 65 | ...MetadataFragment 66 | } 67 | metadata { 68 | ...MetadataFragment 69 | } 70 | status 71 | languageCodeEnum 72 | origin 73 | shippingMethodName 74 | collectionPointName 75 | shippingPrice { 76 | ...TaxedMoneyFragment 77 | } 78 | shippingTaxRate 79 | total { 80 | ...TaxedMoneyFragment 81 | } 82 | undiscountedTotal { 83 | ...TaxedMoneyFragment 84 | } 85 | weight { 86 | value 87 | unit 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /graphql/fragments/PaymentFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment PaymentFragment on Payment { 2 | __typename 3 | id 4 | created 5 | modified 6 | gateway 7 | isActive 8 | chargeStatus 9 | total { 10 | amount 11 | } 12 | capturedAmount { 13 | ...MoneyFragment 14 | } 15 | creditCard { 16 | brand 17 | } 18 | paymentMethodType 19 | } 20 | -------------------------------------------------------------------------------- /graphql/fragments/TaxedMoneyFragment.graphql: -------------------------------------------------------------------------------- 1 | fragment TaxedMoneyFragment on TaxedMoney { 2 | currency 3 | net { 4 | ...MoneyFragment 5 | } 6 | gross { 7 | ...MoneyFragment 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /graphql/mutations/UpdateAppMetadata.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) { 2 | updatePrivateMetadata(id: $id, input: $input) { 3 | item { 4 | privateMetadata { 5 | key 6 | value 7 | } 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /graphql/queries/FetchAppDetails.graphql: -------------------------------------------------------------------------------- 1 | query FetchAppDetails { 2 | app { 3 | id 4 | privateMetadata { 5 | key 6 | value 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /hooks/theme-synchronizer.tsx: -------------------------------------------------------------------------------- 1 | import { useAppBridge } from "@saleor/app-sdk/app-bridge"; 2 | import { useTheme } from "@saleor/macaw-ui"; 3 | import { memo, useEffect } from "react"; 4 | 5 | /** 6 | * Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard, 7 | * Macaw must be informed about this change from AppBridge. 8 | * 9 | * If you are not using Macaw, you can remove this. 10 | */ 11 | function _ThemeSynchronizer() { 12 | const { appBridgeState } = useAppBridge(); 13 | const { setTheme, themeType } = useTheme(); 14 | 15 | useEffect(() => { 16 | if (!setTheme || !appBridgeState?.theme) { 17 | return; 18 | } 19 | 20 | if (themeType !== appBridgeState?.theme) { 21 | setTheme(appBridgeState.theme); 22 | /** 23 | * Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state) 24 | * TODO Fix me when Macaw 2.0 is shipped 25 | */ 26 | window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme); 27 | } 28 | }, [appBridgeState?.theme, setTheme, themeType]); 29 | 30 | return null; 31 | } 32 | 33 | export const ThemeSynchronizer = memo(_ThemeSynchronizer); 34 | -------------------------------------------------------------------------------- /hooks/useAppApi.ts: -------------------------------------------------------------------------------- 1 | import { useAppBridge } from "@saleor/app-sdk/app-bridge"; 2 | import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const"; 3 | import { useEffect, useState } from "react"; 4 | 5 | type Options = Record; 6 | 7 | interface UseFetchProps { 8 | url: string; 9 | options?: Options; 10 | skip?: boolean; 11 | } 12 | 13 | // This hook is meant to be used mainly for internal API calls 14 | const useAppApi = ({ url, options, skip }: UseFetchProps) => { 15 | const { appBridgeState } = useAppBridge(); 16 | 17 | const [data, setData] = useState(); 18 | const [error, setError] = useState(); 19 | const [loading, setLoading] = useState(false); 20 | 21 | const fetchOptions: RequestInit = { 22 | ...options, 23 | headers: [ 24 | [SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!], 25 | [SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!], 26 | ], 27 | }; 28 | 29 | useEffect(() => { 30 | const fetchData = async () => { 31 | setLoading(true); 32 | setError(undefined); 33 | 34 | try { 35 | const res = await fetch(url, fetchOptions); 36 | 37 | if (!res.ok) { 38 | throw new Error(`Error status: ${res.status}`); 39 | } 40 | 41 | const json = await res.json(); 42 | setData(json); 43 | } catch (e) { 44 | setError(e as unknown); 45 | } finally { 46 | setLoading(false); 47 | } 48 | }; 49 | 50 | if (appBridgeState?.ready && !skip) { 51 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 52 | fetchData(); 53 | } 54 | 55 | return () => { 56 | setLoading(false); 57 | setError(undefined); 58 | setData(undefined); 59 | }; 60 | }, [url, options, skip]); 61 | 62 | return { data, error, loading }; 63 | }; 64 | 65 | export default useAppApi; 66 | -------------------------------------------------------------------------------- /lib/graphql.ts: -------------------------------------------------------------------------------- 1 | import { AuthConfig, authExchange } from "@urql/exchange-auth"; 2 | import { 3 | cacheExchange, 4 | createClient as urqlCreateClient, 5 | dedupExchange, 6 | fetchExchange, 7 | } from "urql"; 8 | 9 | interface IAuthState { 10 | token: string; 11 | } 12 | 13 | export const createClient = (url: string, getAuth: AuthConfig["getAuth"]) => 14 | urqlCreateClient({ 15 | url, 16 | exchanges: [ 17 | dedupExchange, 18 | cacheExchange, 19 | authExchange({ 20 | addAuthToOperation: ({ authState, operation }) => { 21 | if (!authState || !authState?.token) { 22 | return operation; 23 | } 24 | 25 | const fetchOptions = 26 | typeof operation.context.fetchOptions === "function" 27 | ? operation.context.fetchOptions() 28 | : operation.context.fetchOptions || {}; 29 | 30 | return { 31 | ...operation, 32 | context: { 33 | ...operation.context, 34 | fetchOptions: { 35 | ...fetchOptions, 36 | headers: { 37 | ...fetchOptions.headers, 38 | "Authorization-Bearer": authState.token, 39 | }, 40 | }, 41 | }, 42 | }; 43 | }, 44 | getAuth, 45 | }), 46 | fetchExchange, 47 | ], 48 | }); 49 | -------------------------------------------------------------------------------- /lib/klaviyo.ts: -------------------------------------------------------------------------------- 1 | interface EmailServiceProvider { 2 | send: (event: string, recipient: string, context: any) => Promise; 3 | } 4 | 5 | const Klaviyo = (token: string): EmailServiceProvider => ({ 6 | send: async (event, recipient, context) => { 7 | const formParams = new URLSearchParams(); 8 | formParams.append( 9 | "data", 10 | JSON.stringify({ 11 | token, 12 | event, 13 | customer_properties: { $email: recipient }, 14 | properties: context, 15 | }) 16 | ); 17 | 18 | console.debug("Klaviyo request: https://a.klaviyo.com/api/track, ", formParams); 19 | 20 | const response = await fetch("https://a.klaviyo.com/api/track", { 21 | method: "POST", 22 | body: formParams, 23 | }); 24 | 25 | console.debug("Klaviyo response: ", response.status, ", ", await response.text()); 26 | 27 | return response; 28 | }, 29 | }); 30 | 31 | export default Klaviyo; 32 | -------------------------------------------------------------------------------- /lib/metadata.ts: -------------------------------------------------------------------------------- 1 | import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager"; 2 | import { Client } from "urql"; 3 | 4 | import { 5 | FetchAppDetailsDocument, 6 | FetchAppDetailsQuery, 7 | UpdateAppMetadataDocument, 8 | } from "../generated/graphql"; 9 | import { settingsManagerSecretKey } from "../saleor-app"; 10 | 11 | // Function is using urql graphql client to fetch all available metadata. 12 | // Before returning query result, we are transforming response to list of objects with key and value fields 13 | // which can be used by the manager. 14 | // Result of this query is cached by the manager. 15 | export async function fetchAllMetadata(client: Client): Promise { 16 | const { error, data } = await client 17 | .query(FetchAppDetailsDocument, {}) 18 | .toPromise(); 19 | 20 | if (error) { 21 | console.debug("Error during fetching the metadata: ", error); 22 | return []; 23 | } 24 | 25 | return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || []; 26 | } 27 | 28 | // Mutate function takes urql client and metadata entries, and construct mutation to the API. 29 | // Before data are send, additional query for required App ID is made. 30 | // The manager will use updated entries returned by this mutation to update it's cache. 31 | export async function mutateMetadata(client: Client, appId: string, metadata: MetadataEntry[]) { 32 | const { error: mutationError, data: mutationData } = await client 33 | .mutation(UpdateAppMetadataDocument, { 34 | id: appId, 35 | input: metadata, 36 | }) 37 | .toPromise(); 38 | 39 | if (mutationError) { 40 | console.debug("Mutation error: ", mutationError); 41 | throw new Error(`Mutation error: ${mutationError.message}`); 42 | } 43 | 44 | return ( 45 | mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({ 46 | key: md.key, 47 | value: md.value, 48 | })) || [] 49 | ); 50 | } 51 | 52 | export const createSettingsManager = (client: Client, appId: string) => 53 | // EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory. 54 | // We recommend it for production, because all values are encrypted. 55 | // If your use case require plain text values, you can use MetadataManager. 56 | new EncryptedMetadataManager({ 57 | // Secret key should be randomly created for production and set as environment variable 58 | encryptionKey: settingsManagerSecretKey, 59 | fetchMetadata: () => fetchAllMetadata(client), 60 | mutateMetadata: (metadata) => mutateMetadata(client, appId, metadata), 61 | }); 62 | -------------------------------------------------------------------------------- /lib/ui/app-columns-layout.tsx: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "@saleor/macaw-ui"; 2 | import { PropsWithChildren } from "react"; 3 | 4 | const useStyles = makeStyles({ 5 | root: { 6 | display: "grid", 7 | gridTemplateColumns: "280px auto 280px", 8 | alignItems: "start", 9 | gap: 32, 10 | maxWidth: 1180, 11 | margin: "0 auto", 12 | }, 13 | }); 14 | 15 | type Props = PropsWithChildren<{}>; 16 | 17 | export function AppColumnsLayout({ children }: Props) { 18 | const styles = useStyles(); 19 | 20 | return
{children}
; 21 | } 22 | -------------------------------------------------------------------------------- /lib/ui/app-icon.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "@material-ui/core"; 2 | import { makeStyles } from "@saleor/macaw-ui"; 3 | 4 | const useStyles = makeStyles({ 5 | appIconContainer: { 6 | background: "rgb(58, 86, 199)", 7 | display: "flex", 8 | flexDirection: "column", 9 | justifyContent: "center", 10 | alignItems: "center", 11 | borderRadius: "50%", 12 | color: "#fff", 13 | width: 50, 14 | height: 50, 15 | }, 16 | }); 17 | 18 | export function AppIcon() { 19 | const styles = useStyles(); 20 | 21 | return ( 22 |
23 |
24 | S 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /lib/ui/main-bar.tsx: -------------------------------------------------------------------------------- 1 | import { Paper, PaperProps } from "@material-ui/core"; 2 | import { makeStyles } from "@saleor/macaw-ui"; 3 | import clsx from "clsx"; 4 | import { ReactNode } from "react"; 5 | 6 | const useStyles = makeStyles((theme) => ({ 7 | root: { 8 | height: 96, 9 | padding: "0 32px", 10 | display: "flex", 11 | alignItems: "center", 12 | justifyContent: "space-between", 13 | }, 14 | leftColumn: { 15 | marginRight: "auto", 16 | }, 17 | rightColumn: {}, 18 | iconColumn: { 19 | marginRight: 24, 20 | }, 21 | appName: { fontSize: 24, margin: 0 }, 22 | appAuthor: { 23 | fontSize: 12, 24 | textTransform: "uppercase", 25 | color: theme.palette.text.secondary, 26 | fontWeight: 500, 27 | margin: 0, 28 | }, 29 | bottomMargin: { 30 | marginBottom: 32, 31 | }, 32 | })); 33 | 34 | type Props = { 35 | name: string; 36 | author: string; 37 | rightColumnContent?: ReactNode; 38 | icon?: ReactNode; 39 | bottomMargin?: boolean; 40 | } & PaperProps; 41 | 42 | export function MainBar({ 43 | name, 44 | author, 45 | rightColumnContent, 46 | className, 47 | icon, 48 | bottomMargin, 49 | }: Props) { 50 | const styles = useStyles(); 51 | 52 | return ( 53 | 59 | {icon &&
{icon}
} 60 |
61 |

{name}

62 |

{author}

63 |
64 |
{rightColumnContent}
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const { withSentryConfig } = require("@sentry/nextjs"); 4 | 5 | const isSentryPropertiesInEnvironment = 6 | process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG; 7 | 8 | /** @type {import('next').NextConfig} */ 9 | const nextConfig = { 10 | reactStrictMode: true, 11 | eslint: { 12 | ignoreDuringBuilds: true, 13 | }, 14 | sentry: { 15 | disableServerWebpackPlugin: !isSentryPropertiesInEnvironment, 16 | disableClientWebpackPlugin: !isSentryPropertiesInEnvironment, 17 | }, 18 | redirects() { 19 | return [ 20 | { 21 | source: "/", 22 | destination: "/configuration", 23 | permanent: false, 24 | }, 25 | ]; 26 | }, 27 | }; 28 | 29 | const sentryWebpackPluginOptions = { 30 | // Additional config options for the Sentry Webpack plugin. Keep in mind that 31 | // the following options are set automatically, and overriding them is not 32 | // recommended: 33 | // release, url, org, project, authToken, configFile, stripPrefix, 34 | // urlPrefix, include, ignore 35 | 36 | silent: true, // Suppresses all logs 37 | // For all available options, see: 38 | // https://github.com/getsentry/sentry-webpack-plugin#options. 39 | }; 40 | 41 | // Make sure adding Sentry options is the last code to run before exporting, to 42 | // ensure that your source maps include changes from all other Webpack plugins 43 | module.exports = withSentryConfig(nextConfig, sentryWebpackPluginOptions); 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saleor-app-klaviyo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "packageManager": "pnpm@7.18.1", 6 | "scripts": { 7 | "dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev", 8 | "build": "pnpm generate && next build", 9 | "start": "next start", 10 | "lint": "pnpm generate && prettier --loglevel warn --write . && eslint --fix .", 11 | "fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql", 12 | "generate": "graphql-codegen", 13 | "prepare": "husky install" 14 | }, 15 | "saleor": { 16 | "schemaVersion": "3.4" 17 | }, 18 | "saleorApp": { 19 | "name": "Klaviyo", 20 | "logo": "/public/logo.png", 21 | "shortDescription": "Email, SMS, and more - a unified customer platform", 22 | "sourceUrl": "https://github.com/saleor/saleor-app-klaviyo", 23 | "vendor": "Saleor Commerce", 24 | "links": [ 25 | { 26 | "type": "developerWebsite", 27 | "href": "https://saleor.io/" 28 | }, 29 | { 30 | "type": "mailContact", 31 | "href": "mailto:info@saleor.io" 32 | }, 33 | { 34 | "type": "privacyPolicy", 35 | "href": "https://saleor.io/legal/privacy/" 36 | } 37 | ], 38 | "description": "Klaviyo is a unified customer platform that gives your online brand direct ownership of your consumer data and interactions, empowering you to turn transactions with customers into productive long-term relationships—at scale. The Klaviyo database integrates seamlessly with your tech stack and gives you the full story on every customer that visits—what makes them click, what makes them bounce, and what makes them buy. From the same platform, you can use Klaviyo’s suite of proven email and SMS templates to automate personalized communications like price drop alerts, friendly cart reminders, and just-in-time recommendations. Customers feel seen, not targeted—which means no more ineffective batching and blasting. Over time, Klaviyo even reveals what works and what doesn’t, uncovering trends that help you acquire and retain new customers while inspiring existing customers to buy again. It’s all there in one intuitive place—no need to start from scratch, and no need to rely on third-party marketplaces and ad networks. With Klaviyo, it’s easy to talk to every customer like you know them, and grow your business—on your own terms.", 39 | "images": [ 40 | "/public/image.png" 41 | ] 42 | }, 43 | "dependencies": { 44 | "@material-ui/core": "^4.12.4", 45 | "@material-ui/icons": "^4.11.3", 46 | "@material-ui/lab": "4.0.0-alpha.61", 47 | "@saleor/app-sdk": "~0.27.1", 48 | "@saleor/macaw-ui": "^0.7.2", 49 | "@sentry/nextjs": "^7.31.0", 50 | "@urql/exchange-auth": "^0.1.7", 51 | "clsx": "^1.2.1", 52 | "graphql": "^16.5.0", 53 | "graphql-tag": "^2.12.6", 54 | "next": "12.3.1", 55 | "node-fetch": "^3.2.6", 56 | "react": "18.2.0", 57 | "react-dom": "18.2.0", 58 | "react-helmet": "^6.1.0", 59 | "urql": "^2.2.2" 60 | }, 61 | "devDependencies": { 62 | "@graphql-codegen/cli": "2.7.0", 63 | "@graphql-codegen/introspection": "2.1.1", 64 | "@graphql-codegen/typed-document-node": "^2.2.14", 65 | "@graphql-codegen/typescript": "2.6.0", 66 | "@graphql-codegen/typescript-operations": "2.4.3", 67 | "@graphql-codegen/typescript-urql": "^3.5.13", 68 | "@graphql-codegen/urql-introspection": "2.1.1", 69 | "@graphql-typed-document-node/core": "^3.1.1", 70 | "@types/node": "18.0.1", 71 | "@types/react": "18.0.14", 72 | "@types/react-dom": "18.0.6", 73 | "@typescript-eslint/eslint-plugin": "^5.17.0", 74 | "@typescript-eslint/parser": "^5.30.7", 75 | "autoprefixer": "^10.4.7", 76 | "clean-publish": "^4.0.1", 77 | "eslint": "8.15.0", 78 | "eslint-config-airbnb": "^19.0.4", 79 | "eslint-config-prettier": "^8.5.0", 80 | "eslint-import-resolver-typescript": "^3.3.0", 81 | "eslint-plugin-import": "^2.26.0", 82 | "eslint-plugin-jsx-a11y": "^6.6.0", 83 | "eslint-plugin-react": "^7.30.1", 84 | "eslint-plugin-react-hooks": "^4.6.0", 85 | "eslint-plugin-simple-import-sort": "^7.0.0", 86 | "husky": "^8.0.1", 87 | "postcss": "^8.4.14", 88 | "prettier": "^2.7.1", 89 | "pretty-quick": "^3.1.3", 90 | "typescript": "4.7.4" 91 | }, 92 | "lint-staged": { 93 | "*.{js,ts,tsx}": "eslint --cache --fix", 94 | "*.{js,ts,tsx,css,md,json}": "prettier --write" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | 3 | import { Theme } from "@material-ui/core/styles"; 4 | import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge"; 5 | import { ThemeProvider as MacawUIThemeProvider } from "@saleor/macaw-ui"; 6 | import React, { PropsWithChildren, useEffect } from "react"; 7 | 8 | import { ThemeSynchronizer } from "../hooks/theme-synchronizer"; 9 | import { AppLayoutProps } from "../types"; 10 | 11 | const themeOverrides: Partial = { 12 | overrides: { 13 | MuiTableCell: { 14 | body: { 15 | paddingBottom: 8, 16 | paddingTop: 8, 17 | }, 18 | root: { 19 | height: 56, 20 | paddingBottom: 4, 21 | paddingTop: 4, 22 | }, 23 | }, 24 | }, 25 | }; 26 | 27 | /** 28 | * Ensure instance is a singleton, so React 18 dev mode doesn't render it twice 29 | */ 30 | const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined; 31 | 32 | // That's a hack required by Macaw-UI incompatibility with React@18 33 | const ThemeProvider = MacawUIThemeProvider as React.FC< 34 | PropsWithChildren<{ overrides: Partial; ssr: boolean }> 35 | >; 36 | 37 | function SaleorApp({ Component, pageProps }: AppLayoutProps) { 38 | const getLayout = Component.getLayout ?? ((page) => page); 39 | 40 | useEffect(() => { 41 | const jssStyles = document.querySelector("#jss-server-side"); 42 | if (jssStyles) { 43 | jssStyles?.parentElement?.removeChild(jssStyles); 44 | } 45 | }, []); 46 | 47 | return ( 48 | 49 | 50 | 51 | {getLayout()} 52 | 53 | 54 | ); 55 | } 56 | 57 | export default SaleorApp; 58 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 13 | 14 |
15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/nextjs"; 2 | import { NextPageContext } from "next"; 3 | import NextErrorComponent, { ErrorProps } from "next/error"; 4 | 5 | interface ErrorPageProps { 6 | err: Error; 7 | statusCode: number; 8 | hasGetInitialPropsRun: boolean; 9 | } 10 | 11 | interface AppErrorProps extends ErrorProps { 12 | err?: Error; 13 | hasGetInitialPropsRun?: boolean; 14 | } 15 | 16 | function MyError({ statusCode, hasGetInitialPropsRun, err }: ErrorPageProps) { 17 | if (!hasGetInitialPropsRun && err) { 18 | // getInitialProps is not called when an exception is thrown 19 | // at the top level of a module while it is being loaded. 20 | // As a workaround, we pass err via _app.js so it can be captured 21 | // Read more: https://github.com/vercel/next.js/issues/8592. 22 | Sentry.captureException(err); 23 | // Flushing is not required in this case as it only happens on the client 24 | } 25 | 26 | return ; 27 | } 28 | 29 | MyError.getInitialProps = async (context: NextPageContext) => { 30 | const errorInitialProps: AppErrorProps = await NextErrorComponent.getInitialProps(context); 31 | 32 | const { res, err, asPath } = context; 33 | 34 | // Workaround for https://github.com/vercel/next.js/issues/8592, mark when 35 | // getInitialProps has run 36 | errorInitialProps.hasGetInitialPropsRun = true; 37 | 38 | // Returning early because we don't want to log 404 errors to Sentry. 39 | if (res?.statusCode === 404) { 40 | return errorInitialProps; 41 | } 42 | 43 | // Running on the server, the response object (`res`) is available. 44 | // 45 | // Next.js will pass an err on the server if a page's data fetching methods 46 | // threw or returned a Promise that rejected 47 | // 48 | // Running on the client (browser), Next.js will provide an err if: 49 | // 50 | // - a page's `getInitialProps` threw or returned a Promise that rejected 51 | // - an exception was thrown somewhere in the React lifecycle (render, 52 | // componentDidMount, etc) that was caught by Next.js's React Error 53 | // Boundary. Read more about what types of exceptions are caught by Error 54 | // Boundaries: https://reactjs.org/docs/error-boundaries.html 55 | 56 | if (err) { 57 | Sentry.captureException(err); 58 | 59 | // Flushing before returning is necessary if deploying to Vercel, see 60 | // https://vercel.com/docs/platform/limits#streaming-responses 61 | await Sentry.flush(2000); 62 | 63 | return errorInitialProps; 64 | } 65 | 66 | // If this point is reached, getInitialProps was called without any 67 | // information about what the error might be. This is unexpected and may 68 | // indicate a bug introduced in Next.js, so record it in Sentry 69 | Sentry.captureException(new Error(`_error.js getInitialProps missing data at path: ${asPath}`)); 70 | await Sentry.flush(2000); 71 | 72 | return errorInitialProps; 73 | }; 74 | 75 | export default MyError; 76 | -------------------------------------------------------------------------------- /pages/api/configuration.ts: -------------------------------------------------------------------------------- 1 | import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next"; 2 | import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager"; 3 | 4 | import { createClient } from "../../lib/graphql"; 5 | import { createSettingsManager } from "../../lib/metadata"; 6 | import { saleorApp } from "../../saleor-app"; 7 | 8 | type ConfigurationKeysType = 9 | | "PUBLIC_TOKEN" 10 | | "CUSTOMER_CREATED_METRIC" 11 | | "FULFILLMENT_CREATED_METRIC" 12 | | "ORDER_CREATED_METRIC" 13 | | "ORDER_FULLY_PAID_METRIC"; 14 | 15 | interface PostRequestBody { 16 | data: { 17 | key: ConfigurationKeysType; 18 | value: string; 19 | }[]; 20 | } 21 | 22 | const getAppSettings = async (settingsManager: EncryptedMetadataManager) => [ 23 | { 24 | key: "CUSTOMER_CREATED_METRIC", 25 | value: (await settingsManager.get("CUSTOMER_CREATED_METRIC")) ?? "CUSTOMER_CREATED_METRIC", 26 | }, 27 | { 28 | key: "FULFILLMENT_CREATED_METRIC", 29 | value: 30 | (await settingsManager.get("FULFILLMENT_CREATED_METRIC")) ?? "FULFILLMENT_CREATED_METRIC", 31 | }, 32 | { 33 | key: "ORDER_CREATED_METRIC", 34 | value: (await settingsManager.get("ORDER_CREATED_METRIC")) ?? "ORDER_CREATED_METRIC", 35 | }, 36 | { 37 | key: "ORDER_FULLY_PAID_METRIC", 38 | value: (await settingsManager.get("ORDER_FULLY_PAID_METRIC")) ?? "ORDER_FULLY_PAID_METRIC", 39 | }, 40 | { key: "PUBLIC_TOKEN", value: await settingsManager.get("PUBLIC_TOKEN") }, 41 | ]; 42 | 43 | const handler: NextProtectedApiHandler = async (request, res, ctx) => { 44 | console.debug("Configuration handler called"); 45 | 46 | const { 47 | authData: { token, saleorApiUrl, appId }, 48 | } = ctx; 49 | const client = createClient(saleorApiUrl, async () => Promise.resolve({ token })); 50 | 51 | const settings = createSettingsManager(client, appId); 52 | 53 | switch (request.method!) { 54 | case "GET": 55 | return res.json({ 56 | success: true, 57 | data: await getAppSettings(settings), 58 | }); 59 | case "POST": { 60 | await settings.set((request.body as PostRequestBody).data); 61 | 62 | return res.json({ 63 | success: true, 64 | data: await getAppSettings(settings), 65 | }); 66 | } 67 | default: 68 | return res.status(405).end(); 69 | } 70 | }; 71 | 72 | export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS"]); 73 | -------------------------------------------------------------------------------- /pages/api/manifest.ts: -------------------------------------------------------------------------------- 1 | import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; 2 | import { AppManifest } from "@saleor/app-sdk/types"; 3 | 4 | import pkg from "../../package.json"; 5 | import { customerCreatedWebhook } from "./webhooks/customer-created"; 6 | import { fulfillmentCreatedWebhook } from "./webhooks/fulfillment-created"; 7 | import { orderCreatedWebhook } from "./webhooks/order-created"; 8 | import { orderFullyPaidWebhook } from "./webhooks/order-fully-paid"; 9 | 10 | const handler = createManifestHandler({ 11 | async manifestFactory(context): Promise { 12 | const { appBaseUrl } = context; 13 | 14 | return { 15 | id: "saleor.app.klaviyo", 16 | version: pkg.version, 17 | name: pkg.name, 18 | permissions: ["MANAGE_USERS", "MANAGE_ORDERS"], 19 | appUrl: appBaseUrl, 20 | tokenTargetUrl: `${appBaseUrl}/api/register`, 21 | webhooks: [ 22 | customerCreatedWebhook.getWebhookManifest(appBaseUrl), 23 | fulfillmentCreatedWebhook.getWebhookManifest(appBaseUrl), 24 | orderCreatedWebhook.getWebhookManifest(appBaseUrl), 25 | orderFullyPaidWebhook.getWebhookManifest(appBaseUrl), 26 | ], 27 | }; 28 | }, 29 | }); 30 | 31 | export default handler; 32 | -------------------------------------------------------------------------------- /pages/api/register.ts: -------------------------------------------------------------------------------- 1 | import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next"; 2 | 3 | import { saleorApp } from "../../saleor-app"; 4 | 5 | const allowedUrlsPattern = process.env.ALLOWED_DOMAIN_PATTERN; 6 | 7 | const handler = createAppRegisterHandler({ 8 | apl: saleorApp.apl, 9 | /** 10 | * Prohibit installation from Saleors other than specified by the regex. 11 | * Regex source is ENV so if ENV is not set, all installations will be allowed. 12 | */ 13 | allowedSaleorUrls: [ 14 | (url) => { 15 | if (allowedUrlsPattern) { 16 | const regex = new RegExp(allowedUrlsPattern); 17 | 18 | return regex.test(url); 19 | } 20 | 21 | return true; 22 | }, 23 | ], 24 | }); 25 | 26 | export default handler; 27 | -------------------------------------------------------------------------------- /pages/api/webhooks/customer-created.ts: -------------------------------------------------------------------------------- 1 | import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; 2 | import { gql } from "urql"; 3 | 4 | import { 5 | CustomerCreatedWebhookPayloadFragment, 6 | UntypedCustomerCreatedDocument, 7 | } from "../../../generated/graphql"; 8 | import { createClient } from "../../../lib/graphql"; 9 | import Klaviyo from "../../../lib/klaviyo"; 10 | import { createSettingsManager } from "../../../lib/metadata"; 11 | import { saleorApp } from "../../../saleor-app"; 12 | 13 | const CustomerCreatedWebhookPayload = gql` 14 | fragment CustomerCreatedWebhookPayload on CustomerCreated { 15 | user { 16 | __typename 17 | id 18 | defaultShippingAddress { 19 | ...AddressFragment 20 | } 21 | defaultBillingAddress { 22 | ...AddressFragment 23 | } 24 | addresses { 25 | ...AddressFragment 26 | } 27 | privateMetadata { 28 | ...MetadataFragment 29 | } 30 | metadata { 31 | ...MetadataFragment 32 | } 33 | email 34 | firstName 35 | lastName 36 | isActive 37 | dateJoined 38 | languageCode 39 | } 40 | } 41 | `; 42 | 43 | export const CustomerCreatedGraphqlSubscription = gql` 44 | ${CustomerCreatedWebhookPayload} 45 | subscription CustomerCreated { 46 | event { 47 | ...CustomerCreatedWebhookPayload 48 | } 49 | } 50 | `; 51 | 52 | export const customerCreatedWebhook = new SaleorAsyncWebhook( 53 | { 54 | name: "Customer Created", 55 | webhookPath: "api/webhooks/customer-created", 56 | asyncEvent: "CUSTOMER_CREATED", 57 | apl: saleorApp.apl, 58 | subscriptionQueryAst: UntypedCustomerCreatedDocument, 59 | } 60 | ); 61 | 62 | const handler: NextWebhookApiHandler = async ( 63 | req, 64 | res, 65 | context 66 | ) => { 67 | console.debug("customerCreatedWebhook handler called"); 68 | 69 | const { payload, authData } = context; 70 | const { saleorApiUrl, token, appId } = authData; 71 | const client = createClient(saleorApiUrl, async () => Promise.resolve({ token })); 72 | const settings = createSettingsManager(client, appId); 73 | 74 | const klaviyoToken = await settings.get("PUBLIC_TOKEN"); 75 | const klaviyoMetric = await settings.get("CUSTOMER_CREATED_METRIC"); 76 | 77 | if (!klaviyoToken || !klaviyoMetric) { 78 | console.debug("Request rejected - app not configured"); 79 | return res.status(400).json({ success: false, message: "App not configured." }); 80 | } 81 | 82 | const userEmail = payload.user?.email; 83 | 84 | if (!userEmail) { 85 | console.debug("Request rejected - missing user email"); 86 | return res.status(400).json({ success: false, message: "No user email." }); 87 | } 88 | 89 | const klaviyoClient = Klaviyo(klaviyoToken); 90 | const klaviyoResponse = await klaviyoClient.send(klaviyoMetric, userEmail, payload); 91 | 92 | if (klaviyoResponse.status !== 200) { 93 | const klaviyoMessage = ` Message: ${(await klaviyoResponse.json())?.message}.` || ""; 94 | console.debug("Klaviyo returned error: ", klaviyoMessage); 95 | 96 | return res.status(500).json({ 97 | success: false, 98 | message: `Klaviyo API responded with status ${klaviyoResponse.status}.${klaviyoMessage}`, 99 | }); 100 | } 101 | 102 | console.debug("Webhook processed successfully"); 103 | return res.status(200).json({ success: true, message: "Message sent!" }); 104 | }; 105 | 106 | export default customerCreatedWebhook.createHandler(handler); 107 | 108 | export const config = { 109 | api: { 110 | bodyParser: false, 111 | }, 112 | }; 113 | -------------------------------------------------------------------------------- /pages/api/webhooks/fulfillment-created.ts: -------------------------------------------------------------------------------- 1 | import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; 2 | import { gql } from "urql"; 3 | 4 | import { 5 | FulfillmentCreatedWebhookPayloadFragment, 6 | UntypedFulfillmentCreatedDocument, 7 | } from "../../../generated/graphql"; 8 | import { createClient } from "../../../lib/graphql"; 9 | import Klaviyo from "../../../lib/klaviyo"; 10 | import { createSettingsManager } from "../../../lib/metadata"; 11 | import { saleorApp } from "../../../saleor-app"; 12 | 13 | const FulfillmentCreatedWebhookPayload = gql` 14 | fragment FulfillmentCreatedWebhookPayload on FulfillmentCreated { 15 | fulfillment { 16 | __typename 17 | id 18 | warehouse { 19 | address { 20 | ...AddressFragment 21 | } 22 | } 23 | lines { 24 | __typename 25 | id 26 | quantity 27 | orderLine { 28 | productName 29 | variantName 30 | productSku 31 | productVariantId 32 | unitPrice { 33 | ...TaxedMoneyFragment 34 | } 35 | undiscountedUnitPrice { 36 | ...TaxedMoneyFragment 37 | } 38 | totalPrice { 39 | ...TaxedMoneyFragment 40 | } 41 | } 42 | } 43 | } 44 | order { 45 | ...OrderFragment 46 | } 47 | } 48 | `; 49 | 50 | export const FulfillmentCreatedGraphqlSubscription = gql` 51 | ${FulfillmentCreatedWebhookPayload} 52 | subscription FulfillmentCreated { 53 | event { 54 | ...FulfillmentCreatedWebhookPayload 55 | } 56 | } 57 | `; 58 | 59 | export const fulfillmentCreatedWebhook = 60 | new SaleorAsyncWebhook({ 61 | name: "Fulfillment Created", 62 | webhookPath: "api/webhooks/fulfillment-created", 63 | asyncEvent: "FULFILLMENT_CREATED", 64 | apl: saleorApp.apl, 65 | subscriptionQueryAst: UntypedFulfillmentCreatedDocument, 66 | }); 67 | 68 | const handler: NextWebhookApiHandler = async ( 69 | req, 70 | res, 71 | context 72 | ) => { 73 | console.debug("fulfillmentCreatedWebhook handler called"); 74 | 75 | const { payload, authData } = context; 76 | const { saleorApiUrl, token, appId } = authData; 77 | const client = createClient(saleorApiUrl, async () => Promise.resolve({ token })); 78 | const settings = createSettingsManager(client, appId); 79 | 80 | const klaviyoToken = await settings.get("PUBLIC_TOKEN"); 81 | const klaviyoMetric = await settings.get("FULFILLMENT_CREATED_METRIC"); 82 | 83 | if (!klaviyoToken || !klaviyoMetric) { 84 | return res.status(400).json({ success: false, message: "App not configured." }); 85 | } 86 | 87 | const { userEmail } = payload.order || {}; 88 | 89 | if (!userEmail) { 90 | console.debug("Request rejected - missing user email"); 91 | return res.status(400).json({ success: false, message: "No user email." }); 92 | } 93 | 94 | const klaviyoClient = Klaviyo(klaviyoToken); 95 | const klaviyoResponse = await klaviyoClient.send(klaviyoMetric, userEmail, payload); 96 | 97 | if (klaviyoResponse.status !== 200) { 98 | const klaviyoMessage = ` Message: ${(await klaviyoResponse.json())?.message}.` || ""; 99 | console.debug("Klaviyo returned error: ", klaviyoMessage); 100 | 101 | return res.status(500).json({ 102 | success: false, 103 | message: `Klaviyo API responded with status ${klaviyoResponse.status}.${klaviyoMessage}`, 104 | }); 105 | } 106 | 107 | console.debug("Webhook processed successfully"); 108 | return res.status(200).json({ success: true, message: "Message sent!" }); 109 | }; 110 | 111 | export default fulfillmentCreatedWebhook.createHandler(handler); 112 | 113 | export const config = { 114 | api: { 115 | bodyParser: false, 116 | }, 117 | }; 118 | -------------------------------------------------------------------------------- /pages/api/webhooks/order-created.ts: -------------------------------------------------------------------------------- 1 | import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; 2 | import { gql } from "urql"; 3 | 4 | import { 5 | OrderCreatedWebhookPayloadFragment, 6 | UntypedOrderCreatedDocument, 7 | } from "../../../generated/graphql"; 8 | import { createClient } from "../../../lib/graphql"; 9 | import Klaviyo from "../../../lib/klaviyo"; 10 | import { createSettingsManager } from "../../../lib/metadata"; 11 | import { saleorApp } from "../../../saleor-app"; 12 | 13 | const OrderCreatedWebhookPayload = gql` 14 | fragment OrderCreatedWebhookPayload on OrderCreated { 15 | order { 16 | ...OrderFragment 17 | } 18 | } 19 | `; 20 | 21 | export const OrderCreatedGraphqlSubscription = gql` 22 | ${OrderCreatedWebhookPayload} 23 | subscription OrderCreated { 24 | event { 25 | ...OrderCreatedWebhookPayload 26 | } 27 | } 28 | `; 29 | 30 | export const orderCreatedWebhook = new SaleorAsyncWebhook({ 31 | name: "Order Created", 32 | webhookPath: "api/webhooks/order-created", 33 | asyncEvent: "ORDER_CREATED", 34 | apl: saleorApp.apl, 35 | subscriptionQueryAst: UntypedOrderCreatedDocument, 36 | }); 37 | 38 | const handler: NextWebhookApiHandler = async ( 39 | req, 40 | res, 41 | context 42 | ) => { 43 | console.debug("orderCreatedWebhook handler called"); 44 | 45 | const { payload, authData } = context; 46 | const { saleorApiUrl, token, appId } = authData; 47 | const client = createClient(saleorApiUrl, async () => Promise.resolve({ token })); 48 | const settings = createSettingsManager(client, appId); 49 | 50 | const klaviyoToken = await settings.get("PUBLIC_TOKEN"); 51 | const klaviyoMetric = await settings.get("ORDER_CREATED_METRIC"); 52 | 53 | if (!klaviyoToken || !klaviyoMetric) { 54 | console.debug("Request rejected - app not configured"); 55 | return res.status(400).json({ success: false, message: "App not configured." }); 56 | } 57 | 58 | const { userEmail } = payload.order || {}; 59 | 60 | if (!userEmail) { 61 | console.debug("Request rejected - missing user email"); 62 | return res.status(400).json({ success: false, message: "No user email." }); 63 | } 64 | 65 | const klaviyoClient = Klaviyo(klaviyoToken); 66 | const klaviyoResponse = await klaviyoClient.send(klaviyoMetric, userEmail, payload); 67 | 68 | if (klaviyoResponse.status !== 200) { 69 | const klaviyoMessage = ` Message: ${(await klaviyoResponse.json())?.message}.` || ""; 70 | console.debug("Klaviyo returned error: ", klaviyoMessage); 71 | return res.status(500).json({ 72 | success: false, 73 | message: `Klaviyo API responded with status ${klaviyoResponse.status}.${klaviyoMessage}`, 74 | }); 75 | } 76 | 77 | console.debug("Webhook processed successfully"); 78 | return res.status(200).json({ success: true, message: "Message sent!" }); 79 | }; 80 | 81 | export default orderCreatedWebhook.createHandler(handler); 82 | 83 | export const config = { 84 | api: { 85 | bodyParser: false, 86 | }, 87 | }; 88 | -------------------------------------------------------------------------------- /pages/api/webhooks/order-fully-paid.ts: -------------------------------------------------------------------------------- 1 | import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; 2 | import { gql } from "urql"; 3 | 4 | import { 5 | OrderFullyPaidWebhookPayloadFragment, 6 | UntypedOrderFullyPaidDocument, 7 | } from "../../../generated/graphql"; 8 | import { createClient } from "../../../lib/graphql"; 9 | import Klaviyo from "../../../lib/klaviyo"; 10 | import { createSettingsManager } from "../../../lib/metadata"; 11 | import { saleorApp } from "../../../saleor-app"; 12 | 13 | const OrderFullyPaidWebhookPayload = gql` 14 | fragment OrderFullyPaidWebhookPayload on OrderFullyPaid { 15 | order { 16 | ...OrderFragment 17 | } 18 | } 19 | `; 20 | 21 | export const OrderFullyPaidGraphqlSubscription = gql` 22 | ${OrderFullyPaidWebhookPayload} 23 | subscription OrderFullyPaid { 24 | event { 25 | ...OrderFullyPaidWebhookPayload 26 | } 27 | } 28 | `; 29 | 30 | export const orderFullyPaidWebhook = new SaleorAsyncWebhook({ 31 | name: "Order Fully Paid", 32 | webhookPath: "api/webhooks/order-fully-paid", 33 | asyncEvent: "ORDER_FULLY_PAID", 34 | apl: saleorApp.apl, 35 | subscriptionQueryAst: UntypedOrderFullyPaidDocument, 36 | }); 37 | 38 | const handler: NextWebhookApiHandler = async ( 39 | req, 40 | res, 41 | context 42 | ) => { 43 | console.debug("orderFullyPaidWebhook handler called"); 44 | 45 | const { payload, authData } = context; 46 | const { saleorApiUrl, token, appId } = authData; 47 | const client = createClient(saleorApiUrl, async () => Promise.resolve({ token })); 48 | const settings = createSettingsManager(client, appId); 49 | 50 | const klaviyoToken = await settings.get("PUBLIC_TOKEN"); 51 | const klaviyoMetric = await settings.get("ORDER_FULLY_PAID_METRIC"); 52 | 53 | if (!klaviyoToken || !klaviyoMetric) { 54 | console.debug("Request rejected - app not configured"); 55 | return res.status(400).json({ success: false, message: "App not configured." }); 56 | } 57 | 58 | const { userEmail } = payload.order || {}; 59 | 60 | if (!userEmail) { 61 | console.debug("Request rejected - missing user email"); 62 | return res.status(400).json({ success: false, message: "No user email." }); 63 | } 64 | 65 | const klaviyoClient = Klaviyo(klaviyoToken); 66 | const klaviyoResponse = await klaviyoClient.send(klaviyoMetric, userEmail, payload); 67 | 68 | if (klaviyoResponse.status !== 200) { 69 | const klaviyoMessage = ` Message: ${(await klaviyoResponse.json())?.message}.` || ""; 70 | console.debug("Klaviyo returned error: ", klaviyoMessage); 71 | 72 | return res.status(500).json({ 73 | success: false, 74 | message: `Klaviyo API responded with status ${klaviyoResponse.status}.${klaviyoMessage}`, 75 | }); 76 | } 77 | 78 | console.debug("Webhook processed successfully"); 79 | return res.status(200).json({ success: true, message: "Message sent!" }); 80 | }; 81 | 82 | export default orderFullyPaidWebhook.createHandler(handler); 83 | 84 | export const config = { 85 | api: { 86 | bodyParser: false, 87 | }, 88 | }; 89 | -------------------------------------------------------------------------------- /pages/configuration.tsx: -------------------------------------------------------------------------------- 1 | import { Link, List, ListItem, Paper, PaperProps, TextField, Typography } from "@material-ui/core"; 2 | import Skeleton from "@material-ui/lab/Skeleton"; 3 | import { useAppBridge, withAuthorization } from "@saleor/app-sdk/app-bridge"; 4 | import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const"; 5 | import { ConfirmButton, ConfirmButtonTransitionState, makeStyles } from "@saleor/macaw-ui"; 6 | import { ChangeEvent, SyntheticEvent, useEffect, useState } from "react"; 7 | 8 | import AccessWarning from "../components/AccessWarning/AccessWarning"; 9 | import useAppApi from "../hooks/useAppApi"; 10 | import { AppColumnsLayout } from "../lib/ui/app-columns-layout"; 11 | import { AppIcon } from "../lib/ui/app-icon"; 12 | import { MainBar } from "../lib/ui/main-bar"; 13 | import useDashboardNotifier from "../utils/useDashboardNotifier"; 14 | 15 | interface ConfigurationField { 16 | key: string; 17 | value: string; 18 | } 19 | 20 | const useStyles = makeStyles((theme) => ({ 21 | confirmButton: { 22 | marginLeft: "auto", 23 | }, 24 | fieldContainer: { 25 | marginBottom: theme.spacing(2), 26 | }, 27 | })); 28 | 29 | function Section(props: PaperProps) { 30 | return ; 31 | } 32 | 33 | function Instructions() { 34 | const { appBridge } = useAppBridge(); 35 | 36 | const openExternalUrl = (url: string) => { 37 | // eslint-disable-next-line 38 | appBridge?.dispatch({ 39 | type: "redirect", 40 | payload: { 41 | newContext: true, 42 | actionId: "redirect_from_klaviyo_app", 43 | to: url, 44 | }, 45 | }); 46 | }; 47 | 48 | return ( 49 |
50 | 51 | How to set up 52 | 53 | 54 | App will send events as Klaviyo metrics each time Saleor Event occurs. 55 | 56 | 57 | When first metric is sent, it should be available in Klaviyo to build on top of. 58 | 59 | 60 | Metric name can be customized, PUBLIC_TOKEN must be provided to enable the app. 61 | 62 | Useful links 63 | 64 | 65 | { 67 | e.preventDefault(); 68 | 69 | openExternalUrl("https://github.com/saleor/saleor-app-klaviyo"); 70 | }} 71 | href="https://github.com/saleor/saleor-app-klaviyo" 72 | > 73 | Visit repository & readme 74 | 75 | 76 | 77 | How to configure 78 | 79 | 80 | { 82 | e.preventDefault(); 83 | 84 | openExternalUrl( 85 | "https://help.klaviyo.com/hc/en-us/articles/115005062267-How-to-Manage-Your-Account-s-API-Keys" 86 | ); 87 | }} 88 | href="https://help.klaviyo.com/hc/en-us/articles/115005062267-How-to-Manage-Your-Account-s-API-Keys" 89 | > 90 | Read about public tokens 91 | 92 | 93 | 94 | { 96 | e.preventDefault(); 97 | 98 | openExternalUrl("https://www.klaviyo.com/account#api-keys-tab"); 99 | }} 100 | href="https://www.klaviyo.com/account#api-keys-tab" 101 | > 102 | Get public token here 103 | 104 | 105 | 106 | { 108 | e.preventDefault(); 109 | 110 | openExternalUrl( 111 | "https://help.klaviyo.com/hc/en-us/articles/115005076787-Guide-to-Managing-Your-Metrics" 112 | ); 113 | }} 114 | href="https://help.klaviyo.com/hc/en-us/articles/115005076787-Guide-to-Managing-Your-Metrics" 115 | > 116 | Read about metrics 117 | 118 | 119 | 120 |
121 | ); 122 | } 123 | 124 | function Configuration() { 125 | const { appBridgeState } = useAppBridge(); 126 | const classes = useStyles(); 127 | const [notify] = useDashboardNotifier(); 128 | const [configuration, setConfiguration] = useState(); 129 | const [transitionState, setTransitionState] = useState("default"); 130 | 131 | const { data: configurationData, error } = useAppApi({ 132 | url: "/api/configuration", 133 | }); 134 | 135 | useEffect(() => { 136 | if (configurationData && !configuration) { 137 | setConfiguration(configurationData.data); 138 | } 139 | }, [configurationData, configuration]); 140 | 141 | /** 142 | * TODO Rewrite to tRPC 143 | */ 144 | const handleSubmit = (event: SyntheticEvent) => { 145 | event.preventDefault(); 146 | setTransitionState("loading"); 147 | 148 | fetch("/api/configuration", { 149 | method: "POST", 150 | headers: [ 151 | ["content-type", "application/json"], 152 | [SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!], 153 | [SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!], 154 | ], 155 | body: JSON.stringify({ data: configuration }), 156 | }) 157 | .then(async (response) => { 158 | if (response.status !== 200) { 159 | throw new Error("Error saving configuration data"); 160 | } 161 | setTransitionState("success"); 162 | 163 | await notify({ 164 | status: "success", 165 | title: "Success", 166 | text: "Configuration updated successfully", 167 | }); 168 | }) 169 | .catch(async () => { 170 | setTransitionState("error"); 171 | await notify({ 172 | status: "error", 173 | title: 174 | "Configuration update failed. Ensure fields are filled correctly and you have MANAGE_APPS permission", 175 | }); 176 | }); 177 | }; 178 | 179 | const onChange = (event: ChangeEvent) => { 180 | const { name, value } = event.target as HTMLInputElement; 181 | setConfiguration((prev) => 182 | prev!.map((prevField) => (prevField.key === name ? { ...prevField, value } : prevField)) 183 | ); 184 | }; 185 | 186 | if (error) { 187 | console.error("Can't establish connection with the App API: ", error); 188 | return ( 189 |
190 |

⚠️ Can't connect with the App API

191 | You may see this error because: 192 |
    193 |
  • Internet connection has been lost
  • 194 |
  • 195 | Application installation process is still in progress. If you use Vercel, you may need 196 | to wait for redeployment of the app - try again in a minute. 197 |
  • 198 |
  • 199 | Application is misconfigured. If you would like to know more how auth configuration is 200 | kept,{" "} 201 | 206 | go to APL documentation 207 | 208 | . 209 |
  • 210 |
211 |
212 | ); 213 | } 214 | 215 | if (configuration === undefined) { 216 | return ; 217 | } 218 | 219 | return ( 220 |
221 | } 223 | bottomMargin 224 | name="Saleor Klaviyo App" 225 | author="By Saleor Commerce" 226 | /> 227 | 228 |
229 |
230 |
231 | {configuration!.map(({ key, value }) => ( 232 |
233 | 234 |
235 | ))} 236 |
237 | 247 |
248 |
249 |
250 | 251 | 252 |
253 | ); 254 | } 255 | 256 | export default withAuthorization({ 257 | notIframe: , 258 | unmounted: null, 259 | noDashboardToken: , 260 | dashboardTokenInvalid: , 261 | })(Configuration); 262 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saleor/saleor-app-klaviyo/80deb95e28e12ce1dc5119bf5ba83078b45614e3/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /saleor-app.ts: -------------------------------------------------------------------------------- 1 | import { APL, FileAPL, SaleorCloudAPL, UpstashAPL, VercelAPL } from "@saleor/app-sdk/APL"; 2 | import { SaleorApp } from "@saleor/app-sdk/saleor-app"; 3 | 4 | /** 5 | * By default auth data are stored in the `.auth-data.json` (FileAPL). 6 | * For multi-tenant applications and deployments please use UpstashAPL. 7 | * 8 | * To read more about storing auth data, read the 9 | * [APL documentation](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md) 10 | */ 11 | const aplType = process.env.APL ?? "file"; 12 | let apl: APL; 13 | 14 | switch (aplType) { 15 | case "vercel": 16 | apl = new VercelAPL(); 17 | 18 | break; 19 | case "upstash": 20 | apl = new UpstashAPL(); 21 | 22 | break; 23 | case "file": 24 | apl = new FileAPL(); 25 | 26 | break; 27 | case "saleor-cloud": { 28 | if (!process.env.REST_APL_ENDPOINT || !process.env.REST_APL_TOKEN) { 29 | throw new Error("Rest APL is not configured - missing env variables. Check saleor-app.ts"); 30 | } 31 | 32 | apl = new SaleorCloudAPL({ 33 | resourceUrl: process.env.REST_APL_ENDPOINT, 34 | token: process.env.REST_APL_TOKEN, 35 | }); 36 | 37 | break; 38 | } 39 | default: { 40 | throw new Error("Invalid APL config, "); 41 | } 42 | } 43 | 44 | if (!process.env.SECRET_KEY && process.env.NODE_ENV === "production") { 45 | throw new Error( 46 | "For production deployment SECRET_KEY is mandatory to use EncryptedSettingsManager." 47 | ); 48 | } 49 | 50 | // Use placeholder value for the development 51 | export const settingsManagerSecretKey = process.env.SECRET_KEY || "CHANGE_ME"; 52 | 53 | export const saleorApp = new SaleorApp({ 54 | apl, 55 | }); 56 | -------------------------------------------------------------------------------- /sentry.client.config.js: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the browser. 2 | // The config you add here will be used whenever a page is visited. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from "@sentry/nextjs"; 6 | 7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; 8 | 9 | Sentry.init({ 10 | dsn: SENTRY_DSN, 11 | // Adjust this value in production, or use tracesSampler for greater control 12 | tracesSampleRate: 1.0, 13 | // ... 14 | // Note: if you want to override the automatic release value, do not set a 15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 16 | // that it will also get attached to your source maps 17 | }); 18 | -------------------------------------------------------------------------------- /sentry.server.config.js: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from "@sentry/nextjs"; 6 | 7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; 8 | 9 | Sentry.init({ 10 | dsn: SENTRY_DSN, 11 | // Adjust this value in production, or use tracesSampler for greater control 12 | tracesSampleRate: 1.0, 13 | // ... 14 | // Note: if you want to override the automatic release value, do not set a 15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 16 | // that it will also get attached to your source maps 17 | }); 18 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, 3 | "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 4 | color: #111; 5 | padding: 1rem 2rem; 6 | } 7 | 8 | code { 9 | background: #f6f8fa; 10 | border: 1px solid #eaeaea; 11 | border-radius: 5px; 12 | display: inline-block; 13 | margin-top: 10px; 14 | padding: 0.75rem; 15 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 16 | Bitstream Vera Sans Mono, Courier New, monospace; 17 | } 18 | 19 | code::before { 20 | content: "$ "; 21 | opacity: 0.6; 22 | } 23 | 24 | li { 25 | padding-bottom: 1rem; 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import { NextPage } from "next"; 2 | import { AppProps as NextAppProps } from "next/app"; 3 | import { NextComponentType, NextPageContext } from "next/types"; 4 | import { ReactElement, ReactNode } from "react"; 5 | 6 | export type PageWithLayout = NextPage & { 7 | getLayout?: (page: ReactElement) => ReactNode; 8 | }; 9 | 10 | export type AppProps = { 11 | pageProps: NextAppProps["pageProps"]; 12 | Component: NextComponentType & { layoutProps: any }; 13 | }; 14 | 15 | export type AppLayoutProps = AppProps & { 16 | Component: PageWithLayout; 17 | }; 18 | -------------------------------------------------------------------------------- /utils/useDashboardNotifier.ts: -------------------------------------------------------------------------------- 1 | import { actions, NotificationPayload, useAppBridge } from "@saleor/app-sdk/app-bridge"; 2 | 3 | const useDashboardNotifier = () => { 4 | const { appBridgeState, appBridge } = useAppBridge(); 5 | 6 | const notify = (payload: NotificationPayload) => 7 | appBridgeState?.ready && appBridge?.dispatch(actions.Notification(payload)); 8 | 9 | return [notify]; 10 | }; 11 | 12 | export default useDashboardNotifier; 13 | --------------------------------------------------------------------------------