From 49d55588b06179e3677b2c6fcafcedee246e0048 Mon Sep 17 00:00:00 2001 From: Albez0-An7h Date: Wed, 4 Mar 2026 13:36:11 +0530 Subject: [PATCH] feat: QR redirection app - Scanning the QR code app redicts to page - Option to chose if we want to redirect to page or checkout --- app/models/QRCode.server.js | 105 ++++++ app/routes/app.qrcodes.$id.jsx | 338 ++++++++++++++++++ app/routes/qrcodes.$id.jsx | 30 ++ app/routes/qrcodes.$id.scan.jsx | 21 ++ .../migration.sql | 12 + 5 files changed, 506 insertions(+) create mode 100644 app/models/QRCode.server.js create mode 100644 app/routes/app.qrcodes.$id.jsx create mode 100644 app/routes/qrcodes.$id.jsx create mode 100644 app/routes/qrcodes.$id.scan.jsx create mode 100644 prisma/migrations/20260225070638_add_qrcode_table/migration.sql diff --git a/app/models/QRCode.server.js b/app/models/QRCode.server.js new file mode 100644 index 0000000..202bfa5 --- /dev/null +++ b/app/models/QRCode.server.js @@ -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; + } +} \ No newline at end of file diff --git a/app/routes/app.qrcodes.$id.jsx b/app/routes/app.qrcodes.$id.jsx new file mode 100644 index 0000000..a912c4b --- /dev/null +++ b/app/routes/app.qrcodes.$id.jsx @@ -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 ( + <> +
+ + (isDirty ? e.preventDefault() : navigate("/app/"))} + > + QR Codes + + {initialFormState.id && + Delete} + + + + setFormState({ ...formState, title: e.target.value }) + } + > + + + setFormState({ ...formState, destination: e.target.value }) + } + > + + Link to product page + + + Link to checkout page with product in the cart + + + {initialFormState.destinationUrl ? ( + + Go to destination URL + + ) : null} + + + + Product + {formState.productId ? ( + + Clear + + ) : null} + + {formState.productId ? ( + + + + + {formState.productImage ? ( + + ) : ( + + )} + + + + {formState.productTitle} + + + + + Change + + + + ) : ( + + Select product + + )} + + + + + + + + {initialFormState.image ? ( + + ) : ( + + + See a preview once you save + + + )} + + + + Go to public URL + + + Download + + + + + + +
+ + ); +} + +export const headers = (headersArgs) => { + return boundary.headers(headersArgs); +}; \ No newline at end of file diff --git a/app/routes/qrcodes.$id.jsx b/app/routes/qrcodes.$id.jsx new file mode 100644 index 0000000..eb1b25b --- /dev/null +++ b/app/routes/qrcodes.$id.jsx @@ -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 ( + <> +

{title}

+ {`QR + + ); +} diff --git a/app/routes/qrcodes.$id.scan.jsx b/app/routes/qrcodes.$id.scan.jsx new file mode 100644 index 0000000..54c6a74 --- /dev/null +++ b/app/routes/qrcodes.$id.scan.jsx @@ -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)); +}; \ No newline at end of file diff --git a/prisma/migrations/20260225070638_add_qrcode_table/migration.sql b/prisma/migrations/20260225070638_add_qrcode_table/migration.sql new file mode 100644 index 0000000..cb0f448 --- /dev/null +++ b/prisma/migrations/20260225070638_add_qrcode_table/migration.sql @@ -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 +);