Created
April 3, 2025 21:04
-
-
Save frehner/0632954b343eaabfc81e0454b31477ed to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 >= {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