diff --git a/app/models/QRCode.server.js b/app/models/QRCode.server.js index 6fe3abd..7d4bcc6 100644 --- a/app/models/QRCode.server.js +++ b/app/models/QRCode.server.js @@ -1,55 +1,71 @@ import qrcode from "qrcode"; import invariant from "tiny-invariant"; -import db from "../db.server"; +import { + getQRCodeById, + getQRCodes as getQRCodesFromRepo, +} from "./qrcode.repository"; // [START get-qrcode] -export async function getQRCode(id, graphql) { - const qrCode = await db.qRCode.findFirst({ where: { id } }); +export async function getQRCode(id, graphql, shop) { + const qrCode = await getQRCodeById(graphql, id); if (!qrCode) { return null; } - return supplementQRCode(qrCode, graphql); + return supplementQRCode(qrCode, graphql, shop); } export async function getQRCodes(shop, graphql) { - const qrCodes = await db.qRCode.findMany({ - where: { shop }, - orderBy: { id: "desc" }, - }); + const qrCodes = await getQRCodesFromRepo(graphql); if (qrCodes.length === 0) return []; return Promise.all( - qrCodes.map((qrCode) => supplementQRCode(qrCode, graphql)) + qrCodes.map((qrCode) => supplementQRCode(qrCode, graphql, shop)) ); } // [END get-qrcode] // [START get-qrcode-image] -export function getQRCodeImage(id) { - const url = new URL(`/qrcodes/${id}/scan`, process.env.SHOPIFY_APP_URL); +export function getQRCodeImage(id, shop) { + // Extract the numeric ID from the GID if needed + const idForUrl = extractIdFromGid(id); + const url = new URL(`/qrcodes/${idForUrl}/scan`, process.env.SHOPIFY_APP_URL); + // Include shop as query parameter for unauthenticated access + if (shop) { + url.searchParams.set("shop", shop); + } return qrcode.toDataURL(url.href); } + +function extractIdFromGid(gid) { + // If it's already a simple ID, return as-is + if (!gid.startsWith("gid://")) { + return gid; + } + // Extract the ID from gid://shopify/Metaobject/123456 + const match = gid.match(/\/(\d+)$/); + return match ? match[1] : gid; +} // [END get-qrcode-image] // [START get-destination] -export function getDestinationUrl(qrCode) { +export function getDestinationUrl(qrCode, shop) { if (qrCode.destination === "product") { - return `https://${qrCode.shop}/products/${qrCode.productHandle}`; + return `https://${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`; + return `https://${shop}/cart/${match[1]}:1`; } // [END get-destination] // [START hydrate-qrcode] -async function supplementQRCode(qrCode, graphql) { - const qrCodeImagePromise = getQRCodeImage(qrCode.id); +async function supplementQRCode(qrCode, graphql, shop) { + const qrCodeImagePromise = getQRCodeImage(qrCode.id, shop); const response = await graphql( ` @@ -82,11 +98,12 @@ async function supplementQRCode(qrCode, graphql) { return { ...qrCode, + shop, 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), + destinationUrl: getDestinationUrl(qrCode, shop), image: await qrCodeImagePromise, }; } diff --git a/app/models/qrcode.repository.js b/app/models/qrcode.repository.js new file mode 100644 index 0000000..335eec0 --- /dev/null +++ b/app/models/qrcode.repository.js @@ -0,0 +1,271 @@ +/** + * QR Code Repository + * Handles all metaobject CRUD operations for QR codes + */ + +const METAOBJECT_TYPE = "$app:qrcode"; + +/** + * Get a single QR code by ID + */ +export async function getQRCodeById(graphql, id) { + const response = await graphql( + `#graphql + query GetQRCode($id: ID!) { + metaobject(id: $id) { + id + handle + updatedAt + title: field(key: "title") { value } + productId: field(key: "product_id") { value } + productHandle: field(key: "product_handle") { value } + productVariantId: field(key: "product_variant_id") { value } + destination: field(key: "destination") { value } + scans: field(key: "scans") { value } + } + } + `, + { variables: { id } } + ); + + const { data } = await response.json(); + + if (!data?.metaobject) { + return null; + } + + return mapMetaobjectToQRCode(data.metaobject); +} + +/** + * Get all QR codes for the shop + */ +export async function getQRCodes(graphql) { + const response = await graphql( + `#graphql + query GetQRCodes($type: String!) { + metaobjects(type: $type, first: 100, reverse: true) { + nodes { + id + handle + updatedAt + title: field(key: "title") { value } + productId: field(key: "product_id") { value } + productHandle: field(key: "product_handle") { value } + productVariantId: field(key: "product_variant_id") { value } + destination: field(key: "destination") { value } + scans: field(key: "scans") { value } + } + } + } + `, + { variables: { type: METAOBJECT_TYPE } } + ); + + const { data } = await response.json(); + + if (!data?.metaobjects?.nodes) { + return []; + } + + return data.metaobjects.nodes.map(mapMetaobjectToQRCode); +} + +/** + * Create a new QR code + */ +export async function createQRCode(graphql, qrCodeData) { + const response = await graphql( + `#graphql + mutation CreateQRCode($metaobject: MetaobjectCreateInput!) { + metaobjectCreate(metaobject: $metaobject) { + metaobject { + id + handle + title: field(key: "title") { value } + productId: field(key: "product_id") { value } + productHandle: field(key: "product_handle") { value } + productVariantId: field(key: "product_variant_id") { value } + destination: field(key: "destination") { value } + scans: field(key: "scans") { value } + } + userErrors { + field + message + code + } + } + } + `, + { + variables: { + metaobject: { + type: METAOBJECT_TYPE, + fields: [ + { key: "title", value: qrCodeData.title }, + { key: "product_id", value: qrCodeData.productId }, + { key: "product_handle", value: qrCodeData.productHandle }, + { key: "product_variant_id", value: qrCodeData.productVariantId }, + { key: "destination", value: qrCodeData.destination }, + { key: "scans", value: "0" }, + ], + }, + }, + } + ); + + const { data } = await response.json(); + + if (data?.metaobjectCreate?.userErrors?.length > 0) { + throw new Error(data.metaobjectCreate.userErrors.map(e => e.message).join(", ")); + } + + return mapMetaobjectToQRCode(data.metaobjectCreate.metaobject); +} + +/** + * Update an existing QR code + */ +export async function updateQRCode(graphql, id, qrCodeData) { + const response = await graphql( + `#graphql + mutation UpdateQRCode($id: ID!, $metaobject: MetaobjectUpdateInput!) { + metaobjectUpdate(id: $id, metaobject: $metaobject) { + metaobject { + id + handle + title: field(key: "title") { value } + productId: field(key: "product_id") { value } + productHandle: field(key: "product_handle") { value } + productVariantId: field(key: "product_variant_id") { value } + destination: field(key: "destination") { value } + scans: field(key: "scans") { value } + } + userErrors { + field + message + code + } + } + } + `, + { + variables: { + id, + metaobject: { + fields: [ + { key: "title", value: qrCodeData.title }, + { key: "product_id", value: qrCodeData.productId }, + { key: "product_handle", value: qrCodeData.productHandle }, + { key: "product_variant_id", value: qrCodeData.productVariantId }, + { key: "destination", value: qrCodeData.destination }, + ], + }, + }, + } + ); + + const { data } = await response.json(); + + if (data?.metaobjectUpdate?.userErrors?.length > 0) { + throw new Error(data.metaobjectUpdate.userErrors.map(e => e.message).join(", ")); + } + + return mapMetaobjectToQRCode(data.metaobjectUpdate.metaobject); +} + +/** + * Delete a QR code + */ +export async function deleteQRCode(graphql, id) { + const response = await graphql( + `#graphql + mutation DeleteQRCode($id: ID!) { + metaobjectDelete(id: $id) { + deletedId + userErrors { + field + message + code + } + } + } + `, + { variables: { id } } + ); + + const { data } = await response.json(); + + if (data?.metaobjectDelete?.userErrors?.length > 0) { + throw new Error(data.metaobjectDelete.userErrors.map(e => e.message).join(", ")); + } + + return data.metaobjectDelete.deletedId; +} + +/** + * Increment the scan count for a QR code + */ +export async function incrementQRCodeScans(graphql, id) { + // First get the current scan count + const qrCode = await getQRCodeById(graphql, id); + + if (!qrCode) { + return null; + } + + const newScans = (qrCode.scans || 0) + 1; + + const response = await graphql( + `#graphql + mutation IncrementScans($id: ID!, $metaobject: MetaobjectUpdateInput!) { + metaobjectUpdate(id: $id, metaobject: $metaobject) { + metaobject { + id + scans: field(key: "scans") { value } + } + userErrors { + field + message + code + } + } + } + `, + { + variables: { + id, + metaobject: { + fields: [{ key: "scans", value: String(newScans) }], + }, + }, + } + ); + + const { data } = await response.json(); + + if (data?.metaobjectUpdate?.userErrors?.length > 0) { + throw new Error(data.metaobjectUpdate.userErrors.map(e => e.message).join(", ")); + } + + return { ...qrCode, scans: newScans }; +} + +/** + * Map a metaobject to a QR code object + */ +function mapMetaobjectToQRCode(metaobject) { + if (!metaobject) return null; + + return { + id: metaobject.id, + handle: metaobject.handle, + createdAt: metaobject.updatedAt, + title: metaobject.title?.value || "", + productId: metaobject.productId?.value || "", + productHandle: metaobject.productHandle?.value || "", + productVariantId: metaobject.productVariantId?.value || "", + destination: metaobject.destination?.value || "", + scans: parseInt(metaobject.scans?.value || "0", 10), + }; +} diff --git a/app/routes/app._index.jsx b/app/routes/app._index.jsx index d0001ea..7088719 100644 --- a/app/routes/app._index.jsx +++ b/app/routes/app._index.jsx @@ -16,6 +16,12 @@ import { import { getQRCodes } from "../models/QRCode.server"; import { AlertDiamondIcon, ImageIcon } from "@shopify/polaris-icons"; +function extractIdFromGid(gid) { + if (!gid || typeof gid !== "string") return gid; + const match = gid.match(/Metaobject\/(\d+)/); + return match ? match[1] : gid; +} + // [START loader] export async function loader({ request }) { const { admin, session } = await authenticate.admin(request); @@ -66,7 +72,7 @@ const QRTable = ({ qrCodes }) => ( selectable={false} > {qrCodes.map((qrCode) => ( - + ))} ); @@ -74,7 +80,7 @@ const QRTable = ({ qrCodes }) => ( // [START row] const QRTableRow = ({ qrCode }) => ( - + ( /> - {truncate(qrCode.title)} + {truncate(qrCode.title)} {/* [START deleted] */} diff --git a/app/routes/app.qrcodes.$id.jsx b/app/routes/app.qrcodes.$id.jsx index 3533083..5659c0f 100644 --- a/app/routes/app.qrcodes.$id.jsx +++ b/app/routes/app.qrcodes.$id.jsx @@ -27,12 +27,23 @@ import { } from "@shopify/polaris"; import { ImageIcon } from "@shopify/polaris-icons"; -import db from "../db.server"; import { getQRCode, validateQRCode } from "../models/QRCode.server"; +import { + createQRCode, + updateQRCode, + deleteQRCode, +} from "../models/qrcode.repository"; + +function extractIdFromGid(gid) { + if (!gid || typeof gid !== "string") return gid; + const match = gid.match(/Metaobject\/(\d+)/); + return match ? match[1] : gid; +} export async function loader({ request, params }) { // [START authenticate] - const { admin } = await authenticate.admin(request); + const { admin, session } = await authenticate.admin(request); + const { shop } = session; // [END authenticate] // [START data] @@ -43,23 +54,26 @@ export async function loader({ request, params }) { }); } - return json(await getQRCode(Number(params.id), admin.graphql)); + // Convert numeric ID to GID format for metaobject lookup + const gid = `gid://shopify/Metaobject/${params.id}`; + return json(await getQRCode(gid, admin.graphql, shop)); // [END data] } // [START action] export async function action({ request, params }) { - const { session } = await authenticate.admin(request); - const { shop } = session; + const { admin } = await authenticate.admin(request); /** @type {any} */ const data = { ...Object.fromEntries(await request.formData()), - shop, }; + // Convert numeric ID to GID format for metaobject operations + const gid = params.id !== "new" ? `gid://shopify/Metaobject/${params.id}` : null; + if (data.action === "delete") { - await db.qRCode.delete({ where: { id: Number(params.id) } }); + await deleteQRCode(admin.graphql, gid); return redirect("/app"); } @@ -69,12 +83,22 @@ export async function action({ request, params }) { return json({ errors }, { status: 422 }); } + const qrCodeData = { + title: data.title, + productId: data.productId, + productHandle: data.productHandle, + productVariantId: data.productVariantId, + destination: data.destination, + }; + const qrCode = params.id === "new" - ? await db.qRCode.create({ data }) - : await db.qRCode.update({ where: { id: Number(params.id) }, data }); + ? await createQRCode(admin.graphql, qrCodeData) + : await updateQRCode(admin.graphql, gid, qrCodeData); - return redirect(`/app/qrcodes/${qrCode.id}`); + // Extract the numeric ID from the GID for the redirect URL + const idForUrl = extractIdFromGid(qrCode.id); + return redirect(`/app/qrcodes/${idForUrl}`); } // [END action] @@ -266,7 +290,7 @@ export default function QRCodeForm() {