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