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>
63 lines
2.7 KiB
TypeScript
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." };
|
|
};
|