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:
Albez0-An7h 2026-03-04 13:36:11 +05:30
parent a74a89109d
commit 49d55588b0
5 changed files with 506 additions and 0 deletions

105
app/models/QRCode.server.js Normal file
View 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;
}
}

View 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);
};

View 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`} />
</>
);
}

View 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));
};

View File

@ -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
);