initial version (template only)
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prismaGlobal: PrismaClient;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
if (!global.prismaGlobal) {
|
||||
global.prismaGlobal = new PrismaClient();
|
||||
}
|
||||
}
|
||||
|
||||
const prisma = global.prismaGlobal ?? new PrismaClient();
|
||||
|
||||
export default prisma;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { PassThrough } from "stream";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { ServerRouter } from "react-router";
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { type EntryContext } from "react-router";
|
||||
import { isbot } from "isbot";
|
||||
import { addDocumentResponseHeaders } from "./shopify.server";
|
||||
|
||||
export const streamTimeout = 5000;
|
||||
|
||||
export default async function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
reactRouterContext: 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(
|
||||
<ServerRouter
|
||||
context={reactRouterContext}
|
||||
url={request.url}
|
||||
/>,
|
||||
{
|
||||
[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);
|
||||
});
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="preconnect" href="https://cdn.shopify.com/" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css"
|
||||
/>
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Outlet />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { flatRoutes } from "@react-router/fs-routes";
|
||||
|
||||
export default flatRoutes();
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { redirect, Form, useLoaderData } from "react-router";
|
||||
|
||||
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<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className={styles.index}>
|
||||
<div className={styles.content}>
|
||||
<h1 className={styles.heading}>A short heading about [your app]</h1>
|
||||
<p className={styles.text}>
|
||||
A tagline about [your app] that describes your value proposition.
|
||||
</p>
|
||||
{showForm && (
|
||||
<Form className={styles.form} method="post" action="/auth/login">
|
||||
<label className={styles.label}>
|
||||
<span>Shop domain</span>
|
||||
<input className={styles.input} type="text" name="shop" />
|
||||
<span>e.g: my-shop-domain.myshopify.com</span>
|
||||
</label>
|
||||
<button className={styles.button} type="submit">
|
||||
Log in
|
||||
</button>
|
||||
</Form>
|
||||
)}
|
||||
<ul className={styles.list}>
|
||||
<li>
|
||||
<strong>Product feature</strong>. Some detail about your feature and
|
||||
its benefit to your customer.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Product feature</strong>. Some detail about your feature and
|
||||
its benefit to your customer.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Product feature</strong>. Some detail about your feature and
|
||||
its benefit to your customer.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
.index {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.heading,
|
||||
.text {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 1.2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin: 0 auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
max-width: 20rem;
|
||||
text-align: left;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
padding-top: 3rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.list > li {
|
||||
max-width: 20rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 50rem) {
|
||||
.list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.list > li {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useEffect } from "react";
|
||||
import type {
|
||||
ActionFunctionArgs,
|
||||
HeadersFunction,
|
||||
LoaderFunctionArgs,
|
||||
} from "react-router";
|
||||
import { useFetcher } from "react-router";
|
||||
import { useAppBridge } from "@shopify/app-bridge-react";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/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
|
||||
}
|
||||
}
|
||||
}
|
||||
demoInfo: metafield(namespace: "$app", key: "demo_info") {
|
||||
jsonValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
variables: {
|
||||
product: {
|
||||
title: `${color} Snowboard`,
|
||||
metafields: [
|
||||
{
|
||||
namespace: "$app",
|
||||
key: "demo_info",
|
||||
value: "Created by React Router Template",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
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 shopifyReactRouterTemplateUpdateVariant($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();
|
||||
|
||||
const metaobjectResponse = await admin.graphql(
|
||||
`#graphql
|
||||
mutation shopifyReactRouterTemplateUpsertMetaobject($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
|
||||
metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
|
||||
metaobject {
|
||||
id
|
||||
handle
|
||||
title: field(key: "title") {
|
||||
jsonValue
|
||||
}
|
||||
description: field(key: "description") {
|
||||
jsonValue
|
||||
}
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
variables: {
|
||||
handle: {
|
||||
type: "$app:example",
|
||||
handle: "demo-entry",
|
||||
},
|
||||
metaobject: {
|
||||
fields: [
|
||||
{ key: "title", value: "Demo Entry" },
|
||||
{
|
||||
key: "description",
|
||||
value:
|
||||
"This metaobject was created by the Shopify app template to demonstrate the metaobject API.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const metaobjectResponseJson = await metaobjectResponse.json();
|
||||
|
||||
return {
|
||||
product: responseJson!.data!.productCreate!.product,
|
||||
variant:
|
||||
variantResponseJson!.data!.productVariantsBulkUpdate!.productVariants,
|
||||
metaobject:
|
||||
metaobjectResponseJson!.data!.metaobjectUpsert!.metaobject,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
|
||||
const shopify = useAppBridge();
|
||||
const isLoading =
|
||||
["loading", "submitting"].includes(fetcher.state) &&
|
||||
fetcher.formMethod === "POST";
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.product?.id) {
|
||||
shopify.toast.show("Product created");
|
||||
}
|
||||
}, [fetcher.data?.product?.id, shopify]);
|
||||
|
||||
const generateProduct = () => fetcher.submit({}, { method: "POST" });
|
||||
|
||||
return (
|
||||
<s-page heading="Shopify app template">
|
||||
<s-button slot="primary-action" onClick={generateProduct}>
|
||||
Generate a product
|
||||
</s-button>
|
||||
|
||||
<s-section heading="Congrats on creating a new Shopify app 🎉">
|
||||
<s-paragraph>
|
||||
This embedded app template uses{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/tools/app-bridge"
|
||||
target="_blank"
|
||||
>
|
||||
App Bridge
|
||||
</s-link>{" "}
|
||||
interface examples like an{" "}
|
||||
<s-link href="/app/additional">additional page in the app nav</s-link>
|
||||
, as well as an{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/api/admin-graphql"
|
||||
target="_blank"
|
||||
>
|
||||
Admin GraphQL
|
||||
</s-link>{" "}
|
||||
mutation demo, to provide a starting point for app development.
|
||||
</s-paragraph>
|
||||
</s-section>
|
||||
<s-section heading="Get started with products">
|
||||
<s-paragraph>
|
||||
Generate a product with GraphQL and get the JSON output for that
|
||||
product. Learn more about the{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/api/admin-graphql/latest/mutations/productCreate"
|
||||
target="_blank"
|
||||
>
|
||||
productCreate
|
||||
</s-link>{" "}
|
||||
mutation in our API references. Includes a product{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/build/custom-data/metafields"
|
||||
target="_blank"
|
||||
>
|
||||
metafield
|
||||
</s-link>{" "}
|
||||
and{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/build/custom-data/metaobjects"
|
||||
target="_blank"
|
||||
>
|
||||
metaobject
|
||||
</s-link>
|
||||
.
|
||||
</s-paragraph>
|
||||
<s-stack direction="inline" gap="base">
|
||||
<s-button
|
||||
onClick={generateProduct}
|
||||
{...(isLoading ? { loading: true } : {})}
|
||||
>
|
||||
Generate a product
|
||||
</s-button>
|
||||
{fetcher.data?.product && (
|
||||
<s-button
|
||||
onClick={() => {
|
||||
shopify.intents.invoke?.("edit:shopify/Product", {
|
||||
value: fetcher.data?.product?.id,
|
||||
});
|
||||
}}
|
||||
target="_blank"
|
||||
variant="tertiary"
|
||||
>
|
||||
Edit product
|
||||
</s-button>
|
||||
)}
|
||||
</s-stack>
|
||||
{fetcher.data?.product && (
|
||||
<s-section heading="productCreate mutation">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-box
|
||||
padding="base"
|
||||
borderWidth="base"
|
||||
borderRadius="base"
|
||||
background="subdued"
|
||||
>
|
||||
<pre style={{ margin: 0 }}>
|
||||
<code>{JSON.stringify(fetcher.data.product, null, 2)}</code>
|
||||
</pre>
|
||||
</s-box>
|
||||
|
||||
<s-heading>productVariantsBulkUpdate mutation</s-heading>
|
||||
<s-box
|
||||
padding="base"
|
||||
borderWidth="base"
|
||||
borderRadius="base"
|
||||
background="subdued"
|
||||
>
|
||||
<pre style={{ margin: 0 }}>
|
||||
<code>{JSON.stringify(fetcher.data.variant, null, 2)}</code>
|
||||
</pre>
|
||||
</s-box>
|
||||
|
||||
<s-heading>metaobjectUpsert mutation</s-heading>
|
||||
<s-box
|
||||
padding="base"
|
||||
borderWidth="base"
|
||||
borderRadius="base"
|
||||
background="subdued"
|
||||
>
|
||||
<pre style={{ margin: 0 }}>
|
||||
<code>
|
||||
{JSON.stringify(fetcher.data.metaobject, null, 2)}
|
||||
</code>
|
||||
</pre>
|
||||
</s-box>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
)}
|
||||
</s-section>
|
||||
|
||||
<s-section slot="aside" heading="App template specs">
|
||||
<s-paragraph>
|
||||
<s-text>Framework: </s-text>
|
||||
<s-link href="https://reactrouter.com/" target="_blank">
|
||||
React Router
|
||||
</s-link>
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
<s-text>Interface: </s-text>
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/api/app-home/using-polaris-components"
|
||||
target="_blank"
|
||||
>
|
||||
Polaris web components
|
||||
</s-link>
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
<s-text>API: </s-text>
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/api/admin-graphql"
|
||||
target="_blank"
|
||||
>
|
||||
GraphQL
|
||||
</s-link>
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
<s-text>Custom data: </s-text>
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/build/custom-data"
|
||||
target="_blank"
|
||||
>
|
||||
Metafields & metaobjects
|
||||
</s-link>
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
<s-text>Database: </s-text>
|
||||
<s-link href="https://www.prisma.io/" target="_blank">
|
||||
Prisma
|
||||
</s-link>
|
||||
</s-paragraph>
|
||||
</s-section>
|
||||
|
||||
<s-section slot="aside" heading="Next steps">
|
||||
<s-unordered-list>
|
||||
<s-list-item>
|
||||
Build an{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/getting-started/build-app-example"
|
||||
target="_blank"
|
||||
>
|
||||
example app
|
||||
</s-link>
|
||||
</s-list-item>
|
||||
<s-list-item>
|
||||
Explore Shopify's API with{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/tools/graphiql-admin-api"
|
||||
target="_blank"
|
||||
>
|
||||
GraphiQL
|
||||
</s-link>
|
||||
</s-list-item>
|
||||
</s-unordered-list>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => {
|
||||
return boundary.headers(headersArgs);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
export default function AdditionalPage() {
|
||||
return (
|
||||
<s-page heading="Additional page">
|
||||
<s-section heading="Multiple pages">
|
||||
<s-paragraph>
|
||||
The app template comes with an additional page which demonstrates how
|
||||
to create multiple pages within app navigation using{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/tools/app-bridge"
|
||||
target="_blank"
|
||||
>
|
||||
App Bridge
|
||||
</s-link>
|
||||
.
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
To create your own page and have it show up in the app navigation, add
|
||||
a page inside <code>app/routes</code>, and a link to it in the{" "}
|
||||
<code><ui-nav-menu></code> component found in{" "}
|
||||
<code>app/routes/app.jsx</code>.
|
||||
</s-paragraph>
|
||||
</s-section>
|
||||
<s-section slot="aside" heading="Resources">
|
||||
<s-unordered-list>
|
||||
<s-list-item>
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/design-guidelines/navigation#app-nav"
|
||||
target="_blank"
|
||||
>
|
||||
App nav best practices
|
||||
</s-link>
|
||||
</s-list-item>
|
||||
</s-unordered-list>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { Outlet, useLoaderData, useRouteError } from "react-router";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
import { AppProvider } from "@shopify/shopify-app-react-router/react";
|
||||
|
||||
import { authenticate } from "../shopify.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
await authenticate.admin(request);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const { apiKey } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<AppProvider embedded apiKey={apiKey}>
|
||||
<s-app-nav>
|
||||
<s-link href="/app">Home</s-link>
|
||||
<s-link href="/app/additional">Additional page</s-link>
|
||||
</s-app-nav>
|
||||
<Outlet />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Shopify needs React Router 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);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
await authenticate.admin(request);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => {
|
||||
return boundary.headers(headersArgs);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LoginError } from "@shopify/shopify-app-react-router/server";
|
||||
import { LoginErrorType } from "@shopify/shopify-app-react-router/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 {};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AppProvider } from "@shopify/shopify-app-react-router/react";
|
||||
import { useState } from "react";
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import { Form, useActionData, useLoaderData } from "react-router";
|
||||
|
||||
import { login } from "../../shopify.server";
|
||||
import { loginErrorMessage } from "./error.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const errors = loginErrorMessage(await login(request));
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const errors = loginErrorMessage(await login(request));
|
||||
|
||||
return {
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Auth() {
|
||||
const loaderData = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const [shop, setShop] = useState("");
|
||||
const { errors } = actionData || loaderData;
|
||||
|
||||
return (
|
||||
<AppProvider embedded={false}>
|
||||
<s-page>
|
||||
<Form method="post">
|
||||
<s-section heading="Log in">
|
||||
<s-text-field
|
||||
name="shop"
|
||||
label="Shop domain"
|
||||
details="example.myshopify.com"
|
||||
value={shop}
|
||||
onChange={(e) => setShop(e.currentTarget.value)}
|
||||
autocomplete="on"
|
||||
error={errors.shop}
|
||||
></s-text-field>
|
||||
<s-button type="submit">Log in</s-button>
|
||||
</s-section>
|
||||
</Form>
|
||||
</s-page>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
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();
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
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();
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import "@shopify/shopify-app-react-router/adapters/node";
|
||||
import {
|
||||
ApiVersion,
|
||||
AppDistribution,
|
||||
shopifyApp,
|
||||
} from "@shopify/shopify-app-react-router/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.October25,
|
||||
scopes: process.env.SCOPES?.split(","),
|
||||
appUrl: process.env.SHOPIFY_APP_URL || "",
|
||||
authPathPrefix: "/auth",
|
||||
sessionStorage: new PrismaSessionStorage(prisma),
|
||||
distribution: AppDistribution.AppStore,
|
||||
future: {
|
||||
expiringOfflineAccessTokens: true,
|
||||
},
|
||||
...(process.env.SHOP_CUSTOM_DOMAIN
|
||||
? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
|
||||
: {}),
|
||||
});
|
||||
|
||||
export default shopify;
|
||||
export const apiVersion = ApiVersion.October25;
|
||||
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;
|
||||
Reference in New Issue
Block a user