From 50abf27a7828e75680d45d46275fd8856df2ab30 Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Thu, 19 Dec 2024 14:01:31 -0600 Subject: [PATCH 1/2] Merge pull request #904 from Shopify/cd/app-bridge-react-bump bump `@shopify/app-bridge-react` to latest --- .graphqlrc.ts | 40 +++ CHANGELOG.md | 5 +- app/db.server.ts | 15 + app/entry.server.tsx | 59 ++++ app/globals.d.ts | 1 + app/root.tsx | 30 ++ app/routes.ts | 3 + app/routes/_index/route.tsx | 58 ++++ app/routes/app._index.tsx | 334 ++++++++++++++++++++++ app/routes/app.additional.tsx | 83 ++++++ app/routes/app.tsx | 41 +++ app/routes/auth.$.tsx | 8 + app/routes/auth.login/error.server.tsx | 16 ++ app/routes/auth.login/route.tsx | 68 +++++ app/routes/webhooks.app.scopes_update.tsx | 21 ++ app/routes/webhooks.app.uninstalled.tsx | 17 ++ app/shopify.server.ts | 35 +++ package.json | 2 +- vite.config.ts | 66 +++++ 19 files changed, 900 insertions(+), 2 deletions(-) create mode 100644 .graphqlrc.ts create mode 100644 app/db.server.ts create mode 100644 app/entry.server.tsx create mode 100644 app/globals.d.ts create mode 100644 app/root.tsx create mode 100644 app/routes.ts create mode 100644 app/routes/_index/route.tsx create mode 100644 app/routes/app._index.tsx create mode 100644 app/routes/app.additional.tsx create mode 100644 app/routes/app.tsx create mode 100644 app/routes/auth.$.tsx create mode 100644 app/routes/auth.login/error.server.tsx create mode 100644 app/routes/auth.login/route.tsx create mode 100644 app/routes/webhooks.app.scopes_update.tsx create mode 100644 app/routes/webhooks.app.uninstalled.tsx create mode 100644 app/shopify.server.ts create mode 100644 vite.config.ts diff --git a/.graphqlrc.ts b/.graphqlrc.ts new file mode 100644 index 00000000..ad9c5f81 --- /dev/null +++ b/.graphqlrc.ts @@ -0,0 +1,40 @@ +import fs from "fs"; +import { LATEST_API_VERSION } from "@shopify/shopify-api"; +import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset"; +import type { IGraphQLConfig } from "graphql-config"; + +function getConfig() { + const config: IGraphQLConfig = { + projects: { + default: shopifyApiProject({ + apiType: ApiType.Admin, + apiVersion: LATEST_API_VERSION, + documents: ["./app/**/*.{js,ts,jsx,tsx}", "./app/.server/**/*.{js,ts,jsx,tsx}"], + outputDir: "./app/types", + }), + }, + }; + + let extensions: string[] = []; + try { + extensions = fs.readdirSync("./extensions"); + } catch { + // ignore if no extensions + } + + for (const entry of extensions) { + const extensionPath = `./extensions/${entry}`; + const schema = `${extensionPath}/schema.graphql`; + if (!fs.existsSync(schema)) { + continue; + } + config.projects[entry] = { + schema, + documents: [`${extensionPath}/**/*.graphql`], + }; + } + + return config; +} + +module.exports = getConfig(); diff --git a/CHANGELOG.md b/CHANGELOG.md index 427e152b..b2961a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ # @shopify/shopify-app-template-remix +## 2024.12.19 + +- [#904](https://github.com/Shopify/shopify-app-template-remix/pull/904) bump `@shopify/app-bridge-react` to latest +- ## 2024.12.18 - [875](https://github.com/Shopify/shopify-app-template-remix/pull/875) Add Scopes Update Webhook - ## 2024.12.05 - [#910](https://github.com/Shopify/shopify-app-template-remix/pull/910) Install `openssl` in Docker image to fix Prisma (see [#25817](https://github.com/prisma/prisma/issues/25817#issuecomment-2538544254)) diff --git a/app/db.server.ts b/app/db.server.ts new file mode 100644 index 00000000..bafa6cc2 --- /dev/null +++ b/app/db.server.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from "@prisma/client"; + +declare global { + var prisma: PrismaClient; +} + +if (process.env.NODE_ENV !== "production") { + if (!global.prisma) { + global.prisma = new PrismaClient(); + } +} + +const prisma: PrismaClient = global.prisma || new PrismaClient(); + +export default prisma; diff --git a/app/entry.server.tsx b/app/entry.server.tsx new file mode 100644 index 00000000..86274311 --- /dev/null +++ b/app/entry.server.tsx @@ -0,0 +1,59 @@ +import { PassThrough } from "stream"; +import { renderToPipeableStream } from "react-dom/server"; +import { RemixServer } from "@remix-run/react"; +import { + createReadableStreamFromReadable, + type EntryContext, +} from "@remix-run/node"; +import { isbot } from "isbot"; +import { addDocumentResponseHeaders } from "./shopify.server"; + +export const streamTimeout = 5000; + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + addDocumentResponseHeaders(request, responseHeaders); + const userAgent = request.headers.get("user-agent"); + const callbackName = isbot(userAgent ?? '') + ? "onAllReady" + : "onShellReady"; + + return new Promise((resolve, reject) => { + const { pipe, abort } = renderToPipeableStream( + , + { + [callbackName]: () => { + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + pipe(body); + }, + onShellError(error) { + reject(error); + }, + onError(error) { + responseStatusCode = 500; + console.error(error); + }, + } + ); + + // Automatically timeout the React renderer after 6 seconds, which ensures + // React has enough time to flush down the rejected boundary contents + setTimeout(abort, streamTimeout + 1000); + }); +} diff --git a/app/globals.d.ts b/app/globals.d.ts new file mode 100644 index 00000000..cbe652db --- /dev/null +++ b/app/globals.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 00000000..805f121f --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,30 @@ +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export default function App() { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/app/routes.ts b/app/routes.ts new file mode 100644 index 00000000..83892841 --- /dev/null +++ b/app/routes.ts @@ -0,0 +1,3 @@ +import { flatRoutes } from "@remix-run/fs-routes"; + +export default flatRoutes(); diff --git a/app/routes/_index/route.tsx b/app/routes/_index/route.tsx new file mode 100644 index 00000000..2de9dd47 --- /dev/null +++ b/app/routes/_index/route.tsx @@ -0,0 +1,58 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { Form, useLoaderData } from "@remix-run/react"; + +import { login } from "../../shopify.server"; + +import styles from "./styles.module.css"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + + if (url.searchParams.get("shop")) { + throw redirect(`/app?${url.searchParams.toString()}`); + } + + return { showForm: Boolean(login) }; +}; + +export default function App() { + const { showForm } = useLoaderData(); + + return ( +
+
+

A short heading about [your app]

+

+ A tagline about [your app] that describes your value proposition. +

+ {showForm && ( +
+ + +
+ )} +
    +
  • + Product feature. Some detail about your feature and + its benefit to your customer. +
  • +
  • + Product feature. Some detail about your feature and + its benefit to your customer. +
  • +
  • + Product feature. Some detail about your feature and + its benefit to your customer. +
  • +
+
+
+ ); +} diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx new file mode 100644 index 00000000..18b215b0 --- /dev/null +++ b/app/routes/app._index.tsx @@ -0,0 +1,334 @@ +import { useEffect } from "react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { useFetcher } from "@remix-run/react"; +import { + Page, + Layout, + Text, + Card, + Button, + BlockStack, + Box, + List, + Link, + InlineStack, +} from "@shopify/polaris"; +import { TitleBar, useAppBridge } from "@shopify/app-bridge-react"; +import { authenticate } from "../shopify.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + await authenticate.admin(request); + + return null; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { admin } = await authenticate.admin(request); + const color = ["Red", "Orange", "Yellow", "Green"][ + Math.floor(Math.random() * 4) + ]; + const response = await admin.graphql( + `#graphql + mutation populateProduct($product: ProductCreateInput!) { + productCreate(product: $product) { + product { + id + title + handle + status + variants(first: 10) { + edges { + node { + id + price + barcode + createdAt + } + } + } + } + } + }`, + { + variables: { + product: { + title: `${color} Snowboard`, + }, + }, + }, + ); + const responseJson = await response.json(); + + const product = responseJson.data!.productCreate!.product!; + const variantId = product.variants.edges[0]!.node!.id!; + + const variantResponse = await admin.graphql( + `#graphql + mutation shopifyRemixTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { + productVariantsBulkUpdate(productId: $productId, variants: $variants) { + productVariants { + id + price + barcode + createdAt + } + } + }`, + { + variables: { + productId: product.id, + variants: [{ id: variantId, price: "100.00" }], + }, + }, + ); + + const variantResponseJson = await variantResponse.json(); + + return { + product: responseJson!.data!.productCreate!.product, + variant: + variantResponseJson!.data!.productVariantsBulkUpdate!.productVariants, + }; +}; + +export default function Index() { + const fetcher = useFetcher(); + + const shopify = useAppBridge(); + const isLoading = + ["loading", "submitting"].includes(fetcher.state) && + fetcher.formMethod === "POST"; + const productId = fetcher.data?.product?.id.replace( + "gid://shopify/Product/", + "", + ); + + useEffect(() => { + if (productId) { + shopify.toast.show("Product created"); + } + }, [productId, shopify]); + const generateProduct = () => fetcher.submit({}, { method: "POST" }); + + return ( + + + + + + + + + + + + Congrats on creating a new Shopify app 🎉 + + + This embedded app template uses{" "} + + App Bridge + {" "} + interface examples like an{" "} + + additional page in the app nav + + , as well as an{" "} + + Admin GraphQL + {" "} + mutation demo, to provide a starting point for app + development. + + + + + Get started with products + + + Generate a product with GraphQL and get the JSON output for + that product. Learn more about the{" "} + + productCreate + {" "} + mutation in our API references. + + + + + {fetcher.data?.product && ( + + )} + + {fetcher.data?.product && ( + <> + + {" "} + productCreate mutation + + +
+                        
+                          {JSON.stringify(fetcher.data.product, null, 2)}
+                        
+                      
+
+ + {" "} + productVariantsBulkUpdate mutation + + +
+                        
+                          {JSON.stringify(fetcher.data.variant, null, 2)}
+                        
+                      
+
+ + )} +
+
+
+ + + + + + App template specs + + + + + Framework + + + Remix + + + + + Database + + + Prisma + + + + + Interface + + + + Polaris + + {", "} + + App Bridge + + + + + + API + + + GraphQL API + + + + + + + + + Next steps + + + + Build an{" "} + + {" "} + example app + {" "} + to get started + + + Explore Shopify’s API with{" "} + + GraphiQL + + + + + + + +
+
+
+ ); +} diff --git a/app/routes/app.additional.tsx b/app/routes/app.additional.tsx new file mode 100644 index 00000000..eb9b0cfd --- /dev/null +++ b/app/routes/app.additional.tsx @@ -0,0 +1,83 @@ +import { + Box, + Card, + Layout, + Link, + List, + Page, + Text, + BlockStack, +} from "@shopify/polaris"; +import { TitleBar } from "@shopify/app-bridge-react"; + +export default function AdditionalPage() { + return ( + + + + + + + + The app template comes with an additional page which + demonstrates how to create multiple pages within app navigation + using{" "} + + App Bridge + + . + + + To create your own page and have it show up in the app + navigation, add a page inside app/routes, and a + link to it in the <NavMenu> component found + in app/routes/app.jsx. + + + + + + + + + Resources + + + + + App nav best practices + + + + + + + + + ); +} + +function Code({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/routes/app.tsx b/app/routes/app.tsx new file mode 100644 index 00000000..bdcf1162 --- /dev/null +++ b/app/routes/app.tsx @@ -0,0 +1,41 @@ +import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react"; +import { boundary } from "@shopify/shopify-app-remix/server"; +import { AppProvider } from "@shopify/shopify-app-remix/react"; +import { NavMenu } from "@shopify/app-bridge-react"; +import polarisStyles from "@shopify/polaris/build/esm/styles.css?url"; + +import { authenticate } from "../shopify.server"; + +export const links = () => [{ rel: "stylesheet", href: polarisStyles }]; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + await authenticate.admin(request); + + return { apiKey: process.env.SHOPIFY_API_KEY || "" }; +}; + +export default function App() { + const { apiKey } = useLoaderData(); + + return ( + + + + Home + + Additional page + + + + ); +} + +// Shopify needs Remix to catch some thrown responses, so that their headers are included in the response. +export function ErrorBoundary() { + return boundary.error(useRouteError()); +} + +export const headers: HeadersFunction = (headersArgs) => { + return boundary.headers(headersArgs); +}; diff --git a/app/routes/auth.$.tsx b/app/routes/auth.$.tsx new file mode 100644 index 00000000..8919320d --- /dev/null +++ b/app/routes/auth.$.tsx @@ -0,0 +1,8 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { authenticate } from "../shopify.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + await authenticate.admin(request); + + return null; +}; diff --git a/app/routes/auth.login/error.server.tsx b/app/routes/auth.login/error.server.tsx new file mode 100644 index 00000000..2c794974 --- /dev/null +++ b/app/routes/auth.login/error.server.tsx @@ -0,0 +1,16 @@ +import type { LoginError } from "@shopify/shopify-app-remix/server"; +import { LoginErrorType } from "@shopify/shopify-app-remix/server"; + +interface LoginErrorMessage { + shop?: string; +} + +export function loginErrorMessage(loginErrors: LoginError): LoginErrorMessage { + if (loginErrors?.shop === LoginErrorType.MissingShop) { + return { shop: "Please enter your shop domain to log in" }; + } else if (loginErrors?.shop === LoginErrorType.InvalidShop) { + return { shop: "Please enter a valid shop domain to log in" }; + } + + return {}; +} diff --git a/app/routes/auth.login/route.tsx b/app/routes/auth.login/route.tsx new file mode 100644 index 00000000..0e9aece7 --- /dev/null +++ b/app/routes/auth.login/route.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { Form, useActionData, useLoaderData } from "@remix-run/react"; +import { + AppProvider as PolarisAppProvider, + Button, + Card, + FormLayout, + Page, + Text, + TextField, +} from "@shopify/polaris"; +import polarisTranslations from "@shopify/polaris/locales/en.json"; +import polarisStyles from "@shopify/polaris/build/esm/styles.css?url"; + +import { login } from "../../shopify.server"; + +import { loginErrorMessage } from "./error.server"; + +export const links = () => [{ rel: "stylesheet", href: polarisStyles }]; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const errors = loginErrorMessage(await login(request)); + + return { errors, polarisTranslations }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const errors = loginErrorMessage(await login(request)); + + return { + errors, + }; +}; + +export default function Auth() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + const [shop, setShop] = useState(""); + const { errors } = actionData || loaderData; + + return ( + + + +
+ + + Log in + + + + +
+
+
+
+ ); +} diff --git a/app/routes/webhooks.app.scopes_update.tsx b/app/routes/webhooks.app.scopes_update.tsx new file mode 100644 index 00000000..c36bb64c --- /dev/null +++ b/app/routes/webhooks.app.scopes_update.tsx @@ -0,0 +1,21 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { payload, session, topic, shop } = await authenticate.webhook(request); + console.log(`Received ${topic} webhook for ${shop}`); + + const current = payload.current as string[]; + if (session) { + await db.session.update({ + where: { + id: session.id + }, + data: { + scope: current.toString(), + }, + }); + } + return new Response(); +}; diff --git a/app/routes/webhooks.app.uninstalled.tsx b/app/routes/webhooks.app.uninstalled.tsx new file mode 100644 index 00000000..54d3161c --- /dev/null +++ b/app/routes/webhooks.app.uninstalled.tsx @@ -0,0 +1,17 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { shop, session, topic } = await authenticate.webhook(request); + + console.log(`Received ${topic} webhook for ${shop}`); + + // Webhook requests can trigger multiple times and after an app has already been uninstalled. + // If this webhook already ran, the session may have been deleted previously. + if (session) { + await db.session.deleteMany({ where: { shop } }); + } + + return new Response(); +}; diff --git a/app/shopify.server.ts b/app/shopify.server.ts new file mode 100644 index 00000000..ec980711 --- /dev/null +++ b/app/shopify.server.ts @@ -0,0 +1,35 @@ +import "@shopify/shopify-app-remix/adapters/node"; +import { + ApiVersion, + AppDistribution, + shopifyApp, +} from "@shopify/shopify-app-remix/server"; +import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma"; +import prisma from "./db.server"; + +const shopify = shopifyApp({ + apiKey: process.env.SHOPIFY_API_KEY, + apiSecretKey: process.env.SHOPIFY_API_SECRET || "", + apiVersion: ApiVersion.October24, + scopes: process.env.SCOPES?.split(","), + appUrl: process.env.SHOPIFY_APP_URL || "", + authPathPrefix: "/auth", + sessionStorage: new PrismaSessionStorage(prisma), + distribution: AppDistribution.AppStore, + future: { + unstable_newEmbeddedAuthStrategy: true, + removeRest: true, + }, + ...(process.env.SHOP_CUSTOM_DOMAIN + ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] } + : {}), +}); + +export default shopify; +export const apiVersion = ApiVersion.October24; +export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders; +export const authenticate = shopify.authenticate; +export const unauthenticated = shopify.unauthenticated; +export const login = shopify.login; +export const registerWebhooks = shopify.registerWebhooks; +export const sessionStorage = shopify.sessionStorage; diff --git a/package.json b/package.json index 07e995dc..4838f7ac 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@remix-run/node": "^2.7.1", "@remix-run/react": "^2.7.1", "@remix-run/serve": "^2.7.1", - "@shopify/app-bridge-react": "^4.1.2", + "@shopify/app-bridge-react": "^4.1.6", "@shopify/cli": "^3.63.1", "@shopify/polaris": "^12.0.0", "@shopify/shopify-app-remix": "^3.4.0", diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..82142f42 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,66 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { installGlobals } from "@remix-run/node"; +import { defineConfig, type UserConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +installGlobals({ nativeFetch: true }); + +// Related: https://github.com/remix-run/remix/issues/2835#issuecomment-1144102176 +// Replace the HOST env var with SHOPIFY_APP_URL so that it doesn't break the remix server. The CLI will eventually +// stop passing in HOST, so we can remove this workaround after the next major release. +if ( + process.env.HOST && + (!process.env.SHOPIFY_APP_URL || + process.env.SHOPIFY_APP_URL === process.env.HOST) +) { + process.env.SHOPIFY_APP_URL = process.env.HOST; + delete process.env.HOST; +} + +const host = new URL(process.env.SHOPIFY_APP_URL || "http://localhost") + .hostname; + +let hmrConfig; +if (host === "localhost") { + hmrConfig = { + protocol: "ws", + host: "localhost", + port: 64999, + clientPort: 64999, + }; +} else { + hmrConfig = { + protocol: "wss", + host: host, + port: parseInt(process.env.FRONTEND_PORT!) || 8002, + clientPort: 443, + }; +} + +export default defineConfig({ + server: { + port: Number(process.env.PORT || 3000), + hmr: hmrConfig, + fs: { + // See https://vitejs.dev/config/server-options.html#server-fs-allow for more information + allow: ["app", "node_modules"], + }, + }, + plugins: [ + remix({ + ignoredRouteFiles: ["**/.*"], + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + v3_lazyRouteDiscovery: true, + v3_singleFetch: false, + v3_routeConfig: true, + }, + }), + tsconfigPaths(), + ], + build: { + assetsInlineLimit: 0, + }, +}) satisfies UserConfig; From 598ca094820df00d3423d4b11fb6ca35ac162a62 Mon Sep 17 00:00:00 2001 From: GitHub Date: Thu, 19 Dec 2024 20:02:24 +0000 Subject: [PATCH 2/2] Convert template to Javascript --- .graphqlrc.ts | 40 --- app/db.server.ts | 15 - app/entry.server.tsx | 59 ---- app/globals.d.ts | 1 - app/root.tsx | 30 -- app/routes.ts | 3 - app/routes/_index/route.tsx | 58 ---- app/routes/app._index.tsx | 334 ---------------------- app/routes/app.additional.tsx | 83 ------ app/routes/app.tsx | 41 --- app/routes/auth.$.tsx | 8 - app/routes/auth.login/error.server.tsx | 16 -- app/routes/auth.login/route.tsx | 68 ----- app/routes/webhooks.app.scopes_update.tsx | 21 -- app/routes/webhooks.app.uninstalled.tsx | 17 -- app/shopify.server.ts | 35 --- vite.config.ts | 66 ----- 17 files changed, 895 deletions(-) delete mode 100644 .graphqlrc.ts delete mode 100644 app/db.server.ts delete mode 100644 app/entry.server.tsx delete mode 100644 app/globals.d.ts delete mode 100644 app/root.tsx delete mode 100644 app/routes.ts delete mode 100644 app/routes/_index/route.tsx delete mode 100644 app/routes/app._index.tsx delete mode 100644 app/routes/app.additional.tsx delete mode 100644 app/routes/app.tsx delete mode 100644 app/routes/auth.$.tsx delete mode 100644 app/routes/auth.login/error.server.tsx delete mode 100644 app/routes/auth.login/route.tsx delete mode 100644 app/routes/webhooks.app.scopes_update.tsx delete mode 100644 app/routes/webhooks.app.uninstalled.tsx delete mode 100644 app/shopify.server.ts delete mode 100644 vite.config.ts diff --git a/.graphqlrc.ts b/.graphqlrc.ts deleted file mode 100644 index ad9c5f81..00000000 --- a/.graphqlrc.ts +++ /dev/null @@ -1,40 +0,0 @@ -import fs from "fs"; -import { LATEST_API_VERSION } from "@shopify/shopify-api"; -import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset"; -import type { IGraphQLConfig } from "graphql-config"; - -function getConfig() { - const config: IGraphQLConfig = { - projects: { - default: shopifyApiProject({ - apiType: ApiType.Admin, - apiVersion: LATEST_API_VERSION, - documents: ["./app/**/*.{js,ts,jsx,tsx}", "./app/.server/**/*.{js,ts,jsx,tsx}"], - outputDir: "./app/types", - }), - }, - }; - - let extensions: string[] = []; - try { - extensions = fs.readdirSync("./extensions"); - } catch { - // ignore if no extensions - } - - for (const entry of extensions) { - const extensionPath = `./extensions/${entry}`; - const schema = `${extensionPath}/schema.graphql`; - if (!fs.existsSync(schema)) { - continue; - } - config.projects[entry] = { - schema, - documents: [`${extensionPath}/**/*.graphql`], - }; - } - - return config; -} - -module.exports = getConfig(); diff --git a/app/db.server.ts b/app/db.server.ts deleted file mode 100644 index bafa6cc2..00000000 --- a/app/db.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PrismaClient } from "@prisma/client"; - -declare global { - var prisma: PrismaClient; -} - -if (process.env.NODE_ENV !== "production") { - if (!global.prisma) { - global.prisma = new PrismaClient(); - } -} - -const prisma: PrismaClient = global.prisma || new PrismaClient(); - -export default prisma; diff --git a/app/entry.server.tsx b/app/entry.server.tsx deleted file mode 100644 index 86274311..00000000 --- a/app/entry.server.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { PassThrough } from "stream"; -import { renderToPipeableStream } from "react-dom/server"; -import { RemixServer } from "@remix-run/react"; -import { - createReadableStreamFromReadable, - type EntryContext, -} from "@remix-run/node"; -import { isbot } from "isbot"; -import { addDocumentResponseHeaders } from "./shopify.server"; - -export const streamTimeout = 5000; - -export default async function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext -) { - addDocumentResponseHeaders(request, responseHeaders); - const userAgent = request.headers.get("user-agent"); - const callbackName = isbot(userAgent ?? '') - ? "onAllReady" - : "onShellReady"; - - return new Promise((resolve, reject) => { - const { pipe, abort } = renderToPipeableStream( - , - { - [callbackName]: () => { - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }) - ); - pipe(body); - }, - onShellError(error) { - reject(error); - }, - onError(error) { - responseStatusCode = 500; - console.error(error); - }, - } - ); - - // Automatically timeout the React renderer after 6 seconds, which ensures - // React has enough time to flush down the rejected boundary contents - setTimeout(abort, streamTimeout + 1000); - }); -} diff --git a/app/globals.d.ts b/app/globals.d.ts deleted file mode 100644 index cbe652db..00000000 --- a/app/globals.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "*.css"; diff --git a/app/root.tsx b/app/root.tsx deleted file mode 100644 index 805f121f..00000000 --- a/app/root.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, -} from "@remix-run/react"; - -export default function App() { - return ( - - - - - - - - - - - - - - - - ); -} diff --git a/app/routes.ts b/app/routes.ts deleted file mode 100644 index 83892841..00000000 --- a/app/routes.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { flatRoutes } from "@remix-run/fs-routes"; - -export default flatRoutes(); diff --git a/app/routes/_index/route.tsx b/app/routes/_index/route.tsx deleted file mode 100644 index 2de9dd47..00000000 --- a/app/routes/_index/route.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { LoaderFunctionArgs } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; -import { Form, useLoaderData } from "@remix-run/react"; - -import { login } from "../../shopify.server"; - -import styles from "./styles.module.css"; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const url = new URL(request.url); - - if (url.searchParams.get("shop")) { - throw redirect(`/app?${url.searchParams.toString()}`); - } - - return { showForm: Boolean(login) }; -}; - -export default function App() { - const { showForm } = useLoaderData(); - - return ( -
-
-

A short heading about [your app]

-

- A tagline about [your app] that describes your value proposition. -

- {showForm && ( -
- - -
- )} -
    -
  • - Product feature. Some detail about your feature and - its benefit to your customer. -
  • -
  • - Product feature. Some detail about your feature and - its benefit to your customer. -
  • -
  • - Product feature. Some detail about your feature and - its benefit to your customer. -
  • -
-
-
- ); -} diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx deleted file mode 100644 index 18b215b0..00000000 --- a/app/routes/app._index.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { useEffect } from "react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { useFetcher } from "@remix-run/react"; -import { - Page, - Layout, - Text, - Card, - Button, - BlockStack, - Box, - List, - Link, - InlineStack, -} from "@shopify/polaris"; -import { TitleBar, useAppBridge } from "@shopify/app-bridge-react"; -import { authenticate } from "../shopify.server"; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - await authenticate.admin(request); - - return null; -}; - -export const action = async ({ request }: ActionFunctionArgs) => { - const { admin } = await authenticate.admin(request); - const color = ["Red", "Orange", "Yellow", "Green"][ - Math.floor(Math.random() * 4) - ]; - const response = await admin.graphql( - `#graphql - mutation populateProduct($product: ProductCreateInput!) { - productCreate(product: $product) { - product { - id - title - handle - status - variants(first: 10) { - edges { - node { - id - price - barcode - createdAt - } - } - } - } - } - }`, - { - variables: { - product: { - title: `${color} Snowboard`, - }, - }, - }, - ); - const responseJson = await response.json(); - - const product = responseJson.data!.productCreate!.product!; - const variantId = product.variants.edges[0]!.node!.id!; - - const variantResponse = await admin.graphql( - `#graphql - mutation shopifyRemixTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { - productVariantsBulkUpdate(productId: $productId, variants: $variants) { - productVariants { - id - price - barcode - createdAt - } - } - }`, - { - variables: { - productId: product.id, - variants: [{ id: variantId, price: "100.00" }], - }, - }, - ); - - const variantResponseJson = await variantResponse.json(); - - return { - product: responseJson!.data!.productCreate!.product, - variant: - variantResponseJson!.data!.productVariantsBulkUpdate!.productVariants, - }; -}; - -export default function Index() { - const fetcher = useFetcher(); - - const shopify = useAppBridge(); - const isLoading = - ["loading", "submitting"].includes(fetcher.state) && - fetcher.formMethod === "POST"; - const productId = fetcher.data?.product?.id.replace( - "gid://shopify/Product/", - "", - ); - - useEffect(() => { - if (productId) { - shopify.toast.show("Product created"); - } - }, [productId, shopify]); - const generateProduct = () => fetcher.submit({}, { method: "POST" }); - - return ( - - - - - - - - - - - - Congrats on creating a new Shopify app 🎉 - - - This embedded app template uses{" "} - - App Bridge - {" "} - interface examples like an{" "} - - additional page in the app nav - - , as well as an{" "} - - Admin GraphQL - {" "} - mutation demo, to provide a starting point for app - development. - - - - - Get started with products - - - Generate a product with GraphQL and get the JSON output for - that product. Learn more about the{" "} - - productCreate - {" "} - mutation in our API references. - - - - - {fetcher.data?.product && ( - - )} - - {fetcher.data?.product && ( - <> - - {" "} - productCreate mutation - - -
-                        
-                          {JSON.stringify(fetcher.data.product, null, 2)}
-                        
-                      
-
- - {" "} - productVariantsBulkUpdate mutation - - -
-                        
-                          {JSON.stringify(fetcher.data.variant, null, 2)}
-                        
-                      
-
- - )} -
-
-
- - - - - - App template specs - - - - - Framework - - - Remix - - - - - Database - - - Prisma - - - - - Interface - - - - Polaris - - {", "} - - App Bridge - - - - - - API - - - GraphQL API - - - - - - - - - Next steps - - - - Build an{" "} - - {" "} - example app - {" "} - to get started - - - Explore Shopify’s API with{" "} - - GraphiQL - - - - - - - -
-
-
- ); -} diff --git a/app/routes/app.additional.tsx b/app/routes/app.additional.tsx deleted file mode 100644 index eb9b0cfd..00000000 --- a/app/routes/app.additional.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { - Box, - Card, - Layout, - Link, - List, - Page, - Text, - BlockStack, -} from "@shopify/polaris"; -import { TitleBar } from "@shopify/app-bridge-react"; - -export default function AdditionalPage() { - return ( - - - - - - - - The app template comes with an additional page which - demonstrates how to create multiple pages within app navigation - using{" "} - - App Bridge - - . - - - To create your own page and have it show up in the app - navigation, add a page inside app/routes, and a - link to it in the <NavMenu> component found - in app/routes/app.jsx. - - - - - - - - - Resources - - - - - App nav best practices - - - - - - - - - ); -} - -function Code({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/app/routes/app.tsx b/app/routes/app.tsx deleted file mode 100644 index bdcf1162..00000000 --- a/app/routes/app.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node"; -import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react"; -import { boundary } from "@shopify/shopify-app-remix/server"; -import { AppProvider } from "@shopify/shopify-app-remix/react"; -import { NavMenu } from "@shopify/app-bridge-react"; -import polarisStyles from "@shopify/polaris/build/esm/styles.css?url"; - -import { authenticate } from "../shopify.server"; - -export const links = () => [{ rel: "stylesheet", href: polarisStyles }]; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - await authenticate.admin(request); - - return { apiKey: process.env.SHOPIFY_API_KEY || "" }; -}; - -export default function App() { - const { apiKey } = useLoaderData(); - - return ( - - - - Home - - Additional page - - - - ); -} - -// Shopify needs Remix to catch some thrown responses, so that their headers are included in the response. -export function ErrorBoundary() { - return boundary.error(useRouteError()); -} - -export const headers: HeadersFunction = (headersArgs) => { - return boundary.headers(headersArgs); -}; diff --git a/app/routes/auth.$.tsx b/app/routes/auth.$.tsx deleted file mode 100644 index 8919320d..00000000 --- a/app/routes/auth.$.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { LoaderFunctionArgs } from "@remix-run/node"; -import { authenticate } from "../shopify.server"; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - await authenticate.admin(request); - - return null; -}; diff --git a/app/routes/auth.login/error.server.tsx b/app/routes/auth.login/error.server.tsx deleted file mode 100644 index 2c794974..00000000 --- a/app/routes/auth.login/error.server.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { LoginError } from "@shopify/shopify-app-remix/server"; -import { LoginErrorType } from "@shopify/shopify-app-remix/server"; - -interface LoginErrorMessage { - shop?: string; -} - -export function loginErrorMessage(loginErrors: LoginError): LoginErrorMessage { - if (loginErrors?.shop === LoginErrorType.MissingShop) { - return { shop: "Please enter your shop domain to log in" }; - } else if (loginErrors?.shop === LoginErrorType.InvalidShop) { - return { shop: "Please enter a valid shop domain to log in" }; - } - - return {}; -} diff --git a/app/routes/auth.login/route.tsx b/app/routes/auth.login/route.tsx deleted file mode 100644 index 0e9aece7..00000000 --- a/app/routes/auth.login/route.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useState } from "react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { Form, useActionData, useLoaderData } from "@remix-run/react"; -import { - AppProvider as PolarisAppProvider, - Button, - Card, - FormLayout, - Page, - Text, - TextField, -} from "@shopify/polaris"; -import polarisTranslations from "@shopify/polaris/locales/en.json"; -import polarisStyles from "@shopify/polaris/build/esm/styles.css?url"; - -import { login } from "../../shopify.server"; - -import { loginErrorMessage } from "./error.server"; - -export const links = () => [{ rel: "stylesheet", href: polarisStyles }]; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const errors = loginErrorMessage(await login(request)); - - return { errors, polarisTranslations }; -}; - -export const action = async ({ request }: ActionFunctionArgs) => { - const errors = loginErrorMessage(await login(request)); - - return { - errors, - }; -}; - -export default function Auth() { - const loaderData = useLoaderData(); - const actionData = useActionData(); - const [shop, setShop] = useState(""); - const { errors } = actionData || loaderData; - - return ( - - - -
- - - Log in - - - - -
-
-
-
- ); -} diff --git a/app/routes/webhooks.app.scopes_update.tsx b/app/routes/webhooks.app.scopes_update.tsx deleted file mode 100644 index c36bb64c..00000000 --- a/app/routes/webhooks.app.scopes_update.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { ActionFunctionArgs } from "@remix-run/node"; -import { authenticate } from "../shopify.server"; -import db from "../db.server"; - -export const action = async ({ request }: ActionFunctionArgs) => { - const { payload, session, topic, shop } = await authenticate.webhook(request); - console.log(`Received ${topic} webhook for ${shop}`); - - const current = payload.current as string[]; - if (session) { - await db.session.update({ - where: { - id: session.id - }, - data: { - scope: current.toString(), - }, - }); - } - return new Response(); -}; diff --git a/app/routes/webhooks.app.uninstalled.tsx b/app/routes/webhooks.app.uninstalled.tsx deleted file mode 100644 index 54d3161c..00000000 --- a/app/routes/webhooks.app.uninstalled.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { ActionFunctionArgs } from "@remix-run/node"; -import { authenticate } from "../shopify.server"; -import db from "../db.server"; - -export const action = async ({ request }: ActionFunctionArgs) => { - const { shop, session, topic } = await authenticate.webhook(request); - - console.log(`Received ${topic} webhook for ${shop}`); - - // Webhook requests can trigger multiple times and after an app has already been uninstalled. - // If this webhook already ran, the session may have been deleted previously. - if (session) { - await db.session.deleteMany({ where: { shop } }); - } - - return new Response(); -}; diff --git a/app/shopify.server.ts b/app/shopify.server.ts deleted file mode 100644 index ec980711..00000000 --- a/app/shopify.server.ts +++ /dev/null @@ -1,35 +0,0 @@ -import "@shopify/shopify-app-remix/adapters/node"; -import { - ApiVersion, - AppDistribution, - shopifyApp, -} from "@shopify/shopify-app-remix/server"; -import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma"; -import prisma from "./db.server"; - -const shopify = shopifyApp({ - apiKey: process.env.SHOPIFY_API_KEY, - apiSecretKey: process.env.SHOPIFY_API_SECRET || "", - apiVersion: ApiVersion.October24, - scopes: process.env.SCOPES?.split(","), - appUrl: process.env.SHOPIFY_APP_URL || "", - authPathPrefix: "/auth", - sessionStorage: new PrismaSessionStorage(prisma), - distribution: AppDistribution.AppStore, - future: { - unstable_newEmbeddedAuthStrategy: true, - removeRest: true, - }, - ...(process.env.SHOP_CUSTOM_DOMAIN - ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] } - : {}), -}); - -export default shopify; -export const apiVersion = ApiVersion.October24; -export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders; -export const authenticate = shopify.authenticate; -export const unauthenticated = shopify.unauthenticated; -export const login = shopify.login; -export const registerWebhooks = shopify.registerWebhooks; -export const sessionStorage = shopify.sessionStorage; diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 82142f42..00000000 --- a/vite.config.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { vitePlugin as remix } from "@remix-run/dev"; -import { installGlobals } from "@remix-run/node"; -import { defineConfig, type UserConfig } from "vite"; -import tsconfigPaths from "vite-tsconfig-paths"; - -installGlobals({ nativeFetch: true }); - -// Related: https://github.com/remix-run/remix/issues/2835#issuecomment-1144102176 -// Replace the HOST env var with SHOPIFY_APP_URL so that it doesn't break the remix server. The CLI will eventually -// stop passing in HOST, so we can remove this workaround after the next major release. -if ( - process.env.HOST && - (!process.env.SHOPIFY_APP_URL || - process.env.SHOPIFY_APP_URL === process.env.HOST) -) { - process.env.SHOPIFY_APP_URL = process.env.HOST; - delete process.env.HOST; -} - -const host = new URL(process.env.SHOPIFY_APP_URL || "http://localhost") - .hostname; - -let hmrConfig; -if (host === "localhost") { - hmrConfig = { - protocol: "ws", - host: "localhost", - port: 64999, - clientPort: 64999, - }; -} else { - hmrConfig = { - protocol: "wss", - host: host, - port: parseInt(process.env.FRONTEND_PORT!) || 8002, - clientPort: 443, - }; -} - -export default defineConfig({ - server: { - port: Number(process.env.PORT || 3000), - hmr: hmrConfig, - fs: { - // See https://vitejs.dev/config/server-options.html#server-fs-allow for more information - allow: ["app", "node_modules"], - }, - }, - plugins: [ - remix({ - ignoredRouteFiles: ["**/.*"], - future: { - v3_fetcherPersist: true, - v3_relativeSplatPath: true, - v3_throwAbortReason: true, - v3_lazyRouteDiscovery: true, - v3_singleFetch: false, - v3_routeConfig: true, - }, - }), - tsconfigPaths(), - ], - build: { - assetsInlineLimit: 0, - }, -}) satisfies UserConfig;