diff --git a/.graphqlrc.ts b/.graphqlrc.ts new file mode 100644 index 00000000..a28be7a2 --- /dev/null +++ b/.graphqlrc.ts @@ -0,0 +1,42 @@ +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; +} + +const config = getConfig(); + +export default config; 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 54ef95f4..4bf6e817 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", "eslint": "^8.42.0", - "eslint-config-prettier": "^9.1.0", + "eslint-config-prettier": "^10.0.1", "prettier": "^3.2.4", "typescript": "^5.2.2", "vite": "^5.1.3" 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;