feat: QR redirection app
- Scanning the QR code app redicts to page - Option to chose if we want to redirect to page or checkout
This commit is contained in:
parent
a74a89109d
commit
49d55588b0
105
app/models/QRCode.server.js
Normal file
105
app/models/QRCode.server.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import qrcode from "qrcode";
|
||||||
|
import invariant from "tiny-invariant";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
export async function getQRCode(id, graphql) {
|
||||||
|
const qrCode = await db.qRCode.findFirst({ where: { id } });
|
||||||
|
|
||||||
|
if (!qrCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return supplementQRCode(qrCode, graphql);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQRCodes(shop, graphql) {
|
||||||
|
const qrCodes = await db.qRCode.findMany({
|
||||||
|
where: { shop },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (qrCodes.length === 0) return [];
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
qrCodes.map((qrCode) => supplementQRCode(qrCode, graphql))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQRCodeImage(id) {
|
||||||
|
const url = new URL(`/qrcodes/${id}/scan`, process.env.SHOPIFY_APP_URL);
|
||||||
|
return qrcode.toDataURL(url.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDestinationUrl(qrCode) {
|
||||||
|
if (qrCode.destination === "product") {
|
||||||
|
return `https://${qrCode.shop}/products/${qrCode.productHandle}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /gid:\/\/shopify\/ProductVariant\/([0-9]+)/.exec(qrCode.productVariantId);
|
||||||
|
invariant(match, "Unrecognized product variant ID");
|
||||||
|
|
||||||
|
return `https://${qrCode.shop}/cart/${match[1]}:1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function supplementQRCode(qrCode, graphql) {
|
||||||
|
const qrCodeImagePromise = getQRCodeImage(qrCode.id);
|
||||||
|
|
||||||
|
const response = await graphql(
|
||||||
|
`
|
||||||
|
query supplementQRCode($id: ID!) {
|
||||||
|
product(id: $id) {
|
||||||
|
title
|
||||||
|
media(first: 1) {
|
||||||
|
nodes {
|
||||||
|
preview {
|
||||||
|
image {
|
||||||
|
altText
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
id: qrCode.productId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { product },
|
||||||
|
} = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...qrCode,
|
||||||
|
productDeleted: !product?.title,
|
||||||
|
productTitle: product?.title,
|
||||||
|
productImage: product?.media?.nodes[0]?.preview?.image?.url,
|
||||||
|
productAlt: product?.media?.nodes[0]?.preview?.image?.altText,
|
||||||
|
destinationUrl: getDestinationUrl(qrCode),
|
||||||
|
image: await qrCodeImagePromise,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateQRCode(data) {
|
||||||
|
const errors = {};
|
||||||
|
|
||||||
|
if (!data.title) {
|
||||||
|
errors.title = "Title is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.productId) {
|
||||||
|
errors.productId = "Product is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.destination) {
|
||||||
|
errors.destination = "Destination is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
338
app/routes/app.qrcodes.$id.jsx
Normal file
338
app/routes/app.qrcodes.$id.jsx
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
useActionData,
|
||||||
|
useLoaderData,
|
||||||
|
useSubmit,
|
||||||
|
useNavigation,
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
} from "react-router";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||||
|
|
||||||
|
import db from "../db.server";
|
||||||
|
import { getQRCode, validateQRCode } from "../models/QRCode.server";
|
||||||
|
|
||||||
|
export async function loader({ request, params }) {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
if (params.id === "new") {
|
||||||
|
return {
|
||||||
|
destination: "product",
|
||||||
|
title: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getQRCode(Number(params.id), admin.graphql);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }) {
|
||||||
|
const { session, redirect } = await authenticate.admin(request);
|
||||||
|
const { shop } = session;
|
||||||
|
|
||||||
|
/** @type {any} */
|
||||||
|
const data = {
|
||||||
|
...Object.fromEntries(await request.formData()),
|
||||||
|
shop,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.action === "delete") {
|
||||||
|
await db.qRCode.delete({ where: { id: Number(params.id) } });
|
||||||
|
return redirect("/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validateQRCode(data);
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
return new Response(JSON.stringify({ errors }), {
|
||||||
|
status: 422,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const qrCode =
|
||||||
|
params.id === "new"
|
||||||
|
? await db.qRCode.create({ data })
|
||||||
|
: await db.qRCode.update({ where: { id: Number(params.id) }, data });
|
||||||
|
|
||||||
|
return redirect(`/app/qrcodes/${qrCode.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QRCodeForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
const qrCode = useLoaderData();
|
||||||
|
const [initialFormState, setInitialFormState] = useState(qrCode);
|
||||||
|
const [formState, setFormState] = useState(qrCode);
|
||||||
|
const errors = useActionData()?.errors || {};
|
||||||
|
const isSaving = useNavigation().state === "submitting";
|
||||||
|
const isDirty =
|
||||||
|
JSON.stringify(formState) !== JSON.stringify(initialFormState);
|
||||||
|
|
||||||
|
async function selectProduct() {
|
||||||
|
const products = await window.shopify.resourcePicker({
|
||||||
|
type: "product",
|
||||||
|
action: "select", // customized action verb, either 'select' or 'add',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (products) {
|
||||||
|
const { images, id, variants, title, handle } = products[0];
|
||||||
|
|
||||||
|
setFormState({
|
||||||
|
...formState,
|
||||||
|
productId: id,
|
||||||
|
productVariantId: variants[0].id,
|
||||||
|
productTitle: title,
|
||||||
|
productHandle: handle,
|
||||||
|
productAlt: images[0]?.altText,
|
||||||
|
productImage: images[0]?.originalSrc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProduct() {
|
||||||
|
setFormState({
|
||||||
|
title: formState.title,
|
||||||
|
destination: formState.destination,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const productUrl = formState.productId
|
||||||
|
? `shopify://admin/products/${formState.productId.split("/").at(-1)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const submit = useSubmit();
|
||||||
|
|
||||||
|
function handleSave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: formState.title,
|
||||||
|
productId: formState.productId || "",
|
||||||
|
productVariantId: formState.productVariantId || "",
|
||||||
|
productHandle: formState.productHandle || "",
|
||||||
|
destination: formState.destination,
|
||||||
|
};
|
||||||
|
|
||||||
|
submit(data, { method: "post" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit({ action: "delete" }, { method: "post" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
setFormState(initialFormState);
|
||||||
|
window.shopify.saveBar.hide("qr-code-form");
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDirty) {
|
||||||
|
window.shopify.saveBar.show("qr-code-form");
|
||||||
|
} else {
|
||||||
|
window.shopify.saveBar.hide("qr-code-form");
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.shopify.saveBar.hide("qr-code-form");
|
||||||
|
};
|
||||||
|
}, [isDirty]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInitialFormState(qrCode);
|
||||||
|
setFormState(qrCode);
|
||||||
|
}, [id, qrCode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form data-save-bar onSubmit={handleSave} onReset={handleReset}>
|
||||||
|
<s-page heading={initialFormState.title || "Create QR code"}>
|
||||||
|
<s-link
|
||||||
|
href="/app"
|
||||||
|
slot="breadcrumb-actions"
|
||||||
|
onClick={(e) => (isDirty ? e.preventDefault() : navigate("/app/"))}
|
||||||
|
>
|
||||||
|
QR Codes
|
||||||
|
</s-link>
|
||||||
|
{initialFormState.id &&
|
||||||
|
<s-button slot="secondary-actions" onClick={handleDelete}>Delete</s-button>}
|
||||||
|
<s-section heading="QR Code information">
|
||||||
|
<s-stack gap="base">
|
||||||
|
<s-text-field
|
||||||
|
label="Title"
|
||||||
|
details="Only store staff can see this title"
|
||||||
|
error={errors.title}
|
||||||
|
autoComplete="off"
|
||||||
|
name="title"
|
||||||
|
value={formState.title}
|
||||||
|
onInput={(e) =>
|
||||||
|
setFormState({ ...formState, title: e.target.value })
|
||||||
|
}
|
||||||
|
></s-text-field>
|
||||||
|
<s-stack gap="500" align="space-between" blockAlign="start">
|
||||||
|
<s-select
|
||||||
|
name="destination"
|
||||||
|
label="Scan destination"
|
||||||
|
value={formState.destination}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormState({ ...formState, destination: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<s-option
|
||||||
|
value="product"
|
||||||
|
selected={formState.destination === "product"}
|
||||||
|
>
|
||||||
|
Link to product page
|
||||||
|
</s-option>
|
||||||
|
<s-option
|
||||||
|
value="cart"
|
||||||
|
selected={formState.destination === "cart"}
|
||||||
|
>
|
||||||
|
Link to checkout page with product in the cart
|
||||||
|
</s-option>
|
||||||
|
</s-select>
|
||||||
|
{initialFormState.destinationUrl ? (
|
||||||
|
<s-link
|
||||||
|
variant="plain"
|
||||||
|
href={initialFormState.destinationUrl}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Go to destination URL
|
||||||
|
</s-link>
|
||||||
|
) : null}
|
||||||
|
</s-stack>
|
||||||
|
<s-stack gap="small-400">
|
||||||
|
<s-stack direction="inline" gap="small-100" justifyContent="space-between">
|
||||||
|
<s-text color="subdued">Product</s-text>
|
||||||
|
{formState.productId ? (
|
||||||
|
<s-link
|
||||||
|
onClick={removeProduct}
|
||||||
|
accessibilityLabel="Remove the product from this QR Code"
|
||||||
|
variant="tertiary"
|
||||||
|
tone="neutral"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</s-link>
|
||||||
|
) : null}
|
||||||
|
</s-stack>
|
||||||
|
{formState.productId ? (
|
||||||
|
<s-stack
|
||||||
|
direction="inline"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<s-stack
|
||||||
|
direction="inline"
|
||||||
|
gap="small-100"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<s-clickable
|
||||||
|
href={productUrl}
|
||||||
|
target="_blank"
|
||||||
|
accessibilityLabel={`Go to the product page for ${formState.productTitle}`}
|
||||||
|
borderRadius="base"
|
||||||
|
>
|
||||||
|
<s-box
|
||||||
|
padding="small-200"
|
||||||
|
border="base"
|
||||||
|
borderRadius="base"
|
||||||
|
background="subdued"
|
||||||
|
inlineSize="38px"
|
||||||
|
blockSize="38px"
|
||||||
|
>
|
||||||
|
{formState.productImage ? (
|
||||||
|
<s-image src={formState.productImage}></s-image>
|
||||||
|
) : (
|
||||||
|
<s-icon size="large" type="product" />
|
||||||
|
)}
|
||||||
|
</s-box>
|
||||||
|
</s-clickable>
|
||||||
|
<s-link href={productUrl} target="_blank">
|
||||||
|
{formState.productTitle}
|
||||||
|
</s-link>
|
||||||
|
</s-stack>
|
||||||
|
<s-stack direction="inline" gap="small">
|
||||||
|
<s-button
|
||||||
|
onClick={selectProduct}
|
||||||
|
accessibilityLabel="Change the product the QR code should be for"
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</s-button>
|
||||||
|
</s-stack>
|
||||||
|
</s-stack>
|
||||||
|
) : (
|
||||||
|
<s-button
|
||||||
|
onClick={selectProduct}
|
||||||
|
accessibilityLabel="Select the product the QR code should be for"
|
||||||
|
>
|
||||||
|
Select product
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
</s-stack>
|
||||||
|
</s-section>
|
||||||
|
<s-box slot="aside">
|
||||||
|
<s-section heading="Preview">
|
||||||
|
<s-stack gap="base">
|
||||||
|
<s-box
|
||||||
|
padding="base"
|
||||||
|
border="none"
|
||||||
|
borderRadius="base"
|
||||||
|
background="subdued"
|
||||||
|
>
|
||||||
|
{initialFormState.image ? (
|
||||||
|
<s-image
|
||||||
|
aspectRatio="1/0.8"
|
||||||
|
src={initialFormState.image}
|
||||||
|
alt="The QR Code for the current form"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<s-stack
|
||||||
|
direction="inline"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
blockSize="198px"
|
||||||
|
>
|
||||||
|
<s-text color="subdued">
|
||||||
|
See a preview once you save
|
||||||
|
</s-text>
|
||||||
|
</s-stack>
|
||||||
|
)}
|
||||||
|
</s-box>
|
||||||
|
<s-stack
|
||||||
|
gap="small"
|
||||||
|
direction="inline"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<s-button
|
||||||
|
disabled={!initialFormState.id}
|
||||||
|
href={`/qrcodes/${initialFormState.id}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Go to public URL
|
||||||
|
</s-button>
|
||||||
|
<s-button
|
||||||
|
disabled={!initialFormState?.image}
|
||||||
|
href={initialFormState?.image}
|
||||||
|
download
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</s-button>
|
||||||
|
</s-stack>
|
||||||
|
</s-stack>
|
||||||
|
</s-section>
|
||||||
|
</s-box>
|
||||||
|
</s-page>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const headers = (headersArgs) => {
|
||||||
|
return boundary.headers(headersArgs);
|
||||||
|
};
|
||||||
30
app/routes/qrcodes.$id.jsx
Normal file
30
app/routes/qrcodes.$id.jsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import invariant from "tiny-invariant";
|
||||||
|
import { useLoaderData } from "react-router";
|
||||||
|
|
||||||
|
import db from "../db.server";
|
||||||
|
import { getQRCodeImage } from "../models/QRCode.server";
|
||||||
|
|
||||||
|
export const loader = async ({ params }) => {
|
||||||
|
invariant(params.id, "Could not find QR code destination");
|
||||||
|
|
||||||
|
const id = Number(params.id);
|
||||||
|
const qrCode = await db.qRCode.findFirst({ where: { id } });
|
||||||
|
|
||||||
|
invariant(qrCode, "Could not find QR code destination");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: qrCode.title,
|
||||||
|
image: await getQRCodeImage(id),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function QRCode() {
|
||||||
|
const { image, title } = useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<img src={image} alt={`QR Code for product`} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
app/routes/qrcodes.$id.scan.jsx
Normal file
21
app/routes/qrcodes.$id.scan.jsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { redirect } from "react-router";
|
||||||
|
import invariant from "tiny-invariant";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
import { getDestinationUrl } from "../models/QRCode.server";
|
||||||
|
|
||||||
|
export const loader = async ({ params }) => {
|
||||||
|
invariant(params.id, "Could not find QR code destination");
|
||||||
|
|
||||||
|
const id = Number(params.id);
|
||||||
|
const qrCode = await db.qRCode.findFirst({ where: { id } });
|
||||||
|
|
||||||
|
invariant(qrCode, "Could not find QR code destination");
|
||||||
|
|
||||||
|
await db.qRCode.update({
|
||||||
|
where: { id },
|
||||||
|
data: { scans: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect(getDestinationUrl(qrCode));
|
||||||
|
};
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "QRCode" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"shop" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"productHandle" TEXT NOT NULL,
|
||||||
|
"productVariantId" TEXT NOT NULL,
|
||||||
|
"destination" TEXT NOT NULL,
|
||||||
|
"scans" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
Loading…
x
Reference in New Issue
Block a user