(/* GraphQL */ `
20 | #graphql
21 | query Shop {
22 | shop {
23 | name
24 | }
25 | }
26 | `);
27 | return {
28 | data,
29 | errors,
30 | };
31 | } catch (error) {
32 | shopify.utils.log.error("app.index.loader.error", error);
33 |
34 | if (error instanceof ShopifyException) {
35 | switch (error.type) {
36 | case "GRAPHQL":
37 | return { errors: error.errors };
38 |
39 | default:
40 | return new Response(error.message, {
41 | status: error.status,
42 | });
43 | }
44 | }
45 |
46 | return data(
47 | {
48 | data: undefined,
49 | errors: [{ message: "Unknown Error" }],
50 | },
51 | 500,
52 | );
53 | }
54 | }
55 |
56 | export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
57 | const data = await serverLoader();
58 | return data;
59 | }
60 |
61 | export default function AppIndex({
62 | actionData,
63 | loaderData,
64 | }: Route.ComponentProps) {
65 | const { data, errors } = loaderData ?? actionData ?? {};
66 | console.log("app.index", data);
67 |
68 | const { t } = useTranslation();
69 |
70 | useEffect(() => {
71 | const controller = new AbortController();
72 |
73 | fetch(`shopify:admin/api/${API_VERSION}/graphql.json`, {
74 | body: JSON.stringify({
75 | query: /* GraphQL */ `
76 | #graphql
77 | query Shop {
78 | shop {
79 | name
80 | }
81 | }
82 | `,
83 | variables: {},
84 | }),
85 | method: "POST",
86 | signal: controller.signal,
87 | })
88 | .then<{ data: ShopQuery }>((res) => res.json())
89 | .then((res) => console.log("app.index.useEffect", res))
90 | .catch((err) => console.error("app.index.useEffect.error", err));
91 |
92 | return () => controller.abort();
93 | }, []);
94 |
95 | const shopify = useAppBridge();
96 |
97 | return (
98 |
99 |
100 |
107 |
108 |
109 | {errors ? JSON.stringify(errors, null, 2) : data?.shop?.name}
110 |
111 | shopify.saveBar.show("savebar")}>click
112 |
113 | );
114 | }
115 |
116 | export async function clientAction({ serverAction }: Route.ClientActionArgs) {
117 | const data = await serverAction();
118 | return data;
119 | }
120 |
121 | export async function action(_: Route.ActionArgs) {
122 | const data = {};
123 | return { data };
124 | }
125 |
126 | export { headers } from "./app";
127 |
--------------------------------------------------------------------------------
/app/routes/app.tsx:
--------------------------------------------------------------------------------
1 | import { NavMenu, useAppBridge } from "@shopify/app-bridge-react";
2 | import { AppProvider, type AppProviderProps } from "@shopify/polaris";
3 | import polarisCss from "@shopify/polaris/build/esm/styles.css?url";
4 | import type { LinkLikeComponentProps } from "@shopify/polaris/build/ts/src/utilities/link";
5 | import { useEffect } from "react";
6 | import { useTranslation } from "react-i18next";
7 | import { Link, Outlet, useNavigation } from "react-router";
8 |
9 | import { APP_BRIDGE_URL } from "~/const";
10 | import { createShopify } from "~/shopify.server";
11 | import type { Route } from "./+types/app";
12 |
13 | export async function loader({ context, request }: Route.LoaderArgs) {
14 | try {
15 | const shopify = createShopify(context);
16 | shopify.utils.log.debug("app");
17 |
18 | await shopify.admin(request);
19 |
20 | return {
21 | appDebug: shopify.config.appLogLevel === "debug",
22 | appHandle: shopify.config.appHandle,
23 | apiKey: shopify.config.apiKey,
24 | appUrl: shopify.config.appUrl,
25 | };
26 | // biome-ignore lint/suspicious/noExplicitAny: catch(err)
27 | } catch (error: any) {
28 | if (error instanceof Response) return error;
29 |
30 | return new Response(error.message, {
31 | status: error.status,
32 | statusText: "Unauthorized",
33 | });
34 | }
35 | }
36 |
37 | export default function App({ loaderData }: Route.ComponentProps) {
38 | const { appHandle, apiKey } = loaderData;
39 |
40 | const { t } = useTranslation(["app", "polaris"]);
41 | const i18n = {
42 | Polaris: t("Polaris", {
43 | ns: "polaris",
44 | returnObjects: true,
45 | }),
46 | } as AppProviderProps["i18n"];
47 |
48 | return (
49 | <>
50 |
51 |
52 |
53 |
54 |
55 | {t("app")}
56 |
57 |
61 | {t("pricingPlans")}
62 |
63 |
64 |
65 |
66 |
67 | >
68 | );
69 | }
70 |
71 | function AppOutlet() {
72 | const shopify = useAppBridge();
73 | const navigation = useNavigation();
74 | const isNavigating = navigation.state !== "idle" || !!navigation.location;
75 | useEffect(() => {
76 | shopify.loading(isNavigating);
77 | }, [isNavigating, shopify]);
78 |
79 | return ;
80 | }
81 |
82 | export function ErrorBoundary(error: Route.ErrorBoundaryProps) {
83 | if (
84 | error.constructor.name === "ErrorResponse" ||
85 | error.constructor.name === "ErrorResponseImpl"
86 | ) {
87 | return (
88 |
95 | );
96 | }
97 |
98 | throw error;
99 | }
100 | ErrorBoundary.displayName = "AppErrorBoundary";
101 |
102 | export function headers({
103 | parentHeaders,
104 | loaderHeaders,
105 | actionHeaders,
106 | errorHeaders,
107 | }: Route.HeadersArgs) {
108 | if (errorHeaders && Array.from(errorHeaders.entries()).length > 0) {
109 | return errorHeaders;
110 | }
111 |
112 | return new Headers([
113 | ...(parentHeaders ? Array.from(parentHeaders.entries()) : []),
114 | ...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []),
115 | ...(actionHeaders ? Array.from(actionHeaders.entries()) : []),
116 | ]);
117 | }
118 |
119 | function LinkComponent({ url, ...props }: LinkLikeComponentProps) {
120 | return ;
121 | }
122 |
123 | export const links: Route.LinksFunction = () => [
124 | { href: "https://cdn.shopify.com", rel: "preconnect" },
125 | { as: "script", href: APP_BRIDGE_URL, rel: "preload" },
126 | {
127 | href: "https://cdn.shopify.com/static/fonts/inter/v4/styles.css",
128 | precedence: "default",
129 | rel: "stylesheet",
130 | },
131 | { href: polarisCss, precedence: "high", rel: "stylesheet" },
132 | ];
133 |
134 | export const meta: Route.MetaFunction = ({ data }: Route.MetaArgs) => [
135 | data?.appDebug ? { name: "shopify-debug", content: "web-vitals" } : {},
136 | { name: "shopify-experimental-features", content: "keepAlive" },
137 | ];
138 |
--------------------------------------------------------------------------------
/app/routes/index.browser.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, test } from "vitest";
2 | import { render } from "vitest-browser-react";
3 |
4 | import Index from "./index";
5 |
6 | test("loads and displays h1", async () => {
7 | const screen = render();
8 | const heading = screen.getByTestId("h1");
9 | await expect.element(heading).toHaveTextContent("ShopFlare");
10 | });
11 |
--------------------------------------------------------------------------------
/app/routes/index.e2e.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@playwright/test";
2 |
3 | test("loads", async ({ page }) => {
4 | await page.goto("/");
5 | await expect(page).toHaveTitle(/ShopFlare/);
6 | });
7 |
--------------------------------------------------------------------------------
/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "react-router";
2 |
3 | import type { Route } from "./+types/index";
4 |
5 | export async function loader({ request }: Route.LoaderArgs) {
6 | const url = new URL(request.url);
7 | if (url.searchParams.has("shop")) {
8 | return redirect(`/app?${url.searchParams.toString()}`);
9 | }
10 |
11 | const data = { ok: true };
12 | return { data };
13 | }
14 |
15 | export default function Index() {
16 | return (
17 |
26 |
ShopFlare
27 |
28 | );
29 | }
30 |
31 | export async function action(_: Route.ActionArgs) {
32 | const data = { ok: true };
33 | return { data };
34 | }
35 |
--------------------------------------------------------------------------------
/app/routes/proxy.index.e2e.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@playwright/test";
2 |
3 | test("loads", async ({ page }) => {
4 | await page.goto("/apps/shopflare");
5 | await expect(page).toHaveTitle(/ShopFlare/);
6 | await expect(page.getByRole("heading", { name: "Ops!" })).toBeVisible();
7 | });
8 |
--------------------------------------------------------------------------------
/app/routes/proxy.index.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from "react-i18next";
2 |
3 | import { Form } from "~/components/proxy";
4 | import { createShopify } from "~/shopify.server";
5 | import type { Route } from "./+types/proxy.index";
6 |
7 | export async function loader({ context, request }: Route.LoaderArgs) {
8 | const shopify = createShopify(context);
9 | shopify.utils.log.debug("proxy.index");
10 |
11 | await shopify.proxy(request);
12 |
13 | const data = {};
14 | return { data };
15 | }
16 |
17 | export default function ProxyIndex() {
18 | const { t } = useTranslation("proxy");
19 |
20 | return (
21 |
30 |
{t("proxy")}
31 |
36 |
37 | );
38 | }
39 |
40 | export async function action(_: Route.ActionArgs) {
41 | const data = {};
42 | return { data };
43 | }
44 |
--------------------------------------------------------------------------------
/app/routes/proxy.server.test.ts:
--------------------------------------------------------------------------------
1 | import { env } from "cloudflare:test";
2 | import type { AppLoadContext } from "react-router";
3 | import { describe, expect, test } from "vitest";
4 |
5 | import { ShopifySession } from "../shopify.server";
6 | import type { Route } from "./+types/proxy";
7 | import { loader } from "./proxy";
8 |
9 | const context = {
10 | cloudflare: { env: { ...env, SHOPIFY_APP_LOG_LEVEL: "error" } },
11 | } as unknown as AppLoadContext;
12 |
13 | describe("loader", () => {
14 | test("error on param missing", async () => {
15 | const url = new URL("http://localhost");
16 | const request = new Request(url);
17 | const response = (await loader({
18 | context,
19 | request,
20 | } as Route.LoaderArgs)) as Response;
21 |
22 | expect(response).toBeInstanceOf(Response);
23 | expect(response?.ok).toBe(false);
24 | expect(response?.status).toBe(400);
25 | expect(await response?.text()).toBe("Proxy param is missing");
26 | });
27 |
28 | test("error on proxy timestamp is expired", async () => {
29 | const url = new URL("http://localhost");
30 | url.searchParams.set("signature", "123");
31 | url.searchParams.set("timestamp", `${Math.trunc(Date.now() / 1_000 - 91)}`);
32 | const request = new Request(url, { method: "POST" });
33 | const response = (await loader({
34 | context,
35 | request,
36 | } as Route.LoaderArgs)) as Response;
37 |
38 | expect(response).toBeInstanceOf(Response);
39 | expect(response?.ok).toBe(false);
40 | expect(response?.status).toBe(400);
41 | expect(await response?.text()).toBe("Proxy timestamp is expired");
42 | });
43 |
44 | test("error on encoded byte length mismatch", async () => {
45 | const url = new URL("http://localhost");
46 | url.searchParams.set("signature", "123");
47 | url.searchParams.set("timestamp", `${Math.trunc(Date.now() / 1_000)}`);
48 | const request = new Request(url, { method: "POST" });
49 | const response = (await loader({
50 | context,
51 | request,
52 | } as Route.LoaderArgs)) as Response;
53 |
54 | expect(response).toBeInstanceOf(Response);
55 | expect(response?.ok).toBe(false);
56 | expect(response?.status).toBe(401);
57 | expect(await response?.text()).toBe("Encoded byte length mismatch");
58 | });
59 |
60 | test("error on invalid hmac", async () => {
61 | const url = new URL("http://localhost");
62 | url.searchParams.set(
63 | "signature",
64 | "548e324a5420c20bffa1d81318b5790de43731c278d0435108e5bcdbdc20795d",
65 | ); // NOTE: changed
66 | url.searchParams.set("timestamp", `${Math.trunc(Date.now() / 1_000)}`);
67 | const request = new Request(url, { method: "POST" });
68 | const response = (await loader({
69 | context,
70 | request,
71 | } as Route.LoaderArgs)) as Response;
72 |
73 | expect(response).toBeInstanceOf(Response);
74 | expect(response?.ok).toBe(false);
75 | expect(response?.status).toBe(401);
76 | expect(await response?.text()).toBe("Invalid hmac");
77 | });
78 |
79 | test("error on no session access token", async () => {
80 | const timestamp = Math.trunc(Date.now() / 1_000).toString();
81 |
82 | const url = new URL("http://localhost");
83 | url.searchParams.set("signature", await getHmac({ timestamp }));
84 | url.searchParams.set("timestamp", timestamp);
85 |
86 | const request = new Request(url, { method: "POST" });
87 | const response = (await loader({
88 | context,
89 | request,
90 | } as Route.LoaderArgs)) as Response;
91 |
92 | expect(response).toBeInstanceOf(Response);
93 | expect(response?.ok).toBe(false);
94 | expect(response?.status).toBe(401);
95 | expect(await response?.text()).toBe("No session access token");
96 | });
97 |
98 | test("success", async () => {
99 | const shop = "test.myshopify.com";
100 |
101 | const session = new ShopifySession(context.cloudflare.env.SESSION_STORAGE);
102 | await session.set({
103 | accessToken: "123",
104 | id: shop,
105 | scope: "read_products",
106 | shop,
107 | });
108 |
109 | const timestamp = Math.trunc(Date.now() / 1_000).toString();
110 |
111 | const url = new URL("http://localhost");
112 | url.searchParams.set("signature", await getHmac({ shop, timestamp }));
113 | url.searchParams.set("timestamp", timestamp);
114 | url.searchParams.set("shop", shop);
115 |
116 | const request = new Request(url, { body: "{}", method: "POST" });
117 | const response = await loader({
118 | context,
119 | request,
120 | } as Route.LoaderArgs);
121 |
122 | expect(response).toStrictEqual({
123 | appUrl: context.cloudflare.env.SHOPIFY_APP_URL,
124 | });
125 |
126 | await session.delete(shop);
127 | });
128 | });
129 |
130 | async function getHmac(searchParams: object) {
131 | const params = Object.entries(searchParams)
132 | .filter(([key]) => key !== "signature")
133 | .map(
134 | ([key, value]) =>
135 | `${key}=${Array.isArray(value) ? value.join(",") : value}`,
136 | )
137 | .sort((a, b) => a.localeCompare(b))
138 | .join("");
139 |
140 | const encoder = new TextEncoder();
141 | const key = await crypto.subtle.importKey(
142 | "raw",
143 | encoder.encode(context.cloudflare.env.SHOPIFY_API_SECRET_KEY),
144 | {
145 | name: "HMAC",
146 | hash: "SHA-256",
147 | },
148 | true,
149 | ["sign"],
150 | );
151 | const signature = await crypto.subtle.sign(
152 | "HMAC",
153 | key,
154 | encoder.encode(params),
155 | );
156 | const hmac = [...new Uint8Array(signature)].reduce(
157 | (a, b) => a + b.toString(16).padStart(2, "0"),
158 | "",
159 | ); // hex
160 | return hmac;
161 | }
162 |
--------------------------------------------------------------------------------
/app/routes/proxy.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "react-router";
2 |
3 | import { Provider } from "~/components/proxy";
4 | import { createShopify } from "~/shopify.server";
5 | import type { Route } from "./+types/proxy";
6 |
7 | export async function loader({ context, request }: Route.LoaderArgs) {
8 | try {
9 | const shopify = createShopify(context);
10 | shopify.utils.log.debug("proxy");
11 |
12 | await shopify.proxy(request);
13 |
14 | return { appUrl: shopify.config.appUrl };
15 | // biome-ignore lint/suspicious/noExplicitAny: catch(err)
16 | } catch (error: any) {
17 | return new Response(error.message, {
18 | status: error.status,
19 | statusText: "Unauthorized",
20 | });
21 | }
22 | }
23 |
24 | export default function ProxyRoute({ loaderData }: Route.ComponentProps) {
25 | const { appUrl } = loaderData ?? {};
26 |
27 | return (
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/app/routes/shopify.auth.login.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AppProvider,
3 | type AppProviderProps,
4 | Button,
5 | FormLayout,
6 | Page,
7 | Text,
8 | TextField,
9 | } from "@shopify/polaris";
10 | import polarisCss from "@shopify/polaris/build/esm/styles.css?url";
11 | import { useState } from "react";
12 | import { useTranslation } from "react-i18next";
13 | import { Form, redirect } from "react-router";
14 |
15 | import { createShopify } from "~/shopify.server";
16 | import type { Route } from "./+types/shopify.auth.login";
17 |
18 | export async function loader({ context, request }: Route.LoaderArgs) {
19 | return action({ context, request } as Route.ActionArgs);
20 | }
21 |
22 | export default function AuthLogin({
23 | actionData,
24 | loaderData,
25 | }: Route.ComponentProps) {
26 | const { errors } = actionData ?? loaderData ?? {};
27 | const [shop, setShop] = useState("");
28 |
29 | const { t } = useTranslation(["app", "polaris"]);
30 | const i18n = {
31 | Polaris: t("Polaris", {
32 | ns: "polaris",
33 | returnObjects: true,
34 | }),
35 | } as AppProviderProps["i18n"];
36 |
37 | return (
38 |
47 |
48 |
49 |
70 |
71 |
72 |
73 | );
74 | }
75 |
76 | export async function action({ context, request }: Route.ActionArgs) {
77 | const shopify = createShopify(context);
78 |
79 | const url = new URL(request.url);
80 | let shop = url.searchParams.get("shop");
81 | if (request.method === "GET" && !shop) {
82 | return {};
83 | }
84 |
85 | if (!shop) {
86 | shop = (await request.formData()).get("shop") as string;
87 | }
88 | if (!shop) {
89 | return { errors: { shop: "MISSING_SHOP" } };
90 | }
91 |
92 | const shopWithoutProtocol = shop
93 | .replace(/^https?:\/\//, "")
94 | .replace(/\/$/, "");
95 | const shopWithDomain =
96 | shop?.indexOf(".") === -1
97 | ? `${shopWithoutProtocol}.myshopify.com`
98 | : shopWithoutProtocol;
99 | const sanitizedShop = shopify.utils.sanitizeShop(shopWithDomain);
100 | if (!sanitizedShop) {
101 | return { errors: { shop: "INVALID_SHOP" } };
102 | }
103 |
104 | const adminPath = shopify.utils.legacyUrlToShopAdminUrl(sanitizedShop);
105 | const redirectUrl = `https://${adminPath}/oauth/install?client_id=${shopify.config.apiKey}`;
106 | throw redirect(redirectUrl);
107 | }
108 |
109 | export const links: Route.LinksFunction = () => [
110 | { href: "https://cdn.shopify.com", rel: "preconnect" },
111 | {
112 | href: "https://cdn.shopify.com/static/fonts/inter/v4/styles.css",
113 | precedence: "default",
114 | rel: "stylesheet",
115 | },
116 | { href: polarisCss, precedence: "high", rel: "stylesheet" },
117 | ];
118 |
--------------------------------------------------------------------------------
/app/routes/shopify.auth.session-token-bounce.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "react-router";
2 |
3 | import { APP_BRIDGE_URL } from "~/const";
4 | import { createShopify } from "~/shopify.server";
5 | import type { Route } from "./+types/shopify.auth.session-token-bounce";
6 |
7 | export async function loader({ context, request }: Route.LoaderArgs) {
8 | const shopify = createShopify(context);
9 | shopify.utils.log.debug("shopify.auth.session-token-bounce");
10 |
11 | const url = new URL(request.url);
12 | const shop = shopify.utils.sanitizeShop(url.searchParams.get("shop")!);
13 | if (!shop) {
14 | return redirect("/shopify/auth/login");
15 | }
16 |
17 | const headers = new Headers({
18 | "content-type": "text/html;charset=utf-8",
19 | });
20 | shopify.utils.addHeaders(request, headers);
21 |
22 | return new Response(
23 | /* html */ `
24 |
25 |
26 | `,
27 | { headers },
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/app/routes/shopify.webhooks.server.test.ts:
--------------------------------------------------------------------------------
1 | import { env } from "node:process";
2 | import type { AppLoadContext } from "react-router";
3 | import { describe, expect, test } from "vitest";
4 |
5 | import { API_VERSION } from "~/const";
6 | import type { Route } from "./+types/shopify.webhooks";
7 | import { action } from "./shopify.webhooks";
8 |
9 | const context = { cloudflare: { env } } as unknown as AppLoadContext;
10 |
11 | describe("action", () => {
12 | test("error on body missing", async () => {
13 | const request = new Request("http://localhost");
14 | const response = await action({ context, request } as Route.ActionArgs);
15 |
16 | expect(response).toBeInstanceOf(Response);
17 | expect(response.ok).toBe(false);
18 | expect(response.status).toBe(400);
19 | expect(await response.text()).toBe("Webhook body is missing");
20 | });
21 |
22 | test("error on header missing", async () => {
23 | const request = new Request("http://localhost", {
24 | body: "123",
25 | method: "POST",
26 | });
27 | const response = await action({ context, request } as Route.ActionArgs);
28 |
29 | expect(response).toBeInstanceOf(Response);
30 | expect(response.ok).toBe(false);
31 | expect(response.status).toBe(400);
32 | expect(await response.text()).toBe("Webhook header is missing");
33 | });
34 |
35 | test("error on encoded byte length mismatch", async () => {
36 | const request = new Request("http://localhost", {
37 | body: "123",
38 | headers: { "X-Shopify-Hmac-Sha256": "123" },
39 | method: "POST",
40 | });
41 | const response = await action({ context, request } as Route.ActionArgs);
42 |
43 | expect(response).toBeInstanceOf(Response);
44 | expect(response.ok).toBe(false);
45 | expect(response.status).toBe(401);
46 | expect(await response.text()).toBe("Encoded byte length mismatch");
47 | });
48 |
49 | test("error on invalid hmac", async () => {
50 | const request = new Request("http://localhost", {
51 | body: "132", // NOTE: changed
52 | headers: {
53 | "X-Shopify-Hmac-Sha256": "tKI9km9Efxo6gfUjbUBCo3XJ0CmqMLgb4xNzNhpQhK0=",
54 | },
55 | method: "POST",
56 | });
57 | const response = await action({ context, request } as Route.ActionArgs);
58 |
59 | expect(response).toBeInstanceOf(Response);
60 | expect(response.ok).toBe(false);
61 | expect(response.status).toBe(401);
62 | expect(await response.text()).toBe("Invalid hmac");
63 | });
64 |
65 | test("error on missing headers", async () => {
66 | const request = new Request("http://localhost", {
67 | body: "123",
68 | headers: {
69 | "X-Shopify-Hmac-Sha256": "tKI9km9Efxo6gfUjbUBCo3XJ0CmqMLgb4xNzNhpQhK0=",
70 | },
71 | method: "POST",
72 | });
73 | const response = await action({ context, request } as Route.ActionArgs);
74 |
75 | expect(response).toBeInstanceOf(Response);
76 | expect(response.ok).toBe(false);
77 | expect(response.status).toBe(400);
78 | expect(await response.text()).toBe("Webhook headers are missing");
79 | });
80 |
81 | test("success", async () => {
82 | const request = new Request("http://localhost", {
83 | body: "123",
84 | headers: {
85 | "X-Shopify-API-Version": API_VERSION,
86 | "X-Shopify-Shop-Domain": "test.myshopify.com",
87 | "X-Shopify-Hmac-Sha256": await getHmac("123"),
88 | "X-Shopify-Topic": "app/uninstalled",
89 | "X-Shopify-Webhook-Id": "test",
90 | },
91 | method: "POST",
92 | });
93 | const response = await action({ context, request } as Route.ActionArgs);
94 |
95 | expect(response).toBeInstanceOf(Response);
96 | expect(response.ok).toBe(true);
97 | expect(response.status).toBe(204);
98 | expect(response.body).toBe(null);
99 | });
100 | });
101 |
102 | async function getHmac(body: string) {
103 | const encoder = new TextEncoder();
104 | const key = await crypto.subtle.importKey(
105 | "raw",
106 | encoder.encode(context.cloudflare.env.SHOPIFY_API_SECRET_KEY),
107 | {
108 | name: "HMAC",
109 | hash: "SHA-256",
110 | },
111 | true,
112 | ["sign"],
113 | );
114 | const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
115 | const hmac = btoa(String.fromCharCode(...new Uint8Array(signature))); // base64
116 | return hmac;
117 | }
118 |
--------------------------------------------------------------------------------
/app/routes/shopify.webhooks.tsx:
--------------------------------------------------------------------------------
1 | import { createShopify } from "~/shopify.server";
2 | import type { Route } from "./+types/shopify.webhooks";
3 |
4 | export async function action({ context, request }: Route.ActionArgs) {
5 | try {
6 | const shopify = createShopify(context);
7 | shopify.utils.log.debug("shopify.webhooks");
8 |
9 | const webhook = await shopify.webhook(request);
10 | shopify.utils.log.debug("shopify.webhooks", { ...webhook });
11 |
12 | const session = await shopify.session.get(webhook.domain);
13 | const payload = await request.json();
14 |
15 | switch (webhook.topic) {
16 | case "APP_UNINSTALLED":
17 | if (session) {
18 | await shopify.session.delete(session.id);
19 | }
20 | break;
21 |
22 | case "APP_SCOPES_UPDATE":
23 | if (session) {
24 | await shopify.session.set({
25 | ...session,
26 | scope: (payload as { current: string[] }).current.toString(),
27 | });
28 | }
29 | break;
30 | }
31 |
32 | await context.cloudflare.env.WEBHOOK_QUEUE?.send(
33 | {
34 | payload,
35 | webhook,
36 | },
37 | { contentType: "json" },
38 | );
39 |
40 | return new Response(undefined, { status: 204 });
41 | // biome-ignore lint/suspicious/noExplicitAny: catch(err)
42 | } catch (error: any) {
43 | return new Response(error.message, {
44 | status: error.status,
45 | statusText: "Unauthorized",
46 | });
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/app/shopify.server.test.ts:
--------------------------------------------------------------------------------
1 | import { env } from "node:process";
2 | import type { AppLoadContext } from "react-router";
3 | import { describe, expect, test } from "vitest";
4 |
5 | import { createShopify } from "./shopify.server";
6 |
7 | const context = { cloudflare: { env } } as unknown as AppLoadContext;
8 |
9 | test("createShopify", () => {
10 | const shopify = createShopify(context);
11 | expect(shopify.admin).toBeDefined();
12 | });
13 |
14 | describe("utils", () => {
15 | const { utils } = createShopify(context);
16 |
17 | test("allowedDomains", () => {
18 | expect(utils.allowedDomains).toBe(
19 | "myshopify\\.com|myshopify\\.io|shop\\.dev|shopify\\.com",
20 | );
21 | });
22 |
23 | test("encode", async () => {
24 | const encoder = new TextEncoder();
25 | const data = encoder.encode("test");
26 |
27 | expect(utils.encode(data, "base64")).toBe("dGVzdA==");
28 | expect(utils.encode(data, "hex")).toBe("74657374");
29 | });
30 |
31 | test("legacyUrlToShopAdminUrl", () => {
32 | expect(utils.legacyUrlToShopAdminUrl("test.myshopify.com")).toBe(
33 | "admin.shopify.com/store/test",
34 | );
35 | expect(utils.legacyUrlToShopAdminUrl("test.example.com")).toBe(null);
36 | });
37 |
38 | test("sanitizeHost", () => {
39 | const host = btoa("test.myshopify.com");
40 | expect(utils.sanitizeHost(host)).toBe(host);
41 | expect(utils.sanitizeHost(btoa("test.example.com"))).toBe(null);
42 | });
43 |
44 | test("sanitizeShop", () => {
45 | const shop = "test.myshopify.com";
46 | expect(utils.sanitizeShop("admin.shopify.com/store/test")).toBe(shop);
47 | expect(utils.sanitizeShop(shop)).toBe(shop);
48 | expect(utils.sanitizeShop("test.example.com")).toBe(null);
49 | });
50 |
51 | test("validateHmac", async () => {
52 | const data = "123";
53 | const hmac = "tKI9km9Efxo6gfUjbUBCo3XJ0CmqMLgb4xNzNhpQhK0=";
54 | const encoding = "base64";
55 |
56 | expect.assertions(2);
57 | expect(await utils.validateHmac(data, hmac, encoding)).toBeUndefined();
58 | await expect(utils.validateHmac("124", hmac, encoding)).rejects.toThrow(
59 | "Invalid hmac",
60 | );
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/app/shopify.server.ts:
--------------------------------------------------------------------------------
1 | import { createGraphQLClient } from "@shopify/graphql-client";
2 | import { type JWTPayload, jwtVerify } from "jose";
3 | import { type AppLoadContext, redirect as routerRedirect } from "react-router";
4 | import * as v from "valibot";
5 |
6 | import { API_VERSION, APP_BRIDGE_URL } from "./const";
7 |
8 | export function createShopify(context: AppLoadContext) {
9 | const env = v.parse(schema, context.cloudflare.env);
10 | const config = {
11 | apiKey: env.SHOPIFY_API_KEY,
12 | apiSecretKey: env.SHOPIFY_API_SECRET_KEY,
13 | apiVersion: API_VERSION,
14 | appHandle: env.SHOPIFY_APP_HANDLE,
15 | appUrl: env.SHOPIFY_APP_URL,
16 | appLogLevel: env.SHOPIFY_APP_LOG_LEVEL,
17 | appTest: env.SHOPIFY_APP_TEST === "1",
18 | };
19 |
20 | async function admin(request: Request) {
21 | const url = new URL(request.url);
22 |
23 | if (request.method === "OPTIONS") {
24 | const response = new Response(null, {
25 | headers: new Headers({
26 | "Access-Control-Max-Age": "7200",
27 | }),
28 | status: 204,
29 | });
30 | utils.addCorsHeaders(request, response.headers);
31 | throw response;
32 | }
33 |
34 | let encodedSessionToken = null;
35 | let decodedSessionToken = null;
36 | try {
37 | encodedSessionToken =
38 | request.headers.get("Authorization")?.replace("Bearer ", "") ||
39 | url.searchParams.get("id_token") ||
40 | "";
41 |
42 | const key = config.apiSecretKey;
43 | const hmacKey = new Uint8Array(key.length);
44 | for (let i = 0, keyLen = key.length; i < keyLen; i++) {
45 | hmacKey[i] = key.charCodeAt(i);
46 | }
47 |
48 | const { payload } = await jwtVerify(
49 | encodedSessionToken,
50 | hmacKey,
51 | {
52 | algorithms: ["HS256"],
53 | clockTolerance: 10,
54 | },
55 | );
56 |
57 | // The exp and nbf fields are validated by the JWT library
58 | if (payload.aud !== config.apiKey) {
59 | throw new ShopifyException("Session token had invalid API key", {
60 | status: 401,
61 | type: "JWT",
62 | });
63 | }
64 | decodedSessionToken = payload;
65 | } catch (error) {
66 | utils.log.debug("admin.jwt", {
67 | error,
68 | headers: Object.fromEntries(request.headers),
69 | url,
70 | });
71 |
72 | const isDocumentRequest = !request.headers.has("Authorization");
73 | if (isDocumentRequest) {
74 | // Remove `id_token` from the query string to prevent an invalid session token sent to the redirect path.
75 | url.searchParams.delete("id_token");
76 |
77 | // Using shopify-reload path to redirect the bounce automatically.
78 | url.searchParams.append(
79 | "shopify-reload",
80 | `${config.appUrl}${url.pathname}?${url.searchParams.toString()}`,
81 | );
82 | throw routerRedirect(
83 | `/shopify/auth/session-token-bounce?${url.searchParams.toString()}`,
84 | );
85 | }
86 |
87 | const response = new Response(undefined, {
88 | headers: new Headers({
89 | "X-Shopify-Retry-Invalid-Session-Request": "1",
90 | }),
91 | status: 401,
92 | statusText: "Unauthorized",
93 | });
94 | utils.addCorsHeaders(request, response.headers);
95 | throw response;
96 | }
97 |
98 | const shop = utils.sanitizeShop(new URL(decodedSessionToken.dest).hostname);
99 | if (!shop) {
100 | throw new ShopifyException("Received invalid shop argument", {
101 | status: 400,
102 | type: "SHOP",
103 | });
104 | }
105 |
106 | const body = {
107 | client_id: config.apiKey,
108 | client_secret: config.apiSecretKey,
109 | grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
110 | subject_token: encodedSessionToken,
111 | subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
112 | requested_token_type:
113 | "urn:shopify:params:oauth:token-type:offline-access-token",
114 | };
115 |
116 | const response = await fetch(`https://${shop}/admin/oauth/access_token`, {
117 | method: "POST",
118 | body: JSON.stringify(body),
119 | headers: {
120 | "Content-Type": "application/json",
121 | Accept: "application/json",
122 | },
123 | signal: AbortSignal.timeout(1_000),
124 | });
125 | if (!response.ok) {
126 | // biome-ignore lint/suspicious/noExplicitAny: upstream
127 | const body: any = await response.json();
128 | if (typeof response === "undefined") {
129 | const message = body?.errors?.message ?? "";
130 | throw new ShopifyException(
131 | `Http request error, no response available: ${message}`,
132 | {
133 | status: 400,
134 | type: "REQUEST",
135 | },
136 | );
137 | }
138 |
139 | if (response.status === 200 && body.errors.graphQLErrors) {
140 | throw new ShopifyException(
141 | body.errors.graphQLErrors?.[0].message ?? "GraphQL operation failed",
142 | {
143 | status: 400,
144 | type: "GRAPHQL",
145 | },
146 | );
147 | }
148 |
149 | const errorMessages: string[] = [];
150 | if (body.errors) {
151 | errorMessages.push(JSON.stringify(body.errors, null, 2));
152 | }
153 | const xRequestId = response.headers.get("x-request-id");
154 | if (xRequestId) {
155 | errorMessages.push(
156 | `If you report this error, please include this id: ${xRequestId}`,
157 | );
158 | }
159 |
160 | const errorMessage = errorMessages.length
161 | ? `:\n${errorMessages.join("\n")}`
162 | : "";
163 |
164 | switch (true) {
165 | case response.status === 429: {
166 | throw new ShopifyException(
167 | `Shopify is throttling requests ${errorMessage}`,
168 | {
169 | status: response.status,
170 | type: "THROTTLING",
171 | // retryAfter: response.headers.has("Retry-After") ? parseFloat(response.headers.get("Retry-After")) : undefined,
172 | },
173 | );
174 | }
175 | case response.status >= 500:
176 | throw new ShopifyException(`Shopify internal error${errorMessage}`, {
177 | status: response.status,
178 | type: "SERVER",
179 | });
180 | default:
181 | throw new ShopifyException(
182 | `Received an error response (${response.status} ${response.statusText}) from Shopify${errorMessage}`,
183 | {
184 | status: response.status,
185 | type: "RESPONSE",
186 | },
187 | );
188 | }
189 | }
190 |
191 | const accessTokenResponse = await response.json<{
192 | access_token: string;
193 | expires_in?: number;
194 | scope: string;
195 | }>();
196 | await session.set({
197 | id: shop,
198 | shop,
199 | scope: accessTokenResponse.scope,
200 | expires: accessTokenResponse.expires_in
201 | ? new Date(Date.now() + accessTokenResponse.expires_in * 1000)
202 | : undefined,
203 | accessToken: accessTokenResponse.access_token,
204 | });
205 |
206 | const client = createShopifyClient({
207 | headers: { "X-Shopify-Access-Token": accessTokenResponse.access_token },
208 | shop,
209 | });
210 | return client;
211 | }
212 |
213 | async function proxy(request: Request) {
214 | const url = new URL(request.url);
215 |
216 | const param = url.searchParams.get("signature");
217 | if (param === null) {
218 | throw new ShopifyException("Proxy param is missing", {
219 | status: 400,
220 | type: "REQUEST",
221 | });
222 | }
223 |
224 | const timestamp = Number(url.searchParams.get("timestamp"));
225 | if (
226 | Math.abs(Math.trunc(Date.now() / 1000) - timestamp) > 90 // HMAC_TIMESTAMP_PERMITTED_CLOCK_TOLERANCE_SEC
227 | ) {
228 | throw new ShopifyException("Proxy timestamp is expired", {
229 | status: 400,
230 | type: "REQUEST",
231 | });
232 | }
233 |
234 | // NOTE: https://shopify.dev/docs/apps/build/online-store/display-dynamic-data#calculate-a-digital-signature
235 | const params = Object.entries(Object.fromEntries(url.searchParams))
236 | .filter(([key]) => key !== "signature")
237 | .map(
238 | ([key, value]) =>
239 | `${key}=${Array.isArray(value) ? value.join(",") : value}`,
240 | )
241 | .sort((a, b) => a.localeCompare(b))
242 | .join("");
243 |
244 | await utils.validateHmac(params, param, "hex");
245 |
246 | const shop = utils.sanitizeShop(url.searchParams.get("shop")!)!; // shop is value due to hmac validation
247 | const shopify = await session.get(shop);
248 | if (!shopify?.accessToken) {
249 | throw new ShopifyException("No session access token", {
250 | status: 401,
251 | type: "SESSION",
252 | });
253 | }
254 |
255 | const client = createShopifyClient({
256 | headers: { "X-Shopify-Access-Token": shopify.accessToken },
257 | shop,
258 | });
259 |
260 | return client;
261 | }
262 |
263 | function redirect(
264 | request: Request,
265 | url: string,
266 | {
267 | shop,
268 | target,
269 | ...init
270 | }: ResponseInit & {
271 | shop: string;
272 | target?: "_self" | "_parent" | "_top" | "_blank";
273 | },
274 | ) {
275 | const headers = new Headers({
276 | "content-type": "text/html;charset=utf-8",
277 | ...init.headers,
278 | });
279 |
280 | let windowTarget = target ?? "_self";
281 | let windowUrl = new URL(url, config.appUrl);
282 |
283 | const isSameOrigin = config.appUrl === windowUrl.origin;
284 | const isRelativePath = url.startsWith("/");
285 | if (isSameOrigin || isRelativePath) {
286 | for (const [key, value] of new URL(request.url).searchParams.entries()) {
287 | if (!windowUrl.searchParams.has(key)) {
288 | windowUrl.searchParams.set(key, value);
289 | }
290 | }
291 | }
292 |
293 | const adminLinkRegExp = /^shopify:\/*admin\//i;
294 | const isAdminLink = adminLinkRegExp.test(url);
295 | if (isAdminLink) {
296 | const shopHandle = shop.replace(".myshopify.com", "");
297 | const adminUri = url.replace(adminLinkRegExp, "/");
298 | windowUrl = new URL(
299 | `https://admin.shopify.com/store/${shopHandle}${adminUri}`,
300 | );
301 |
302 | const remove = [
303 | "appLoadId", // sent when clicking rel="home" nav item
304 | "hmac",
305 | "host",
306 | "embedded",
307 | "id_token",
308 | "locale",
309 | "protocol",
310 | "session",
311 | "shop",
312 | "timestamp",
313 | ];
314 | for (const param of remove) {
315 | if (windowUrl.searchParams.has(param)) {
316 | windowUrl.searchParams.delete(param);
317 | }
318 | }
319 |
320 | if (!target) {
321 | windowTarget = "_parent";
322 | }
323 | }
324 |
325 | switch (true) {
326 | case target === "_self" && isBounce(request):
327 | case target !== "_self" && isEmbedded(request): {
328 | const response = new Response(
329 | /* html */ `
330 |
331 |
332 |
338 | `,
339 | {
340 | ...init,
341 | headers,
342 | },
343 | );
344 | utils.addCorsHeaders(request, response.headers);
345 | throw response;
346 | }
347 |
348 | case isData(request): {
349 | const response = new Response(undefined, {
350 | headers: new Headers({
351 | "X-Shopify-API-Request-Failure-Reauthorize-Url":
352 | windowUrl.toString(),
353 | }),
354 | status: 401,
355 | statusText: "Unauthorized",
356 | });
357 | utils.addCorsHeaders(request, response.headers);
358 | throw response;
359 | }
360 |
361 | default: {
362 | throw routerRedirect(url, init);
363 | }
364 | }
365 |
366 | function authorizationHeader(request: Request) {
367 | return request.headers.get("authorization")?.replace(/Bearer\s?/, "");
368 | }
369 |
370 | function isBounce(request: Request) {
371 | return (
372 | !!authorizationHeader(request) &&
373 | request.headers.has("X-Shopify-Bounce")
374 | );
375 | }
376 |
377 | function isData(request: Request) {
378 | return (
379 | !!authorizationHeader(request) &&
380 | !isBounce(request) &&
381 | (!isEmbedded(request) || request.method !== "GET")
382 | );
383 | }
384 |
385 | function isEmbedded(request: Request) {
386 | return new URL(request.url).searchParams.get("embedded") === "1";
387 | }
388 | }
389 |
390 | const session = new ShopifySession(context.cloudflare.env.SESSION_STORAGE);
391 |
392 | const utils = {
393 | addCorsHeaders(request: Request, responseHeaders: Headers) {
394 | const origin = request.headers.get("Origin");
395 | if (origin && origin !== config.appUrl) {
396 | if (!responseHeaders.has("Access-Control-Allow-Headers")) {
397 | responseHeaders.set("Access-Control-Allow-Headers", "Authorization");
398 | }
399 | if (!responseHeaders.has("Access-Control-Allow-Origin")) {
400 | responseHeaders.set("Access-Control-Allow-Origin", origin);
401 | }
402 | if (responseHeaders.get("Access-Control-Allow-Origin") !== "*") {
403 | responseHeaders.set("Vary", "Origin");
404 | }
405 | if (!responseHeaders.has("Access-Control-Expose-Headers")) {
406 | responseHeaders.set(
407 | "Access-Control-Expose-Headers",
408 | "X-Shopify-API-Request-Failure-Reauthorize-Url",
409 | );
410 | }
411 | }
412 | },
413 |
414 | addHeaders(request: Request, responseHeaders: Headers) {
415 | const url = new URL(request.url);
416 | const shop = utils.sanitizeShop(url.searchParams.get("shop")!);
417 | if (shop && !url.pathname.startsWith("/apps")) {
418 | responseHeaders.set(
419 | "Link",
420 | `<${APP_BRIDGE_URL}>; rel="preload"; as="script";`,
421 | );
422 | }
423 | },
424 |
425 | allowedDomains: ["myshopify.com", "myshopify.io", "shop.dev", "shopify.com"]
426 | .map((v) => v.replace(/\./g, "\\.")) // escape
427 | .join("|"),
428 |
429 | encode(value: ArrayBuffer, encoding: "base64" | "hex") {
430 | switch (encoding) {
431 | case "base64":
432 | return btoa(String.fromCharCode(...new Uint8Array(value)));
433 |
434 | case "hex":
435 | return [...new Uint8Array(value)].reduce(
436 | (a, b) => a + b.toString(16).padStart(2, "0"),
437 | "",
438 | );
439 | }
440 | },
441 |
442 | legacyUrlToShopAdminUrl(shop: string) {
443 | const shopUrl = shop.replace(/^https?:\/\//, "").replace(/\/$/, "");
444 | const regExp = /(.+)\.myshopify\.com$/;
445 |
446 | const matches = shopUrl.match(regExp);
447 | if (matches && matches.length === 2) {
448 | const shopName = matches[1];
449 | return `admin.shopify.com/store/${shopName}`;
450 | }
451 | return null;
452 | },
453 |
454 | log: createLogger(config.appLogLevel),
455 |
456 | sanitizeHost(host: string) {
457 | const base64RegExp = /^[0-9a-z+/]+={0,2}$/i;
458 | let sanitizedHost = base64RegExp.test(host) ? host : null;
459 | if (sanitizedHost) {
460 | const { hostname } = new URL(`https://${atob(sanitizedHost)}`);
461 |
462 | const hostRegExp = new RegExp(`\\.(${utils.allowedDomains})$`);
463 | if (!hostRegExp.test(hostname)) {
464 | sanitizedHost = null;
465 | }
466 | }
467 | return sanitizedHost;
468 | },
469 |
470 | sanitizeShop(shop: string) {
471 | let sanitizedShop = shop;
472 |
473 | const shopAdminRegExp = new RegExp(
474 | `^admin\\.(${utils.allowedDomains})/store/([a-zA-Z0-9][a-zA-Z0-9-_]*)$`,
475 | );
476 | if (shopAdminRegExp.test(shop)) {
477 | sanitizedShop = shop.replace(/^https?:\/\//, "").replace(/\/$/, "");
478 | if (sanitizedShop.split(".").at(0) !== "admin") {
479 | return null;
480 | }
481 |
482 | const regex = /admin\..+\/store\/([^\/]+)/;
483 | const matches = sanitizedShop.match(regex);
484 | if (matches && matches.length === 2) {
485 | sanitizedShop = `${matches.at(1)}.myshopify.com`;
486 | } else {
487 | return null;
488 | }
489 | }
490 |
491 | const shopRegExp = new RegExp(
492 | `^[a-zA-Z0-9][a-zA-Z0-9-_]*\\.(${utils.allowedDomains})[/]*$`,
493 | );
494 | if (!shopRegExp.test(sanitizedShop)) return null;
495 |
496 | return sanitizedShop;
497 | },
498 |
499 | async validateHmac(data: string, hmac: string, encoding: "hex" | "base64") {
500 | const encoder = new TextEncoder();
501 | const key = await crypto.subtle.importKey(
502 | "raw",
503 | encoder.encode(env.SHOPIFY_API_SECRET_KEY),
504 | {
505 | name: "HMAC",
506 | hash: "SHA-256",
507 | },
508 | false,
509 | ["sign"],
510 | );
511 | const signature = await crypto.subtle.sign(
512 | "HMAC",
513 | key,
514 | encoder.encode(data),
515 | );
516 |
517 | const computed = utils.encode(signature, encoding);
518 | const bufA = encoder.encode(computed);
519 | const bufB = encoder.encode(hmac);
520 | if (bufA.byteLength !== bufB.byteLength) {
521 | throw new ShopifyException("Encoded byte length mismatch", {
522 | status: 401,
523 | type: "HMAC",
524 | });
525 | }
526 |
527 | // biome-ignore lint/suspicious/noExplicitAny: lib: [DOM] overrides worker-configuration.d.ts
528 | const valid = (crypto.subtle as any).timingSafeEqual(bufA, bufB);
529 | utils.log.debug("validateHmac", {
530 | hmac,
531 | computed,
532 | valid,
533 | });
534 | if (!valid) {
535 | throw new ShopifyException("Invalid hmac", {
536 | status: 401,
537 | type: "HMAC",
538 | });
539 | }
540 | },
541 | };
542 |
543 | async function webhook(request: Request) {
544 | // validate.body
545 | const body = await request.clone().text();
546 | if (body.length === 0) {
547 | throw new ShopifyException("Webhook body is missing", {
548 | status: 400,
549 | type: "REQUEST",
550 | });
551 | }
552 |
553 | // validate.hmac
554 | const header = request.headers.get("X-Shopify-Hmac-Sha256");
555 | if (header === null) {
556 | throw new ShopifyException("Webhook header is missing", {
557 | status: 400,
558 | type: "REQUEST",
559 | });
560 | }
561 |
562 | await utils.validateHmac(body, header, "base64");
563 |
564 | // validate.headers
565 | const requiredHeaders = {
566 | apiVersion: "X-Shopify-API-Version",
567 | domain: "X-Shopify-Shop-Domain",
568 | hmac: "X-Shopify-Hmac-Sha256",
569 | topic: "X-Shopify-Topic",
570 | webhookId: "X-Shopify-Webhook-Id",
571 | };
572 | if (
573 | !Object.values(requiredHeaders).every((header) =>
574 | request.headers.has(header),
575 | )
576 | ) {
577 | throw new ShopifyException("Webhook headers are missing", {
578 | status: 400,
579 | type: "REQUEST",
580 | });
581 | }
582 | const optionalHeaders = { subTopic: "X-Shopify-Sub-Topic" };
583 | const headers = { ...requiredHeaders, ...optionalHeaders };
584 | const webhook = Object.entries(headers).reduce(
585 | (headers, [key, value]) => ({
586 | // biome-ignore lint/performance/noAccumulatingSpread: upstream
587 | ...headers,
588 | [key]: request.headers.get(value),
589 | }),
590 | {} as typeof headers,
591 | );
592 | return webhook;
593 | }
594 |
595 | return {
596 | admin,
597 | config,
598 | proxy,
599 | redirect,
600 | session,
601 | utils,
602 | webhook,
603 | };
604 | }
605 |
606 | export function createShopifyClient({
607 | apiVersion = API_VERSION,
608 | headers,
609 | shop,
610 | }: {
611 | apiVersion?: string;
612 | headers: Record;
613 | shop: string;
614 | }) {
615 | const admin = "X-Shopify-Access-Token";
616 | const storefront = "X-Shopify-Storefront-Access-Token";
617 | if (!headers[admin] && !headers[storefront]) {
618 | throw new ShopifyException(
619 | `Missing auth header [${admin}, ${storefront}]`,
620 | {
621 | status: 401,
622 | type: "REQUEST",
623 | },
624 | );
625 | }
626 |
627 | const url = headers[storefront]
628 | ? `https://${shop}/api/${apiVersion}/graphql.json`
629 | : `https://${shop}/admin/api/${apiVersion}/graphql.json`;
630 | const client = createGraphQLClient({
631 | customFetchApi: fetch,
632 | headers: {
633 | "Content-Type": "application/json",
634 | ...headers,
635 | },
636 | url,
637 | });
638 | return client;
639 | }
640 |
641 | const Log = {
642 | error: 0,
643 | info: 1,
644 | debug: 2,
645 | };
646 | type LogLevel = keyof typeof Log;
647 |
648 | function createLogger(level: LogLevel) {
649 | function noop() {}
650 |
651 | return {
652 | debug(...args: unknown[]) {
653 | if (Log[level] >= Log.debug) {
654 | return console.debug("log.debug", ...args);
655 | }
656 | return noop;
657 | },
658 |
659 | info(...args: unknown[]) {
660 | if (Log[level] >= Log.info) {
661 | return console.info("log.info", ...args);
662 | }
663 | return noop;
664 | },
665 |
666 | error(...args: unknown[]) {
667 | if (Log[level] >= Log.error) {
668 | return console.error("log.error", ...args);
669 | }
670 | return noop;
671 | },
672 | };
673 | }
674 |
675 | const schema = v.object({
676 | SHOPIFY_API_KEY: v.pipe(v.string(), v.minLength(32)),
677 | SHOPIFY_API_SECRET_KEY: v.pipe(v.string(), v.minLength(32)),
678 | SHOPIFY_APP_HANDLE: v.string(),
679 | SHOPIFY_APP_LOG_LEVEL: v.optional(
680 | v.picklist(["debug", "info", "error"]),
681 | "error",
682 | ),
683 | SHOPIFY_APP_TEST: v.optional(v.picklist(["0", "1"]), "0"),
684 | SHOPIFY_APP_URL: v.pipe(v.string(), v.url()),
685 | });
686 |
687 | export class ShopifyException extends Error {
688 | errors?: unknown[];
689 | status = 500;
690 | type:
691 | | "GRAPHQL"
692 | | "HMAC"
693 | | "JWT"
694 | | "REQUEST"
695 | | "RESPONSE"
696 | | "SESSION"
697 | | "SERVER"
698 | | "SHOP"
699 | | "THROTTLING" = "SERVER";
700 |
701 | constructor(
702 | message: string,
703 | options: ErrorOptions & {
704 | errors?: unknown[];
705 | status: number;
706 | type: string;
707 | },
708 | ) {
709 | super(message);
710 |
711 | Object.setPrototypeOf(this, new.target.prototype);
712 | Object.assign(this, {
713 | name: this.constructor.name,
714 | errors: [],
715 | ...(options ?? {}),
716 | });
717 | }
718 | }
719 |
720 | interface ShopifyJWTPayload extends Required {
721 | dest: string;
722 | }
723 |
724 | export class ShopifySession {
725 | #namespace: KVNamespace;
726 | #properties = ["accessToken", "expires", "id", "scope", "shop"];
727 |
728 | constructor(namespace: KVNamespace) {
729 | this.#namespace = namespace;
730 | }
731 |
732 | async clear(shop: string) {
733 | const shops = await this.#namespace.list({ prefix: shop });
734 | await Promise.all(
735 | shops.keys.map((key) => this.#namespace.delete(key.name)),
736 | );
737 | }
738 |
739 | async delete(id: string | undefined) {
740 | if (!id) return false;
741 |
742 | const session = await this.get(id);
743 | if (!session) return false;
744 |
745 | await this.#namespace.delete(id);
746 | return true;
747 | }
748 |
749 | deserialize(data: ShopifySessionSerialized): ShopifySessionObject {
750 | const obj = Object.fromEntries(
751 | data
752 | .filter(([_key, value]) => value !== null && value !== undefined)
753 | .map(([key, value]) => {
754 | switch (key.toLowerCase()) {
755 | case "accesstoken":
756 | return ["accessToken", value];
757 | default:
758 | return [key.toLowerCase(), value];
759 | }
760 | }),
761 | );
762 |
763 | return Object.entries(obj).reduce((session, [key, value]) => {
764 | switch (key) {
765 | case "scope":
766 | session[key] = value.toString();
767 | break;
768 | case "expires":
769 | session[key] = value ? new Date(Number(value)) : undefined;
770 | break;
771 | default:
772 | // biome-ignore lint/suspicious/noExplicitAny: upstream
773 | (session as any)[key] = value;
774 | break;
775 | }
776 | return session;
777 | }, {} as ShopifySessionObject);
778 | }
779 |
780 | async get(id: string | undefined) {
781 | if (!id) return;
782 |
783 | const data = await this.#namespace.get<[string, string | number][]>(
784 | id,
785 | "json",
786 | );
787 | return data ? this.deserialize(data) : undefined;
788 | }
789 |
790 | async set(session: ShopifySessionObject) {
791 | return this.#namespace.put(
792 | session.id,
793 | JSON.stringify(this.serialize(session)),
794 | { metadata: { shop: session.shop } },
795 | );
796 | }
797 |
798 | serialize(session: ShopifySessionObject): ShopifySessionSerialized {
799 | return Object.entries(session)
800 | .filter(
801 | ([key, value]) =>
802 | this.#properties.includes(key) &&
803 | value !== undefined &&
804 | value !== null,
805 | )
806 | .flatMap(([key, value]): [string, string | number | boolean][] => {
807 | switch (key) {
808 | case "expires":
809 | return [[key, value ? value.getTime() : undefined]];
810 | default:
811 | return [[key, value]];
812 | }
813 | })
814 | .filter(([_key, value]) => value !== undefined);
815 | }
816 | }
817 | interface ShopifySessionObject {
818 | id: string;
819 | shop: string;
820 | scope: string;
821 | expires?: Date;
822 | accessToken: string;
823 | }
824 | type ShopifySessionSerialized = [string, string | number | boolean][];
825 |
--------------------------------------------------------------------------------
/app/types/admin.generated.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable eslint-comments/disable-enable-pair */
2 | /* eslint-disable eslint-comments/no-unlimited-disable */
3 | /* eslint-disable */
4 | import type * as AdminTypes from './admin.types';
5 |
6 | export type ShopQueryVariables = AdminTypes.Exact<{ [key: string]: never; }>;
7 |
8 |
9 | export type ShopQuery = { shop: Pick };
10 |
11 | interface GeneratedQueryTypes {
12 | "\n\t\t\t#graphql\n\t\t\tquery Shop {\n\t\t\t\tshop {\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t}\n\t\t": {return: ShopQuery, variables: ShopQueryVariables},
13 | "\n\t\t\t\t\t#graphql\n\t\t\t\t\tquery Shop {\n\t\t\t\t\t\tshop {\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t": {return: ShopQuery, variables: ShopQueryVariables},
14 | }
15 |
16 | interface GeneratedMutationTypes {
17 | }
18 | declare module '@shopify/admin-api-client' {
19 | type InputMaybe = AdminTypes.InputMaybe;
20 | interface AdminQueries extends GeneratedQueryTypes {}
21 | interface AdminMutations extends GeneratedMutationTypes {}
22 | }
23 |
--------------------------------------------------------------------------------
/app/types/app.ts:
--------------------------------------------------------------------------------
1 | export interface WebhookQueueMessage {
2 | payload: unknown;
3 | webhook: {
4 | subTopic: string;
5 | apiVersion: string;
6 | domain: string;
7 | hmac: string;
8 | topic: string;
9 | webhookId: string;
10 | }
11 | }
--------------------------------------------------------------------------------
/app/types/app.types.d.ts:
--------------------------------------------------------------------------------
1 | import type { FunctionComponent, SVGAttributes } from "react";
2 |
3 | declare module "*.css" {
4 | const content: string;
5 | export default content;
6 | }
7 |
8 | declare module "*.json" {
9 | const content: string;
10 | export default content;
11 | }
12 |
13 | declare module "*.svg" {
14 | const content: FunctionComponent>;
15 | export default content;
16 | }
17 |
--------------------------------------------------------------------------------
/bin.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -eo pipefail
4 | shopt -s nullglob
5 |
6 | source .env
7 |
8 | function addGitHook() {
9 | printf '%s\n' \
10 | "#!/usr/bin/env sh" \
11 | "set -eu" \
12 | "" \
13 | "npx @biomejs/biome check --staged --files-ignore-unknown=true --no-errors-on-unmatched" \
14 | "npx tsc --noEmit" \
15 | > .git/hooks/pre-commit
16 | chmod +x .git/hooks/pre-commit
17 | }
18 |
19 | function help() {
20 | echo "npx shopflare [addGitHook,triggerWebhook,triggerWorkflow,update,version]"
21 | }
22 |
23 | function triggerWebhook() {
24 | topic=${1:-'app/uninstalled'}
25 | npx shopify app webhook trigger \
26 | --address=http://localhost:8080/shopify/webhooks \
27 | --api-version=2025-04 \
28 | --client-secret=${SHOPIFY_API_SECRET_KEY} \
29 | --delivery-method=http \
30 | --topic=${topic}
31 | }
32 |
33 | function triggerWorkflow() {
34 | workflow=${1:-github}
35 | act \
36 | --action-offline-mode \
37 | --container-architecture=linux/amd64 \
38 | --eventpath=.github/act/event.${workflow}.json \
39 | --remote-name=github \
40 | --workflows=.github/workflows/${workflow}.yml
41 | }
42 |
43 | function update() {
44 | if [[ $(git status --porcelain) ]]; then
45 | echo "ERROR: Please commit or stash your changes first"
46 | exit 1
47 | fi
48 |
49 | curl \
50 | --location \
51 | --silent https://api.github.com/repos/chr33s/shopflare/tarball \
52 | | tar \
53 | --directory=. \
54 | --exclude={.dev.vars,.github/act,.gitignore,extensions,public,LICENSE.md,package-lock.json,README.md,SECURITY.md} \
55 | --extract \
56 | --strip-components=1 \
57 | --gzip
58 |
59 | npm install
60 | npm run typegen
61 | }
62 |
63 | function version() {
64 | echo ${npm_package_version}
65 | }
66 |
67 | ${@:-help}
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
3 | "css": {
4 | "linter": {
5 | "enabled": true
6 | }
7 | },
8 | "files": {
9 | "ignoreUnknown": false,
10 | "ignore": [
11 | "**/+types/*",
12 | "**/build/*",
13 | "**/dist/*",
14 | "**/generated/*",
15 | "**/node_modules/*",
16 | "**/types/*",
17 | ".react-router/*",
18 | ".shopify/*",
19 | ".wrangler/*",
20 | "worker-configuration.d.ts"
21 | ]
22 | },
23 | "formatter": {
24 | "enabled": true,
25 | "ignore": ["package.json"],
26 | "useEditorconfig": true,
27 | "formatWithErrors": false,
28 | "bracketSpacing": true
29 | },
30 | "javascript": {
31 | "formatter": {
32 | "jsxQuoteStyle": "double",
33 | "quoteProperties": "asNeeded",
34 | "trailingCommas": "all",
35 | "semicolons": "always",
36 | "arrowParentheses": "always",
37 | "bracketSameLine": false,
38 | "quoteStyle": "double",
39 | "attributePosition": "auto",
40 | "bracketSpacing": true
41 | }
42 | },
43 | "linter": {
44 | "enabled": true,
45 | "rules": {
46 | "recommended": true,
47 | "style": {
48 | "noNonNullAssertion": "off"
49 | }
50 | }
51 | },
52 | "organizeImports": {
53 | "enabled": true
54 | },
55 | "vcs": {
56 | "enabled": true,
57 | "clientKind": "git",
58 | "useIgnoreFile": true
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/cspell.json:
--------------------------------------------------------------------------------
1 | {
2 | "ignorePaths": [
3 | "**/types",
4 | "**/dist",
5 | "**/node_modules",
6 | "build",
7 | "patches",
8 | "worker-configuration.d.ts"
9 | ],
10 | "ignoreWords": [
11 | "accesstoken",
12 | "autoupdate",
13 | "biomejs",
14 | "cloudflared",
15 | "codegen",
16 | "evenodd",
17 | "eventpath",
18 | "HSBA",
19 | "isbot",
20 | "logpush",
21 | "miniflare",
22 | "myshopify",
23 | "noopen",
24 | "nosniff",
25 | "nullglob",
26 | "picklist",
27 | "shopflare",
28 | "supportedLngs",
29 | "savebar",
30 | "typegen",
31 | "upsteam",
32 | "valibot",
33 | "workerd",
34 | "xlink"
35 | ],
36 | "language": "en",
37 | "languageSettings": [
38 | {
39 | "languageId": "bash,css,html,markdown,node,typescript",
40 | "locale": "en"
41 | }
42 | ]
43 | }
44 |
--------------------------------------------------------------------------------
/extensions/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/extensions/.gitkeep
--------------------------------------------------------------------------------
/graphql.config.ts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs";
2 | import { ApiType, shopifyApiProject } from "@shopify/api-codegen-preset";
3 | import type { IGraphQLProject, IGraphQLProjects } from "graphql-config";
4 |
5 | type Config = IGraphQLProject & IGraphQLProjects;
6 |
7 | import { API_VERSION } from "./app/const";
8 |
9 | function getConfig() {
10 | const config: Config = {
11 | projects: {
12 | default: shopifyApiProject({
13 | apiType: ApiType.Admin,
14 | apiVersion: API_VERSION,
15 | documents: ["./app/**/*.{ts,tsx}"],
16 | outputDir: "./app/types",
17 | }),
18 | },
19 | schema: `https://shopify.dev/admin-graphql-direct-proxy/${API_VERSION}`,
20 | };
21 |
22 | let extensions: string[] = [];
23 | try {
24 | extensions = fs.readdirSync("./extensions");
25 | } catch {
26 | // ignore if no extensions
27 | }
28 |
29 | for (const entry of extensions) {
30 | const extensionPath = `./extensions/${entry}`;
31 | const schema = `${extensionPath}/schema.graphql`;
32 | if (!fs.existsSync(schema)) {
33 | continue;
34 | }
35 | config.projects[entry] = {
36 | schema,
37 | documents: [`${extensionPath}/**/*.graphql`],
38 | };
39 | }
40 |
41 | return config;
42 | }
43 |
44 | export default getConfig();
45 |
--------------------------------------------------------------------------------
/i18n.config.ts:
--------------------------------------------------------------------------------
1 | import type { Options } from "vite-plugin-i18next-loader";
2 |
3 | export default {
4 | include: ["**/*.json"],
5 | logLevel: "warn",
6 | namespaceResolution: "basename",
7 | paths: ["./app/i18n"],
8 | } satisfies Options;
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chr33s/shopflare",
3 | "version": "2.9.7",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "shopify app build",
8 | "check": "concurrently 'npm:check:*'",
9 | "check:actions": "if command -v actionlint 2>&1 >/dev/null; then actionlint; fi",
10 | "check:code": "biome check --write",
11 | "check:spell": "if command -v cspell 2>&1 >/dev/null; then cspell --gitignore --quiet .; fi",
12 | "check:types": "tsc",
13 | "clean": "rm -rf .{react-router,shopify,wrangler} build node_modules/.{cache,mf,tmp,vite,vite-temp} && find . -type d -name __screenshots__ -exec rm -rf {} \\;",
14 | "deploy": "concurrently 'npm:deploy:*'",
15 | "deploy:cloudflare": "wrangler deploy",
16 | "deploy:shopify": "shopify app deploy --message=$(git rev-parse --abbrev-ref HEAD):$npm_package_version --version=$(git rev-parse HEAD)",
17 | "dev": "shopify app dev --localhost-port=8080 --use-localhost",
18 | "dev:tunnel": "source .env && shopify app dev --tunnel-url=${SHOPIFY_APP_URL}:${PORT:-8080}",
19 | "gen": "concurrently 'npm:gen:*'",
20 | "gen:code": "graphql-codegen --errors-only",
21 | "gen:types": "wrangler types && react-router typegen",
22 | "postinstall": "patch-package",
23 | "prepare": "[[ $NODE_ENV = 'production' ]] && exit 0; cp node_modules/@shopify/polaris/locales/en.json ./app/i18n/en/polaris.json && biome check --write ./app/i18n/en/polaris.json",
24 | "start": "wrangler dev",
25 | "test": "vitest --run",
26 | "test:e2e": "node --env-file=.env --env-file=.env.test $(npm root)/.bin/playwright test",
27 | "tunnel": "source .env && cloudflared tunnel --no-autoupdate run --token=${CLOUDFLARE_API_TOKEN}"
28 | },
29 | "dependencies": {
30 | "@shopify/graphql-client": "1.3.2",
31 | "i18next": "25.2.1",
32 | "isbot": "5.1.28",
33 | "jose": "6.0.11",
34 | "patch-package": "8.0.0",
35 | "react": "19.1.0",
36 | "react-dom": "19.1.0",
37 | "react-i18next": "15.5.2",
38 | "react-router": "7.6.1",
39 | "valibot": "1.1.0"
40 | },
41 | "devDependencies": {
42 | "@biomejs/biome": "1.9.4",
43 | "@cloudflare/vite-plugin": "1.3.1",
44 | "@cloudflare/vitest-pool-workers": "0.8.34",
45 | "@playwright/test": "1.52.0",
46 | "@react-router/dev": "7.6.1",
47 | "@shopify/api-codegen-preset": "1.1.7",
48 | "@shopify/app-bridge-react": "4.1.10",
49 | "@shopify/app-bridge-types": "0.0.18",
50 | "@shopify/polaris": "13.9.5",
51 | "@shopify/polaris-icons": "9.3.1",
52 | "@types/react": "19.1.6",
53 | "@types/react-dom": "19.1.5",
54 | "@vitest/browser": "3.1.4",
55 | "concurrently": "9.1.2",
56 | "happy-dom": "17.5.6",
57 | "playwright": "1.52.0",
58 | "typescript": "5.8.3",
59 | "vite": "6.3.5",
60 | "vite-plugin-i18next-loader": "3.1.2",
61 | "vite-tsconfig-paths": "5.1.4",
62 | "vitest": "3.1.4",
63 | "vitest-browser-react": "0.2.0",
64 | "wrangler": "4.18.0"
65 | },
66 | "optionalDependencies": {
67 | "@shopify/cli": "3.80.7"
68 | },
69 | "engines": {
70 | "node": "^22.14.0",
71 | "npm": ">=9.6.4"
72 | },
73 | "bin": {
74 | "shopflare": "./bin.sh"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/patches/@react-router+dev+7.6.1.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/@react-router/dev/dist/vite.js b/node_modules/@react-router/dev/dist/vite.js
2 | index c327ce5..8537591 100644
3 | --- a/node_modules/@react-router/dev/dist/vite.js
4 | +++ b/node_modules/@react-router/dev/dist/vite.js
5 | @@ -3319,7 +3319,7 @@ var reactRouterVitePlugin = () => {
6 | if (ctx.reactRouterConfig.future.unstable_viteEnvironmentApi) {
7 | viteDevServer.middlewares.use(async (req, res, next) => {
8 | let [reqPathname, reqSearch] = (req.url ?? "").split("?");
9 | - if (reqPathname === `${ctx.publicPath}@react-router/critical.css`) {
10 | + if (reqPathname.endsWith("/@react-router/critical.css")) {
11 | let pathname = new URLSearchParams(reqSearch).get("pathname");
12 | if (!pathname) {
13 | return next("No pathname provided");
14 |
--------------------------------------------------------------------------------
/patches/@shopify+polaris+13.9.5.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/@shopify/polaris/build/cjs/components/ChoiceList/ChoiceList.js b/node_modules/@shopify/polaris/build/cjs/components/ChoiceList/ChoiceList.js
2 | index c4fbe9c..f77b8f6 100644
3 | --- a/node_modules/@shopify/polaris/build/cjs/components/ChoiceList/ChoiceList.js
4 | +++ b/node_modules/@shopify/polaris/build/cjs/components/ChoiceList/ChoiceList.js
5 | @@ -27,7 +27,6 @@ function ChoiceList({
6 | const ControlComponent = allowMultiple ? Checkbox.Checkbox : RadioButton.RadioButton;
7 | const uniqName = React.useId();
8 | const name = nameProp ?? uniqName;
9 | - const finalName = allowMultiple ? `${name}[]` : name;
10 | const titleMarkup = title ? /*#__PURE__*/React.createElement(Box.Box, {
11 | as: "legend",
12 | paddingBlockEnd: {
13 | @@ -71,7 +70,7 @@ function ChoiceList({
14 | xs: '0'
15 | }
16 | }, /*#__PURE__*/React.createElement(ControlComponent, {
17 | - name: finalName,
18 | + name: name,
19 | value: value,
20 | id: id,
21 | label: label,
22 | @@ -83,7 +82,7 @@ function ChoiceList({
23 | checked: choiceIsSelected(choice, selected),
24 | helpText: helpText,
25 | onChange: handleChange,
26 | - ariaDescribedBy: error && describedByError ? InlineError.errorTextID(finalName) : null,
27 | + ariaDescribedBy: error && describedByError ? InlineError.errorTextID(name) : null,
28 | tone: tone
29 | }), children));
30 | });
31 | @@ -95,7 +94,7 @@ function ChoiceList({
32 | paddingBlockEnd: "200"
33 | }, /*#__PURE__*/React.createElement(InlineError.InlineError, {
34 | message: error,
35 | - fieldID: finalName
36 | + fieldID: name
37 | }));
38 | return /*#__PURE__*/React.createElement(BlockStack.BlockStack, {
39 | as: "fieldset",
40 | @@ -104,7 +103,7 @@ function ChoiceList({
41 | md: '0'
42 | },
43 | "aria-invalid": error != null,
44 | - id: finalName
45 | + id: name
46 | }, titleMarkup, /*#__PURE__*/React.createElement(BlockStack.BlockStack, {
47 | as: "ul",
48 | gap: {
49 | diff --git a/node_modules/@shopify/polaris/build/cjs/components/DropZone/DropZone.js b/node_modules/@shopify/polaris/build/cjs/components/DropZone/DropZone.js
50 | index 690afda..62165b5 100644
51 | --- a/node_modules/@shopify/polaris/build/cjs/components/DropZone/DropZone.js
52 | +++ b/node_modules/@shopify/polaris/build/cjs/components/DropZone/DropZone.js
53 | @@ -29,6 +29,7 @@ const DropZone = function DropZone({
54 | label,
55 | labelAction,
56 | labelHidden,
57 | + name,
58 | children,
59 | disabled = false,
60 | outline = true,
61 | @@ -120,7 +121,6 @@ const DropZone = function DropZone({
62 | onDropAccepted && acceptedFiles.length && onDropAccepted(acceptedFiles);
63 | onDropRejected && rejectedFiles.length && onDropRejected(rejectedFiles);
64 | if (!(event.target && 'value' in event.target)) return;
65 | - event.target.value = '';
66 | }, [disabled, getValidatedFiles, onDrop, onDropAccepted, onDropRejected]);
67 | const handleDragEnter = React.useCallback(event => {
68 | stopEvent(event);
69 | @@ -244,6 +244,7 @@ const DropZone = function DropZone({
70 | accept: accept,
71 | disabled: disabled,
72 | multiple: allowMultiple,
73 | + name: name,
74 | onChange: handleDrop,
75 | onFocus: handleFocus,
76 | onBlur: handleBlur,
77 | diff --git a/node_modules/@shopify/polaris/build/cjs/components/Link/Link.js b/node_modules/@shopify/polaris/build/cjs/components/Link/Link.js
78 | index 628281e..1ff050e 100644
79 | --- a/node_modules/@shopify/polaris/build/cjs/components/Link/Link.js
80 | +++ b/node_modules/@shopify/polaris/build/cjs/components/Link/Link.js
81 | @@ -11,6 +11,7 @@ function Link({
82 | children,
83 | onClick,
84 | external,
85 | + rel,
86 | target,
87 | id,
88 | monochrome,
89 | @@ -25,6 +26,7 @@ function Link({
90 | onClick: onClick,
91 | className: className,
92 | url: url,
93 | + rel: rel,
94 | external: external,
95 | target: target,
96 | id: id,
97 | diff --git a/node_modules/@shopify/polaris/build/cjs/components/UnstyledLink/UnstyledLink.js b/node_modules/@shopify/polaris/build/cjs/components/UnstyledLink/UnstyledLink.js
98 | index 15c1002..331ba82 100644
99 | --- a/node_modules/@shopify/polaris/build/cjs/components/UnstyledLink/UnstyledLink.js
100 | +++ b/node_modules/@shopify/polaris/build/cjs/components/UnstyledLink/UnstyledLink.js
101 | @@ -32,12 +32,12 @@ const UnstyledLink = /*#__PURE__*/React.memo(/*#__PURE__*/React.forwardRef(funct
102 | } else {
103 | target = targetProp ?? undefined;
104 | }
105 | - const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
106 | + const rel = props.ref ?? target === '_blank' ? 'noopener noreferrer' : undefined;
107 | return /*#__PURE__*/React.createElement("a", Object.assign({
108 | - target: target
109 | - }, rest, {
110 | - href: url,
111 | + target: target,
112 | rel: rel
113 | + }, rest, {
114 | + href: url
115 | }, shared.unstyled.props, {
116 | ref: _ref
117 | }));
118 | diff --git a/node_modules/@shopify/polaris/build/esm/components/ChoiceList/ChoiceList.js b/node_modules/@shopify/polaris/build/esm/components/ChoiceList/ChoiceList.js
119 | index 2974ab6..b0e72bf 100644
120 | --- a/node_modules/@shopify/polaris/build/esm/components/ChoiceList/ChoiceList.js
121 | +++ b/node_modules/@shopify/polaris/build/esm/components/ChoiceList/ChoiceList.js
122 | @@ -25,7 +25,6 @@ function ChoiceList({
123 | const ControlComponent = allowMultiple ? Checkbox : RadioButton;
124 | const uniqName = useId();
125 | const name = nameProp ?? uniqName;
126 | - const finalName = allowMultiple ? `${name}[]` : name;
127 | const titleMarkup = title ? /*#__PURE__*/React.createElement(Box, {
128 | as: "legend",
129 | paddingBlockEnd: {
130 | @@ -69,7 +68,7 @@ function ChoiceList({
131 | xs: '0'
132 | }
133 | }, /*#__PURE__*/React.createElement(ControlComponent, {
134 | - name: finalName,
135 | + name: name,
136 | value: value,
137 | id: id,
138 | label: label,
139 | @@ -81,7 +80,7 @@ function ChoiceList({
140 | checked: choiceIsSelected(choice, selected),
141 | helpText: helpText,
142 | onChange: handleChange,
143 | - ariaDescribedBy: error && describedByError ? errorTextID(finalName) : null,
144 | + ariaDescribedBy: error && describedByError ? errorTextID(name) : null,
145 | tone: tone
146 | }), children));
147 | });
148 | @@ -93,7 +92,7 @@ function ChoiceList({
149 | paddingBlockEnd: "200"
150 | }, /*#__PURE__*/React.createElement(InlineError, {
151 | message: error,
152 | - fieldID: finalName
153 | + fieldID: name
154 | }));
155 | return /*#__PURE__*/React.createElement(BlockStack, {
156 | as: "fieldset",
157 | @@ -102,7 +101,7 @@ function ChoiceList({
158 | md: '0'
159 | },
160 | "aria-invalid": error != null,
161 | - id: finalName
162 | + id: name
163 | }, titleMarkup, /*#__PURE__*/React.createElement(BlockStack, {
164 | as: "ul",
165 | gap: {
166 | diff --git a/node_modules/@shopify/polaris/build/esm/components/DropZone/DropZone.js b/node_modules/@shopify/polaris/build/esm/components/DropZone/DropZone.js
167 | index 2926c87..4e26217 100644
168 | --- a/node_modules/@shopify/polaris/build/esm/components/DropZone/DropZone.js
169 | +++ b/node_modules/@shopify/polaris/build/esm/components/DropZone/DropZone.js
170 | @@ -27,6 +27,7 @@ const DropZone = function DropZone({
171 | label,
172 | labelAction,
173 | labelHidden,
174 | + name,
175 | children,
176 | disabled = false,
177 | outline = true,
178 | @@ -118,7 +119,6 @@ const DropZone = function DropZone({
179 | onDropAccepted && acceptedFiles.length && onDropAccepted(acceptedFiles);
180 | onDropRejected && rejectedFiles.length && onDropRejected(rejectedFiles);
181 | if (!(event.target && 'value' in event.target)) return;
182 | - event.target.value = '';
183 | }, [disabled, getValidatedFiles, onDrop, onDropAccepted, onDropRejected]);
184 | const handleDragEnter = useCallback(event => {
185 | stopEvent(event);
186 | @@ -242,6 +242,7 @@ const DropZone = function DropZone({
187 | accept: accept,
188 | disabled: disabled,
189 | multiple: allowMultiple,
190 | + name: name,
191 | onChange: handleDrop,
192 | onFocus: handleFocus,
193 | onBlur: handleBlur,
194 | diff --git a/node_modules/@shopify/polaris/build/esm/components/Link/Link.js b/node_modules/@shopify/polaris/build/esm/components/Link/Link.js
195 | index d9e781a..cea39cb 100644
196 | --- a/node_modules/@shopify/polaris/build/esm/components/Link/Link.js
197 | +++ b/node_modules/@shopify/polaris/build/esm/components/Link/Link.js
198 | @@ -9,6 +9,7 @@ function Link({
199 | children,
200 | onClick,
201 | external,
202 | + rel,
203 | target,
204 | id,
205 | monochrome,
206 | @@ -23,6 +24,7 @@ function Link({
207 | onClick: onClick,
208 | className: className,
209 | url: url,
210 | + rel: rel,
211 | external: external,
212 | target: target,
213 | id: id,
214 | diff --git a/node_modules/@shopify/polaris/build/esm/components/UnstyledLink/UnstyledLink.js b/node_modules/@shopify/polaris/build/esm/components/UnstyledLink/UnstyledLink.js
215 | index ada2ee3..c0d5ce4 100644
216 | --- a/node_modules/@shopify/polaris/build/esm/components/UnstyledLink/UnstyledLink.js
217 | +++ b/node_modules/@shopify/polaris/build/esm/components/UnstyledLink/UnstyledLink.js
218 | @@ -30,12 +30,12 @@ const UnstyledLink = /*#__PURE__*/memo(/*#__PURE__*/forwardRef(function Unstyled
219 | } else {
220 | target = targetProp ?? undefined;
221 | }
222 | - const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
223 | + const rel = props.ref ?? target === '_blank' ? 'noopener noreferrer' : undefined;
224 | return /*#__PURE__*/React.createElement("a", Object.assign({
225 | - target: target
226 | - }, rest, {
227 | - href: url,
228 | + target: target,
229 | rel: rel
230 | + }, rest, {
231 | + href: url
232 | }, unstyled.props, {
233 | ref: _ref
234 | }));
235 | diff --git a/node_modules/@shopify/polaris/build/esnext/components/ChoiceList/ChoiceList.esnext b/node_modules/@shopify/polaris/build/esnext/components/ChoiceList/ChoiceList.esnext
236 | index 45fd642..4f68720 100644
237 | --- a/node_modules/@shopify/polaris/build/esnext/components/ChoiceList/ChoiceList.esnext
238 | +++ b/node_modules/@shopify/polaris/build/esnext/components/ChoiceList/ChoiceList.esnext
239 | @@ -25,7 +25,6 @@ function ChoiceList({
240 | const ControlComponent = allowMultiple ? Checkbox : RadioButton;
241 | const uniqName = useId();
242 | const name = nameProp ?? uniqName;
243 | - const finalName = allowMultiple ? `${name}[]` : name;
244 | const titleMarkup = title ? /*#__PURE__*/React.createElement(Box, {
245 | as: "legend",
246 | paddingBlockEnd: {
247 | @@ -69,7 +68,7 @@ function ChoiceList({
248 | xs: '0'
249 | }
250 | }, /*#__PURE__*/React.createElement(ControlComponent, {
251 | - name: finalName,
252 | + name: name,
253 | value: value,
254 | id: id,
255 | label: label,
256 | @@ -81,7 +80,7 @@ function ChoiceList({
257 | checked: choiceIsSelected(choice, selected),
258 | helpText: helpText,
259 | onChange: handleChange,
260 | - ariaDescribedBy: error && describedByError ? errorTextID(finalName) : null,
261 | + ariaDescribedBy: error && describedByError ? errorTextID(name) : null,
262 | tone: tone
263 | }), children));
264 | });
265 | @@ -93,7 +92,7 @@ function ChoiceList({
266 | paddingBlockEnd: "200"
267 | }, /*#__PURE__*/React.createElement(InlineError, {
268 | message: error,
269 | - fieldID: finalName
270 | + fieldID: name
271 | }));
272 | return /*#__PURE__*/React.createElement(BlockStack, {
273 | as: "fieldset",
274 | @@ -102,7 +101,7 @@ function ChoiceList({
275 | md: '0'
276 | },
277 | "aria-invalid": error != null,
278 | - id: finalName
279 | + id: name
280 | }, titleMarkup, /*#__PURE__*/React.createElement(BlockStack, {
281 | as: "ul",
282 | gap: {
283 | diff --git a/node_modules/@shopify/polaris/build/esnext/components/DropZone/DropZone.esnext b/node_modules/@shopify/polaris/build/esnext/components/DropZone/DropZone.esnext
284 | index bfd1ace..7d2b69a 100644
285 | --- a/node_modules/@shopify/polaris/build/esnext/components/DropZone/DropZone.esnext
286 | +++ b/node_modules/@shopify/polaris/build/esnext/components/DropZone/DropZone.esnext
287 | @@ -27,6 +27,7 @@ const DropZone = function DropZone({
288 | label,
289 | labelAction,
290 | labelHidden,
291 | + name,
292 | children,
293 | disabled = false,
294 | outline = true,
295 | @@ -118,7 +119,6 @@ const DropZone = function DropZone({
296 | onDropAccepted && acceptedFiles.length && onDropAccepted(acceptedFiles);
297 | onDropRejected && rejectedFiles.length && onDropRejected(rejectedFiles);
298 | if (!(event.target && 'value' in event.target)) return;
299 | - event.target.value = '';
300 | }, [disabled, getValidatedFiles, onDrop, onDropAccepted, onDropRejected]);
301 | const handleDragEnter = useCallback(event => {
302 | stopEvent(event);
303 | @@ -242,6 +242,7 @@ const DropZone = function DropZone({
304 | accept: accept,
305 | disabled: disabled,
306 | multiple: allowMultiple,
307 | + name: name,
308 | onChange: handleDrop,
309 | onFocus: handleFocus,
310 | onBlur: handleBlur,
311 | diff --git a/node_modules/@shopify/polaris/build/esnext/components/Link/Link.esnext b/node_modules/@shopify/polaris/build/esnext/components/Link/Link.esnext
312 | index 3500f54..62f6d89 100644
313 | --- a/node_modules/@shopify/polaris/build/esnext/components/Link/Link.esnext
314 | +++ b/node_modules/@shopify/polaris/build/esnext/components/Link/Link.esnext
315 | @@ -9,6 +9,7 @@ function Link({
316 | children,
317 | onClick,
318 | external,
319 | + rel,
320 | target,
321 | id,
322 | monochrome,
323 | @@ -23,6 +24,7 @@ function Link({
324 | onClick: onClick,
325 | className: className,
326 | url: url,
327 | + rel: rel,
328 | external: external,
329 | target: target,
330 | id: id,
331 | diff --git a/node_modules/@shopify/polaris/build/esnext/components/UnstyledLink/UnstyledLink.esnext b/node_modules/@shopify/polaris/build/esnext/components/UnstyledLink/UnstyledLink.esnext
332 | index 2661804..b6be48a 100644
333 | --- a/node_modules/@shopify/polaris/build/esnext/components/UnstyledLink/UnstyledLink.esnext
334 | +++ b/node_modules/@shopify/polaris/build/esnext/components/UnstyledLink/UnstyledLink.esnext
335 | @@ -30,12 +30,12 @@ const UnstyledLink = /*#__PURE__*/memo(/*#__PURE__*/forwardRef(function Unstyled
336 | } else {
337 | target = targetProp ?? undefined;
338 | }
339 | - const rel = target === '_blank' ? 'noopener noreferrer' : undefined;
340 | + const rel = props.ref ?? target === '_blank' ? 'noopener noreferrer' : undefined;
341 | return /*#__PURE__*/React.createElement("a", Object.assign({
342 | - target: target
343 | - }, rest, {
344 | - href: url,
345 | + target: target,
346 | rel: rel
347 | + }, rest, {
348 | + href: url
349 | }, unstyled.props, {
350 | ref: _ref
351 | }));
352 | diff --git a/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts b/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts
353 | index e687b01..1a646dd 100644
354 | --- a/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts
355 | +++ b/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts
356 | @@ -11,6 +11,8 @@ export interface DropZoneProps {
357 | labelHidden?: boolean;
358 | /** ID for file input */
359 | id?: string;
360 | + /** name for file input */
361 | + name?: string;
362 | /** Allowed file types */
363 | accept?: string;
364 | /**
365 | diff --git a/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts.map b/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts.map
366 | index 1cd0f19..69a3e6e 100644
367 | --- a/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts.map
368 | +++ b/node_modules/@shopify/polaris/build/ts/src/components/DropZone/DropZone.d.ts.map
369 | @@ -1 +1 @@
370 | -{"version":3,"file":"DropZone.d.ts","sourceRoot":"","sources":["../../../../../src/components/DropZone/DropZone.tsx"],"names":[],"mappings":"AAAA,OAAO,KAON,MAAM,OAAO,CAAC;AAUf,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,aAAa,CAAC;AAQ/C,OAAO,EAAC,UAAU,EAAC,MAAM,cAAc,CAAC;AAWxC,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;AAE1D,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,kCAAkC;IAClC,WAAW,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACtC,8BAA8B;IAC9B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,wBAAwB;IACxB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,0BAA0B;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,4BAA4B;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oDAAoD;IACpD,QAAQ,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IACpC,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yCAAyC;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,8BAA8B;IAC9B,eAAe,CAAC,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC;IACtC,kCAAkC;IAClC,OAAO,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,WAAW,CAAC,GAAG,IAAI,CAAC;IACrD,0CAA0C;IAC1C,MAAM,CAAC,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC3E,6EAA6E;IAC7E,cAAc,CAAC,CAAC,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC7C,6EAA6E;IAC7E,cAAc,CAAC,CAAC,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC7C,gFAAgF;IAChF,UAAU,CAAC,IAAI,IAAI,CAAC;IACpB,sEAAsE;IACtE,WAAW,CAAC,IAAI,IAAI,CAAC;IACrB,mEAAmE;IACnE,WAAW,CAAC,IAAI,IAAI,CAAC;IACrB,0DAA0D;IAC1D,iBAAiB,CAAC,IAAI,IAAI,CAAC;CAC5B;AAOD,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,iBAAiB,CAAC,aAAa,CAAC,GAAG;IAC9D,UAAU,EAAE,OAAO,UAAU,CAAC;CAmV/B,CAAC"}
371 | \ No newline at end of file
372 | +{"version":3,"file":"DropZone.d.ts","sourceRoot":"","sources":["../../../../../src/components/DropZone/DropZone.tsx"],"names":[],"mappings":"AAAA,OAAO,KAON,MAAM,OAAO,CAAC;AAUf,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,aAAa,CAAC;AAQ/C,OAAO,EAAC,UAAU,EAAC,MAAM,cAAc,CAAC;AAWxC,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;AAE1D,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,kCAAkC;IAClC,WAAW,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACtC,8BAA8B;IAC9B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,wBAAwB;IACxB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,0BAA0B;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,0BAA0B;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,4BAA4B;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oDAAoD;IACpD,QAAQ,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IACpC,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yCAAyC;IACzC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,8BAA8B;IAC9B,eAAe,CAAC,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC;IACtC,kCAAkC;IAClC,OAAO,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,WAAW,CAAC,GAAG,IAAI,CAAC;IACrD,0CAA0C;IAC1C,MAAM,CAAC,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC3E,6EAA6E;IAC7E,cAAc,CAAC,CAAC,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC7C,6EAA6E;IAC7E,cAAc,CAAC,CAAC,aAAa,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC7C,gFAAgF;IAChF,UAAU,CAAC,IAAI,IAAI,CAAC;IACpB,sEAAsE;IACtE,WAAW,CAAC,IAAI,IAAI,CAAC;IACrB,mEAAmE;IACnE,WAAW,CAAC,IAAI,IAAI,CAAC;IACrB,0DAA0D;IAC1D,iBAAiB,CAAC,IAAI,IAAI,CAAC;CAC5B;AAOD,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,iBAAiB,CAAC,aAAa,CAAC,GAAG;IAC9D,UAAU,EAAE,OAAO,UAAU,CAAC;CAqV/B,CAAC"}
373 | \ No newline at end of file
374 | diff --git a/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts b/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts
375 | index 13dbd6f..eada1a4 100644
376 | --- a/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts
377 | +++ b/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts
378 | @@ -11,6 +11,8 @@ export interface LinkProps {
379 | * @deprecated use `target` set to `_blank` instead
380 | */
381 | external?: boolean;
382 | + /** The relationship of the linked URL as space-separated link types. */
383 | + rel?: string;
384 | /** Where to display the url */
385 | target?: Target;
386 | /** Makes the link color the same as the current text color and adds an underline */
387 | @@ -24,5 +26,5 @@ export interface LinkProps {
388 | /** Indicates whether or not the link is the primary navigation link when rendered inside of an `IndexTable.Row` */
389 | dataPrimaryLink?: boolean;
390 | }
391 | -export declare function Link({ url, children, onClick, external, target, id, monochrome, removeUnderline, accessibilityLabel, dataPrimaryLink, }: LinkProps): React.JSX.Element;
392 | +export declare function Link({ url, children, onClick, external, rel, target, id, monochrome, removeUnderline, accessibilityLabel, dataPrimaryLink, }: LinkProps): React.JSX.Element;
393 | //# sourceMappingURL=Link.d.ts.map
394 | \ No newline at end of file
395 | diff --git a/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts.map b/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts.map
396 | index 046d207..15da0b4 100644
397 | --- a/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts.map
398 | +++ b/node_modules/@shopify/polaris/build/ts/src/components/Link/Link.d.ts.map
399 | @@ -1 +1 @@
400 | -{"version":3,"file":"Link.d.ts","sourceRoot":"","sources":["../../../../../src/components/Link/Link.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAK1B,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AAIxC,MAAM,WAAW,SAAS;IACxB,sBAAsB;IACtB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B;;OAEG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oFAAoF;IACpF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,oDAAoD;IACpD,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,sCAAsC;IACtC,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,mDAAmD;IACnD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,mHAAmH;IACnH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAgB,IAAI,CAAC,EACnB,GAAG,EACH,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,MAAM,EACN,EAAE,EACF,UAAU,EACV,eAAe,EACf,kBAAkB,EAClB,eAAe,GAChB,EAAE,SAAS,qBAwCX"}
401 | \ No newline at end of file
402 | +{"version":3,"file":"Link.d.ts","sourceRoot":"","sources":["../../../../../src/components/Link/Link.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAK1B,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,aAAa,CAAC;AAIxC,MAAM,WAAW,SAAS;IACxB,sBAAsB;IACtB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B;;OAEG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wEAAwE;IACxE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oFAAoF;IACpF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,oDAAoD;IACpD,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,sCAAsC;IACtC,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,mDAAmD;IACnD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,mHAAmH;IACnH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAgB,IAAI,CAAC,EACnB,GAAG,EACH,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,GAAG,EACH,MAAM,EACN,EAAE,EACF,UAAU,EACV,eAAe,EACf,kBAAkB,EAClB,eAAe,GAChB,EAAE,SAAS,qBAyCX"}
403 | \ No newline at end of file
404 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { env } from "node:process";
2 | import { defineConfig } from "@playwright/test";
3 |
4 | const appUrl = env.HOST ?? env.SHOPIFY_APP_URL;
5 |
6 | export default defineConfig({
7 | outputDir: "node_modules/.playwright",
8 | testDir: "./",
9 | testMatch: /.*\.e2e.test.ts/,
10 | use: {
11 | baseURL: appUrl,
12 | extraHTTPHeaders: {
13 | Accept: "application/json",
14 | // Authorization: `token ${env.SHOPIFY_STOREFRONT_ACCESS_TOKEN}`,
15 | },
16 | locale: "en",
17 | serviceWorkers: "allow",
18 | },
19 | webServer: {
20 | command: "npm run dev",
21 | reuseExistingServer: true,
22 | timeout: 10 * 1000,
23 | url: appUrl,
24 | },
25 | });
26 |
--------------------------------------------------------------------------------
/public/.well-known/publickey.txt:
--------------------------------------------------------------------------------
1 | -----BEGIN PUBLIC KEY-----
2 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExd0kyBRcG4CtF9piBXI+Mk9AUtOb
3 | ldJDq8VW2PWoefdL9nNNX+nheBowIMqzJtmYXa17MWVAtWc7S5Mds8QICQ==
4 | -----END PUBLIC KEY-----
5 |
--------------------------------------------------------------------------------
/public/.well-known/security.txt:
--------------------------------------------------------------------------------
1 | Contact: chr33s@icloud.com
2 | Encryption: /.well-known/publickey.txt
3 | Preferred-Languages: en
4 |
--------------------------------------------------------------------------------
/public/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/public/apple-touch-icon-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/assets/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
36 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chr33s/shopflare/e1efe988bbbf49fc7ba343d2895e86c3c9b622d8/public/favicon.ico
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
--------------------------------------------------------------------------------
/react-router.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "@react-router/dev/config";
2 |
3 | export default {
4 | // Config options...
5 | future: {
6 | unstable_optimizeDeps: true,
7 | unstable_splitRouteModules: true,
8 | unstable_viteEnvironmentApi: true,
9 | },
10 | // Fixes hot-reload on proxy paths
11 | routeDiscovery: { mode: "initial" },
12 | // Server-side render by default, to enable SPA mode set this to `false`
13 | ssr: true,
14 | } satisfies Config;
15 |
--------------------------------------------------------------------------------
/shopify.app.toml:
--------------------------------------------------------------------------------
1 | # Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
2 |
3 | client_id = "17b048405a3e2ffe901be65f5783837d"
4 | application_url = "https://local.chr33s.dev"
5 | embedded = true
6 | name = "ShopFlare"
7 | handle = "shopflare"
8 |
9 | [access.admin]
10 | direct_api_mode = "online"
11 | embedded_app_direct_api_access = true
12 |
13 | [access_scopes]
14 | # Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
15 | scopes = "read_products, write_app_proxy"
16 | optional_scopes = [ "write_products" ]
17 | use_legacy_install_flow = false
18 |
19 | [app_proxy]
20 | url = "https://local.chr33s.dev/apps/shopflare"
21 | subpath = "shopflare"
22 | prefix = "apps"
23 |
24 | [auth]
25 | redirect_urls = [ "https://local.chr33s.dev/shopify/auth/callback" ]
26 |
27 | [build]
28 | automatically_update_urls_on_dev = false
29 | dev_store_url = "glue-dev-store.myshopify.com"
30 | include_config_on_deploy = true
31 |
32 | [webhooks]
33 | api_version = "2025-04"
34 |
35 | [[webhooks.subscriptions]]
36 | uri = "/shopify/webhooks"
37 | compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact" ]
38 | topics = [ "app/scopes_update", "app/uninstalled" ]
39 |
40 | [pos]
41 | embedded = false
42 |
--------------------------------------------------------------------------------
/shopify.web.toml:
--------------------------------------------------------------------------------
1 | name = "app"
2 | webhooks_path = "/shopify/webhooks"
3 |
4 | [commands]
5 | build = "npx react-router build"
6 | dev = "npx react-router dev"
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "checkJs": true,
5 | "composite": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
9 | "module": "ES2022",
10 | "moduleResolution": "bundler",
11 | "noEmit": true,
12 | "paths": {
13 | "~/*": ["./app/*"]
14 | },
15 | "resolveJsonModule": true,
16 | "rootDirs": [".", "./.react-router/types"],
17 | "skipLibCheck": true,
18 | "strict": true,
19 | "target": "ES2022",
20 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
21 | "types": [
22 | "@cloudflare/vitest-pool-workers",
23 | "@vitest/browser/matchers",
24 | "@vitest/browser/providers/playwright",
25 | "vite/client"
26 | ],
27 | "verbatimModuleSyntax": true
28 | },
29 | "exclude": ["build", "**/dist"],
30 | "include": [
31 | "**/*",
32 | "**/.server/**/*",
33 | "**/.client/**/*",
34 | ".react-router/types/**/*",
35 | "worker-configuration.d.ts",
36 | "node_modules/vite-plugin-i18next-loader/typings/*"
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { cloudflare } from "@cloudflare/vite-plugin";
2 | import { reactRouter } from "@react-router/dev/vite";
3 | import { defineConfig, loadEnv } from "vite";
4 | import i18nextLoader from "vite-plugin-i18next-loader";
5 | import tsconfigPaths from "vite-tsconfig-paths";
6 |
7 | import i18nextLoaderOptions from "./i18n.config";
8 |
9 | export default defineConfig(({ mode }) => {
10 | const env = loadEnv(mode, process.cwd(), "");
11 | const app = new URL(env.HOST ?? env.SHOPIFY_APP_URL);
12 |
13 | return {
14 | base: app.href,
15 | clearScreen: false,
16 | plugins: [
17 | i18nextLoader(i18nextLoaderOptions),
18 | cloudflare({ viteEnvironment: { name: "ssr" } }),
19 | reactRouter(),
20 | tsconfigPaths(),
21 | ],
22 | resolve: {
23 | mainFields: ["browser", "module", "main"],
24 | },
25 | server: {
26 | allowedHosts: [app.hostname],
27 | cors: {
28 | origin: true,
29 | preflightContinue: true,
30 | },
31 | origin: app.origin,
32 | port: Number(env.PORT || 8080),
33 | },
34 | ssr: {
35 | resolve: {
36 | conditions: ["workerd", "worker", "browser"],
37 | },
38 | },
39 | };
40 | });
41 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { loadEnv } from "vite";
2 | import i18nextLoader from "vite-plugin-i18next-loader";
3 | import tsconfigPaths from "vite-tsconfig-paths";
4 | import { defineConfig } from "vitest/config";
5 |
6 | import i18nextLoaderOptions from "./i18n.config";
7 |
8 | export default defineConfig((config) => {
9 | const env = loadEnv(config.mode, process.cwd(), "");
10 |
11 | return {
12 | optimizeDeps: {
13 | include: ["react/jsx-dev-runtime"],
14 | },
15 | plugins: [i18nextLoader(i18nextLoaderOptions), tsconfigPaths()],
16 | test: {
17 | css: true,
18 | env,
19 | watch: false,
20 | },
21 | };
22 | });
23 |
--------------------------------------------------------------------------------
/vitest.workspace.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath } from "node:url";
2 | import {
3 | defineWorkersConfig,
4 | defineWorkersProject,
5 | } from "@cloudflare/vitest-pool-workers/config";
6 | import { defineWorkspace, mergeConfig } from "vitest/config";
7 |
8 | export default defineWorkspace([
9 | {
10 | extends: "./vitest.config.ts",
11 | test: {
12 | browser: {
13 | headless: true,
14 | enabled: true,
15 | instances: [{ browser: "webkit" }],
16 | provider: "playwright",
17 | },
18 | include: ["app/**/*.browser.test.tsx"],
19 | name: "browser",
20 | },
21 | },
22 | {
23 | extends: "./vitest.config.ts",
24 | test: {
25 | environment: "happy-dom",
26 | include: ["app/**/*.client.test.tsx"],
27 | name: "client",
28 | },
29 | },
30 | defineWorkersConfig(
31 | mergeConfig(
32 | { extends: "./vitest.config.ts" },
33 | defineWorkersProject({
34 | test: {
35 | alias: [
36 | {
37 | find: "virtual:react-router/server-build",
38 | replacement: fileURLToPath(
39 | new URL("./build/server/index.js", import.meta.url),
40 | ),
41 | },
42 | ],
43 | include: ["worker.test.ts", "app/**/*.server.test.ts"],
44 | name: "server",
45 | poolOptions: {
46 | workers: {
47 | main: "./build/server/index.js",
48 | miniflare: {
49 | compatibilityFlags: [
50 | "nodejs_compat",
51 | "service_binding_extra_handlers",
52 | ],
53 | },
54 | singleWorker: true,
55 | wrangler: { configPath: "./wrangler.json" },
56 | },
57 | },
58 | },
59 | }),
60 | ),
61 | ),
62 | ]);
63 |
--------------------------------------------------------------------------------
/worker.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SELF,
3 | createExecutionContext,
4 | env,
5 | waitOnExecutionContext,
6 | } from "cloudflare:test";
7 | import { afterEach, expect, test, vi } from "vitest";
8 |
9 | import worker from "./worker";
10 |
11 | afterEach(() => {
12 | vi.restoreAllMocks();
13 | });
14 |
15 | test("fetch", async () => {
16 | const response = await SELF.fetch("http://example.com");
17 | expect(await response.text()).toContain("ShopFlare");
18 | expect(response.status).toBe(200);
19 | });
20 |
21 | // FIXME: upstream bundler issue
22 | test.skip("worker", async () => {
23 | const request = new Request("http://example.com");
24 | const ctx = createExecutionContext();
25 | // biome-ignore lint/suspicious/noExplicitAny: upstream
26 | const response = await worker.fetch(request as any, env as Env, ctx);
27 | await waitOnExecutionContext(ctx);
28 | expect(await response.text()).toContain("ShopFlare");
29 | expect(response.status).toBe(200);
30 | });
31 |
--------------------------------------------------------------------------------
/worker.ts:
--------------------------------------------------------------------------------
1 | import { createRequestHandler } from "react-router";
2 |
3 | import type { WebhookQueueMessage } from "~/types/app";
4 |
5 | declare module "react-router" {
6 | export interface AppLoadContext {
7 | cloudflare: {
8 | ctx: ExecutionContext;
9 | env: Env;
10 | };
11 | }
12 | }
13 |
14 | const requestHandler = createRequestHandler(
15 | () => import("virtual:react-router/server-build"),
16 | import.meta.env.MODE,
17 | );
18 |
19 | export default {
20 | async fetch(request, env, ctx) {
21 | return requestHandler(request, {
22 | cloudflare: { env, ctx },
23 | });
24 | },
25 |
26 | async queue(batch, _env, _ctx): Promise {
27 | console.log(`server.queue: ${JSON.stringify(batch.messages)}`);
28 |
29 | for (const message of batch.messages) {
30 | message.ack();
31 | }
32 | },
33 | } satisfies ExportedHandler;
34 |
--------------------------------------------------------------------------------
/wrangler.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/wrangler/config-schema.json",
3 | "name": "shopflare",
4 | "compatibility_date": "2025-04-10",
5 | "main": "./worker.ts",
6 | "assets": {
7 | "binding": "ASSETS",
8 | "directory": "./build/client"
9 | },
10 | "dev": {
11 | "ip": "0.0.0.0",
12 | "port": 8080
13 | },
14 | "kv_namespaces": [
15 | {
16 | "binding": "SESSION_STORAGE",
17 | "id": "?"
18 | }
19 | ],
20 | "logpush": true,
21 | "observability": {
22 | "enabled": true,
23 | "logs": {
24 | "invocation_logs": false
25 | }
26 | },
27 | "placement": {
28 | "mode": "smart"
29 | },
30 | "queues": {
31 | "consumers": [
32 | {
33 | "queue": "shopflare"
34 | }
35 | ],
36 | "producers": [
37 | {
38 | "queue": "shopflare",
39 | "binding": "WEBHOOK_QUEUE"
40 | }
41 | ]
42 | },
43 | "upload_source_maps": true,
44 | "vars": {
45 | "NODE_VERSION": 22,
46 | "SHOPIFY_API_KEY": "",
47 | "SHOPIFY_APP_URL": ""
48 | }
49 | }
50 |
--------------------------------------------------------------------------------