Introduce Twilio Segment App (#881)

* bootstrap segment app from cms app

* tracking logic

* schema configuratioj

* config form

* form saving

* Connected order_created

* add more fields

* Order updated webhook

* Order cancelled event

* order refunded webhook

* order fully paid

* update deps

* error handling

* logger

* logs

* Add app to workflow

* add icon

* remove breadcrumbs

* Change 400 to 200 response if payload is invalid

* link to docs

* change semgent.io to segment
This commit is contained in:
Lukasz Ostrowski 2023-08-16 15:30:46 +02:00 committed by GitHub
parent 69fe973121
commit 7d574c70c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 32611 additions and 509 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-segment.io": major
---
Introduced a Segment.io app that integrates order events with Segment tracking

View file

@ -109,3 +109,11 @@ updates:
prefix: "[skip ci]"
schedule:
interval: weekly
- package-ecosystem: "npm"
open-pull-requests-limit: 1
directory: apps/segment
commit-message:
prefix: "[skip ci]"
schedule:
interval: weekly

3
.github/labeler.yml vendored
View file

@ -6,5 +6,6 @@
"App: Slack": "apps/slack/**/*"
"App: Taxes": "apps/taxes/**/*"
"App: Emails & Messages": "apps/emails-and-messages/**/*"
"App: CMS": ["apps/cms-v2/**/*"]
"App: CMS": "apps/cms-v2/**/*"
"App: CRM": "apps/crm/**/*"
"App: Segment": "apps/segment/**/*"

18
apps/segment/.env.example Normal file
View file

@ -0,0 +1,18 @@
# The key used for metadata encryption. Required for production builds
SECRET_KEY=
# APL Config
# https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md
APL=file
REST_APL_ENDPOINT=
REST_APL_TOKEN=
APP_LOG_LEVEL=info
# Local development variables. When developped locally with Saleor inside docker, these can be set to:
# APP_IFRAME_BASE_URL = http://localhost:3000, so Dashboard on host can access iframe
# APP_API_BASE_URL=http://host.docker.internal:3000 - so Saleor can reach App running on host, from the container.
# If developped with tunnels, set this empty, it will fallback to default Next's localhost:3000
# https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
APP_IFRAME_BASE_URL=
APP_API_BASE_URL=

View file

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["saleor"]
}

View file

@ -0,0 +1,19 @@
schema: graphql/schema.graphql
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx]
extensions:
codegen:
overwrite: true
generates:
generated/graphql.ts:
config:
dedupeFragments: true
plugins:
- typescript
- typescript-operations
- typescript-urql:
documentVariablePrefix: "Untyped"
fragmentVariablePrefix: "Untyped"
- typed-document-node
generated/schema.graphql:
plugins:
- schema-ast

View file

@ -0,0 +1,31 @@
fragment OrderBase on Order {
id
user {
id
email
}
channel {
id
slug
name
}
userEmail
shippingMethodName
total {
gross {
amount
currency
}
net {
currency
amount
}
}
lines {
id
productVariantId
productSku
variantName
}
number
}

View file

@ -0,0 +1,10 @@
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
fragment OrderCancelledSubscriptionPayload on OrderCancelled {
order {
...OrderBase
}
}
subscription OrderCancelled {
event {
...OrderCancelledSubscriptionPayload
}
}

View file

@ -0,0 +1,11 @@
fragment OrderCreatedSubscriptionPayload on OrderCreated {
order {
...OrderBase
}
}
subscription OrderCreated {
event {
...OrderCreatedSubscriptionPayload
}
}

View file

@ -0,0 +1,11 @@
fragment OrderFullyPaidSubscriptionPayload on OrderFullyPaid {
order {
...OrderBase
}
}
subscription OrderFullyPaid {
event {
...OrderFullyPaidSubscriptionPayload
}
}

View file

@ -0,0 +1,11 @@
fragment OrderRefundedSubscriptionPayload on OrderRefunded {
order {
...OrderBase
}
}
subscription OrderRefunded {
event {
...OrderRefundedSubscriptionPayload
}
}

View file

@ -0,0 +1,11 @@
fragment OrderUpdatedSubscriptionPayload on OrderUpdated {
order {
...OrderBase
}
}
subscription OrderUpdated {
event {
...OrderUpdatedSubscriptionPayload
}
}

5
apps/segment/next-env.d.ts vendored Normal file
View 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.

View file

@ -0,0 +1,43 @@
const { z } = require("zod");
const { withSentryConfig } = require("@sentry/nextjs");
const RequiredEnvs = z.object({
APL: z.string().min(1),
});
/** @type {import('next').NextConfig} */
const nextConfig = () => {
try {
RequiredEnvs.parse(process.env);
} catch (e) {
console.error("🚫 Missing required env variables, see message below");
console.error(e.issues);
process.exit(1);
}
return {
reactStrictMode: true,
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
};
};
const isSentryPropertiesInEnvironment =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
const configWithSentry = withSentryConfig(
nextConfig,
{
silent: true,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
{
widenClientFileUpload: true,
transpileClientSDK: true,
tunnelRoute: "/monitoring",
hideSourceMaps: true,
disableLogger: true,
}
);
module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig;

67
apps/segment/package.json Normal file
View file

@ -0,0 +1,67 @@
{
"name": "saleor-app-segment",
"version": "0.0.0",
"scripts": {
"build": "pnpm generate && next build",
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"lint": "next lint",
"lint:fix": "eslint --fix .",
"start": "next start",
"test": "vitest"
},
"dependencies": {
"@hookform/resolvers": "^3.1.0",
"@saleor/app-sdk": "0.41.1",
"@saleor/apps-shared": "workspace:*",
"@saleor/apps-ui": "workspace:*",
"@saleor/macaw-ui": "0.8.0-pre.106",
"@saleor/react-hook-form-macaw": "workspace:*",
"@segment/analytics-node": "^1.1.0",
"@sentry/nextjs": "7.55.2",
"@tanstack/react-query": "^4.29.19",
"@trpc/client": "10.34.0",
"@trpc/next": "10.34.0",
"@trpc/react-query": "10.34.0",
"@trpc/server": "10.34.0",
"@urql/exchange-auth": "^2.1.4",
"@vitejs/plugin-react": "4.0.4",
"graphql": "16.7.1",
"graphql-tag": "^2.12.6",
"jsdom": "^20.0.3",
"next": "13.4.8",
"pino": "^8.14.1",
"pino-pretty": "^10.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.10",
"react-hook-form": "^7.43.9",
"urql": "^4.0.4",
"usehooks-ts": "^2.9.1",
"vite": "4.4.8",
"vitest": "0.34.1",
"zod": "3.21.4"
},
"devDependencies": {
"@graphql-codegen/cli": "4.0.1",
"@graphql-codegen/introspection": "4.0.0",
"@graphql-codegen/typed-document-node": "5.0.1",
"@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-operations": "4.0.1",
"@graphql-codegen/typescript-urql": "3.7.3",
"@graphql-typed-document-node/core": "3.2.0",
"@testing-library/react": "^13.4.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "18.2.5",
"@types/react-dom": "18.2.5",
"eslint": "8.46.0",
"eslint-config-saleor": "workspace:*",
"node-mocks-http": "^1.12.2",
"typescript": "5.1.6"
},
"private": true,
"saleor": {
"schemaVersion": "3.14"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -0,0 +1,37 @@
/*
* This file configures the initialization of Sentry on the client.
* The config you add here will be used whenever a users loads a page in their browser.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
import pkg from "./package.json";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
replaysOnErrorSampleRate: 1.0,
/*
* This sets the sample rate to be 10%. You may want this to be 100% while
* in development and sample at a lower rate in production
*/
replaysSessionSampleRate: 0.1,
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
integrations: [
new Sentry.Replay({
// Additional Replay configuration goes in here, for example:
maskAllText: true,
blockAllMedia: true,
}),
],
environment: process.env.SENTRY_ENVIRONMENT,
release: `${pkg.name}@${pkg.version}`,
});

View file

@ -0,0 +1,21 @@
/*
* This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
* The config you add here will be used whenever one of the edge features is loaded.
* Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
import pkg from "./package.json";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
environment: process.env.SENTRY_ENVIRONMENT,
release: `${pkg.name}@${pkg.version}`,
});

View file

@ -0,0 +1,20 @@
/*
* 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";
import pkg from "./package.json";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
environment: process.env.SENTRY_ENVIRONMENT,
release: `${pkg.name}@${pkg.version}`,
});

View file

@ -0,0 +1,5 @@
export class SegmentNotConfiguredError extends Error {
constructor() {
super("Segment not configured");
}
}

View file

@ -0,0 +1,36 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { AppConfig } from "./app-config";
import { createSettingsManager } from "./metadata-manager";
import { createGraphQLClient } from "@saleor/apps-shared";
import { AuthData } from "@saleor/app-sdk/APL";
export class AppConfigMetadataManager {
public readonly metadataKey = "app-config-v1";
constructor(private mm: SettingsManager) {}
async get() {
const metadata = await this.mm.get(this.metadataKey);
return metadata ? AppConfig.parse(metadata) : new AppConfig();
}
set(config: AppConfig) {
return this.mm.set({
key: this.metadataKey,
value: config.serialize(),
});
}
static createFromAuthData(authData: AuthData): AppConfigMetadataManager {
const settingsManager = createSettingsManager(
createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
}),
authData.appId,
);
return new AppConfigMetadataManager(settingsManager);
}
}

View file

@ -0,0 +1,38 @@
import { z } from "zod";
import { RootConfig } from "./schemas/root-config.schema";
export class AppConfig {
private rootData: RootConfig.Shape = null;
constructor(initialData?: RootConfig.Shape) {
if (initialData) {
this.rootData = RootConfig.Schema.parse(initialData);
}
}
static parse(serializedSchema: string) {
return new AppConfig(JSON.parse(serializedSchema));
}
serialize() {
return JSON.stringify(this.rootData);
}
setSegmentWriteKey(key: string) {
const parsedKey = z.string().min(1).parse(key);
if (this.rootData) {
this.rootData.segmentWriteKey = parsedKey;
} else {
this.rootData = {
segmentWriteKey: parsedKey,
};
}
return this;
}
getConfig() {
return this.rootData;
}
}

View file

@ -0,0 +1,43 @@
import { createLogger } from "@saleor/apps-shared";
import { z } from "zod";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { AppConfigMetadataManager } from "./app-config-metadata-manager";
const logger = createLogger({ name: "configuration.router" });
export const configurationRouter = router({
getConfig: protectedClientProcedure.query(async ({ ctx }) => {
const manager = AppConfigMetadataManager.createFromAuthData({
appId: ctx.appId,
saleorApiUrl: ctx.saleorApiUrl,
token: ctx.appToken,
});
const config = await manager.get();
logger.info("Fetched config");
return config.getConfig();
}),
setConfig: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(z.string().min(1))
.mutation(async ({ input, ctx }) => {
logger.info("Request to set config");
const manager = AppConfigMetadataManager.createFromAuthData({
appId: ctx.appId,
saleorApiUrl: ctx.saleorApiUrl,
token: ctx.appToken,
});
const config = await manager.get();
config.setSegmentWriteKey(input);
await manager.set(config);
logger.info("Config set successfully");
}),
});

View file

@ -0,0 +1,101 @@
import {
MetadataEntry,
EncryptedMetadataManager,
SettingsManager,
} from "@saleor/app-sdk/settings-manager";
import { Client, gql } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../../generated/graphql";
gql`
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}
`;
gql`
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}
`;
gql`
mutation RemoveMetadata($id: ID!, $keys: [String!]!) {
deletePrivateMetadata(id: $id, keys: $keys) {
errors {
message
}
}
}
`;
export type SimpleGraphqlClient = Pick<Client, "mutation" | "query">;
async function fetchAllMetadata(client: SimpleGraphqlClient): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
async function mutateMetadata(
client: SimpleGraphqlClient,
metadata: MetadataEntry[],
appId: string,
) {
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (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: SimpleGraphqlClient,
appId: string,
): SettingsManager => {
/*
* 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.
*/
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: process.env.SECRET_KEY!,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata, appId),
});
};

View file

@ -0,0 +1,16 @@
import { z } from "zod";
export namespace RootConfig {
/**
* Store entire app config in single file
* - Only one request
* - Always transactional
*/
export const Schema = z
.object({
segmentWriteKey: z.string(),
})
.nullable();
export type Shape = z.infer<typeof Schema>;
}

View file

@ -0,0 +1,77 @@
import { Box, Button } from "@saleor/macaw-ui/next";
import { Input } from "@saleor/react-hook-form-macaw";
import { RootConfig } from "../schemas/root-config.schema";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { TextLink } from "@saleor/apps-ui";
import { Text } from "@saleor/macaw-ui/next";
import { ButtonsBox } from "@/modules/ui/buttons-box";
import { trpcClient } from "@/modules/trpc/trpc-client";
import { Skeleton } from "@/modules/ui/skeleton";
import { useDashboardNotification } from "@saleor/apps-shared";
const Schema = RootConfig.Schema.unwrap();
type Shape = z.infer<typeof Schema>;
const SegmentConfigFormBase = (props: { values: Shape; onSubmit(values: Shape): void }) => {
const { control, handleSubmit } = useForm({
resolver: zodResolver(Schema),
defaultValues: props.values,
});
return (
<Box as="form" onSubmit={handleSubmit(props.onSubmit)}>
<Input
control={control}
name="segmentWriteKey"
type="password"
label="Segment write key"
helperText={
<Text variant="caption" as="p" marginTop={2}>
Read about write keys in{" "}
<TextLink
newTab
size="small"
href="https://segment.com/docs/connections/find-writekey/"
>
Segment documentation
</TextLink>
</Text>
}
/>
<ButtonsBox marginTop={6}>
<Button type="submit">Save</Button>
</ButtonsBox>
</Box>
);
};
export const SegmentConfigForm = () => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { data: config, isLoading, refetch } = trpcClient.configuration.getConfig.useQuery();
const { mutate } = trpcClient.configuration.setConfig.useMutation({
onSuccess() {
notifySuccess("Configuration saved");
refetch();
},
onError() {
notifyError("Error saving configuration");
},
});
if (isLoading) {
return <Skeleton.Section />;
}
return (
<SegmentConfigFormBase
values={{ segmentWriteKey: config?.segmentWriteKey ?? "" }}
onSubmit={(values) => {
mutate(values.segmentWriteKey);
}}
/>
);
};

View file

@ -0,0 +1,21 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { AppConfigMetadataManager } from "./configuration/app-config-metadata-manager";
import { SegmentEventsTracker } from "./tracking-events/segment-events-tracker";
import { SegmentClient } from "./segment/segment.client";
import { SegmentNotConfiguredError } from "@/errors";
export const createSegmentClientForWebhookContext = async (context: { authData: AuthData }) => {
const config = await AppConfigMetadataManager.createFromAuthData(context.authData).get();
const segmentKey = config.getConfig()?.segmentWriteKey;
if (!segmentKey) {
throw new SegmentNotConfiguredError();
}
return new SegmentEventsTracker(
new SegmentClient({
segmentWriteKey: segmentKey,
}),
);
};

View file

@ -0,0 +1,21 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { PropsWithChildren } from "react";
import { Provider } from "urql";
import { createGraphQLClient } from "@saleor/apps-shared";
export function GraphQLProvider(props: PropsWithChildren<{}>) {
const { appBridgeState } = useAppBridge();
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
if (!appBridgeState?.saleorApiUrl) {
return <div {...props}></div>;
}
const client = createGraphQLClient({
saleorApiUrl,
token: appBridgeState.token,
});
return <Provider value={client} {...props} />;
}

View file

@ -0,0 +1,31 @@
import { Analytics, TrackParams } from "@segment/analytics-node";
//https://segment.com/docs/connections/sources/catalog/libraries/server/node/#graceful-shutdown
export class SegmentClient {
private readonly client: Analytics;
constructor({ segmentWriteKey }: { segmentWriteKey: string }) {
this.client = new Analytics({
writeKey: segmentWriteKey,
/**
* Since client is used per-event there will be likely a single event per request.
* The libarary is configured for a server architecture, where events are batched.
* Here we set the batch size to 1 to avoid any delays and submit event instantly.
*/
flushInterval: 1,
maxEventsInBatch: 1,
});
}
// https://segment.com/docs/connections/sources/catalog/libraries/server/node/#track
track(event: Pick<TrackParams, "properties" | "event"> & { userId: string }) {
this.client.track({
...event,
timestamp: new Date(),
});
}
flush() {
return this.client.closeAndFlush({ timeout: 1000 });
}
}

View file

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

View file

@ -0,0 +1,16 @@
import { SegmentClient } from "../segment/segment.client";
import { TrackingBaseEvent } from "./tracking-events";
export class SegmentEventsTracker {
constructor(private segmentClient: SegmentClient) {}
trackEvent(event: TrackingBaseEvent) {
this.segmentClient.track({
event: event.type,
userId: event.userId,
properties: event.payload,
});
return this.segmentClient.flush();
}
}

View file

@ -0,0 +1,91 @@
import { z } from "zod";
import { OrderBaseFragment } from "../../../generated/graphql";
import * as Sentry from "@sentry/nextjs";
export type TrackingBaseEvent = {
type: string;
userId: string;
payload: Record<string, unknown>;
};
const getUserId = ({ user, userEmail }: OrderBaseFragment) => {
const stringValidator = z.string().min(1);
const userId = user?.id ?? userEmail;
try {
const parsedUserId = stringValidator.parse(userId);
return parsedUserId;
} catch (e) {
Sentry.captureMessage(
"User ID resolution failed. Both user.id and userEmail are empty in order",
);
Sentry.captureException(e);
throw e;
}
};
/**
* Semantic events from Segment:
* https://segment.com/docs/connections/spec/ecommerce/v2/
*/
export const trackingEventFactory = {
createOrderCreatedEvent(orderBase: OrderBaseFragment): TrackingBaseEvent {
const { user, userEmail, ...order } = orderBase;
return {
type: "Checkout Started",
userId: getUserId(orderBase),
payload: {
...order,
},
};
},
createOrderUpdatedEvent(orderBase: OrderBaseFragment): TrackingBaseEvent {
const { user, userEmail, ...order } = orderBase;
return {
type: "Order Updated",
userId: getUserId(orderBase),
payload: {
...order,
},
};
},
createOrderCancelledEvent(orderBase: OrderBaseFragment): TrackingBaseEvent {
const { user, userEmail, ...order } = orderBase;
return {
type: "Order Cancelled",
userId: getUserId(orderBase),
payload: {
...order,
},
};
},
createOrderRefundedEvent(orderBase: OrderBaseFragment): TrackingBaseEvent {
const { user, userEmail, ...order } = orderBase;
return {
type: "Order Refunded",
userId: getUserId(orderBase),
payload: {
...order,
},
};
},
createOrderCompletedEvent(orderBase: OrderBaseFragment): TrackingBaseEvent {
const { user, userEmail, ...order } = orderBase;
return {
type: "Order Completed",
userId: getUserId(orderBase),
payload: {
...order,
},
};
},
};

View file

@ -0,0 +1,117 @@
import { verifyJWT } from "@saleor/app-sdk/verify-jwt";
import { middleware, procedure } from "./trpc-server";
import { TRPCError } from "@trpc/server";
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../saleor-app";
import { createGraphQLClient, logger } from "@saleor/apps-shared";
const attachAppToken = middleware(async ({ ctx, next }) => {
logger.debug("attachAppToken middleware");
if (!ctx.saleorApiUrl) {
logger.debug("ctx.saleorApiUrl not found, throwing");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Missing saleorApiUrl in request",
});
}
const authData = await saleorApp.apl.get(ctx.saleorApiUrl);
if (!authData) {
logger.debug("authData not found, throwing 401");
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Missing auth data",
});
}
return next({
ctx: {
appToken: authData.token,
saleorApiUrl: authData.saleorApiUrl,
appId: authData.appId,
},
});
});
const validateClientToken = middleware(async ({ ctx, next, meta }) => {
logger.debug(
{
permissions: meta?.requiredClientPermissions,
},
"Calling validateClientToken middleware with permissions required",
);
if (!ctx.token) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Missing token in request. This middleware can be used only in frontend",
});
}
if (!ctx.appId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Missing appId in request. This middleware can be used after auth is attached",
});
}
if (!ctx.saleorApiUrl) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"Missing saleorApiUrl in request. This middleware can be used after auth is attached",
});
}
if (!ctx.ssr) {
try {
logger.debug("trying to verify JWT token from frontend");
logger.debug({ token: ctx.token ? `${ctx.token[0]}...` : undefined });
await verifyJWT({
appId: ctx.appId,
token: ctx.token,
saleorApiUrl: ctx.saleorApiUrl,
requiredPermissions: meta?.requiredClientPermissions ?? [],
});
} catch (e) {
logger.debug("JWT verification failed, throwing");
throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED");
}
}
return next({
ctx: {
...ctx,
saleorApiUrl: ctx.saleorApiUrl,
},
});
});
/**
* Construct common graphQL client and attach it to the context
*
* Can be used only if called from the frontend (react-query),
* otherwise jwks validation will fail (if createCaller used)
*
* TODO Rethink middleware composition to enable safe server-side router calls
*/
export const protectedClientProcedure = procedure
.use(attachAppToken)
.use(validateClientToken)
.use(async ({ ctx, next }) => {
const client = createGraphQLClient({ saleorApiUrl: ctx.saleorApiUrl, token: ctx.appToken });
return next({
ctx: {
apiClient: client,
appToken: ctx.appToken,
saleorApiUrl: ctx.saleorApiUrl,
appId: ctx.appId!,
},
});
});

View file

@ -0,0 +1,8 @@
import { configurationRouter } from "../configuration/configuration.router";
import { router } from "./trpc-server";
export const appRouter = router({
configuration: configurationRouter,
});
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,44 @@
import { httpBatchLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { appBridgeInstance } from "../../pages/_app";
import { AppRouter } from "./trpc-app-router";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export const trpcClient = createTRPCNext<AppRouter>({
config({ ctx }) {
return {
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {
const { token, saleorApiUrl } = appBridgeInstance?.getState() || {};
if (!token || !saleorApiUrl) {
console.error(
"Can't initialize tRPC client before establishing the App Bridge connection",
);
throw new Error("Token and Saleor API URL unknown");
}
return {
/**
* Attach headers from app to client requests, so tRPC can add them to context
*/
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token,
[SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl,
};
},
}),
],
queryClientConfig: { defaultOptions: { queries: { refetchOnWindowFocus: false } } },
};
},
ssr: false,
});

View file

@ -0,0 +1,30 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
import { inferAsyncReturnType } from "@trpc/server";
const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
const { host, "x-forwarded-proto": xForwardedProto = "http" } = headers;
const xForwardedProtos = Array.isArray(xForwardedProto)
? xForwardedProto.join(",")
: xForwardedProto;
const protocols = xForwardedProtos.split(",");
// prefer https over other protocols
const protocol = protocols.find((el) => el === "https") || protocols[0];
return `${protocol}://${host}`;
};
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
const baseUrl = getBaseUrl(req.headers);
return {
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,
saleorApiUrl: req.headers[SALEOR_API_URL_HEADER] as string | undefined,
appId: undefined as undefined | string,
ssr: undefined as undefined | boolean,
baseUrl,
};
};
export type TrpcContext = inferAsyncReturnType<typeof createTrpcContext>;

View file

@ -0,0 +1,31 @@
import { initTRPC } from "@trpc/server";
import { TrpcContext } from "./trpc-context";
import { Permission } from "@saleor/app-sdk/types";
import { ZodError } from "zod";
interface Meta {
requiredClientPermissions?: Permission[];
updateWebhooks?: boolean;
}
const t = initTRPC
.context<TrpcContext>()
.meta<Meta>()
.create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.code === "BAD_REQUEST" && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
export const router = t.router;
export const procedure = t.procedure;
export const middleware = t.middleware;

View file

@ -0,0 +1,30 @@
import { TextLink } from "@saleor/apps-ui";
import { Breadcrumbs } from "@saleor/apps-ui/src/breadcrumbs";
import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next";
import { ComponentProps, ReactElement } from "react";
type Props = PropsWithBox<{}>;
export const AppHeader = ({ ...props }: Props) => {
return (
<Box
marginBottom={14}
borderBottomWidth={1}
borderBottomStyle="solid"
borderColor={"neutralHighlight"}
paddingBottom={6}
{...props}
>
<Text as="h1" marginBottom={4} variant="title">
Configuration
</Text>
<Text>
Read the{" "}
<TextLink href="https://docs.saleor.io/docs/3.x/developer/app-store/apps/segment" newTab>
docs
</TextLink>{" "}
to learn more
</Text>
</Box>
);
};

View file

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

View file

@ -0,0 +1,5 @@
import { BoxProps, Box } from "@saleor/macaw-ui/next";
export const ButtonsBox = (props: BoxProps) => {
return <Box display={"flex"} justifyContent="flex-end" gap={4} {...props} />;
};

View file

@ -0,0 +1,32 @@
import { Box, BoxProps } from "@saleor/macaw-ui/next";
/*
* TODO: Make it more generic, move to shared or contribute to macaw
* todo use macaw skeleton
*/
const Section = (props: BoxProps) => {
return (
<Box display="grid" gap={2} {...props}>
<Box
__height="10px"
backgroundColor="surfaceNeutralHighlight"
borderRadius={2}
__width="50%"
/>
<Box
__height="10px"
backgroundColor="surfaceNeutralHighlight"
borderRadius={2}
__width="70%"
/>
<Box
__height="10px"
backgroundColor="surfaceNeutralHighlight"
borderRadius={2}
__width="60%"
/>
</Box>
);
};
export const Skeleton = { Section };

View file

@ -0,0 +1,46 @@
import { GraphQLProvider } from "@/modules/graphql/GraphQLProvider";
import { ThemeSynchronizer } from "@/modules/theme/theme-synchronizer";
import { trpcClient } from "@/modules/trpc/trpc-client";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import { NoSSRWrapper } from "@saleor/apps-shared";
import { Box, ThemeProvider } from "@saleor/macaw-ui/next";
import "@saleor/macaw-ui/next/style";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppProps } from "next/app";
/**
* Ensure instance is a singleton.
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
*/
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
function NextApp({ Component, pageProps }: AppProps) {
return (
<NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<GraphQLProvider>
<ThemeProvider>
<ThemeSynchronizer />
<RoutePropagator />
<QueryClientProvider client={queryClient}>
<Box padding={10}>
<Component {...pageProps} />
</Box>
</QueryClientProvider>
</ThemeProvider>
</GraphQLProvider>
</AppBridgeProvider>
</NoSSRWrapper>
);
}
export default trpcClient.withTRPC(NextApp);

View file

@ -0,0 +1,13 @@
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}

View file

@ -0,0 +1,54 @@
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { AppManifest } from "@saleor/app-sdk/types";
import packageJson from "../../../package.json";
import { orderCreatedWebhook } from "./webhooks/order-created";
import { orderUpdatedWebhook } from "./webhooks/order-updated";
import { orderCancelledWebhook } from "./webhooks/order-cancelled";
import { orderRefundedWebhook } from "./webhooks/order-refunded";
import { orderFullyPaidWebhook } from "./webhooks/order-fully-paid";
export default createManifestHandler({
async manifestFactory({ appBaseUrl }) {
const iframeBaseUrl = process.env.APP_IFRAME_BASE_URL ?? appBaseUrl;
const apiBaseURL = process.env.APP_API_BASE_URL ?? appBaseUrl;
const manifest: AppManifest = {
about: "Seamlessly feed Segment with Saleor events",
appUrl: iframeBaseUrl,
author: "Saleor Commerce",
brand: {
logo: {
default: `${apiBaseURL}/logo.png`,
},
},
dataPrivacyUrl: "https://saleor.io/legal/privacy/",
extensions: [
/**
* Optionally, extend Dashboard with custom UIs
* https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps
*/
],
homepageUrl: "https://github.com/saleor/apps",
id: "saleor.app.segment",
name: "Twilio Segment",
permissions: ["MANAGE_ORDERS"],
requiredSaleorVersion: ">=3.14 <4",
supportUrl: "https://github.com/saleor/apps/discussions",
tokenTargetUrl: `${apiBaseURL}/api/register`,
version: packageJson.version,
/*
* TODO Add webhooks disabled and enable then when configured
*/
webhooks: [
orderCreatedWebhook.getWebhookManifest(appBaseUrl),
orderUpdatedWebhook.getWebhookManifest(appBaseUrl),
orderCancelledWebhook.getWebhookManifest(appBaseUrl),
orderRefundedWebhook.getWebhookManifest(appBaseUrl),
orderFullyPaidWebhook.getWebhookManifest(appBaseUrl),
],
};
return manifest;
},
});

View file

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

View file

@ -0,0 +1,8 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
import { appRouter } from "../../../modules/trpc/trpc-app-router";
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: createTrpcContext,
});

View file

@ -0,0 +1,66 @@
import { createSegmentClientForWebhookContext } from "@/modules/create-segment-client-for-webhook-context";
import { trackingEventFactory } from "@/modules/tracking-events/tracking-events";
import { saleorApp } from "@/saleor-app";
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderCancelledDocument,
OrderUpdatedSubscriptionPayloadFragment,
} from "../../../../generated/graphql";
import * as Sentry from "@sentry/nextjs";
import { SegmentNotConfiguredError } from "@/errors";
import { createLogger } from "@saleor/apps-shared";
export const config = {
api: {
bodyParser: false,
},
};
export const orderCancelledWebhook =
new SaleorAsyncWebhook<OrderUpdatedSubscriptionPayloadFragment>({
name: "Order Cancelled v1",
webhookPath: "api/webhooks/order-cancelled",
event: "ORDER_CANCELLED",
apl: saleorApp.apl,
query: OrderCancelledDocument,
});
const logger = createLogger({ name: "orderCancelledWebhook" });
const handler: NextWebhookApiHandler<OrderUpdatedSubscriptionPayloadFragment> = async (
req,
res,
context,
) => {
const { authData, payload } = context;
if (!payload.order) {
Sentry.captureException(new Error("Order not found in payload. This should not happen."));
return res.status(200).end();
}
try {
const segmentEventTracker = await createSegmentClientForWebhookContext({ authData });
logger.info("Sending order cancelled event to Segment");
await segmentEventTracker.trackEvent(
trackingEventFactory.createOrderCancelledEvent(payload.order),
);
return res.status(200).end();
} catch (e) {
if (e instanceof SegmentNotConfiguredError) {
// todo disable webhooks if not configured
return res.status(200).end();
}
Sentry.captureException(e);
return res.status(500).end();
}
};
export default orderCancelledWebhook.createHandler(handler);

View file

@ -0,0 +1,65 @@
import { createSegmentClientForWebhookContext } from "@/modules/create-segment-client-for-webhook-context";
import { trackingEventFactory } from "@/modules/tracking-events/tracking-events";
import { saleorApp } from "@/saleor-app";
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderCreatedDocument,
OrderCreatedSubscriptionPayloadFragment,
} from "../../../../generated/graphql";
import { SegmentNotConfiguredError } from "@/errors";
import * as Sentry from "@sentry/nextjs";
import { createLogger } from "@saleor/apps-shared";
export const config = {
api: {
bodyParser: false,
},
};
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedSubscriptionPayloadFragment>({
name: "Order Created v1",
webhookPath: "api/webhooks/order-created",
event: "ORDER_CREATED",
apl: saleorApp.apl,
query: OrderCreatedDocument,
});
const logger = createLogger({ name: "orderCreatedWebhook" });
const handler: NextWebhookApiHandler<OrderCreatedSubscriptionPayloadFragment> = async (
req,
res,
context,
) => {
const { authData, payload } = context;
if (!payload.order) {
Sentry.captureException(new Error("Order not found in payload. This should not happen."));
return res.status(200).end();
}
try {
const segmentEventTracker = await createSegmentClientForWebhookContext({ authData });
logger.info("Sending order created event to Segment");
await segmentEventTracker.trackEvent(
trackingEventFactory.createOrderCreatedEvent(payload.order),
);
return res.status(200).end();
} catch (e) {
if (e instanceof SegmentNotConfiguredError) {
// todo disable webhooks if not configured
return res.status(200).end();
}
Sentry.captureException(e);
return res.status(500).end();
}
};
export default orderCreatedWebhook.createHandler(handler);

View file

@ -0,0 +1,67 @@
import { createSegmentClientForWebhookContext } from "@/modules/create-segment-client-for-webhook-context";
import { trackingEventFactory } from "@/modules/tracking-events/tracking-events";
import { saleorApp } from "@/saleor-app";
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderFullyPaidDocument,
OrderFullyPaidSubscriptionPayloadFragment,
} from "../../../../generated/graphql";
import { SegmentNotConfiguredError } from "@/errors";
import * as Sentry from "@sentry/nextjs";
import { createLogger } from "@saleor/apps-shared";
export const config = {
api: {
bodyParser: false,
},
};
export const orderFullyPaidWebhook =
new SaleorAsyncWebhook<OrderFullyPaidSubscriptionPayloadFragment>({
name: "Order Fully Paid v1",
webhookPath: "api/webhooks/order-fully-paid",
event: "ORDER_FULLY_PAID",
apl: saleorApp.apl,
query: OrderFullyPaidDocument,
});
const logger = createLogger({ name: "orderFullyPaidWebhook" });
const handler: NextWebhookApiHandler<OrderFullyPaidSubscriptionPayloadFragment> = async (
req,
res,
context,
) => {
const { authData, payload } = context;
if (!payload.order) {
Sentry.captureException(new Error("Order not found in payload. This should not happen."));
return res.status(200).end();
}
try {
const segmentEventTracker = await createSegmentClientForWebhookContext({ authData });
logger.info("Sending order fully paid event to Segment");
await segmentEventTracker.trackEvent(
trackingEventFactory.createOrderCompletedEvent(payload.order),
);
return res.status(200).end();
} catch (e) {
if (e instanceof SegmentNotConfiguredError) {
// todo disable webhooks if not configured
return res.status(200).end();
}
Sentry.captureException(e);
return res.status(500).end();
}
};
export default orderFullyPaidWebhook.createHandler(handler);

View file

@ -0,0 +1,67 @@
import { createSegmentClientForWebhookContext } from "@/modules/create-segment-client-for-webhook-context";
import { trackingEventFactory } from "@/modules/tracking-events/tracking-events";
import { saleorApp } from "@/saleor-app";
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderRefundedDocument,
OrderRefundedSubscriptionPayloadFragment,
} from "../../../../generated/graphql";
import { SegmentNotConfiguredError } from "@/errors";
import * as Sentry from "@sentry/nextjs";
import { createLogger } from "@saleor/apps-shared";
export const config = {
api: {
bodyParser: false,
},
};
export const orderRefundedWebhook =
new SaleorAsyncWebhook<OrderRefundedSubscriptionPayloadFragment>({
name: "Order Refunded v1",
webhookPath: "api/webhooks/order-refunded",
event: "ORDER_REFUNDED",
apl: saleorApp.apl,
query: OrderRefundedDocument,
});
const logger = createLogger({ name: "orderRefundedWebhook" });
const handler: NextWebhookApiHandler<OrderRefundedSubscriptionPayloadFragment> = async (
req,
res,
context,
) => {
const { authData, payload } = context;
if (!payload.order) {
Sentry.captureException(new Error("Order not found in payload"));
return res.status(400).end();
}
try {
const segmentEventTracker = await createSegmentClientForWebhookContext({ authData });
logger.info("Sending order refunded event to Segment");
await segmentEventTracker.trackEvent(
trackingEventFactory.createOrderRefundedEvent(payload.order),
);
return res.status(200).end();
} catch (e) {
if (e instanceof SegmentNotConfiguredError) {
// todo disable webhooks if not configured
return res.status(200).end();
}
Sentry.captureException(e);
return res.status(500).end();
}
};
export default orderRefundedWebhook.createHandler(handler);

View file

@ -0,0 +1,65 @@
import { createSegmentClientForWebhookContext } from "@/modules/create-segment-client-for-webhook-context";
import { trackingEventFactory } from "@/modules/tracking-events/tracking-events";
import { saleorApp } from "@/saleor-app";
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderUpdatedDocument,
OrderUpdatedSubscriptionPayloadFragment,
} from "../../../../generated/graphql";
import { SegmentNotConfiguredError } from "@/errors";
import * as Sentry from "@sentry/nextjs";
import { createLogger } from "@saleor/apps-shared";
export const config = {
api: {
bodyParser: false,
},
};
export const orderUpdatedWebhook = new SaleorAsyncWebhook<OrderUpdatedSubscriptionPayloadFragment>({
name: "Order Updated v1",
webhookPath: "api/webhooks/order-updated",
event: "ORDER_UPDATED",
apl: saleorApp.apl,
query: OrderUpdatedDocument,
});
const logger = createLogger({ name: "orderUpdatedWebhook" });
const handler: NextWebhookApiHandler<OrderUpdatedSubscriptionPayloadFragment> = async (
req,
res,
context,
) => {
const { authData, payload } = context;
if (!payload.order) {
Sentry.captureException(new Error("Order not found in payload. This should not happen."));
return res.status(200).end();
}
try {
const segmentEventTracker = await createSegmentClientForWebhookContext({ authData });
logger.info("Sending order updated event to Segment");
await segmentEventTracker.trackEvent(
trackingEventFactory.createOrderUpdatedEvent(payload.order),
);
return res.status(200).end();
} catch (e) {
if (e instanceof SegmentNotConfiguredError) {
// todo disable webhooks if not configured
return res.status(200).end();
}
Sentry.captureException(e);
return res.status(500).end();
}
};
export default orderUpdatedWebhook.createHandler(handler);

View file

@ -0,0 +1,32 @@
import { SegmentConfigForm } from "@/modules/configuration/segment-config-form/segment-config-form";
import { AppHeader } from "@/modules/ui/app-header";
import { AppSection } from "@/modules/ui/app-section";
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Box, Text } from "@saleor/macaw-ui/next";
import { NextPage } from "next";
const ConfigurationPage: NextPage = () => {
const { appBridgeState } = useAppBridge();
if (!appBridgeState) {
return null;
}
if (appBridgeState.user?.permissions.includes("MANAGE_APPS") === false) {
return <Text>You do not have permission to access this page.</Text>;
}
return (
<Box>
<AppHeader />
<AppSection
marginBottom={14}
heading="Segment.io configration"
sideContent={<Text>Provide Segment credentials to allow sending events.</Text>}
mainContent={<SegmentConfigForm />}
/>
</Box>
);
};
export default ConfigurationPage;

View file

@ -0,0 +1,32 @@
import { NextPage } from "next";
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useEffect } from "react";
import { useIsMounted } from "usehooks-ts";
import { useRouter } from "next/router";
import { isInIframe } from "@saleor/apps-shared";
const IndexPage: NextPage = () => {
const { appBridgeState } = useAppBridge();
const isMounted = useIsMounted();
const { replace } = useRouter();
useEffect(() => {
if (isMounted() && appBridgeState?.ready) {
replace("/configuration");
}
}, [isMounted, appBridgeState?.ready, replace]);
if (isInIframe()) {
return <p>Loading</p>;
}
return (
<div>
<h1>Saleor App - Twilio Segment integration</h1>
<p>This is Saleor App that synchronized order events to Segment</p>
<p>Install app in your Saleor instance and open in with Dashboard</p>
</div>
);
};
export default IndexPage;

View file

@ -0,0 +1,35 @@
import { APL, FileAPL, SaleorCloudAPL, UpstashAPL } from "@saleor/app-sdk/APL";
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
const aplType = process.env.APL ?? "file";
export let apl: APL;
switch (aplType) {
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, ");
}
}
export const saleorApp = new SaleorApp({
apl,
});

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1,23 @@
{
"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,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

28
apps/segment/turbo.json Normal file
View file

@ -0,0 +1,28 @@
{
"extends": ["//"],
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"env": [
"APL",
"APP_DEBUG",
"NODE_ENV",
"SECRET_KEY",
"ALLOWED_DOMAIN_PATTERN",
"REST_APL_ENDPOINT",
"REST_APL_TOKEN",
"NEXT_PUBLIC_VERCEL_ENV",
"VERCEL_URL",
"PORT",
"SENTRY_ORG",
"SENTRY_PROJECT",
"SENTRY_DSN",
"SENTRY_AUTH_TOKEN",
"NEXT_PUBLIC_SENTRY_DSN",
"SENTRY_ENVIRONMENT",
"APP_IFRAME_BASE_URL",
"APP_API_BASE_URL"
]
}
}
}

View file

@ -0,0 +1,17 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
passWithNoTests: true,
environment: "jsdom",
setupFiles: "./src/setup-tests.ts",
css: false,
alias: {
"@": "./src",
},
},
});

File diff suppressed because it is too large Load diff