bundle-offers-app/app/routes/app.upload.tsx
metatroncubeswdev 365e45406a Bundle Offers Shopify app: discount function, admin, storefront, deploy stack
Rust discount function (one automatic discount evaluating all offers in priority
order, parity with the Odoo engine), React Router admin with Polaris, theme app
extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and
a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:35:58 -04:00

63 lines
2.7 KiB
TypeScript

// Offer image upload: staged upload -> Shopify Files (write_files). Returns the CDN url once processed.
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import { gql } from "../lib/shop.server";
const STAGE = `#graphql
mutation BundleOffersStage($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets { url resourceUrl parameters { name value } }
userErrors { field message }
}
}`;
const CREATE = `#graphql
mutation BundleOffersFile($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files { id fileStatus ... on MediaImage { image { url } } }
userErrors { field message }
}
}`;
const STATUS = `#graphql
query BundleOffersFileStatus($id: ID!) {
node(id: $id) { ... on MediaImage { fileStatus image { url } } }
}`;
const MAX_BYTES = 10 * 1024 * 1024;
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin } = await authenticate.admin(request);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File) || !file.size) return { error: "Choose an image." };
if (!file.type.startsWith("image/")) return { error: "Use a JPG, PNG, GIF or WebP image." };
if (file.size > MAX_BYTES) return { error: "Images must be 10 MB or smaller." };
const staged = await gql(admin, STAGE, {
input: [{ filename: file.name, mimeType: file.type, resource: "IMAGE", httpMethod: "POST", fileSize: String(file.size) }],
});
const target = staged.stagedUploadsCreate.stagedTargets?.[0];
if (!target) return { error: staged.stagedUploadsCreate.userErrors?.[0]?.message || "Upload failed." };
const body = new FormData();
for (const p of target.parameters) body.append(p.name, p.value);
body.append("file", file);
const up = await fetch(target.url, { method: "POST", body });
if (!up.ok) return { error: `Upload failed (${up.status}).` };
const created = await gql(admin, CREATE, { files: [{ originalSource: target.resourceUrl, contentType: "IMAGE", alt: "Bundle offer image" }] });
const f = created.fileCreate.files?.[0];
if (!f) return { error: created.fileCreate.userErrors?.[0]?.message || "Could not save the image." };
if (f.image?.url) return { url: f.image.url };
// processing is asynchronous: poll for a few seconds
for (let i = 0; i < 10; i += 1) {
await new Promise((r) => setTimeout(r, 1000));
const s = await gql(admin, STATUS, { id: f.id });
if (s.node?.image?.url) return { url: s.node.image.url as string };
if (s.node?.fileStatus === "FAILED") return { error: "Shopify could not process this image." };
}
return { error: "The image is still processing. Try saving again in a minute." };
};