From 53d485b987e939d4e580258a6f7d79e1b95591d4 Mon Sep 17 00:00:00 2001 From: Paulo Margarido <64600052+paulomarg@users.noreply.github.com> Date: Fri, 26 Apr 2024 13:30:09 -0400 Subject: [PATCH 1/2] Merge pull request #690 from Shopify/update_shopify_dependencies Update Shopify dependencies and add a CI step to ensure installs work --- .github/workflows/ci.yml | 18 ++ .graphqlrc.ts | 40 +++ app/db.server.ts | 15 ++ app/entry.server.tsx | 58 +++++ app/globals.d.ts | 1 + app/root.tsx | 30 +++ app/routes/_index/route.tsx | 58 +++++ app/routes/app._index.tsx | 333 +++++++++++++++++++++++++ app/routes/app.additional.tsx | 82 ++++++ app/routes/app.tsx | 41 +++ app/routes/auth.$.tsx | 8 + app/routes/auth.login/error.server.tsx | 16 ++ app/routes/auth.login/route.tsx | 69 +++++ app/routes/webhooks.tsx | 28 +++ app/shopify.server.ts | 51 ++++ package.json | 6 +- vite.config.ts | 55 ++++ 17 files changed, 906 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml 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/_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.tsx create mode 100644 app/shopify.server.ts create mode 100644 vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..8181f207 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +on: [push, pull_request] + +name: CI + +jobs: + CI: + name: CI_Node_${{ matrix.version }} + runs-on: ubuntu-latest + strategy: + matrix: + version: [18, 20] + steps: + - uses: actions/checkout@master + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.version }} + - name: Install + run: yarn install diff --git a/.graphqlrc.ts b/.graphqlrc.ts new file mode 100644 index 00000000..23c8d1bc --- /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}"], + 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 new file mode 100644 index 00000000..e92f23e6 --- /dev/null +++ b/app/db.server.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from "@prisma/client"; + +declare global { + var prisma: PrismaClient; +} + +const prisma: PrismaClient = global.prisma || new PrismaClient(); + +if (process.env.NODE_ENV !== "production") { + if (!global.prisma) { + 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..07860846 --- /dev/null +++ b/app/entry.server.tsx @@ -0,0 +1,58 @@ +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"; + +const ABORT_DELAY = 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); + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} 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/_index/route.tsx b/app/routes/_index/route.tsx new file mode 100644 index 00000000..cf668d31 --- /dev/null +++ b/app/routes/_index/route.tsx @@ -0,0 +1,58 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { json, 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 json({ 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..d595805b --- /dev/null +++ b/app/routes/app._index.tsx @@ -0,0 +1,333 @@ +import { useEffect } from "react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { useActionData, useNavigation, useSubmit } from "@remix-run/react"; +import { + Page, + Layout, + Text, + Card, + Button, + BlockStack, + Box, + List, + Link, + InlineStack, +} from "@shopify/polaris"; +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($input: ProductInput!) { + productCreate(input: $input) { + product { + id + title + handle + status + variants(first: 10) { + edges { + node { + id + price + barcode + createdAt + } + } + } + } + } + }`, + { + variables: { + input: { + title: `${color} Snowboard`, + }, + }, + }, + ); + const responseJson = await response.json(); + + const variantId = + responseJson.data!.productCreate!.product!.variants.edges[0]!.node!.id!; + const variantResponse = await admin.graphql( + `#graphql + mutation shopifyRemixTemplateUpdateVariant($input: ProductVariantInput!) { + productVariantUpdate(input: $input) { + productVariant { + id + price + barcode + createdAt + } + } + }`, + { + variables: { + input: { + id: variantId, + price: Math.random() * 100, + }, + }, + }, + ); + + const variantResponseJson = await variantResponse.json(); + + return json({ + product: responseJson!.data!.productCreate!.product, + variant: variantResponseJson!.data!.productVariantUpdate!.productVariant, + }); +}; + +export default function Index() { + const nav = useNavigation(); + const actionData = useActionData(); + const submit = useSubmit(); + const isLoading = + ["loading", "submitting"].includes(nav.state) && nav.formMethod === "POST"; + const productId = actionData?.product?.id.replace( + "gid://shopify/Product/", + "", + ); + + useEffect(() => { + if (productId) { + shopify.toast.show("Product created"); + } + }, [productId]); + const generateProduct = () => submit({}, { replace: true, 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. + + + + + {actionData?.product && ( + + )} + + {actionData?.product && ( + <> + + {" "} + productCreate mutation + + +
+                        
+                          {JSON.stringify(actionData.product, null, 2)}
+                        
+                      
+
+ + {" "} + productVariantUpdate mutation + + +
+                        
+                          {JSON.stringify(actionData.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..a018c3e2 --- /dev/null +++ b/app/routes/app.additional.tsx @@ -0,0 +1,82 @@ +import { + Box, + Card, + Layout, + Link, + List, + Page, + Text, + BlockStack, +} from "@shopify/polaris"; + +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 <ui-nav-menu> 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..702d238e --- /dev/null +++ b/app/routes/app.tsx @@ -0,0 +1,41 @@ +import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { json } 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 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 json({ 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..db0e9025 --- /dev/null +++ b/app/routes/auth.login/route.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { json } 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 json({ errors, polarisTranslations }); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const errors = loginErrorMessage(await login(request)); + + return json({ + 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.tsx b/app/routes/webhooks.tsx new file mode 100644 index 00000000..4099f1a0 --- /dev/null +++ b/app/routes/webhooks.tsx @@ -0,0 +1,28 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { authenticate } from "../shopify.server"; +import db from "../db.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const { topic, shop, session, admin } = await authenticate.webhook(request); + + if (!admin) { + // The admin context isn't returned if the webhook fired after a shop was uninstalled. + throw new Response(); + } + + switch (topic) { + case "APP_UNINSTALLED": + if (session) { + await db.session.deleteMany({ where: { shop } }); + } + + break; + case "CUSTOMERS_DATA_REQUEST": + case "CUSTOMERS_REDACT": + case "SHOP_REDACT": + default: + throw new Response("Unhandled webhook topic", { status: 404 }); + } + + throw new Response(); +}; diff --git a/app/shopify.server.ts b/app/shopify.server.ts new file mode 100644 index 00000000..4736a4bc --- /dev/null +++ b/app/shopify.server.ts @@ -0,0 +1,51 @@ +import "@shopify/shopify-app-remix/adapters/node"; +import { + ApiVersion, + AppDistribution, + DeliveryMethod, + shopifyApp, +} from "@shopify/shopify-app-remix/server"; +import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma"; +import { restResources } from "@shopify/shopify-api/rest/admin/2024-04"; +import prisma from "./db.server"; + +const shopify = shopifyApp({ + apiKey: process.env.SHOPIFY_API_KEY, + apiSecretKey: process.env.SHOPIFY_API_SECRET || "", + apiVersion: ApiVersion.April24, + scopes: process.env.SCOPES?.split(","), + appUrl: process.env.SHOPIFY_APP_URL || "", + authPathPrefix: "/auth", + sessionStorage: new PrismaSessionStorage(prisma), + distribution: AppDistribution.AppStore, + restResources, + webhooks: { + APP_UNINSTALLED: { + deliveryMethod: DeliveryMethod.Http, + callbackUrl: "/webhooks", + }, + }, + hooks: { + afterAuth: async ({ session }) => { + shopify.registerWebhooks({ session }); + }, + }, + future: { + v3_webhookAdminContext: true, + v3_authenticatePublic: true, + v3_lineItemBilling: true, + unstable_newEmbeddedAuthStrategy: true, + }, + ...(process.env.SHOP_CUSTOM_DOMAIN + ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] } + : {}), +}); + +export default shopify; +export const apiVersion = ApiVersion.April24; +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 c0c86c77..31140537 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,9 @@ "@shopify/app": "^3.57.1", "@shopify/cli": "^3.57.1", "@shopify/polaris": "^12.0.0", - "@shopify/shopify-api": "^9.5.1", - "@shopify/shopify-app-remix": "^2.5.0", - "@shopify/shopify-app-session-storage-prisma": "^4.0.2", + "@shopify/shopify-api": "^10.0.0", + "@shopify/shopify-app-remix": "^2.8.2", + "@shopify/shopify-app-session-storage-prisma": "^4.0.5", "isbot": "^5.1.0", "prisma": "^5.11.0", "react": "^18.2.0", diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..81402fa8 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,55 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig, type UserConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +// 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: ["**/.*"], + }), + tsconfigPaths(), + ], + build: { + assetsInlineLimit: 0, + }, +}) satisfies UserConfig; From 39231bc137e72156624d0163190cf8d22e78c9e2 Mon Sep 17 00:00:00 2001 From: GitHub Date: Fri, 26 Apr 2024 17:31:15 +0000 Subject: [PATCH 2/2] Convert template to Javascript --- .graphqlrc.ts | 40 --- app/db.server.ts | 15 -- app/entry.server.tsx | 58 ----- app/globals.d.ts | 1 - app/root.tsx | 30 --- app/routes/_index/route.tsx | 58 ----- app/routes/app._index.jsx | 2 +- app/routes/app._index.tsx | 333 ------------------------- app/routes/app.additional.tsx | 82 ------ app/routes/app.tsx | 41 --- app/routes/auth.$.tsx | 8 - app/routes/auth.login/error.server.tsx | 16 -- app/routes/auth.login/route.tsx | 69 ----- app/routes/webhooks.tsx | 28 --- app/shopify.server.ts | 51 ---- vite.config.ts | 55 ---- 16 files changed, 1 insertion(+), 886 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/_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.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 23c8d1bc..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}"], - 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 e92f23e6..00000000 --- a/app/db.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PrismaClient } from "@prisma/client"; - -declare global { - var prisma: PrismaClient; -} - -const prisma: PrismaClient = global.prisma || new PrismaClient(); - -if (process.env.NODE_ENV !== "production") { - if (!global.prisma) { - global.prisma = new PrismaClient(); - } -} - -export default prisma; diff --git a/app/entry.server.tsx b/app/entry.server.tsx deleted file mode 100644 index 07860846..00000000 --- a/app/entry.server.tsx +++ /dev/null @@ -1,58 +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"; - -const ABORT_DELAY = 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); - }, - } - ); - - setTimeout(abort, ABORT_DELAY); - }); -} 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/_index/route.tsx b/app/routes/_index/route.tsx deleted file mode 100644 index cf668d31..00000000 --- a/app/routes/_index/route.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { LoaderFunctionArgs } from "@remix-run/node"; -import { json, 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 json({ 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.jsx b/app/routes/app._index.jsx index 4d803344..6986c891 100644 --- a/app/routes/app._index.jsx +++ b/app/routes/app._index.jsx @@ -61,7 +61,7 @@ export const action = async ({ request }) => { responseJson.data.productCreate.product.variants.edges[0].node.id; const variantResponse = await admin.graphql( `#graphql - mutation updateVariant($input: ProductVariantInput!) { + mutation shopifyRemixTemplateUpdateVariant($input: ProductVariantInput!) { productVariantUpdate(input: $input) { productVariant { id diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx deleted file mode 100644 index d595805b..00000000 --- a/app/routes/app._index.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { useEffect } from "react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { json } from "@remix-run/node"; -import { useActionData, useNavigation, useSubmit } from "@remix-run/react"; -import { - Page, - Layout, - Text, - Card, - Button, - BlockStack, - Box, - List, - Link, - InlineStack, -} from "@shopify/polaris"; -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($input: ProductInput!) { - productCreate(input: $input) { - product { - id - title - handle - status - variants(first: 10) { - edges { - node { - id - price - barcode - createdAt - } - } - } - } - } - }`, - { - variables: { - input: { - title: `${color} Snowboard`, - }, - }, - }, - ); - const responseJson = await response.json(); - - const variantId = - responseJson.data!.productCreate!.product!.variants.edges[0]!.node!.id!; - const variantResponse = await admin.graphql( - `#graphql - mutation shopifyRemixTemplateUpdateVariant($input: ProductVariantInput!) { - productVariantUpdate(input: $input) { - productVariant { - id - price - barcode - createdAt - } - } - }`, - { - variables: { - input: { - id: variantId, - price: Math.random() * 100, - }, - }, - }, - ); - - const variantResponseJson = await variantResponse.json(); - - return json({ - product: responseJson!.data!.productCreate!.product, - variant: variantResponseJson!.data!.productVariantUpdate!.productVariant, - }); -}; - -export default function Index() { - const nav = useNavigation(); - const actionData = useActionData(); - const submit = useSubmit(); - const isLoading = - ["loading", "submitting"].includes(nav.state) && nav.formMethod === "POST"; - const productId = actionData?.product?.id.replace( - "gid://shopify/Product/", - "", - ); - - useEffect(() => { - if (productId) { - shopify.toast.show("Product created"); - } - }, [productId]); - const generateProduct = () => submit({}, { replace: true, 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. - - - - - {actionData?.product && ( - - )} - - {actionData?.product && ( - <> - - {" "} - productCreate mutation - - -
-                        
-                          {JSON.stringify(actionData.product, null, 2)}
-                        
-                      
-
- - {" "} - productVariantUpdate mutation - - -
-                        
-                          {JSON.stringify(actionData.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 a018c3e2..00000000 --- a/app/routes/app.additional.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { - Box, - Card, - Layout, - Link, - List, - Page, - Text, - BlockStack, -} from "@shopify/polaris"; - -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 <ui-nav-menu> 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 702d238e..00000000 --- a/app/routes/app.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node"; -import { json } 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 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 json({ 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 db0e9025..00000000 --- a/app/routes/auth.login/route.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from "react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { json } 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 json({ errors, polarisTranslations }); -}; - -export const action = async ({ request }: ActionFunctionArgs) => { - const errors = loginErrorMessage(await login(request)); - - return json({ - 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.tsx b/app/routes/webhooks.tsx deleted file mode 100644 index 4099f1a0..00000000 --- a/app/routes/webhooks.tsx +++ /dev/null @@ -1,28 +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 { topic, shop, session, admin } = await authenticate.webhook(request); - - if (!admin) { - // The admin context isn't returned if the webhook fired after a shop was uninstalled. - throw new Response(); - } - - switch (topic) { - case "APP_UNINSTALLED": - if (session) { - await db.session.deleteMany({ where: { shop } }); - } - - break; - case "CUSTOMERS_DATA_REQUEST": - case "CUSTOMERS_REDACT": - case "SHOP_REDACT": - default: - throw new Response("Unhandled webhook topic", { status: 404 }); - } - - throw new Response(); -}; diff --git a/app/shopify.server.ts b/app/shopify.server.ts deleted file mode 100644 index 4736a4bc..00000000 --- a/app/shopify.server.ts +++ /dev/null @@ -1,51 +0,0 @@ -import "@shopify/shopify-app-remix/adapters/node"; -import { - ApiVersion, - AppDistribution, - DeliveryMethod, - shopifyApp, -} from "@shopify/shopify-app-remix/server"; -import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma"; -import { restResources } from "@shopify/shopify-api/rest/admin/2024-04"; -import prisma from "./db.server"; - -const shopify = shopifyApp({ - apiKey: process.env.SHOPIFY_API_KEY, - apiSecretKey: process.env.SHOPIFY_API_SECRET || "", - apiVersion: ApiVersion.April24, - scopes: process.env.SCOPES?.split(","), - appUrl: process.env.SHOPIFY_APP_URL || "", - authPathPrefix: "/auth", - sessionStorage: new PrismaSessionStorage(prisma), - distribution: AppDistribution.AppStore, - restResources, - webhooks: { - APP_UNINSTALLED: { - deliveryMethod: DeliveryMethod.Http, - callbackUrl: "/webhooks", - }, - }, - hooks: { - afterAuth: async ({ session }) => { - shopify.registerWebhooks({ session }); - }, - }, - future: { - v3_webhookAdminContext: true, - v3_authenticatePublic: true, - v3_lineItemBilling: true, - unstable_newEmbeddedAuthStrategy: true, - }, - ...(process.env.SHOP_CUSTOM_DOMAIN - ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] } - : {}), -}); - -export default shopify; -export const apiVersion = ApiVersion.April24; -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 81402fa8..00000000 --- a/vite.config.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { vitePlugin as remix } from "@remix-run/dev"; -import { defineConfig, type UserConfig } from "vite"; -import tsconfigPaths from "vite-tsconfig-paths"; - -// 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: ["**/.*"], - }), - tsconfigPaths(), - ], - build: { - assetsInlineLimit: 0, - }, -}) satisfies UserConfig;