Skip to content

Instantly share code, notes, and snippets.

@frehner
Created April 3, 2025 21:04
Show Gist options
  • Save frehner/0632954b343eaabfc81e0454b31477ed to your computer and use it in GitHub Desktop.
Save frehner/0632954b343eaabfc81e0454b31477ed to your computer and use it in GitHub Desktop.
// completely vibe-coded here
// import { useEffect } from "react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useFetcher } from "@remix-run/react";
// import { useFetcher } from "@remix-run/react";
// import {
// Page,
// Layout,
// Text,
// Card,
// Button,
// BlockStack,
// Box,
// List,
// Link,
// InlineStack,
// } from "@shopify/polaris";
// import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import { json } from "@remix-run/node"; // Import json helper
import { useState, useEffect, useRef } from "react"; // Import useState, useEffect, useRef
// Define constants for the metafield
const METAFIELD_NAMESPACE = "my_bulk_discount_app";
const METAFIELD_KEY = "bulk_discount_tiers_history";
interface Tier {
// Define an interface for better type safety
volume: number;
discount: number;
}
// Represents one submission in the history
interface HistoryEntry {
timestamp: string;
tiers: Tier[];
}
// Interface for data returned by loader and action
interface ReturnedData {
history: HistoryEntry[] | null; // Now returns the full history
errors?: any;
}
export const loader = async ({
request,
}: LoaderFunctionArgs): Promise<Response> => {
const { admin } = await authenticate.admin(request);
const metafieldQuery = `#graphql
query shopMetafield {
shop {
metafield(namespace: "${METAFIELD_NAMESPACE}", key: "${METAFIELD_KEY}") {
id
value
type
}
}
}`;
try {
const response = await admin.graphql(metafieldQuery);
const responseJson = await response.json();
const metafield = responseJson.data?.shop?.metafield;
let history: HistoryEntry[] | null = null;
if (metafield && metafield.type === "json" && metafield.value) {
try {
history = JSON.parse(metafield.value);
// Add validation here if needed
} catch (parseError) {
console.error("Loader: Error parsing history JSON:", parseError);
}
}
console.log("Loader: Loaded history from metafield:", history);
return json<ReturnedData>({ history: history ?? [] }); // Return empty array if null
} catch (error) {
console.error("Loader: Error fetching metafield history:", error);
return json<ReturnedData>(
{ history: [], errors: { fetch: "Failed to load history." } },
{ status: 500 },
);
}
};
export const action = async ({
request,
}: ActionFunctionArgs): Promise<Response> => {
const { admin } = await authenticate.admin(request);
// 1. Get current form data
const formData = await request.formData();
const currentTiers: Tier[] = [];
for (let i = 1; i <= 2; i++) {
const volumeStr = formData.get(`volume-${i}`)?.toString();
const discountStr = formData.get(`discount-${i}`)?.toString();
if (volumeStr && discountStr) {
const volume = parseInt(volumeStr, 10);
const discount = parseFloat(discountStr);
if (!isNaN(volume) && !isNaN(discount)) {
currentTiers.push({ volume, discount });
} else {
return json<ReturnedData>(
{
history: null,
errors: { form: `Invalid number format for Tier ${i}` },
},
{ status: 400 },
);
}
} else if (volumeStr || discountStr) {
// Return error if tiers are incomplete (optional)
// return json<ReturnedData>({ history: null, errors: { form: `Incomplete data for Tier ${i}` } }, { status: 400 });
}
}
console.log("Action: Parsed current submission:", currentTiers);
if (currentTiers.length === 0) {
console.log("Action: No valid tiers submitted.");
// Decide if this is an error or just do nothing
// Let's fetch existing history even if submission is empty
// return json<ReturnedData>({ history: null, errors: { form: "No tiers submitted." } }, { status: 400 });
}
// 2. Get Shop ID (needed for metafieldsSet)
let ownerId: string;
try {
const shopDataResponse = await admin.graphql(
`#graphql\n query shopInfo { shop { id } }`,
);
const shopData = await shopDataResponse.json();
ownerId = shopData.data.shop.id;
} catch (err) {
console.error("Action: Error fetching shop GID:", err);
return json<ReturnedData>(
{ history: null, errors: { shop: "Failed to fetch shop information." } },
{ status: 500 },
);
}
// 3. Fetch existing history metafield
let existingHistory: HistoryEntry[] = [];
const metafieldQuery = `#graphql
query shopMetafield {
shop {
metafield(namespace: "${METAFIELD_NAMESPACE}", key: "${METAFIELD_KEY}") {
value
}
}
}`;
try {
const response = await admin.graphql(metafieldQuery);
const responseJson = await response.json();
const existingValue = responseJson.data?.shop?.metafield?.value;
if (existingValue) {
try {
existingHistory = JSON.parse(existingValue);
if (!Array.isArray(existingHistory)) existingHistory = []; // Ensure it's an array
} catch (parseError) {
console.error(
"Action: Error parsing existing history JSON:",
parseError,
);
existingHistory = []; // Reset to empty if parsing failed
}
}
console.log(
"Action: Fetched existing history length:",
existingHistory.length,
);
} catch (error) {
console.error("Action: Error fetching existing metafield:", error);
// Decide if we should proceed without history or return error
// return json<ReturnedData>({ history: null, errors: { fetch: "Failed to fetch existing data." } }, { status: 500 });
}
// 4. Create new history entry and append (only if current tiers are valid)
let updatedHistory = [...existingHistory]; // Copy existing
if (currentTiers.length > 0) {
const newEntry: HistoryEntry = {
timestamp: new Date().toISOString(),
tiers: currentTiers,
};
updatedHistory.push(newEntry);
console.log(
"Action: Appended new entry. New history length:",
updatedHistory.length,
);
} else {
console.log("Action: No valid tiers submitted, history not updated.");
}
// 5. Save updated history
const metafieldsSetMutation = `#graphql
mutation MetafieldsSet($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id key namespace value
}
userErrors { field message code }
}
}`;
const metafieldVariables = {
metafields: [
{
ownerId: ownerId,
namespace: METAFIELD_NAMESPACE,
key: METAFIELD_KEY,
type: "json",
value: JSON.stringify(updatedHistory), // Save the full updated history
},
],
};
try {
const response = await admin.graphql(metafieldsSetMutation, {
variables: metafieldVariables,
});
const responseJson = await response.json();
const userErrors = responseJson.data?.metafieldsSet?.userErrors;
if (userErrors && userErrors.length > 0) {
console.error("Action: Metafield User Errors on save:", userErrors);
return json<ReturnedData>(
{ history: existingHistory, errors: userErrors },
{ status: 400 },
); // Return old history on save error
}
console.log("Action: Successfully saved updated history.");
// Return the full, newly saved history
return json<ReturnedData>({ history: updatedHistory });
} catch (error) {
console.error("Action: Error saving updated history:", error);
return json<ReturnedData>(
{ history: existingHistory, errors: { save: "Failed to save history." } },
{ status: 500 },
); // Return old history on save error
}
};
export default function Index() {
const loaderData = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();
const [displayedHistory, setDisplayedHistory] = useState<
HistoryEntry[] | null
>(null);
const formRef = useRef<HTMLFormElement>(null); // Create form ref
// Initialize history state from loader
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data == null) {
setDisplayedHistory(loaderData.history ?? []);
}
}, [loaderData.history, fetcher.state, fetcher.data]);
// Update history state & RESET form on successful fetcher action return
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.history !== undefined) {
setDisplayedHistory(fetcher.data.history);
// Reset form only if there were no errors returned by the action
if (!fetcher.data.errors) {
console.log("Effect: Resetting form after successful submission.");
formRef.current?.reset(); // Call reset on the form element
}
}
if (fetcher.data?.errors) {
console.error("Action Errors:", fetcher.data.errors);
}
}, [fetcher.state, fetcher.data]);
const isSubmitting = fetcher.state === "submitting";
return (
<s-page>
<s-heading level="1">My bulk discounts app</s-heading>
{/* Add data-save-bar attribute and ref */}
<fetcher.Form method="post" ref={formRef} data-save-bar>
<s-section>
<s-heading level="2">Configure Discount Tiers</s-heading>
<s-grid
gridTemplateColumns="auto 1fr 1fr auto"
gap="base"
alignment="center"
>
{/* Header Row */}
<s-text></s-text>
<s-text fontWeight="semibold">Volume</s-text>
<s-text fontWeight="semibold">Discount %</s-text>
<s-text></s-text>
{/* Tier 1 */}
<s-text>Tier 1</s-text>
<s-number-field
label="Volume for Tier 1"
labelHidden
name="volume-1"
></s-number-field>
<s-number-field
label="Discount % for Tier 1"
labelHidden
name="discount-1"
></s-number-field>
<s-button
aria-label="Delete Tier 1"
type="button"
disabled={isSubmitting ? true : undefined}
>
<s-icon type="delete"></s-icon>
</s-button>
{/* Tier 2 */}
<s-text>Tier 2</s-text>
<s-number-field
label="Volume for Tier 2"
labelHidden
name="volume-2"
></s-number-field>
<s-number-field
label="Discount % for Tier 2"
labelHidden
name="discount-2"
></s-number-field>
<s-button
aria-label="Delete Tier 2"
type="button"
disabled={isSubmitting ? true : undefined}
>
<s-icon type="delete"></s-icon>
</s-button>
</s-grid>
<s-box paddingBlockStart="base">
<s-button type="button" disabled={isSubmitting ? true : undefined}>
Add tier
</s-button>
</s-box>
</s-section>
</fetcher.Form>
{/* Section to display history (uses state variable now) */}
{displayedHistory && displayedHistory.length > 0 && (
<s-section>
<s-heading level="2">Submission History</s-heading>{" "}
{/* Updated title */}
<s-box padding-block-start="base">
{/* Map over history entries in reverse for newest first */}
{[...displayedHistory].reverse().map((entry, entryIndex) => (
<s-box
key={entry.timestamp || entryIndex}
padding-block-end="base"
>
{" "}
{/* Use timestamp as key */}
<s-text fontWeight="semibold">
Saved: {new Date(entry.timestamp).toLocaleString()}
</s-text>
{/* Map over tiers within this entry */}
{entry.tiers.map((tier, tierIndex) => (
<s-box key={tierIndex} padding-block-start="extraSmall">
<s-text>
Tier {tierIndex + 1}: Volume &gt;= {tier.volume},
Discount: {tier.discount}%
</s-text>
</s-box>
))}
</s-box>
))}
</s-box>
</s-section>
)}
</s-page>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment