Add Klaviyo (#112)
* Add Klaviyo * Update schema * Fix build * Empty commit to trigger deploy
5
.changeset/large-apes-deny.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-klaviyo": major
|
||||
---
|
||||
|
||||
Add Klaviyo app to workspace
|
9
apps/klaviyo/.env.example
Normal file
|
@ -0,0 +1,9 @@
|
|||
APL=file
|
||||
# Optional
|
||||
# Regex pattern consumed conditionally to restrcit app installation to specific urls.
|
||||
# See api/register.tsx
|
||||
# Leave empty to allow all domains
|
||||
# Example: "https:\/\/.*.saleor.cloud\/graphql\/" to enable Saleor Cloud APIs
|
||||
ALLOWED_DOMAIN_PATTERN=
|
||||
# Encryption key used by the EncryptedSettingsManager. Required by the production builds
|
||||
SECRET_KEY=
|
3
apps/klaviyo/.eslintrc
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["saleor"]
|
||||
}
|
20
apps/klaviyo/.graphqlrc.yml
Normal file
|
@ -0,0 +1,20 @@
|
|||
schema: graphql/schema.graphql
|
||||
documents: [graphql/**/*.graphql, ./**/*.ts, ./**/*.tsx]
|
||||
extensions:
|
||||
codegen:
|
||||
overwrite: true
|
||||
generates:
|
||||
generated/graphql.ts:
|
||||
config:
|
||||
dedupeFragments: true
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
- urql-introspection
|
||||
- typescript-urql:
|
||||
documentVariablePrefix: "Untyped"
|
||||
fragmentVariablePrefix: "Untyped"
|
||||
- typed-document-node
|
||||
generated/schema.graphql:
|
||||
plugins:
|
||||
- schema-ast
|
85
apps/klaviyo/README.md
Normal file
|
@ -0,0 +1,85 @@
|
|||
# Saleor App Klaviyo
|
||||
|
||||
Simple Klaviyo example app for Saleor
|
||||
|
||||

|
||||
|
||||
### What's included?
|
||||
|
||||
- 🧩 Connection between Saleor and Klaviyo
|
||||
- 🏆 4 built-in, example webhooks, that trigger Klaviyo metrics
|
||||
- 🎨 Add new webhooks with ease
|
||||
|
||||
### The stack
|
||||
|
||||
Klaviyo app is based on App Template - you can check it [here](https://github.com/saleor/saleor-app-template)
|
||||
|
||||
## How to use this project
|
||||
|
||||
### Requirements
|
||||
|
||||
- [node v16](http://nodejs.com)
|
||||
- [pnpm](https://pnpm.io/)
|
||||
- Saleor Cloud account (free!) or local instance
|
||||
- [Saleor CLI](https://docs.saleor.io/docs/3.x/developer/cli)
|
||||
|
||||
### Saleor CLI for the win 🚀
|
||||
|
||||
[Saleor CLI](https://github.com/saleor/saleor-cli) is designed to save you from the repetitive chores around Saleor development, including creating Apps. It will take the burden of spawning new apps locally, connecting them with Saleor environments, and establishing a tunnel for local development in seconds.
|
||||
|
||||
[Full Saleor CLI reference](https://docs.saleor.io/docs/3.x/developer/cli)
|
||||
|
||||
### Local development
|
||||
|
||||
1. Make sure you have installed required tools
|
||||
2. Clone repository
|
||||
3. Install app dependencies with `pnpm i`
|
||||
4. Start dev server `pnpm dev`
|
||||
5. To install app in your Saleor Instance and expose dev server to it, run in the separate terminal `saleor app tunnel`
|
||||
6. Now you can access the app configuration at [your dashboard]/apps and click on [klavio]
|
||||
7. Read how to connect the app with the Klaviyo [here](https://github.com/saleor/saleor-app-template)
|
||||
|
||||
### Local development without CLI
|
||||
|
||||
Add URL of your Saleor instance to the `.env` file:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SALEOR_HOST_URL=https://your-saleor-instance.com
|
||||
```
|
||||
|
||||
Install dependencies `pnpm install`
|
||||
|
||||
Start local server `pnpm run dev`
|
||||
|
||||
Follow the guide [how install your app](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installation-using-graphql-api) and use tunneling tools like [localtunnel](https://github.com/localtunnel/localtunnel) or [ngrok](https://ngrok.com/) in order to expose your local server.
|
||||
|
||||
If you use [saleor-dashboard](https://github.com/saleor/saleor-dashboard) and your local server is exposed, you can install your app by following this link:
|
||||
|
||||
```
|
||||
[YOUR_SALEOR_DASHBOARD_URL]/apps/install?manifestUrl=[YOUR_APPS_MANIFEST_URL]
|
||||
```
|
||||
|
||||
### Generated schema and typings
|
||||
|
||||
Commands `build` and `dev` would generate schema and typed functions using Saleor's GraphQL endpoint. Commit `generated` folder to your repo as they are necessary for queries and keeping track of the schema changes.
|
||||
|
||||
[Learn more](https://www.graphql-code-generator.com/) about GraphQL code generation.
|
||||
|
||||
### Storing registration data - APL
|
||||
|
||||
During registration process Saleor API pass the auth token to the app. With this token App can query Saleor API with privileged access (depending on requested permissions during the installation).
|
||||
To store this data, app-template use a different [APL interfaces](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md).
|
||||
|
||||
The choice of the APL is done using `APL` environment variable. If value is not set, FileAPL is used. Available choices:
|
||||
|
||||
- `file`: no additional setup is required. Good choice for local development. Can't be used for multi tenant-apps or be deployed (not intended for production)
|
||||
- `upstash`: use [Upstash](https://upstash.com/) Redis as storage method. Free account required. Can be used for development and production and supports multi-tenancy. Requires `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables to be set
|
||||
- `vercel`: used by deployments from the Marketplace. It's single-tenant only and only supported by Vercel deployments done with Saleor CLI. Requires `SALEOR_REGISTER_APP_URL` and `SALEOR_DEPLOYMENT_TOKEN` environment variables to be set (handled automatically by the Saleor CLI)
|
||||
|
||||
If you want to use your own database, you can implement your own APL. [Check the documentation to read more.](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
|
||||
|
||||
### Learn more about Saleor Apps
|
||||
|
||||
[Apps guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts)
|
||||
|
||||
[Configuring apps in dashboard](https://docs.saleor.io/docs/3.x/dashboard/apps)
|
34
apps/klaviyo/components/AccessWarning/AccessWarning.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { Typography } from "@material-ui/core";
|
||||
import React from "react";
|
||||
|
||||
type WarningCause =
|
||||
| "not_in_iframe"
|
||||
| "missing_access_token"
|
||||
| "invalid_access_token"
|
||||
| "unknown_cause";
|
||||
|
||||
interface AccessWarningProps {
|
||||
cause?: WarningCause;
|
||||
}
|
||||
|
||||
const warnings: Record<WarningCause, string> = {
|
||||
not_in_iframe: "The view can only be displayed in the iframe.",
|
||||
missing_access_token: "App doesn't have an access token.",
|
||||
invalid_access_token: "Access token is invalid.",
|
||||
unknown_cause: "Something went wrong.",
|
||||
};
|
||||
|
||||
function AccessWarning({ cause = "unknown_cause" }: AccessWarningProps) {
|
||||
return (
|
||||
<div suppressHydrationWarning>
|
||||
<Typography variant="subtitle1">
|
||||
App can't be accessed outside of the Saleor Dashboard
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" style={{ marginTop: "2rem" }}>
|
||||
❌ {warnings[cause]}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccessWarning;
|
20
apps/klaviyo/components/LoadingPage/LoadingPage.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { CircularProgress, Typography } from "@material-ui/core";
|
||||
import React from "react";
|
||||
|
||||
import { useStyles } from "./styles";
|
||||
|
||||
function LoadingPage() {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div className={classes.loaderContainer}>
|
||||
<CircularProgress size={100} />
|
||||
|
||||
<Typography variant="subtitle1" className={classes.message}>
|
||||
Attempting connection to Saleor Dashboard
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingPage;
|
14
apps/klaviyo/components/LoadingPage/styles.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
loaderContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
message: {
|
||||
marginTop: theme.spacing(4),
|
||||
},
|
||||
}));
|
||||
|
||||
export { useStyles };
|
45
apps/klaviyo/docs/connect-with-klaviyo.md
Normal file
|
@ -0,0 +1,45 @@
|
|||
# How to connect your App with Klavio
|
||||
|
||||
## Installation
|
||||
|
||||
Follow [readme](../README.md) and deploy app. Then, install it in your Saleor Dashboard
|
||||
|
||||
## Creating Klaviyo account
|
||||
|
||||
Before we continue, you need a Klaviyo account. You can sign up [here](https://www.klaviyo.com/).
|
||||
|
||||
## Accessing your public key
|
||||
|
||||
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).
|
||||
|
||||
## Dashboard configuration
|
||||
|
||||
1. Open Dashboard and navigate to the "Apps" section.
|
||||
2. Find your fresh installed Klaviyo app.
|
||||
3. Paste your public key into the input field and save.
|
||||
|
||||
## Triggering the initial webhook
|
||||
|
||||
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).
|
||||
|
||||
Let's navigate to "Customers" and create the first, dummy customer.
|
||||
|
||||
Then, open Klavio [Metrics page](https://www.klaviyo.com/analytics/metrics).
|
||||
|
||||
Your Metric should be visible on the list:
|
||||
|
||||

|
||||
|
||||
## Creating a flow
|
||||
|
||||
Now, you can create your first flow
|
||||
|
||||
1. Open the [flow creation page](https://www.klaviyo.com/flows/create).
|
||||
2. Click "Create from scratch" and name your flow.
|
||||
[](readme-assets/flow-creation.png)
|
||||
3. Create a new trigger with "Metric".
|
||||
[](readme-assets/trigger-setup.png)
|
||||
4. Your freshly sent Metric should be available.
|
||||
[](readme-assets/trigger-metric.png)
|
||||
5. Now you can proceed to create your flow. Feel free to welcome your user.
|
||||
[](readme-assets/flow-screen.png)
|
BIN
apps/klaviyo/docs/readme-assets/app-screen.png
Normal file
After Width: | Height: | Size: 79 KiB |
BIN
apps/klaviyo/docs/readme-assets/flow-creation.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
apps/klaviyo/docs/readme-assets/flow-screen.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
apps/klaviyo/docs/readme-assets/new-metric.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
apps/klaviyo/docs/readme-assets/trigger-metric.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
apps/klaviyo/docs/readme-assets/trigger-setup.png
Normal file
After Width: | Height: | Size: 42 KiB |
82847
apps/klaviyo/generated/graphql.ts
Normal file
26689
apps/klaviyo/generated/schema.graphql
Normal file
17
apps/klaviyo/graphql/fragments/AddressFragment.graphql
Normal file
|
@ -0,0 +1,17 @@
|
|||
fragment AddressFragment on Address {
|
||||
__typename
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
companyName
|
||||
streetAddress1
|
||||
streetAddress2
|
||||
city
|
||||
cityArea
|
||||
postalCode
|
||||
country {
|
||||
code
|
||||
}
|
||||
countryArea
|
||||
phone
|
||||
}
|
4
apps/klaviyo/graphql/fragments/MetadataFragment.graphql
Normal file
|
@ -0,0 +1,4 @@
|
|||
fragment MetadataFragment on MetadataItem {
|
||||
key
|
||||
value
|
||||
}
|
4
apps/klaviyo/graphql/fragments/MoneyFragment.graphql
Normal file
|
@ -0,0 +1,4 @@
|
|||
fragment MoneyFragment on Money {
|
||||
amount
|
||||
currency
|
||||
}
|
89
apps/klaviyo/graphql/fragments/OrderFragment.graphql
Normal file
|
@ -0,0 +1,89 @@
|
|||
fragment OrderFragment on Order {
|
||||
__typename
|
||||
id
|
||||
channel {
|
||||
__typename
|
||||
id
|
||||
slug
|
||||
currencyCode
|
||||
}
|
||||
shippingMethod {
|
||||
type
|
||||
id
|
||||
name
|
||||
}
|
||||
shippingAddress {
|
||||
...AddressFragment
|
||||
}
|
||||
billingAddress {
|
||||
...AddressFragment
|
||||
}
|
||||
discounts {
|
||||
id
|
||||
}
|
||||
token
|
||||
userEmail
|
||||
created
|
||||
original
|
||||
lines {
|
||||
__typename
|
||||
id
|
||||
productVariantId
|
||||
totalPrice {
|
||||
...TaxedMoneyFragment
|
||||
}
|
||||
allocations {
|
||||
quantity
|
||||
warehouse {
|
||||
id
|
||||
}
|
||||
}
|
||||
productName
|
||||
variantName
|
||||
translatedProductName
|
||||
translatedVariantName
|
||||
productSku
|
||||
quantity
|
||||
unitDiscountValue
|
||||
unitDiscountType
|
||||
unitDiscountReason
|
||||
unitPrice {
|
||||
...TaxedMoneyFragment
|
||||
}
|
||||
undiscountedUnitPrice {
|
||||
...TaxedMoneyFragment
|
||||
}
|
||||
taxRate
|
||||
}
|
||||
fulfillments {
|
||||
id
|
||||
}
|
||||
payments {
|
||||
...PaymentFragment
|
||||
}
|
||||
privateMetadata {
|
||||
...MetadataFragment
|
||||
}
|
||||
metadata {
|
||||
...MetadataFragment
|
||||
}
|
||||
status
|
||||
languageCodeEnum
|
||||
origin
|
||||
shippingMethodName
|
||||
collectionPointName
|
||||
shippingPrice {
|
||||
...TaxedMoneyFragment
|
||||
}
|
||||
shippingTaxRate
|
||||
total {
|
||||
...TaxedMoneyFragment
|
||||
}
|
||||
undiscountedTotal {
|
||||
...TaxedMoneyFragment
|
||||
}
|
||||
weight {
|
||||
value
|
||||
unit
|
||||
}
|
||||
}
|
19
apps/klaviyo/graphql/fragments/PaymentFragment.graphql
Normal file
|
@ -0,0 +1,19 @@
|
|||
fragment PaymentFragment on Payment {
|
||||
__typename
|
||||
id
|
||||
created
|
||||
modified
|
||||
gateway
|
||||
isActive
|
||||
chargeStatus
|
||||
total {
|
||||
amount
|
||||
}
|
||||
capturedAmount {
|
||||
...MoneyFragment
|
||||
}
|
||||
creditCard {
|
||||
brand
|
||||
}
|
||||
paymentMethodType
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
fragment TaxedMoneyFragment on TaxedMoney {
|
||||
currency
|
||||
net {
|
||||
...MoneyFragment
|
||||
}
|
||||
gross {
|
||||
...MoneyFragment
|
||||
}
|
||||
}
|
10
apps/klaviyo/graphql/mutations/UpdateAppMetadata.graphql
Normal file
|
@ -0,0 +1,10 @@
|
|||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||
updatePrivateMetadata(id: $id, input: $input) {
|
||||
item {
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
9
apps/klaviyo/graphql/queries/FetchAppDetails.graphql
Normal file
|
@ -0,0 +1,9 @@
|
|||
query FetchAppDetails {
|
||||
app {
|
||||
id
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
26596
apps/klaviyo/graphql/schema.graphql
Normal file
33
apps/klaviyo/hooks/theme-synchronizer.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useTheme } from "@saleor/macaw-ui";
|
||||
import { memo, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
|
||||
* Macaw must be informed about this change from AppBridge.
|
||||
*
|
||||
* If you are not using Macaw, you can remove this.
|
||||
*/
|
||||
function _ThemeSynchronizer() {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { setTheme, themeType } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTheme || !appBridgeState?.theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (themeType !== appBridgeState?.theme) {
|
||||
setTheme(appBridgeState.theme);
|
||||
/**
|
||||
* Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state)
|
||||
* TODO Fix me when Macaw 2.0 is shipped
|
||||
*/
|
||||
window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme);
|
||||
}
|
||||
}, [appBridgeState?.theme, setTheme, themeType]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ThemeSynchronizer = memo(_ThemeSynchronizer);
|
64
apps/klaviyo/hooks/useAppApi.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Options = Record<string, string>;
|
||||
|
||||
interface UseFetchProps {
|
||||
url: string;
|
||||
options?: Options;
|
||||
skip?: boolean;
|
||||
}
|
||||
|
||||
// This hook is meant to be used mainly for internal API calls
|
||||
const useAppApi = ({ url, options, skip }: UseFetchProps) => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
|
||||
const [data, setData] = useState<any>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
...options,
|
||||
headers: [
|
||||
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
|
||||
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
|
||||
],
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, fetchOptions);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Error status: ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} catch (e) {
|
||||
setError(e as unknown);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (appBridgeState?.ready && !skip) {
|
||||
fetchData();
|
||||
}
|
||||
|
||||
return () => {
|
||||
setLoading(false);
|
||||
setError(undefined);
|
||||
setData(undefined);
|
||||
};
|
||||
}, [url, options, skip]);
|
||||
|
||||
return { data, error, loading };
|
||||
};
|
||||
|
||||
export default useAppApi;
|
48
apps/klaviyo/lib/graphql.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { AuthConfig, authExchange } from "@urql/exchange-auth";
|
||||
import {
|
||||
cacheExchange,
|
||||
createClient as urqlCreateClient,
|
||||
dedupExchange,
|
||||
fetchExchange,
|
||||
} from "urql";
|
||||
|
||||
interface IAuthState {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
|
||||
urqlCreateClient({
|
||||
url,
|
||||
exchanges: [
|
||||
dedupExchange,
|
||||
cacheExchange,
|
||||
authExchange<IAuthState>({
|
||||
addAuthToOperation: ({ authState, operation }) => {
|
||||
if (!authState || !authState?.token) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
const fetchOptions =
|
||||
typeof operation.context.fetchOptions === "function"
|
||||
? operation.context.fetchOptions()
|
||||
: operation.context.fetchOptions || {};
|
||||
|
||||
return {
|
||||
...operation,
|
||||
context: {
|
||||
...operation.context,
|
||||
fetchOptions: {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...fetchOptions.headers,
|
||||
"Authorization-Bearer": authState.token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
getAuth,
|
||||
}),
|
||||
fetchExchange,
|
||||
],
|
||||
});
|
31
apps/klaviyo/lib/klaviyo.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
interface EmailServiceProvider {
|
||||
send: (event: string, recipient: string, context: any) => Promise<Response>;
|
||||
}
|
||||
|
||||
const Klaviyo = (token: string): EmailServiceProvider => ({
|
||||
send: async (event, recipient, context) => {
|
||||
const formParams = new URLSearchParams();
|
||||
formParams.append(
|
||||
"data",
|
||||
JSON.stringify({
|
||||
token,
|
||||
event,
|
||||
customer_properties: { $email: recipient },
|
||||
properties: context,
|
||||
})
|
||||
);
|
||||
|
||||
console.debug("Klaviyo request: https://a.klaviyo.com/api/track, ", formParams);
|
||||
|
||||
const response = await fetch("https://a.klaviyo.com/api/track", {
|
||||
method: "POST",
|
||||
body: formParams,
|
||||
});
|
||||
|
||||
console.debug("Klaviyo response: ", response.status, ", ", await response.text());
|
||||
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
export default Klaviyo;
|
61
apps/klaviyo/lib/metadata.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
|
||||
import { Client } from "urql";
|
||||
|
||||
import {
|
||||
FetchAppDetailsDocument,
|
||||
FetchAppDetailsQuery,
|
||||
UpdateAppMetadataDocument,
|
||||
} from "../generated/graphql";
|
||||
import { settingsManagerSecretKey } from "../saleor-app";
|
||||
|
||||
// Function is using urql graphql client to fetch all available metadata.
|
||||
// Before returning query result, we are transforming response to list of objects with key and value fields
|
||||
// which can be used by the manager.
|
||||
// Result of this query is cached by the manager.
|
||||
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
||||
const { error, data } = await client
|
||||
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
if (error) {
|
||||
console.debug("Error during fetching the metadata: ", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
|
||||
}
|
||||
|
||||
// Mutate function takes urql client and metadata entries, and construct mutation to the API.
|
||||
// Before data are send, additional query for required App ID is made.
|
||||
// The manager will use updated entries returned by this mutation to update it's cache.
|
||||
export async function mutateMetadata(client: Client, appId: string, metadata: MetadataEntry[]) {
|
||||
const { error: mutationError, data: mutationData } = await client
|
||||
.mutation(UpdateAppMetadataDocument, {
|
||||
id: appId,
|
||||
input: metadata,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (mutationError) {
|
||||
console.debug("Mutation error: ", mutationError);
|
||||
throw new Error(`Mutation error: ${mutationError.message}`);
|
||||
}
|
||||
|
||||
return (
|
||||
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
|
||||
key: md.key,
|
||||
value: md.value,
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
export const createSettingsManager = (client: Client, appId: string) =>
|
||||
// EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
||||
// We recommend it for production, because all values are encrypted.
|
||||
// If your use case require plain text values, you can use MetadataManager.
|
||||
new EncryptedMetadataManager({
|
||||
// Secret key should be randomly created for production and set as environment variable
|
||||
encryptionKey: settingsManagerSecretKey,
|
||||
fetchMetadata: () => fetchAllMetadata(client),
|
||||
mutateMetadata: (metadata) => mutateMetadata(client, appId, metadata),
|
||||
});
|
21
apps/klaviyo/lib/ui/app-columns-layout.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px auto 280px",
|
||||
alignItems: "start",
|
||||
gap: 32,
|
||||
maxWidth: 1180,
|
||||
margin: "0 auto",
|
||||
},
|
||||
});
|
||||
|
||||
type Props = PropsWithChildren<{}>;
|
||||
|
||||
export function AppColumnsLayout({ children }: Props) {
|
||||
const styles = useStyles();
|
||||
|
||||
return <div className={styles.root}>{children}</div>;
|
||||
}
|
28
apps/klaviyo/lib/ui/app-icon.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Typography } from "@material-ui/core";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
appIconContainer: {
|
||||
background: "rgb(58, 86, 199)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: "50%",
|
||||
color: "#fff",
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
});
|
||||
|
||||
export function AppIcon() {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.appIconContainer}>
|
||||
<div>
|
||||
<Typography variant="h2">S</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
67
apps/klaviyo/lib/ui/main-bar.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { Paper, PaperProps } from "@material-ui/core";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import clsx from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
height: 96,
|
||||
padding: "0 32px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
leftColumn: {
|
||||
marginRight: "auto",
|
||||
},
|
||||
rightColumn: {},
|
||||
iconColumn: {
|
||||
marginRight: 24,
|
||||
},
|
||||
appName: { fontSize: 24, margin: 0 },
|
||||
appAuthor: {
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
margin: 0,
|
||||
},
|
||||
bottomMargin: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
author: string;
|
||||
rightColumnContent?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
bottomMargin?: boolean;
|
||||
} & PaperProps;
|
||||
|
||||
export function MainBar({
|
||||
name,
|
||||
author,
|
||||
rightColumnContent,
|
||||
className,
|
||||
icon,
|
||||
bottomMargin,
|
||||
}: Props) {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
className={clsx(styles.root, className, {
|
||||
[styles.bottomMargin]: bottomMargin,
|
||||
})}
|
||||
>
|
||||
{icon && <div className={styles.iconColumn}>{icon}</div>}
|
||||
<div className={styles.leftColumn}>
|
||||
<h1 className={styles.appName}>{name}</h1>
|
||||
<h1 className={styles.appAuthor}>{author}</h1>
|
||||
</div>
|
||||
<div className={styles.rightColumn}>{rightColumnContent}</div>
|
||||
</Paper>
|
||||
);
|
||||
}
|
5
apps/klaviyo/next-env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
43
apps/klaviyo/next.config.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
|
||||
const isSentryPropertiesInEnvironment =
|
||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: false,
|
||||
},
|
||||
sentry: {
|
||||
disableServerWebpackPlugin: !isSentryPropertiesInEnvironment,
|
||||
disableClientWebpackPlugin: !isSentryPropertiesInEnvironment,
|
||||
},
|
||||
redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/",
|
||||
destination: "/configuration",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const sentryWebpackPluginOptions = {
|
||||
// Additional config options for the Sentry Webpack plugin. Keep in mind that
|
||||
// the following options are set automatically, and overriding them is not
|
||||
// recommended:
|
||||
// release, url, org, project, authToken, configFile, stripPrefix,
|
||||
// urlPrefix, include, ignore
|
||||
|
||||
silent: true, // Suppresses all logs
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options.
|
||||
};
|
||||
|
||||
// Make sure adding Sentry options is the last code to run before exporting, to
|
||||
// ensure that your source maps include changes from all other Webpack plugins
|
||||
module.exports = withSentryConfig(nextConfig, sentryWebpackPluginOptions);
|
57
apps/klaviyo/package.json
Normal file
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "saleor-app-klaviyo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@7.18.1",
|
||||
"scripts": {
|
||||
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||
"build": "pnpm generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "pnpm generate && prettier --loglevel warn --write . && eslint --fix .",
|
||||
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
|
||||
"generate": "graphql-codegen"
|
||||
},
|
||||
"saleor": {
|
||||
"schemaVersion": "3.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@saleor/app-sdk": "~0.27.1",
|
||||
"@saleor/macaw-ui": "^0.7.2",
|
||||
"@sentry/nextjs": "^7.31.0",
|
||||
"@urql/exchange-auth": "^1.0.0",
|
||||
"clsx": "^1.2.1",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"next": "12.3.1",
|
||||
"node-fetch": "^3.2.6",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"urql": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "2.7.0",
|
||||
"@graphql-codegen/introspection": "2.1.1",
|
||||
"@graphql-codegen/typed-document-node": "^2.2.14",
|
||||
"@graphql-codegen/typescript": "2.6.0",
|
||||
"@graphql-codegen/typescript-operations": "2.4.3",
|
||||
"@graphql-codegen/typescript-urql": "^3.5.13",
|
||||
"@graphql-codegen/urql-introspection": "2.1.1",
|
||||
"@graphql-typed-document-node/core": "^3.1.1",
|
||||
"@types/node": "18.0.1",
|
||||
"@types/react": "18.0.14",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"clean-publish": "^4.0.1",
|
||||
"eslint": "8.15.0",
|
||||
"husky": "^8.0.1",
|
||||
"postcss": "^8.4.14",
|
||||
"prettier": "^2.7.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
"typescript": "4.9.5",
|
||||
"eslint-config-saleor": "workspace:*"
|
||||
}
|
||||
}
|
57
apps/klaviyo/pages/_app.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import "../styles/globals.css";
|
||||
|
||||
import { Theme } from "@material-ui/core/styles";
|
||||
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
||||
import { ThemeProvider as MacawUIThemeProvider } from "@saleor/macaw-ui";
|
||||
import React, { PropsWithChildren, useEffect } from "react";
|
||||
|
||||
import { ThemeSynchronizer } from "../hooks/theme-synchronizer";
|
||||
import { AppLayoutProps } from "../types";
|
||||
|
||||
const themeOverrides: Partial<Theme> = {
|
||||
overrides: {
|
||||
MuiTableCell: {
|
||||
body: {
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
},
|
||||
root: {
|
||||
height: 56,
|
||||
paddingBottom: 4,
|
||||
paddingTop: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure instance is a singleton, so React 18 dev mode doesn't render it twice
|
||||
*/
|
||||
const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
|
||||
|
||||
// That's a hack required by Macaw-UI incompatibility with React@18
|
||||
const ThemeProvider = MacawUIThemeProvider as React.FC<
|
||||
PropsWithChildren<{ overrides: Partial<Theme>; ssr: boolean }>
|
||||
>;
|
||||
|
||||
function SaleorApp({ Component, pageProps }: AppLayoutProps) {
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
useEffect(() => {
|
||||
const jssStyles = document.querySelector("#jss-server-side");
|
||||
if (jssStyles) {
|
||||
jssStyles?.parentElement?.removeChild(jssStyles);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||
<ThemeProvider overrides={themeOverrides} ssr>
|
||||
<ThemeSynchronizer />
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</ThemeProvider>
|
||||
</AppBridgeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaleorApp;
|
19
apps/klaviyo/pages/_document.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Head, Html, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<Head />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
75
apps/klaviyo/pages/_error.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import * as Sentry from "@sentry/nextjs";
|
||||
import { NextPageContext } from "next";
|
||||
import NextErrorComponent, { ErrorProps } from "next/error";
|
||||
|
||||
interface ErrorPageProps {
|
||||
err: Error;
|
||||
statusCode: number;
|
||||
hasGetInitialPropsRun: boolean;
|
||||
}
|
||||
|
||||
interface AppErrorProps extends ErrorProps {
|
||||
err?: Error;
|
||||
hasGetInitialPropsRun?: boolean;
|
||||
}
|
||||
|
||||
function MyError({ statusCode, hasGetInitialPropsRun, err }: ErrorPageProps) {
|
||||
if (!hasGetInitialPropsRun && err) {
|
||||
// getInitialProps is not called when an exception is thrown
|
||||
// at the top level of a module while it is being loaded.
|
||||
// As a workaround, we pass err via _app.js so it can be captured
|
||||
// Read more: https://github.com/vercel/next.js/issues/8592.
|
||||
Sentry.captureException(err);
|
||||
// Flushing is not required in this case as it only happens on the client
|
||||
}
|
||||
|
||||
return <NextErrorComponent statusCode={statusCode} />;
|
||||
}
|
||||
|
||||
MyError.getInitialProps = async (context: NextPageContext) => {
|
||||
const errorInitialProps: AppErrorProps = await NextErrorComponent.getInitialProps(context);
|
||||
|
||||
const { res, err, asPath } = context;
|
||||
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592, mark when
|
||||
// getInitialProps has run
|
||||
errorInitialProps.hasGetInitialPropsRun = true;
|
||||
|
||||
// Returning early because we don't want to log 404 errors to Sentry.
|
||||
if (res?.statusCode === 404) {
|
||||
return errorInitialProps;
|
||||
}
|
||||
|
||||
// Running on the server, the response object (`res`) is available.
|
||||
//
|
||||
// Next.js will pass an err on the server if a page's data fetching methods
|
||||
// threw or returned a Promise that rejected
|
||||
//
|
||||
// Running on the client (browser), Next.js will provide an err if:
|
||||
//
|
||||
// - a page's `getInitialProps` threw or returned a Promise that rejected
|
||||
// - an exception was thrown somewhere in the React lifecycle (render,
|
||||
// componentDidMount, etc) that was caught by Next.js's React Error
|
||||
// Boundary. Read more about what types of exceptions are caught by Error
|
||||
// Boundaries: https://reactjs.org/docs/error-boundaries.html
|
||||
|
||||
if (err) {
|
||||
Sentry.captureException(err);
|
||||
|
||||
// Flushing before returning is necessary if deploying to Vercel, see
|
||||
// https://vercel.com/docs/platform/limits#streaming-responses
|
||||
await Sentry.flush(2000);
|
||||
|
||||
return errorInitialProps;
|
||||
}
|
||||
|
||||
// If this point is reached, getInitialProps was called without any
|
||||
// information about what the error might be. This is unexpected and may
|
||||
// indicate a bug introduced in Next.js, so record it in Sentry
|
||||
Sentry.captureException(new Error(`_error.js getInitialProps missing data at path: ${asPath}`));
|
||||
await Sentry.flush(2000);
|
||||
|
||||
return errorInitialProps;
|
||||
};
|
||||
|
||||
export default MyError;
|
72
apps/klaviyo/pages/api/configuration.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
|
||||
|
||||
import { createClient } from "../../lib/graphql";
|
||||
import { createSettingsManager } from "../../lib/metadata";
|
||||
import { saleorApp } from "../../saleor-app";
|
||||
|
||||
type ConfigurationKeysType =
|
||||
| "PUBLIC_TOKEN"
|
||||
| "CUSTOMER_CREATED_METRIC"
|
||||
| "FULFILLMENT_CREATED_METRIC"
|
||||
| "ORDER_CREATED_METRIC"
|
||||
| "ORDER_FULLY_PAID_METRIC";
|
||||
|
||||
interface PostRequestBody {
|
||||
data: {
|
||||
key: ConfigurationKeysType;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const getAppSettings = async (settingsManager: EncryptedMetadataManager) => [
|
||||
{
|
||||
key: "CUSTOMER_CREATED_METRIC",
|
||||
value: (await settingsManager.get("CUSTOMER_CREATED_METRIC")) ?? "CUSTOMER_CREATED_METRIC",
|
||||
},
|
||||
{
|
||||
key: "FULFILLMENT_CREATED_METRIC",
|
||||
value:
|
||||
(await settingsManager.get("FULFILLMENT_CREATED_METRIC")) ?? "FULFILLMENT_CREATED_METRIC",
|
||||
},
|
||||
{
|
||||
key: "ORDER_CREATED_METRIC",
|
||||
value: (await settingsManager.get("ORDER_CREATED_METRIC")) ?? "ORDER_CREATED_METRIC",
|
||||
},
|
||||
{
|
||||
key: "ORDER_FULLY_PAID_METRIC",
|
||||
value: (await settingsManager.get("ORDER_FULLY_PAID_METRIC")) ?? "ORDER_FULLY_PAID_METRIC",
|
||||
},
|
||||
{ key: "PUBLIC_TOKEN", value: await settingsManager.get("PUBLIC_TOKEN") },
|
||||
];
|
||||
|
||||
const handler: NextProtectedApiHandler = async (request, res, ctx) => {
|
||||
console.debug("Configuration handler called");
|
||||
|
||||
const {
|
||||
authData: { token, saleorApiUrl, appId },
|
||||
} = ctx;
|
||||
const client = createClient(saleorApiUrl, async () => Promise.resolve({ token }));
|
||||
|
||||
const settings = createSettingsManager(client, appId);
|
||||
|
||||
switch (request.method!) {
|
||||
case "GET":
|
||||
return res.json({
|
||||
success: true,
|
||||
data: await getAppSettings(settings),
|
||||
});
|
||||
case "POST": {
|
||||
await settings.set((request.body as PostRequestBody).data);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: await getAppSettings(settings),
|
||||
});
|
||||
}
|
||||
default:
|
||||
return res.status(405).end();
|
||||
}
|
||||
};
|
||||
|
||||
export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS"]);
|
31
apps/klaviyo/pages/api/manifest.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { AppManifest } from "@saleor/app-sdk/types";
|
||||
|
||||
import pkg from "../../package.json";
|
||||
import { customerCreatedWebhook } from "./webhooks/customer-created";
|
||||
import { fulfillmentCreatedWebhook } from "./webhooks/fulfillment-created";
|
||||
import { orderCreatedWebhook } from "./webhooks/order-created";
|
||||
import { orderFullyPaidWebhook } from "./webhooks/order-fully-paid";
|
||||
|
||||
const handler = createManifestHandler({
|
||||
async manifestFactory(context): Promise<AppManifest> {
|
||||
const { appBaseUrl } = context;
|
||||
|
||||
return {
|
||||
id: "saleor.app.klaviyo",
|
||||
version: pkg.version,
|
||||
name: pkg.name,
|
||||
permissions: ["MANAGE_USERS", "MANAGE_ORDERS"],
|
||||
appUrl: appBaseUrl,
|
||||
tokenTargetUrl: `${appBaseUrl}/api/register`,
|
||||
webhooks: [
|
||||
customerCreatedWebhook.getWebhookManifest(appBaseUrl),
|
||||
fulfillmentCreatedWebhook.getWebhookManifest(appBaseUrl),
|
||||
orderCreatedWebhook.getWebhookManifest(appBaseUrl),
|
||||
orderFullyPaidWebhook.getWebhookManifest(appBaseUrl),
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default handler;
|
26
apps/klaviyo/pages/api/register.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
|
||||
|
||||
import { saleorApp } from "../../saleor-app";
|
||||
|
||||
const allowedUrlsPattern = process.env.ALLOWED_DOMAIN_PATTERN;
|
||||
|
||||
const handler = createAppRegisterHandler({
|
||||
apl: saleorApp.apl,
|
||||
/**
|
||||
* Prohibit installation from Saleors other than specified by the regex.
|
||||
* Regex source is ENV so if ENV is not set, all installations will be allowed.
|
||||
*/
|
||||
allowedSaleorUrls: [
|
||||
(url) => {
|
||||
if (allowedUrlsPattern) {
|
||||
const regex = new RegExp(allowedUrlsPattern);
|
||||
|
||||
return regex.test(url);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default handler;
|
112
apps/klaviyo/pages/api/webhooks/customer-created.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { gql } from "urql";
|
||||
|
||||
import {
|
||||
CustomerCreatedWebhookPayloadFragment,
|
||||
UntypedCustomerCreatedDocument,
|
||||
} from "../../../generated/graphql";
|
||||
import { createClient } from "../../../lib/graphql";
|
||||
import Klaviyo from "../../../lib/klaviyo";
|
||||
import { createSettingsManager } from "../../../lib/metadata";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
|
||||
const CustomerCreatedWebhookPayload = gql`
|
||||
fragment CustomerCreatedWebhookPayload on CustomerCreated {
|
||||
user {
|
||||
__typename
|
||||
id
|
||||
defaultShippingAddress {
|
||||
...AddressFragment
|
||||
}
|
||||
defaultBillingAddress {
|
||||
...AddressFragment
|
||||
}
|
||||
addresses {
|
||||
...AddressFragment
|
||||
}
|
||||
privateMetadata {
|
||||
...MetadataFragment
|
||||
}
|
||||
metadata {
|
||||
...MetadataFragment
|
||||
}
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
isActive
|
||||
dateJoined
|
||||
languageCode
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CustomerCreatedGraphqlSubscription = gql`
|
||||
${CustomerCreatedWebhookPayload}
|
||||
subscription CustomerCreated {
|
||||
event {
|
||||
...CustomerCreatedWebhookPayload
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const customerCreatedWebhook = new SaleorAsyncWebhook<CustomerCreatedWebhookPayloadFragment>(
|
||||
{
|
||||
name: "Customer Created",
|
||||
webhookPath: "api/webhooks/customer-created",
|
||||
asyncEvent: "CUSTOMER_CREATED",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: UntypedCustomerCreatedDocument,
|
||||
}
|
||||
);
|
||||
|
||||
const handler: NextWebhookApiHandler<CustomerCreatedWebhookPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
console.debug("customerCreatedWebhook handler called");
|
||||
|
||||
const { payload, authData } = context;
|
||||
const { saleorApiUrl, token, appId } = authData;
|
||||
const client = createClient(saleorApiUrl, async () => Promise.resolve({ token }));
|
||||
const settings = createSettingsManager(client, appId);
|
||||
|
||||
const klaviyoToken = await settings.get("PUBLIC_TOKEN");
|
||||
const klaviyoMetric = await settings.get("CUSTOMER_CREATED_METRIC");
|
||||
|
||||
if (!klaviyoToken || !klaviyoMetric) {
|
||||
console.debug("Request rejected - app not configured");
|
||||
return res.status(400).json({ success: false, message: "App not configured." });
|
||||
}
|
||||
|
||||
const userEmail = payload.user?.email;
|
||||
|
||||
if (!userEmail) {
|
||||
console.debug("Request rejected - missing user email");
|
||||
return res.status(400).json({ success: false, message: "No user email." });
|
||||
}
|
||||
|
||||
const klaviyoClient = Klaviyo(klaviyoToken);
|
||||
const klaviyoResponse = await klaviyoClient.send(klaviyoMetric, userEmail, payload);
|
||||
|
||||
if (klaviyoResponse.status !== 200) {
|
||||
const klaviyoMessage = ` Message: ${(await klaviyoResponse.json())?.message}.` || "";
|
||||
console.debug("Klaviyo returned error: ", klaviyoMessage);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: `Klaviyo API responded with status ${klaviyoResponse.status}.${klaviyoMessage}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.debug("Webhook processed successfully");
|
||||
return res.status(200).json({ success: true, message: "Message sent!" });
|
||||
};
|
||||
|
||||
export default customerCreatedWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
117
apps/klaviyo/pages/api/webhooks/fulfillment-created.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { gql } from "urql";
|
||||
|
||||
import {
|
||||
FulfillmentCreatedWebhookPayloadFragment,
|
||||
UntypedFulfillmentCreatedDocument,
|
||||
} from "../../../generated/graphql";
|
||||
import { createClient } from "../../../lib/graphql";
|
||||
import Klaviyo from "../../../lib/klaviyo";
|
||||
import { createSettingsManager } from "../../../lib/metadata";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
|
||||
const FulfillmentCreatedWebhookPayload = gql`
|
||||
fragment FulfillmentCreatedWebhookPayload on FulfillmentCreated {
|
||||
fulfillment {
|
||||
__typename
|
||||
id
|
||||
warehouse {
|
||||
address {
|
||||
...AddressFragment
|
||||
}
|
||||
}
|
||||
lines {
|
||||
__typename
|
||||
id
|
||||
quantity
|
||||
orderLine {
|
||||
productName
|
||||
variantName
|
||||
productSku
|
||||
productVariantId
|
||||
unitPrice {
|
||||
...TaxedMoneyFragment
|
||||
}
|
||||
undiscountedUnitPrice {
|
||||
...TaxedMoneyFragment
|
||||
}
|
||||
totalPrice {
|
||||
...TaxedMoneyFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
order {
|
||||
...OrderFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FulfillmentCreatedGraphqlSubscription = gql`
|
||||
${FulfillmentCreatedWebhookPayload}
|
||||
subscription FulfillmentCreated {
|
||||
event {
|
||||
...FulfillmentCreatedWebhookPayload
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const fulfillmentCreatedWebhook =
|
||||
new SaleorAsyncWebhook<FulfillmentCreatedWebhookPayloadFragment>({
|
||||
name: "Fulfillment Created",
|
||||
webhookPath: "api/webhooks/fulfillment-created",
|
||||
asyncEvent: "FULFILLMENT_CREATED",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: UntypedFulfillmentCreatedDocument,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<FulfillmentCreatedWebhookPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
console.debug("fulfillmentCreatedWebhook handler called");
|
||||
|
||||
const { payload, authData } = context;
|
||||
const { saleorApiUrl, token, appId } = authData;
|
||||
const client = createClient(saleorApiUrl, async () => Promise.resolve({ token }));
|
||||
const settings = createSettingsManager(client, appId);
|
||||
|
||||
const klaviyoToken = await settings.get("PUBLIC_TOKEN");
|
||||
const klaviyoMetric = await settings.get("FULFILLMENT_CREATED_METRIC");
|
||||
|
||||
if (!klaviyoToken || !klaviyoMetric) {
|
||||
return res.status(400).json({ success: false, message: "App not configured." });
|
||||
}
|
||||
|
||||
const { userEmail } = payload.order || {};
|
||||
|
||||
if (!userEmail) {
|
||||
console.debug("Request rejected - missing user email");
|
||||
return res.status(400).json({ success: false, message: "No user email." });
|
||||
}
|
||||
|
||||
const klaviyoClient = Klaviyo(klaviyoToken);
|
||||
const klaviyoResponse = await klaviyoClient.send(klaviyoMetric, userEmail, payload);
|
||||
|
||||
if (klaviyoResponse.status !== 200) {
|
||||
const klaviyoMessage = ` Message: ${(await klaviyoResponse.json())?.message}.` || "";
|
||||
console.debug("Klaviyo returned error: ", klaviyoMessage);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: `Klaviyo API responded with status ${klaviyoResponse.status}.${klaviyoMessage}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.debug("Webhook processed successfully");
|
||||
return res.status(200).json({ success: true, message: "Message sent!" });
|
||||
};
|
||||
|
||||
export default fulfillmentCreatedWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
87
apps/klaviyo/pages/api/webhooks/order-created.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { gql } from "urql";
|
||||
|
||||
import {
|
||||
OrderCreatedWebhookPayloadFragment,
|
||||
UntypedOrderCreatedDocument,
|
||||
} from "../../../generated/graphql";
|
||||
import { createClient } from "../../../lib/graphql";
|
||||
import Klaviyo from "../../../lib/klaviyo";
|
||||
import { createSettingsManager } from "../../../lib/metadata";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
|
||||
const OrderCreatedWebhookPayload = gql`
|
||||
fragment OrderCreatedWebhookPayload on OrderCreated {
|
||||
order {
|
||||
...OrderFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const OrderCreatedGraphqlSubscription = gql`
|
||||
${OrderCreatedWebhookPayload}
|
||||
subscription OrderCreated {
|
||||
event {
|
||||
...OrderCreatedWebhookPayload
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
|
||||
name: "Order Created",
|
||||
webhookPath: "api/webhooks/order-created",
|
||||
asyncEvent: "ORDER_CREATED",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: UntypedOrderCreatedDocument,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderCreatedWebhookPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
console.debug("orderCreatedWebhook handler called");
|
||||
|
||||
const { payload, authData } = context;
|
||||
const { saleorApiUrl, token, appId } = authData;
|
||||
const client = createClient(saleorApiUrl, async () => Promise.resolve({ token }));
|
||||
const settings = createSettingsManager(client, appId);
|
||||
|
||||
const klaviyoToken = await settings.get("PUBLIC_TOKEN");
|
||||
const klaviyoMetric = await settings.get("ORDER_CREATED_METRIC");
|
||||
|
||||
if (!klaviyoToken || !klaviyoMetric) {
|
||||
console.debug("Request rejected - app not configured");
|
||||
return res.status(400).json({ success: false, message: "App not configured." });
|
||||
}
|
||||
|
||||
const { userEmail } = payload.order || {};
|
||||
|
||||
if (!userEmail) {
|
||||
console.debug("Request rejected - missing user email");
|
||||
return res.status(400).json({ success: false, message: "No user email." });
|
||||
}
|
||||
|
||||
const klaviyoClient = Klaviyo(klaviyoToken);
|
||||
const klaviyoResponse = await klaviyoClient.send(klaviyoMetric, userEmail, payload);
|
||||
|
||||
if (klaviyoResponse.status !== 200) {
|
||||
const klaviyoMessage = ` Message: ${(await klaviyoResponse.json())?.message}.` || "";
|
||||
console.debug("Klaviyo returned error: ", klaviyoMessage);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: `Klaviyo API responded with status ${klaviyoResponse.status}.${klaviyoMessage}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.debug("Webhook processed successfully");
|
||||
return res.status(200).json({ success: true, message: "Message sent!" });
|
||||
};
|
||||
|
||||
export default orderCreatedWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
88
apps/klaviyo/pages/api/webhooks/order-fully-paid.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { gql } from "urql";
|
||||
|
||||
import {
|
||||
OrderFullyPaidWebhookPayloadFragment,
|
||||
UntypedOrderFullyPaidDocument,
|
||||
} from "../../../generated/graphql";
|
||||
import { createClient } from "../../../lib/graphql";
|
||||
import Klaviyo from "../../../lib/klaviyo";
|
||||
import { createSettingsManager } from "../../../lib/metadata";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
|
||||
const OrderFullyPaidWebhookPayload = gql`
|
||||
fragment OrderFullyPaidWebhookPayload on OrderFullyPaid {
|
||||
order {
|
||||
...OrderFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const OrderFullyPaidGraphqlSubscription = gql`
|
||||
${OrderFullyPaidWebhookPayload}
|
||||
subscription OrderFullyPaid {
|
||||
event {
|
||||
...OrderFullyPaidWebhookPayload
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const orderFullyPaidWebhook = new SaleorAsyncWebhook<OrderFullyPaidWebhookPayloadFragment>({
|
||||
name: "Order Fully Paid",
|
||||
webhookPath: "api/webhooks/order-fully-paid",
|
||||
asyncEvent: "ORDER_FULLY_PAID",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: UntypedOrderFullyPaidDocument,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderFullyPaidWebhookPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
console.debug("orderFullyPaidWebhook handler called");
|
||||
|
||||
const { payload, authData } = context;
|
||||
const { saleorApiUrl, token, appId } = authData;
|
||||
const client = createClient(saleorApiUrl, async () => Promise.resolve({ token }));
|
||||
const settings = createSettingsManager(client, appId);
|
||||
|
||||
const klaviyoToken = await settings.get("PUBLIC_TOKEN");
|
||||
const klaviyoMetric = await settings.get("ORDER_FULLY_PAID_METRIC");
|
||||
|
||||
if (!klaviyoToken || !klaviyoMetric) {
|
||||
console.debug("Request rejected - app not configured");
|
||||
return res.status(400).json({ success: false, message: "App not configured." });
|
||||
}
|
||||
|
||||
const { userEmail } = payload.order || {};
|
||||
|
||||
if (!userEmail) {
|
||||
console.debug("Request rejected - missing user email");
|
||||
return res.status(400).json({ success: false, message: "No user email." });
|
||||
}
|
||||
|
||||
const klaviyoClient = Klaviyo(klaviyoToken);
|
||||
const klaviyoResponse = await klaviyoClient.send(klaviyoMetric, userEmail, payload);
|
||||
|
||||
if (klaviyoResponse.status !== 200) {
|
||||
const klaviyoMessage = ` Message: ${(await klaviyoResponse.json())?.message}.` || "";
|
||||
console.debug("Klaviyo returned error: ", klaviyoMessage);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: `Klaviyo API responded with status ${klaviyoResponse.status}.${klaviyoMessage}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.debug("Webhook processed successfully");
|
||||
return res.status(200).json({ success: true, message: "Message sent!" });
|
||||
};
|
||||
|
||||
export default orderFullyPaidWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
261
apps/klaviyo/pages/configuration.tsx
Normal file
|
@ -0,0 +1,261 @@
|
|||
import { Link, List, ListItem, Paper, PaperProps, TextField, Typography } from "@material-ui/core";
|
||||
import Skeleton from "@material-ui/lab/Skeleton";
|
||||
import { useAppBridge, withAuthorization } from "@saleor/app-sdk/app-bridge";
|
||||
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
|
||||
import { ConfirmButton, ConfirmButtonTransitionState, makeStyles } from "@saleor/macaw-ui";
|
||||
import { ChangeEvent, SyntheticEvent, useEffect, useState } from "react";
|
||||
|
||||
import AccessWarning from "../components/AccessWarning/AccessWarning";
|
||||
import useAppApi from "../hooks/useAppApi";
|
||||
import { AppColumnsLayout } from "../lib/ui/app-columns-layout";
|
||||
import { AppIcon } from "../lib/ui/app-icon";
|
||||
import { MainBar } from "../lib/ui/main-bar";
|
||||
import useDashboardNotifier from "../utils/useDashboardNotifier";
|
||||
|
||||
interface ConfigurationField {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
confirmButton: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
fieldContainer: {
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
function Section(props: PaperProps) {
|
||||
return <Paper style={{ padding: 24 }} elevation={0} {...props} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Section>
|
||||
<Typography paragraph variant="h3">
|
||||
How to set up
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
App will send events as Klaviyo metrics each time Saleor Event occurs.
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
When first metric is sent, it should be available in Klaviyo to build on top of.
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
Metric name can be customized, PUBLIC_TOKEN must be provided to enable the app.
|
||||
</Typography>
|
||||
<Typography variant="h3">Useful links</Typography>
|
||||
<List>
|
||||
<ListItem>
|
||||
<Link
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
openExternalUrl("https://github.com/saleor/saleor-app-klaviyo");
|
||||
}}
|
||||
href="https://github.com/saleor/saleor-app-klaviyo"
|
||||
>
|
||||
Visit repository & readme
|
||||
</Link>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Typography variant="h3">How to configure</Typography>
|
||||
<List>
|
||||
<ListItem>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
Read about public tokens
|
||||
</Link>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
Read about metrics
|
||||
</Link>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Configuration() {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const classes = useStyles();
|
||||
const [notify] = useDashboardNotifier();
|
||||
const [configuration, setConfiguration] = useState<ConfigurationField[]>();
|
||||
const [transitionState, setTransitionState] = useState<ConfirmButtonTransitionState>("default");
|
||||
|
||||
const { data: configurationData, error } = useAppApi({
|
||||
url: "/api/configuration",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (configurationData && !configuration) {
|
||||
setConfiguration(configurationData.data);
|
||||
}
|
||||
}, [configurationData, configuration]);
|
||||
|
||||
/**
|
||||
* TODO Rewrite to tRPC
|
||||
*/
|
||||
const handleSubmit = (event: SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
setTransitionState("loading");
|
||||
|
||||
fetch("/api/configuration", {
|
||||
method: "POST",
|
||||
headers: [
|
||||
["content-type", "application/json"],
|
||||
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
|
||||
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
|
||||
],
|
||||
body: JSON.stringify({ data: configuration }),
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Error saving configuration data");
|
||||
}
|
||||
setTransitionState("success");
|
||||
|
||||
await notify({
|
||||
status: "success",
|
||||
title: "Success",
|
||||
text: "Configuration updated successfully",
|
||||
});
|
||||
})
|
||||
.catch(async () => {
|
||||
setTransitionState("error");
|
||||
await notify({
|
||||
status: "error",
|
||||
title:
|
||||
"Configuration update failed. Ensure fields are filled correctly and you have MANAGE_APPS permission",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onChange = (event: ChangeEvent) => {
|
||||
const { name, value } = event.target as HTMLInputElement;
|
||||
setConfiguration((prev) =>
|
||||
prev!.map((prevField) => (prevField.key === name ? { ...prevField, value } : prevField))
|
||||
);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
console.error("Can't establish connection with the App API: ", error);
|
||||
return (
|
||||
<div>
|
||||
<h1>⚠️ Can't connect with the App API</h1>
|
||||
You may see this error because:
|
||||
<ul>
|
||||
<li>Internet connection has been lost</li>
|
||||
<li>
|
||||
Application installation process is still in progress. If you use Vercel, you may need
|
||||
to wait for redeployment of the app - try again in a minute.
|
||||
</li>
|
||||
<li>
|
||||
Application is misconfigured. If you would like to know more how auth configuration is
|
||||
kept,{" "}
|
||||
<a
|
||||
href="https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
go to APL documentation
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (configuration === undefined) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MainBar
|
||||
icon={<AppIcon />}
|
||||
bottomMargin
|
||||
name="Saleor Klaviyo App"
|
||||
author="By Saleor Commerce"
|
||||
/>
|
||||
<AppColumnsLayout>
|
||||
<div />
|
||||
<Section>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{configuration!.map(({ key, value }) => (
|
||||
<div key={key} className={classes.fieldContainer}>
|
||||
<TextField label={key} name={key} fullWidth onChange={onChange} value={value} />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<ConfirmButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
transitionState={transitionState}
|
||||
labels={{
|
||||
confirm: "Save",
|
||||
error: "Error",
|
||||
}}
|
||||
className={classes.confirmButton}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
<Instructions />
|
||||
</AppColumnsLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withAuthorization({
|
||||
notIframe: <AccessWarning cause="not_in_iframe" />,
|
||||
unmounted: null,
|
||||
noDashboardToken: <AccessWarning cause="missing_access_token" />,
|
||||
dashboardTokenInvalid: <AccessWarning cause="invalid_access_token" />,
|
||||
})(Configuration);
|
9046
apps/klaviyo/pnpm-lock.yaml
Normal file
BIN
apps/klaviyo/public/favicon.ico
Normal file
After Width: | Height: | Size: 25 KiB |
4
apps/klaviyo/public/vercel.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<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>
|
After Width: | Height: | Size: 1.1 KiB |
55
apps/klaviyo/saleor-app.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { APL, FileAPL, SaleorCloudAPL, UpstashAPL, VercelAPL } from "@saleor/app-sdk/APL";
|
||||
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
|
||||
|
||||
/**
|
||||
* By default auth data are stored in the `.auth-data.json` (FileAPL).
|
||||
* For multi-tenant applications and deployments please use UpstashAPL.
|
||||
*
|
||||
* To read more about storing auth data, read the
|
||||
* [APL documentation](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
|
||||
*/
|
||||
const aplType = process.env.APL ?? "file";
|
||||
let apl: APL;
|
||||
|
||||
switch (aplType) {
|
||||
case "vercel":
|
||||
apl = new VercelAPL();
|
||||
|
||||
break;
|
||||
case "upstash":
|
||||
apl = new UpstashAPL();
|
||||
|
||||
break;
|
||||
case "file":
|
||||
apl = new FileAPL();
|
||||
|
||||
break;
|
||||
case "saleor-cloud": {
|
||||
if (!process.env.REST_APL_ENDPOINT || !process.env.REST_APL_TOKEN) {
|
||||
throw new Error("Rest APL is not configured - missing env variables. Check saleor-app.ts");
|
||||
}
|
||||
|
||||
apl = new SaleorCloudAPL({
|
||||
resourceUrl: process.env.REST_APL_ENDPOINT,
|
||||
token: process.env.REST_APL_TOKEN,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error("Invalid APL config, ");
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.SECRET_KEY && process.env.NODE_ENV === "production") {
|
||||
throw new Error(
|
||||
"For production deployment SECRET_KEY is mandatory to use EncryptedSettingsManager."
|
||||
);
|
||||
}
|
||||
|
||||
// Use placeholder value for the development
|
||||
export const settingsManagerSecretKey = process.env.SECRET_KEY || "CHANGE_ME";
|
||||
|
||||
export const saleorApp = new SaleorApp({
|
||||
apl,
|
||||
});
|
17
apps/klaviyo/sentry.client.config.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
// This file configures the initialization of Sentry on the browser.
|
||||
// The config you add here will be used whenever a page is visited.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1.0,
|
||||
// ...
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
17
apps/klaviyo/sentry.server.config.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
// This file configures the initialization of Sentry on the server.
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1.0,
|
||||
// ...
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
26
apps/klaviyo/styles/globals.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
body {
|
||||
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
color: #111;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
padding: 0.75rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
code::before {
|
||||
content: "$ ";
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
li {
|
||||
padding-bottom: 1rem;
|
||||
}
|
20
apps/klaviyo/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
17
apps/klaviyo/types.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { NextPage } from "next";
|
||||
import { AppProps as NextAppProps } from "next/app";
|
||||
import { NextComponentType, NextPageContext } from "next/types";
|
||||
import { ReactElement, ReactNode } from "react";
|
||||
|
||||
export type PageWithLayout = NextPage & {
|
||||
getLayout?: (page: ReactElement) => ReactNode;
|
||||
};
|
||||
|
||||
export type AppProps = {
|
||||
pageProps: NextAppProps["pageProps"];
|
||||
Component: NextComponentType<NextPageContext, any, {}> & { layoutProps: any };
|
||||
};
|
||||
|
||||
export type AppLayoutProps = AppProps & {
|
||||
Component: PageWithLayout;
|
||||
};
|
12
apps/klaviyo/utils/useDashboardNotifier.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { actions, NotificationPayload, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
const useDashboardNotifier = () => {
|
||||
const { appBridgeState, appBridge } = useAppBridge();
|
||||
|
||||
const notify = (payload: NotificationPayload) =>
|
||||
appBridgeState?.ready && appBridge?.dispatch(actions.Notification(payload));
|
||||
|
||||
return [notify];
|
||||
};
|
||||
|
||||
export default useDashboardNotifier;
|
1630
pnpm-lock.yaml
17
turbo.json
|
@ -32,6 +32,23 @@
|
|||
"REST_APL_TOKEN"
|
||||
]
|
||||
},
|
||||
"build#saleor-app-klaviyo": {
|
||||
"env": [
|
||||
"APL",
|
||||
"APP_DEBUG",
|
||||
"NODE_ENV",
|
||||
"SECRET_KEY",
|
||||
"ALLOWED_DOMAIN_PATTERN",
|
||||
"REST_APL_ENDPOINT",
|
||||
"REST_APL_TOKEN",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"SENTRY_DSN",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"SENTRY_ORG",
|
||||
"SENTRY_PROJECT",
|
||||
"SENTRY_AUTH_TOKEN"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"outputs": []
|
||||
},
|
||||
|
|