Version 1
This commit is contained in:
@@ -0,0 +1,896 @@
|
||||
---
|
||||
import SiteFooter from "../components/royal-pop/SiteFooter.astro";
|
||||
import { colorways, defaultColorway } from "../data/royalPop";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import styles from "./buy.module.scss";
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081");
|
||||
|
||||
const styleOptions = [
|
||||
{
|
||||
id: "A",
|
||||
label: "Style A",
|
||||
title: "12 o'clock crown",
|
||||
description: "Pocket-watch inspired orientation with the crown centered at the top.",
|
||||
},
|
||||
{
|
||||
id: "B",
|
||||
label: "Style B",
|
||||
title: "3 o'clock crown",
|
||||
description: "Sport-watch ergonomics with direct access to the crown on the right side.",
|
||||
},
|
||||
];
|
||||
|
||||
const finishOptions = [
|
||||
{ id: "silver", label: "Silver", note: "Classic brushed 316L steel" },
|
||||
{ id: "black-pvd", label: "Black PVD", note: "Stealth satin hardware" },
|
||||
{ id: "rose-gold", label: "Rose Gold", note: "Warm contrast finish" },
|
||||
];
|
||||
|
||||
const defaultFinish = finishOptions[0];
|
||||
const pricePerKit = 49.99;
|
||||
const retailPerKit = 89.99;
|
||||
const siteUrl = "https://royal-pop-accessory.com";
|
||||
const primaryImage = `${siteUrl}/images/png/pure-white.png`;
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
name: "Royal Pop Accessory",
|
||||
description:
|
||||
"Royal Pop Accessory is a bold interchangeable watch strap accessory offered in standout colourways with multiple finish options and preorder configuration.",
|
||||
brand: {
|
||||
"@type": "Brand",
|
||||
name: "Royal Pop Accessory",
|
||||
},
|
||||
image: [primaryImage],
|
||||
url: `${siteUrl}/buy`,
|
||||
category: "Watch Accessories",
|
||||
material: "Bioceramic case, silicone strap, 316L steel hardware",
|
||||
additionalProperty: [
|
||||
{
|
||||
"@type": "PropertyValue",
|
||||
name: "Available colourways",
|
||||
value: `${colorways.length}`,
|
||||
},
|
||||
{
|
||||
"@type": "PropertyValue",
|
||||
name: "Available finishes",
|
||||
value: `${finishOptions.length}`,
|
||||
},
|
||||
{
|
||||
"@type": "PropertyValue",
|
||||
name: "Water resistance",
|
||||
value: "50m",
|
||||
},
|
||||
],
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
url: `${siteUrl}/buy`,
|
||||
priceCurrency: "GBP",
|
||||
price: pricePerKit.toFixed(2),
|
||||
availability: "https://schema.org/PreOrder",
|
||||
itemCondition: "https://schema.org/NewCondition",
|
||||
},
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Pre-Order Royal Pop Accessory"
|
||||
description="Configure your Royal Pop Accessory preorder, choose your preferred colourway and finish, and reserve your build before public release."
|
||||
structuredData={structuredData}
|
||||
>
|
||||
<main class={styles.buyPage} data-buy-page>
|
||||
<header class={styles.topbar}>
|
||||
<div class={styles.topbarInner}>
|
||||
<a href="/" class={styles.backLink}>Royal Pop</a>
|
||||
|
||||
<div class={styles.progress} aria-label="Purchase steps">
|
||||
<span class={styles.progressActive}>1. Configure</span>
|
||||
<span>2. Details</span>
|
||||
<span>3. Review & Pay</span>
|
||||
</div>
|
||||
|
||||
<p class={styles.helpText}>Pre-order concierge available · Ships in around 1 month</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class={styles.hero}>
|
||||
<div class={styles.heroInner}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>Royal Pop preorder</p>
|
||||
<h1 class={styles.heroTitle}>Choose your Royal Pop wrist conversion kit.</h1>
|
||||
</div>
|
||||
|
||||
<div class={styles.heroMeta}>
|
||||
<div>
|
||||
<strong>Early Bird</strong>
|
||||
<span data-hero-price>£49.99 before public release</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Colorways</strong>
|
||||
<span>8 matched Royal Pop references</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Included</strong>
|
||||
<span>Bioceramic case · 50m water resistant · silicone strap</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.configSection}>
|
||||
<div class={styles.configGrid}>
|
||||
<aside class={styles.summaryRail}>
|
||||
<div class={styles.summarySticky}>
|
||||
<div class={styles.previewCard}>
|
||||
<p class={styles.sectionKicker}>Your configuration</p>
|
||||
<div class={styles.previewFrame}>
|
||||
<img
|
||||
data-summary-image
|
||||
src={defaultColorway.previewImage}
|
||||
alt={`${defaultColorway.name} Royal Pop configuration preview`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles.previewCopy}>
|
||||
<p class={styles.previewTitle} data-summary-name>{defaultColorway.name}</p>
|
||||
<p class={styles.previewSubtitle} data-summary-subtitle>{defaultColorway.subtitle}</p>
|
||||
<p class={styles.previewSpec} data-summary-style>{defaultColorway.styleText}</p>
|
||||
<p class={styles.previewSpec} data-summary-crown>{defaultColorway.crownSpec}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.orderCard}>
|
||||
<div class={styles.orderHeader}>
|
||||
<div>
|
||||
<p class={styles.sectionKicker}>Order summary</p>
|
||||
<h2>Cart summary</h2>
|
||||
</div>
|
||||
<p class={styles.priceNow} data-summary-cart-total>£0.00</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.orderRows}>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Unit price</span>
|
||||
<strong data-summary-unit-price>£49.99 per kit</strong>
|
||||
</div>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Current build</span>
|
||||
<strong><span data-summary-colorway>{defaultColorway.name}</span></strong>
|
||||
</div>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Selected quantity</span>
|
||||
<strong data-summary-quantity>1 kit</strong>
|
||||
</div>
|
||||
<div class={styles.orderRow}>
|
||||
<span>This selection</span>
|
||||
<strong data-summary-selection-total>£49.99</strong>
|
||||
</div>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Cart total</span>
|
||||
<strong data-summary-cart-total>£0.00</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.cartStatusRow}>
|
||||
<div class={styles.cartStatusCopy}>
|
||||
<span data-cart-status>Cart empty · add your first configuration</span>
|
||||
<strong data-cart-count>0 kits</strong>
|
||||
</div>
|
||||
<button type="button" class={styles.cartUtilityButton} data-clear-cart disabled>Clear cart</button>
|
||||
</div>
|
||||
|
||||
<div class={styles.cartList} data-cart-items>
|
||||
<div class={styles.cartItemEmpty}>Your cart is empty.</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.summaryActions}>
|
||||
<button type="button" class={styles.primaryCta} data-add-to-cart>Add to cart</button>
|
||||
<button type="button" class={styles.secondaryCta} data-go-details>Buy now</button>
|
||||
</div>
|
||||
<p class={styles.orderFine} data-cart-feedback data-cart-feedback-state="info">Build your cart first, then continue to details.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class={styles.builderColumn}>
|
||||
<section class={styles.stepSection} data-step-section="1">
|
||||
<div class={styles.stepHeader}>
|
||||
<p class={styles.stepCount}>Step 1</p>
|
||||
<h2>Choose your crown layout.</h2>
|
||||
<p>Pick the wearing orientation first. Available colours update automatically.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.choiceGrid}>
|
||||
{styleOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
class={styles.choiceCard}
|
||||
data-style-option
|
||||
data-style-id={option.id}
|
||||
aria-pressed={String(option.id === defaultColorway.style)}
|
||||
>
|
||||
<p class={styles.choiceEyebrow}>{option.label}</p>
|
||||
<strong>{option.title}</strong>
|
||||
<span>{option.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.stepSection} data-step-section="2">
|
||||
<div class={styles.stepHeader}>
|
||||
<p class={styles.stepCount}>Step 2</p>
|
||||
<h2>Pick a colourway.</h2>
|
||||
<p>Each kit is matched to a specific Royal Pop reference with live availability and preorder status shown below.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.colorGrid}>
|
||||
{colorways.map((colorway) => (
|
||||
<button
|
||||
type="button"
|
||||
class={styles.colorCard}
|
||||
data-colorway-option
|
||||
data-colorway-id={colorway.id}
|
||||
data-style-id={colorway.style}
|
||||
data-colorway-name={colorway.name}
|
||||
data-colorway-subtitle={colorway.subtitle}
|
||||
data-colorway-style={colorway.styleText}
|
||||
data-colorway-crown={colorway.crownSpec}
|
||||
data-colorway-image={colorway.previewImage}
|
||||
data-colorway-card-image={colorway.cardImage}
|
||||
data-colorway-background={colorway.previewBackground}
|
||||
aria-pressed={String(colorway.id === defaultColorway.id)}
|
||||
>
|
||||
<div class={styles.colorImageWrap} style={`background:${colorway.previewBackground}`}>
|
||||
<img src={colorway.cardImage} alt={`${colorway.name} product card`} loading="lazy" />
|
||||
</div>
|
||||
<div class={styles.colorCopy}>
|
||||
<div class={styles.colorSwatchRow}>
|
||||
<span class={styles.colorSwatch} style={`background:${colorway.swatchColor}`}></span>
|
||||
<p>{colorway.name}</p>
|
||||
</div>
|
||||
<strong>{colorway.subtitle}</strong>
|
||||
<span>{colorway.styleText}</span>
|
||||
<p class={styles.availabilityLine}>
|
||||
<span
|
||||
class={styles.availabilityBadge}
|
||||
data-availability-badge
|
||||
data-style-id={colorway.style}
|
||||
data-colorway-id={colorway.id}
|
||||
data-finish-id={defaultFinish.id}
|
||||
data-state="default"
|
||||
>
|
||||
Checking availability…
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.stepSection} data-step-section="3">
|
||||
<div class={styles.stepHeader}>
|
||||
<p class={styles.stepCount}>Step 3</p>
|
||||
<h2>Confirm quantity.</h2>
|
||||
</div>
|
||||
|
||||
<div class={styles.quantityRow}>
|
||||
<label class={styles.quantityField}>
|
||||
<span>Quantity</span>
|
||||
<input type="number" min="1" step="1" value="1" inputmode="numeric" data-quantity-input />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class={styles.notesPanel}>
|
||||
<div>
|
||||
<strong>Shipping window</strong>
|
||||
<span>Pre-orders are expected to ship in around 1 month, dispatched in allocation order.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Returns</strong>
|
||||
<span>30-day returns after delivery, unused kits only.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Support</strong>
|
||||
<span>Dedicated install and fitting support after purchase.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class:list={[styles.summaryActions, styles.bottomCheckoutCta]}>
|
||||
<button type="button" class={styles.primaryCta} data-add-to-cart>Add to cart</button>
|
||||
<button type="button" class={styles.secondaryCta} data-checkout-cta data-go-details>Buy now</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.supportRow}>
|
||||
<div class={styles.assuranceCard}>
|
||||
<h3>What ships in the box</h3>
|
||||
<ul>
|
||||
<li>Bioceramic Royal Pop conversion shell</li>
|
||||
<li>Matched silicone strap set</li>
|
||||
<li>Matched Royal Pop colourway configuration</li>
|
||||
<li>Fit guide and installation card</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class={styles.cartToast} data-cart-toast hidden>
|
||||
<strong data-cart-toast-title>Added to cart</strong>
|
||||
<span data-cart-toast-body>Your kit is ready in the cart.</span>
|
||||
</div>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
<script is:inline src="/scripts/royal-pop-cart.js"></script>
|
||||
<script define:vars={{
|
||||
apiBaseUrl,
|
||||
colorways,
|
||||
finishOptions,
|
||||
pricePerKit,
|
||||
retailPerKit,
|
||||
cartItemClass: styles.cartItem,
|
||||
cartItemImageWrapClass: styles.cartItemImageWrap,
|
||||
cartItemBodyClass: styles.cartItemBody,
|
||||
cartItemTopClass: styles.cartItemTop,
|
||||
cartItemTitleClass: styles.cartItemTitle,
|
||||
cartItemSubtitleClass: styles.cartItemSubtitle,
|
||||
cartItemMetaClass: styles.cartItemMeta,
|
||||
cartItemFooterClass: styles.cartItemFooter,
|
||||
cartItemPriceClass: styles.cartItemPrice,
|
||||
cartItemRemoveClass: styles.cartItemRemove,
|
||||
cartItemEmptyClass: styles.cartItemEmpty,
|
||||
}}>
|
||||
const normalizedApiBaseUrl = String(apiBaseUrl || "").replace(/\/$/, "");
|
||||
const {
|
||||
addSelectionToCart,
|
||||
clearCart,
|
||||
getCartCount,
|
||||
loadBuilderSelection,
|
||||
loadCart,
|
||||
removeCartItem,
|
||||
saveBuilderSelection,
|
||||
} = window.RoyalPopCart || {};
|
||||
|
||||
const initBuyPage = () => {
|
||||
const root = document.querySelector("[data-buy-page]");
|
||||
if (!root || root.dataset.initialized === "true") return;
|
||||
|
||||
root.dataset.initialized = "true";
|
||||
|
||||
const styleButtons = Array.from(root.querySelectorAll("[data-style-option]"));
|
||||
const colorButtons = Array.from(root.querySelectorAll("[data-colorway-option]"));
|
||||
const availabilityBadges = Array.from(root.querySelectorAll("[data-availability-badge]"));
|
||||
const finishButtons = Array.from(root.querySelectorAll("[data-finish-option]"));
|
||||
const quantityInput = root.querySelector("[data-quantity-input]");
|
||||
const detailsButtons = Array.from(root.querySelectorAll("[data-go-details]"));
|
||||
const addToCartButtons = Array.from(root.querySelectorAll("[data-add-to-cart]"));
|
||||
const clearCartButtons = Array.from(root.querySelectorAll("[data-clear-cart]"));
|
||||
const cartStatusNodes = Array.from(root.querySelectorAll("[data-cart-status]"));
|
||||
const cartCountNodes = Array.from(root.querySelectorAll("[data-cart-count]"));
|
||||
const cartFeedbackNodes = Array.from(root.querySelectorAll("[data-cart-feedback]"));
|
||||
const cartItemsNode = root.querySelector("[data-cart-items]");
|
||||
const cartToast = root.querySelector("[data-cart-toast]");
|
||||
const cartToastTitle = root.querySelector("[data-cart-toast-title]");
|
||||
const cartToastBody = root.querySelector("[data-cart-toast-body]");
|
||||
|
||||
const summaryImage = root.querySelector("[data-summary-image]");
|
||||
const summaryName = root.querySelector("[data-summary-name]");
|
||||
const summarySubtitle = root.querySelector("[data-summary-subtitle]");
|
||||
const summaryStyle = root.querySelector("[data-summary-style]");
|
||||
const summaryCrown = root.querySelector("[data-summary-crown]");
|
||||
const heroPrice = root.querySelector("[data-hero-price]");
|
||||
const summaryColorway = root.querySelector("[data-summary-colorway]");
|
||||
const summaryQuantity = root.querySelector("[data-summary-quantity]");
|
||||
const summaryUnitPrice = root.querySelector("[data-summary-unit-price]");
|
||||
const summarySelectionTotal = root.querySelector("[data-summary-selection-total]");
|
||||
const summaryCartTotals = Array.from(root.querySelectorAll("[data-summary-cart-total]"));
|
||||
|
||||
let currentPricePerKit = Number(pricePerKit || 0);
|
||||
let currentRetailPerKit = Number(retailPerKit || 0);
|
||||
const INVENTORY_FINISH_ID = "silver";
|
||||
const inventoryMap = new Map();
|
||||
if (!addSelectionToCart || !clearCart || !getCartCount || !loadBuilderSelection || !saveBuilderSelection || !loadCart || !removeCartItem) return;
|
||||
const colorwayMap = new Map((colorways || []).map((colorway) => [colorway.id, colorway]));
|
||||
const finishMap = new Map((finishOptions || []).map((finish) => [finish.id, finish]));
|
||||
const DEFAULT_SELECTION = {
|
||||
style: "B",
|
||||
colorwayId: "sorbet-pop-multi-color",
|
||||
finishId: "silver",
|
||||
quantity: 1,
|
||||
};
|
||||
const storedSelection = loadBuilderSelection();
|
||||
const allowedStyleIds = new Set(styleButtons.map((button) => button.dataset.styleId).filter(Boolean));
|
||||
const allowedFinishIds = new Set(finishButtons.map((button) => button.dataset.finishId).filter(Boolean));
|
||||
|
||||
let selectedStyle = allowedStyleIds.has(storedSelection?.style) ? storedSelection.style : "B";
|
||||
let selectedColorwayId = storedSelection?.colorwayId || "sorbet-pop-multi-color";
|
||||
let selectedFinishId = allowedFinishIds.has(storedSelection?.finishId) ? storedSelection.finishId : "silver";
|
||||
let selectedQuantity = Math.max(1, Math.round(Number(storedSelection?.quantity || "1") || 1));
|
||||
|
||||
const formatGBP = (value) => `£${Number(value).toFixed(2)}`;
|
||||
const getInventoryKey = (styleId, colorwayId, finishId = INVENTORY_FINISH_ID) => `${styleId}::${colorwayId}::${finishId}`;
|
||||
const getInventoryLevel = (styleId, colorwayId, finishId = INVENTORY_FINISH_ID) => inventoryMap.get(getInventoryKey(styleId, colorwayId, finishId));
|
||||
const getAvailabilityState = (level) => {
|
||||
if (!level) return { label: "Sold Out/Pre-Order Available", state: "default", soldOut: false };
|
||||
const quantity = Number(level.quantityOnHand || 0);
|
||||
if (quantity <= 0) return { label: "Sold Out/Pre-Order Available", state: "default", soldOut: false };
|
||||
if (quantity <= 3) return { label: `Only ${quantity} left`, state: "low", soldOut: false };
|
||||
return { label: `${quantity} available`, state: "ready", soldOut: false };
|
||||
};
|
||||
|
||||
const loadPricing = async () => {
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/storefront/pricing`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "Pricing is temporarily unavailable.");
|
||||
}
|
||||
|
||||
const pricing = payload?.data || {};
|
||||
const nextUnit = Number(pricing.unitAmount || 0) / 100;
|
||||
const nextRetail = Number(pricing.retailAmount || 0) / 100;
|
||||
|
||||
if (nextUnit > 0) currentPricePerKit = nextUnit;
|
||||
if (nextRetail > 0) currentRetailPerKit = nextRetail;
|
||||
} catch (_error) {
|
||||
// Keep baked fallback pricing when the public pricing endpoint is unavailable.
|
||||
} finally {
|
||||
if (heroPrice) heroPrice.textContent = `${formatGBP(currentPricePerKit)} before public release`;
|
||||
updateTotals();
|
||||
}
|
||||
};
|
||||
let cartToastTimer = null;
|
||||
|
||||
const flashCartUi = () => {
|
||||
cartItemsNode?.setAttribute("data-cart-flash", "true");
|
||||
cartCountNodes.forEach((node) => {
|
||||
node.dataset.cartFlash = "true";
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
cartItemsNode?.removeAttribute("data-cart-flash");
|
||||
cartCountNodes.forEach((node) => {
|
||||
delete node.dataset.cartFlash;
|
||||
});
|
||||
}, 550);
|
||||
};
|
||||
|
||||
const showCartToast = (title, body, state = "success") => {
|
||||
if (!cartToast || !cartToastTitle || !cartToastBody) return;
|
||||
|
||||
cartToastTitle.textContent = title;
|
||||
cartToastBody.textContent = body;
|
||||
cartToast.dataset.state = state;
|
||||
cartToast.hidden = false;
|
||||
requestAnimationFrame(() => {
|
||||
cartToast.dataset.visible = "true";
|
||||
});
|
||||
|
||||
if (cartToastTimer) {
|
||||
window.clearTimeout(cartToastTimer);
|
||||
}
|
||||
|
||||
cartToastTimer = window.setTimeout(() => {
|
||||
cartToast.dataset.visible = "false";
|
||||
window.setTimeout(() => {
|
||||
if (cartToast?.dataset.visible === "false") {
|
||||
cartToast.hidden = true;
|
||||
}
|
||||
}, 220);
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
const pulseAddButtons = (label) => {
|
||||
addToCartButtons.forEach((button) => {
|
||||
button.textContent = label;
|
||||
button.dataset.added = "true";
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
addToCartButtons.forEach((button) => {
|
||||
delete button.dataset.added;
|
||||
button.textContent = button.disabled
|
||||
? "Sold out"
|
||||
: `Add ${selectedQuantity} ${selectedQuantity === 1 ? "kit" : "kits"} to cart`;
|
||||
});
|
||||
}, 950);
|
||||
};
|
||||
|
||||
const renderCartItems = (cart) => {
|
||||
if (!cartItemsNode) return;
|
||||
|
||||
if (!cart.length) {
|
||||
cartItemsNode.innerHTML = `<div class="${cartItemEmptyClass}">Your cart is empty.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
cartItemsNode.innerHTML = cart
|
||||
.map((item) => {
|
||||
const colorway = colorwayMap.get(item.colorwayId);
|
||||
const finish = finishMap.get(item.finishId);
|
||||
|
||||
if (!colorway || !finish) return "";
|
||||
|
||||
return `
|
||||
<article class="${cartItemClass}">
|
||||
<div class="${cartItemImageWrapClass}" style="background:${colorway.previewBackground}">
|
||||
<img src="${colorway.cardImage}" alt="${colorway.name} cart item" loading="lazy" />
|
||||
</div>
|
||||
<div class="${cartItemBodyClass}">
|
||||
<div class="${cartItemTopClass}">
|
||||
<p class="${cartItemTitleClass}">${colorway.name}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="${cartItemRemoveClass}"
|
||||
data-remove-cart-item="${item.id}"
|
||||
aria-label="Remove ${colorway.name} from cart"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p class="${cartItemSubtitleClass}">${colorway.subtitle}</p>
|
||||
<p class="${cartItemMetaClass}">${colorway.styleText}</p>
|
||||
<div class="${cartItemFooterClass}">
|
||||
<p class="${cartItemMetaClass}">${item.quantity} ${item.quantity === 1 ? "kit" : "kits"} · Matched configuration</p>
|
||||
<p class="${cartItemPriceClass}">${formatGBP(currentPricePerKit * item.quantity)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const syncInventoryUi = () => {
|
||||
availabilityBadges.forEach((badge) => {
|
||||
const styleId = badge.dataset.styleId || "";
|
||||
const colorwayId = badge.dataset.colorwayId || "";
|
||||
const finishId = badge.dataset.finishId || INVENTORY_FINISH_ID;
|
||||
const availability = getAvailabilityState(getInventoryLevel(styleId, colorwayId, finishId));
|
||||
badge.textContent = availability.label;
|
||||
badge.dataset.state = availability.state;
|
||||
const card = badge.closest("[data-colorway-option]");
|
||||
if (card) {
|
||||
card.dataset.inventoryState = availability.state;
|
||||
card.dataset.soldOut = String(availability.soldOut);
|
||||
card.toggleAttribute("disabled", availability.soldOut || card.hidden);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadInventory = async () => {
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/inventory`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "Inventory availability is temporarily unavailable.");
|
||||
}
|
||||
|
||||
inventoryMap.clear();
|
||||
(payload?.data?.inventory || []).forEach((entry) => {
|
||||
inventoryMap.set(getInventoryKey(entry.style, entry.colorwayId, entry.finishId), entry);
|
||||
});
|
||||
} catch (_error) {
|
||||
inventoryMap.clear();
|
||||
} finally {
|
||||
syncInventoryUi();
|
||||
updateCartUi();
|
||||
}
|
||||
};
|
||||
|
||||
const syncBuilderSelection = () => {
|
||||
saveBuilderSelection({
|
||||
style: selectedStyle,
|
||||
colorwayId: selectedColorwayId,
|
||||
finishId: selectedFinishId,
|
||||
quantity: selectedQuantity,
|
||||
});
|
||||
};
|
||||
|
||||
const updateCartUi = (message = "", state = "info") => {
|
||||
const cart = loadCart();
|
||||
const cartCount = getCartCount();
|
||||
const cartIsReady = cartCount > 0;
|
||||
const selectedAvailability = getAvailabilityState(getInventoryLevel(selectedStyle, selectedColorwayId, INVENTORY_FINISH_ID));
|
||||
const cartTotal = currentPricePerKit * cartCount;
|
||||
const statusText = cartCount === 0 ? "Cart empty · add your first configuration" : `${cartCount} ${cartCount === 1 ? "kit" : "kits"} in cart`;
|
||||
const feedbackText = message || (selectedAvailability.soldOut
|
||||
? "This configuration is currently sold out. Choose another colourway to continue."
|
||||
: cartIsReady
|
||||
? "Cart ready. Continue to details when you’re set."
|
||||
: "Add this kit to your cart, or use Buy now to go straight to details.");
|
||||
|
||||
if (quantityInput) {
|
||||
quantityInput.value = String(selectedQuantity);
|
||||
}
|
||||
|
||||
addToCartButtons.forEach((button) => {
|
||||
button.hidden = false;
|
||||
button.disabled = selectedAvailability.soldOut;
|
||||
button.textContent = selectedAvailability.soldOut
|
||||
? "Sold out"
|
||||
: `Add ${selectedQuantity} ${selectedQuantity === 1 ? "kit" : "kits"} to cart`;
|
||||
});
|
||||
|
||||
clearCartButtons.forEach((button) => {
|
||||
button.disabled = !cartIsReady;
|
||||
button.setAttribute("aria-disabled", String(!cartIsReady));
|
||||
});
|
||||
|
||||
cartStatusNodes.forEach((node) => {
|
||||
node.textContent = statusText;
|
||||
});
|
||||
|
||||
cartCountNodes.forEach((node) => {
|
||||
node.textContent = `${cartCount} ${cartCount === 1 ? "kit" : "kits"}`;
|
||||
});
|
||||
|
||||
renderCartItems(cart);
|
||||
|
||||
cartFeedbackNodes.forEach((node) => {
|
||||
node.textContent = feedbackText;
|
||||
node.dataset.cartFeedbackState = state;
|
||||
});
|
||||
|
||||
summaryCartTotals.forEach((node) => {
|
||||
node.textContent = formatGBP(cartTotal);
|
||||
});
|
||||
|
||||
detailsButtons.forEach((button) => {
|
||||
button.disabled = selectedAvailability.soldOut;
|
||||
button.textContent = cartIsReady ? "Continue to details" : `Buy now for ${selectedQuantity} ${selectedQuantity === 1 ? "kit" : "kits"}`;
|
||||
button.dataset.mode = cartIsReady ? "details" : "buy-now";
|
||||
});
|
||||
};
|
||||
|
||||
const getColorButton = (id) => colorButtons.find((button) => button.dataset.colorwayId === id);
|
||||
|
||||
const resetBuilderSelection = () => {
|
||||
selectedStyle = DEFAULT_SELECTION.style;
|
||||
selectedColorwayId = DEFAULT_SELECTION.colorwayId;
|
||||
selectedFinishId = DEFAULT_SELECTION.finishId;
|
||||
selectedQuantity = DEFAULT_SELECTION.quantity;
|
||||
|
||||
applyStyle(selectedStyle);
|
||||
|
||||
const defaultColorButton = getColorButton(selectedColorwayId);
|
||||
if (!defaultColorButton || defaultColorButton.hidden || defaultColorButton.dataset.styleId !== selectedStyle) {
|
||||
selectedColorwayId = colorButtons.find((button) => button.dataset.styleId === selectedStyle && !button.hidden)?.dataset.colorwayId || selectedColorwayId;
|
||||
}
|
||||
|
||||
applyColorway(selectedColorwayId);
|
||||
applyFinish(selectedFinishId);
|
||||
applyQuantity(selectedQuantity);
|
||||
};
|
||||
|
||||
const updateTotals = () => {
|
||||
const total = currentPricePerKit * selectedQuantity;
|
||||
if (summaryQuantity) summaryQuantity.textContent = `${selectedQuantity} ${selectedQuantity === 1 ? "kit" : "kits"}`;
|
||||
if (summaryUnitPrice) summaryUnitPrice.textContent = `${formatGBP(currentPricePerKit)} per kit`;
|
||||
if (summarySelectionTotal) summarySelectionTotal.textContent = formatGBP(total);
|
||||
syncBuilderSelection();
|
||||
updateCartUi();
|
||||
};
|
||||
|
||||
const applyColorway = (id) => {
|
||||
const activeButton = getColorButton(id);
|
||||
|
||||
if (!activeButton) return;
|
||||
|
||||
selectedColorwayId = id;
|
||||
|
||||
colorButtons.forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(button === activeButton));
|
||||
});
|
||||
|
||||
const image = activeButton.dataset.colorwayImage || "";
|
||||
const name = activeButton.dataset.colorwayName || "";
|
||||
const subtitle = activeButton.dataset.colorwaySubtitle || "";
|
||||
const style = activeButton.dataset.colorwayStyle || "";
|
||||
const crown = activeButton.dataset.colorwayCrown || "";
|
||||
|
||||
if (summaryImage && image) summaryImage.setAttribute("src", image);
|
||||
if (summaryImage && name) summaryImage.setAttribute("alt", `${name} Royal Pop configuration preview`);
|
||||
if (summaryName) summaryName.textContent = name;
|
||||
if (summarySubtitle) summarySubtitle.textContent = subtitle;
|
||||
if (summaryStyle) summaryStyle.textContent = style;
|
||||
if (summaryCrown) summaryCrown.textContent = crown;
|
||||
if (summaryColorway) summaryColorway.textContent = name;
|
||||
syncBuilderSelection();
|
||||
};
|
||||
|
||||
const applyStyle = (styleId) => {
|
||||
selectedStyle = styleId;
|
||||
|
||||
styleButtons.forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(button.dataset.styleId === styleId));
|
||||
});
|
||||
|
||||
let firstVisibleId = null;
|
||||
|
||||
colorButtons.forEach((button) => {
|
||||
const matches = button.dataset.styleId === styleId;
|
||||
button.hidden = !matches;
|
||||
button.toggleAttribute("disabled", !matches || button.dataset.soldOut === "true");
|
||||
button.tabIndex = matches ? 0 : -1;
|
||||
|
||||
if (matches && button.dataset.soldOut !== "true" && !firstVisibleId) {
|
||||
firstVisibleId = button.dataset.colorwayId;
|
||||
}
|
||||
});
|
||||
|
||||
if (!firstVisibleId) {
|
||||
firstVisibleId = colorButtons.find((button) => button.dataset.styleId === styleId && !button.hidden)?.dataset.colorwayId || null;
|
||||
}
|
||||
|
||||
const current = getColorButton(selectedColorwayId);
|
||||
const currentMatches = current && current.dataset.styleId === styleId;
|
||||
|
||||
if (!currentMatches && firstVisibleId) {
|
||||
applyColorway(firstVisibleId);
|
||||
}
|
||||
|
||||
syncBuilderSelection();
|
||||
};
|
||||
|
||||
const applyFinish = (finishId) => {
|
||||
const activeButton = finishButtons.find((button) => button.dataset.finishId === finishId) || finishButtons[0];
|
||||
if (!activeButton) return;
|
||||
|
||||
selectedFinishId = activeButton.dataset.finishId || "silver";
|
||||
|
||||
finishButtons.forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(button === activeButton));
|
||||
});
|
||||
|
||||
syncBuilderSelection();
|
||||
};
|
||||
|
||||
const applyQuantity = (quantity) => {
|
||||
selectedQuantity = Math.max(1, Math.round(Number(quantity) || 1));
|
||||
if (quantityInput) {
|
||||
quantityInput.value = String(selectedQuantity);
|
||||
}
|
||||
updateTotals();
|
||||
syncBuilderSelection();
|
||||
};
|
||||
|
||||
styleButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const { styleId } = button.dataset;
|
||||
if (styleId) {
|
||||
applyStyle(styleId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
colorButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (button.hidden || button.dataset.soldOut === "true") return;
|
||||
const { colorwayId } = button.dataset;
|
||||
if (colorwayId) {
|
||||
applyColorway(colorwayId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
finishButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const { finishId } = button.dataset;
|
||||
if (finishId) {
|
||||
applyFinish(finishId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
quantityInput?.addEventListener("input", () => {
|
||||
applyQuantity(quantityInput.value);
|
||||
});
|
||||
|
||||
quantityInput?.addEventListener("change", () => {
|
||||
applyQuantity(quantityInput.value);
|
||||
});
|
||||
|
||||
addToCartButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (button.disabled) return;
|
||||
const result = addSelectionToCart({
|
||||
style: selectedStyle,
|
||||
colorwayId: selectedColorwayId,
|
||||
finishId: selectedFinishId,
|
||||
quantity: selectedQuantity,
|
||||
});
|
||||
|
||||
if (result.addedCount === 0) {
|
||||
updateCartUi("We couldn’t add that configuration right now. Please try again.", "error");
|
||||
showCartToast("Couldn’t add to cart", "Please try adding that configuration again.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const addedLabel = `${result.addedCount} ${result.addedCount === 1 ? "kit" : "kits"}`;
|
||||
resetBuilderSelection();
|
||||
updateCartUi(`Added ${addedLabel} to your cart.`, "success");
|
||||
pulseAddButtons(result.addedCount === 1 ? "Added to cart" : `Added ${addedLabel}`);
|
||||
flashCartUi();
|
||||
showCartToast(
|
||||
"Added to cart",
|
||||
`${addedLabel} added to your Royal Pop cart.`,
|
||||
"success",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
clearCartButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
clearCart();
|
||||
resetBuilderSelection();
|
||||
updateCartUi("Cart cleared. You can build a new selection now.", "success");
|
||||
showCartToast("Cart cleared", "Your saved kits were removed from the cart.", "success");
|
||||
});
|
||||
});
|
||||
|
||||
cartItemsNode?.addEventListener("click", (event) => {
|
||||
const removeButton = event.target.closest("[data-remove-cart-item]");
|
||||
if (!removeButton) return;
|
||||
|
||||
const { removeCartItem: itemId } = removeButton.dataset;
|
||||
if (!itemId) return;
|
||||
|
||||
removeCartItem(itemId);
|
||||
updateCartUi("Kit removed from your cart.", "success");
|
||||
showCartToast("Removed from cart", "That kit was removed from your Royal Pop cart.", "success");
|
||||
});
|
||||
|
||||
detailsButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (button.disabled) return;
|
||||
const cartCount = getCartCount();
|
||||
|
||||
if (cartCount === 0) {
|
||||
const result = addSelectionToCart({
|
||||
style: selectedStyle,
|
||||
colorwayId: selectedColorwayId,
|
||||
finishId: selectedFinishId,
|
||||
quantity: selectedQuantity,
|
||||
});
|
||||
|
||||
if (result.addedCount === 0) {
|
||||
updateCartUi("We couldn’t add that configuration right now. Please try again.", "error");
|
||||
showCartToast("Couldn’t add to cart", "Please try adding that configuration again.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
updateCartUi(`Added ${result.addedCount} ${result.addedCount === 1 ? "kit" : "kits"} and continuing to details.`, "success");
|
||||
}
|
||||
|
||||
window.location.href = "/details";
|
||||
});
|
||||
});
|
||||
|
||||
applyStyle(selectedStyle);
|
||||
|
||||
const initialColorButton = getColorButton(selectedColorwayId);
|
||||
if (!initialColorButton || initialColorButton.dataset.styleId !== selectedStyle || initialColorButton.hidden) {
|
||||
selectedColorwayId = colorButtons.find((button) => button.dataset.styleId === selectedStyle && !button.hidden)?.dataset.colorwayId || selectedColorwayId;
|
||||
}
|
||||
|
||||
applyColorway(selectedColorwayId);
|
||||
applyFinish(selectedFinishId);
|
||||
applyQuantity(selectedQuantity);
|
||||
updateCartUi();
|
||||
loadInventory();
|
||||
loadPricing();
|
||||
};
|
||||
|
||||
initBuyPage();
|
||||
document.addEventListener("astro:page-load", initBuyPage);
|
||||
</script>
|
||||
|
||||
</main>
|
||||
</BaseLayout>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,877 @@
|
||||
---
|
||||
import SiteFooter from "../components/royal-pop/SiteFooter.astro";
|
||||
import { colorways, defaultColorway } from "../data/royalPop";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import styles from "./checkout.module.scss";
|
||||
|
||||
const finishOptions = [
|
||||
{ id: "silver", label: "Silver", note: "Classic brushed 316L steel" },
|
||||
{ id: "black-pvd", label: "Black PVD", note: "Stealth satin hardware" },
|
||||
{ id: "rose-gold", label: "Rose Gold", note: "Warm contrast finish" },
|
||||
];
|
||||
|
||||
const pricePerKit = 49.99;
|
||||
const retailPerKit = 89.99;
|
||||
const defaultFinish = finishOptions[0];
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const stripePriceId = process.env.STRIPE_PRICE_ID ?? "price_1TkPnvCpoCwKMSycHiQBPVms";
|
||||
const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081");
|
||||
const detailsStorageKey = "royal-pop-checkout-details";
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Royal Pop Checkout"
|
||||
description="Review your configuration and complete your Royal Pop preorder checkout."
|
||||
noindex={true}
|
||||
>
|
||||
<main class={styles.checkoutPage} data-checkout-page>
|
||||
<header class={styles.topbar}>
|
||||
<div class={styles.topbarInner}>
|
||||
<a href="/details" class={styles.backLink} data-back-link>Back to details</a>
|
||||
|
||||
<div class={styles.progress} aria-label="Purchase steps">
|
||||
<span>1. Configure</span>
|
||||
<span>2. Details</span>
|
||||
<span class={styles.progressActive}>3. Review & Pay</span>
|
||||
</div>
|
||||
|
||||
<p class={styles.helpText}>Final review & payment</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class={styles.hero}>
|
||||
<div class={styles.heroInner}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>Review & Pay</p>
|
||||
<h1 class={styles.heroTitle}>Review your configuration and complete payment.</h1>
|
||||
<p class={styles.heroBody}>Review your selected kit, check the total, and choose how you want to complete your preorder.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.heroMeta}>
|
||||
<div>
|
||||
<strong>Review stage</strong>
|
||||
<span>Check your selected kit, pricing, and preorder summary before submission.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Payment next</strong>
|
||||
<span>Choose the payment route that fits you best before you place the request.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Everything in one place</strong>
|
||||
<span>Your configuration, details, and final total stay visible here for one last check.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.checkoutSection}>
|
||||
<div class={styles.checkoutGrid}>
|
||||
<aside class={styles.summaryRail}>
|
||||
<div class={styles.summarySticky}>
|
||||
<div class={styles.orderCard}>
|
||||
<div class={styles.orderHeader}>
|
||||
<div>
|
||||
<p class={styles.sectionKicker}>Order summary</p>
|
||||
<h2>Your cart</h2>
|
||||
</div>
|
||||
<p class={styles.priceNow} data-summary-total>Updating…</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.cartList} data-cart-items>
|
||||
<div class={styles.cartItemEmpty}>Your cart is empty.</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.orderRows}>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Kits in cart</span>
|
||||
<strong data-summary-quantity>0 kits</strong>
|
||||
</div>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Early Bird total</span>
|
||||
<strong data-summary-total-inline>Updating…</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class={styles.assuranceCard}>
|
||||
<h3>Before you submit</h3>
|
||||
<ul>
|
||||
<li>Review your chosen colourway and quantity one last time.</li>
|
||||
<li>Zero-stock configurations stay open as preorder requests until the next batch is assigned.</li>
|
||||
<li>You will receive install guidance and fit support after confirmation.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class={styles.formColumn}>
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.stepCount}>Step 3A</p>
|
||||
<h2>Payment method</h2>
|
||||
<p>Choose how you would like to pay and complete your preorder securely with Stripe.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.paymentShell} data-payment-shell>
|
||||
<div class={styles.paymentPanel} data-payment-panel="card">
|
||||
<div class={styles.paymentPanelHeader}>
|
||||
<div>
|
||||
<strong>Card payment</strong>
|
||||
<span>Use the secure payment form below to enter your card or any other supported payment method available for your region.</span>
|
||||
</div>
|
||||
<p>Secure</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.stripeMount} data-stripe-payment-element data-stripe-mount="card">
|
||||
<div class={styles.stripeLoadingState} data-stripe-loading>
|
||||
<div class={styles.stripeSpinner} aria-hidden="true"></div>
|
||||
<div class={styles.stripeLoadingCopy}>
|
||||
<p class={styles.stripeMountLabel}>Loading secure card form…</p>
|
||||
<p class={styles.stripeMountBody}>Preparing your secure payment form. This usually takes a moment.</p>
|
||||
</div>
|
||||
<div class={styles.stripeSkeletonGroup} aria-hidden="true">
|
||||
<span class={styles.stripeSkeletonLine}></span>
|
||||
<span class={styles.stripeSkeletonLine}></span>
|
||||
<span class={styles.stripeSkeletonLine}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.stripeReadyState} data-stripe-ready hidden>
|
||||
<p class={styles.stripeMountLabel}>Secure payment form</p>
|
||||
<p class={styles.stripeMountBody}>Enter your payment details below. Billing details and any required authentication will appear automatically.</p>
|
||||
<div data-stripe-element-host="card"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.paymentMetaRow}>
|
||||
<div class={styles.paymentMetaCard}>
|
||||
<strong>Accepted payment options</strong>
|
||||
<span>Your available cards and payment methods will appear automatically based on your device and location.</span>
|
||||
</div>
|
||||
<div class={styles.paymentMetaCard}>
|
||||
<strong>Secure authentication</strong>
|
||||
<span>Billing fields and any required verification steps are handled securely during checkout.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.paymentStatusRow}>
|
||||
<div class={styles.paymentStatusCard}>
|
||||
<strong>Stripe status</strong>
|
||||
<p class={styles.paymentStatusPill} data-payment-status-pill>Initializing</p>
|
||||
<span data-payment-status-copy>Stripe is preparing a secure checkout surface for this payment method.</span>
|
||||
</div>
|
||||
<div class={styles.paymentStatusCard}>
|
||||
<strong>Secure mount point</strong>
|
||||
<span data-stripe-errors>No payment errors. Stripe validation and mount feedback will appear here if anything blocks checkout.</span>
|
||||
<button type="button" class={styles.paymentRetryButton} data-stripe-retry hidden>Retry payment form</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.stepCount}>Step 3B</p>
|
||||
<h2>Review your preorder</h2>
|
||||
<p>Use this step to confirm the product build and pricing before the final submit state.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.stack}>
|
||||
<div class={styles.optionCard}>
|
||||
<div>
|
||||
<strong>Selected kits</strong>
|
||||
<span data-summary-colorway>{defaultColorway.name}</span>
|
||||
</div>
|
||||
<p data-summary-quantity>1 kit</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.optionCard}>
|
||||
<div>
|
||||
<strong>Configuration summary</strong>
|
||||
<span><span data-summary-style>{defaultColorway.styleText}</span></span>
|
||||
</div>
|
||||
<p data-summary-total-inline>Updating…</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.stepCount}>Final review</p>
|
||||
<h2>Confirm your preorder request</h2>
|
||||
<p>Confirm the essentials below before you send your preorder request.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewCard}>
|
||||
<label class={styles.checkboxRow}>
|
||||
<input type="checkbox" data-review-confirmation />
|
||||
<span>I’ve reviewed my configuration, quantity, and total.</span>
|
||||
</label>
|
||||
|
||||
<label class={styles.checkboxRow}>
|
||||
<input type="checkbox" />
|
||||
<span>I’m happy to receive preorder updates and shipping news by email.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class={styles.actions}>
|
||||
<a href="/details" class={styles.secondaryCta}>Back to details</a>
|
||||
<button type="button" class={styles.primaryCta} data-go-thank-you disabled aria-disabled="true">Final payment stays locked for now</button>
|
||||
</div>
|
||||
|
||||
<p class={styles.footnote} data-checkout-footnote>Please confirm that you have reviewed your configuration, quantity, and total before completing payment.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
<script is:inline src="/scripts/royal-pop-cart.js"></script>
|
||||
<script define:vars={{ colorways, defaultColorway, finishOptions, pricePerKit, retailPerKit, stripePublishableKey, stripePriceId, apiBaseUrl, detailsStorageKey, cartItemClass: styles.cartItem, cartItemImageWrapClass: styles.cartItemImageWrap, cartItemBodyClass: styles.cartItemBody, cartItemTitleClass: styles.cartItemTitle, cartItemSubtitleClass: styles.cartItemSubtitle, cartItemMetaClass: styles.cartItemMeta, cartItemFooterClass: styles.cartItemFooter, cartItemPriceClass: styles.cartItemPrice, cartItemRemoveClass: styles.cartItemRemove, cartItemEmptyClass: styles.cartItemEmpty }}>
|
||||
const { getCartCount, loadCart, removeCartItem } = window.RoyalPopCart || {};
|
||||
const STRIPE_JS_URL = "https://js.stripe.com/v3/";
|
||||
const normalizedApiBaseUrl = String(apiBaseUrl || "").replace(/\/$/, "");
|
||||
const formatGBP = (value) => `£${Number(value || 0).toFixed(2)}`;
|
||||
const bakedPricePerKit = Number(pricePerKit || 0);
|
||||
const bakedRetailPerKit = Number(retailPerKit || 0);
|
||||
const fallbackColorway = defaultColorway || colorways[0] || {
|
||||
id: "default",
|
||||
name: "Royal Pop",
|
||||
subtitle: "Selected configuration",
|
||||
styleText: "Configuration selected",
|
||||
previewBackground: "linear-gradient(180deg, #f5f5f7 0%, #eeeeef 100%)",
|
||||
cardImage: "",
|
||||
};
|
||||
let currentPricePerKit = null;
|
||||
let currentRetailPerKit = null;
|
||||
let pricingLoaded = false;
|
||||
|
||||
const ensureStripeJS = async () => {
|
||||
if (window.Stripe) return window.Stripe;
|
||||
|
||||
const existingScript = document.querySelector(`script[src="${STRIPE_JS_URL}"]`);
|
||||
if (existingScript) {
|
||||
await new Promise((resolve, reject) => {
|
||||
if (window.Stripe) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
existingScript.addEventListener("load", resolve, { once: true });
|
||||
existingScript.addEventListener("error", () => reject(new Error("Failed to load Stripe.js.")), { once: true });
|
||||
});
|
||||
return window.Stripe;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = STRIPE_JS_URL;
|
||||
script.async = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = () => reject(new Error("Failed to load Stripe.js."));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return window.Stripe;
|
||||
};
|
||||
|
||||
const initCheckoutPage = () => {
|
||||
const root = document.querySelector("[data-checkout-page]");
|
||||
if (!root || root.dataset.initialized === "true") return;
|
||||
|
||||
root.dataset.initialized = "true";
|
||||
if (!getCartCount || !loadCart || !removeCartItem) return;
|
||||
|
||||
const colorwayMap = new Map(colorways.map((colorway) => [colorway.id, colorway]));
|
||||
const finishMap = new Map(finishOptions.map((finish) => [finish.id, finish]));
|
||||
|
||||
const loadPricing = async () => {
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/storefront/pricing`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "Pricing is temporarily unavailable.");
|
||||
}
|
||||
|
||||
const pricing = payload?.data || {};
|
||||
const nextUnit = Number(pricing.unitAmount || 0) / 100;
|
||||
const nextRetail = Number(pricing.retailAmount || 0) / 100;
|
||||
|
||||
currentPricePerKit = nextUnit > 0 ? nextUnit : bakedPricePerKit;
|
||||
currentRetailPerKit = nextRetail > 0 ? nextRetail : bakedRetailPerKit;
|
||||
} catch (_error) {
|
||||
currentPricePerKit = bakedPricePerKit;
|
||||
currentRetailPerKit = bakedRetailPerKit;
|
||||
} finally {
|
||||
pricingLoaded = Number.isFinite(currentPricePerKit) && currentPricePerKit > 0;
|
||||
renderCart();
|
||||
updatePaymentAction();
|
||||
}
|
||||
};
|
||||
|
||||
const summaryColorway = root.querySelector("[data-summary-colorway]");
|
||||
const summaryQuantity = root.querySelector("[data-summary-quantity]");
|
||||
const summaryTotal = root.querySelector("[data-summary-total]");
|
||||
const summaryTotalInline = Array.from(root.querySelectorAll("[data-summary-total-inline]"));
|
||||
const cartItemsNode = root.querySelector("[data-cart-items]");
|
||||
const backLink = root.querySelector("[data-back-link]");
|
||||
const thankYouButton = root.querySelector("[data-go-thank-you]");
|
||||
const checkoutFootnote = root.querySelector("[data-checkout-footnote]");
|
||||
const reviewConfirmationCheckbox = root.querySelector("[data-review-confirmation]");
|
||||
const paymentShell = root.querySelector("[data-payment-shell]");
|
||||
const paymentPanels = Array.from(root.querySelectorAll("[data-payment-panel]"));
|
||||
const paymentStatusPill = root.querySelector("[data-payment-status-pill]");
|
||||
const paymentStatusCopy = root.querySelector("[data-payment-status-copy]");
|
||||
const stripeErrors = root.querySelector("[data-stripe-errors]");
|
||||
const stripeRetry = root.querySelector("[data-stripe-retry]");
|
||||
const stripeCardHost = root.querySelector('[data-stripe-element-host="card"]');
|
||||
let paymentStateTimer;
|
||||
let activePaymentTab = "card";
|
||||
let stripeInstance = null;
|
||||
let stripeElements = null;
|
||||
let paymentElement = null;
|
||||
let activeClientSecret = "";
|
||||
let activeCartSignature = "";
|
||||
let activePaymentIntentId = "";
|
||||
let activePaymentAmount = 0;
|
||||
let activeSubmitEnabled = false;
|
||||
let currentPaymentState = "loading";
|
||||
let isConfirmingPayment = false;
|
||||
let paymentVerificationState = "idle";
|
||||
|
||||
const loadStoredDetails = () => {
|
||||
try {
|
||||
return JSON.parse(window.sessionStorage.getItem(detailsStorageKey) || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const hasValidSavedDetails = () => {
|
||||
try {
|
||||
const details = loadStoredDetails();
|
||||
if (!details || typeof details !== "object") return false;
|
||||
|
||||
const email = typeof details.email === "string" ? details.email.trim() : "";
|
||||
const phone = typeof details.phone === "string" ? details.phone.trim() : "";
|
||||
const firstName = typeof details.firstName === "string" ? details.firstName.trim() : "";
|
||||
const lastName = typeof details.lastName === "string" ? details.lastName.trim() : "";
|
||||
const addressLine1 = typeof details.addressLine1 === "string" ? details.addressLine1.trim() : "";
|
||||
const city = typeof details.city === "string" ? details.city.trim() : "";
|
||||
const region = typeof details.region === "string" ? details.region.trim() : "";
|
||||
const postalCode = typeof details.postalCode === "string" ? details.postalCode.trim() : "";
|
||||
const country = typeof details.country === "string" ? details.country.trim() : "";
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (phone.length < 7) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstName.length < 2 || lastName.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (addressLine1.length < 5 || city.length < 2 || region.length < 2 || postalCode.length < 3 || country.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const setPaymentTab = (tab = "card") => {
|
||||
activePaymentTab = tab;
|
||||
|
||||
paymentPanels.forEach((panel) => {
|
||||
panel.hidden = panel.dataset.paymentPanel !== "card";
|
||||
});
|
||||
|
||||
updatePaymentAction();
|
||||
};
|
||||
|
||||
const clearMountedElements = () => {
|
||||
if (paymentElement) {
|
||||
paymentElement.destroy();
|
||||
paymentElement = null;
|
||||
}
|
||||
|
||||
if (stripeCardHost) {
|
||||
stripeCardHost.innerHTML = "";
|
||||
}
|
||||
|
||||
stripeElements = null;
|
||||
activeClientSecret = "";
|
||||
};
|
||||
|
||||
const resetPaymentIntentSession = () => {
|
||||
clearMountedElements();
|
||||
activePaymentIntentId = "";
|
||||
activePaymentAmount = 0;
|
||||
activeSubmitEnabled = false;
|
||||
paymentVerificationState = "idle";
|
||||
isConfirmingPayment = false;
|
||||
};
|
||||
|
||||
const getRawCartItems = () => loadCart().map((item) => ({
|
||||
id: item.id,
|
||||
style: item.style,
|
||||
colorwayId: item.colorwayId,
|
||||
finishId: item.finishId,
|
||||
quantity: Math.max(1, Number(item.quantity || 1)),
|
||||
}));
|
||||
|
||||
const getCartSignature = (items) => items
|
||||
.map((item) => [item.style, item.colorwayId, item.finishId, item.quantity].join(":"))
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
const requestPaymentIntent = async (items, tab) => {
|
||||
const customer = loadStoredDetails();
|
||||
if (!customer) {
|
||||
throw new Error("We could not find your saved contact and shipping details. Please return to the details step and try again.");
|
||||
}
|
||||
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/checkout/payment-intent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
items,
|
||||
customer,
|
||||
paymentMethod: tab,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The backend could not create a Stripe PaymentIntent.");
|
||||
}
|
||||
|
||||
return payload?.data || null;
|
||||
};
|
||||
|
||||
const requestPaymentStatus = async (paymentIntentId) => {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/checkout/payment-intent/${encodeURIComponent(paymentIntentId)}`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The backend could not load the Stripe payment status.");
|
||||
}
|
||||
|
||||
return payload?.data || null;
|
||||
};
|
||||
|
||||
const updatePaymentAction = () => {
|
||||
if (!thankYouButton) return;
|
||||
|
||||
const total = Number.isFinite(currentPricePerKit) ? currentPricePerKit * getCartCount() : 0;
|
||||
const hasReviewedConfiguration = Boolean(reviewConfirmationCheckbox?.checked);
|
||||
let label = "Complete payment";
|
||||
let disabled = true;
|
||||
let footnote = "Please confirm that you have reviewed your configuration, quantity, and total before completing payment.";
|
||||
|
||||
if (isConfirmingPayment) {
|
||||
label = "Confirming payment…";
|
||||
footnote = "Your payment is being confirmed securely. Please do not refresh this page.";
|
||||
} else if (paymentVerificationState === "pending") {
|
||||
label = "Waiting for webhook verification…";
|
||||
footnote = "Payment submitted. We are waiting for final confirmation from Stripe before redirecting you.";
|
||||
} else if (paymentVerificationState === "timed_out" && activePaymentIntentId) {
|
||||
label = "Check payment verification again";
|
||||
disabled = false;
|
||||
footnote = "Stripe may still be finalizing confirmation. Check again in a moment.";
|
||||
} else if (currentPaymentState === "loading") {
|
||||
label = "Loading Stripe form…";
|
||||
footnote = "Preparing your secure payment form.";
|
||||
} else if (!pricingLoaded) {
|
||||
label = "Loading pricing…";
|
||||
footnote = "Fetching the latest Royal Pop pricing from the backend.";
|
||||
} else if (!hasReviewedConfiguration) {
|
||||
label = "Review your configuration to continue";
|
||||
footnote = "Tick the review checkbox before completing payment.";
|
||||
} else if (activeSubmitEnabled && currentPaymentState === "ready" && stripeElements && paymentElement && getCartCount() > 0) {
|
||||
label = `Complete payment · ${formatGBP(total)}`;
|
||||
disabled = false;
|
||||
footnote = "Your payment will be processed securely and finalized after Stripe confirms completion.";
|
||||
} else if (currentPaymentState === "ready") {
|
||||
label = "Payment form needs attention";
|
||||
footnote = "The payment form is visible, but it is not ready to submit yet. Please refresh or try again in a moment.";
|
||||
}
|
||||
|
||||
thankYouButton.textContent = label;
|
||||
thankYouButton.disabled = disabled;
|
||||
thankYouButton.setAttribute("aria-disabled", String(disabled));
|
||||
|
||||
if (checkoutFootnote) {
|
||||
checkoutFootnote.textContent = footnote;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCardElementMounted = (clientSecret) => {
|
||||
if (!stripeInstance) {
|
||||
throw new Error("Stripe frontend instance is not ready yet.");
|
||||
}
|
||||
|
||||
if (!stripeCardHost) {
|
||||
throw new Error("Missing card mount host in checkout UI.");
|
||||
}
|
||||
|
||||
if (!stripeElements || activeClientSecret !== clientSecret) {
|
||||
clearMountedElements();
|
||||
stripeElements = stripeInstance.elements({ clientSecret });
|
||||
activeClientSecret = clientSecret;
|
||||
}
|
||||
|
||||
if (!paymentElement) {
|
||||
paymentElement = stripeElements.create("payment");
|
||||
paymentElement.mount(stripeCardHost);
|
||||
}
|
||||
};
|
||||
|
||||
const setPaymentState = (state, detailMessage = "") => {
|
||||
currentPaymentState = state;
|
||||
if (paymentShell) paymentShell.dataset.paymentState = state;
|
||||
|
||||
const activePanel = paymentPanels.find((panel) => panel.dataset.paymentPanel === activePaymentTab && !panel.hidden);
|
||||
const activeLoading = activePanel?.querySelector("[data-stripe-loading]");
|
||||
const activeReady = activePanel?.querySelector("[data-stripe-ready]");
|
||||
|
||||
if (activeLoading) activeLoading.hidden = state !== "loading";
|
||||
if (activeReady) activeReady.hidden = state === "loading";
|
||||
|
||||
if (paymentStatusPill) {
|
||||
paymentStatusPill.textContent = state === "loading"
|
||||
? "Initializing"
|
||||
: state === "error"
|
||||
? "Needs attention"
|
||||
: state === "awaiting-backend"
|
||||
? "Frontend ready"
|
||||
: "Ready";
|
||||
}
|
||||
|
||||
if (paymentStatusCopy) {
|
||||
paymentStatusCopy.textContent = state === "loading"
|
||||
? "Stripe is preparing a secure checkout surface for this payment method."
|
||||
: state === "error"
|
||||
? "Stripe could not finish preparing the payment surface. Retry the mount or switch methods."
|
||||
: state === "awaiting-backend"
|
||||
? "Your payment is being finalized. We’ll update this page as soon as Stripe confirms the result."
|
||||
: "Your secure payment form is ready.";
|
||||
}
|
||||
|
||||
if (stripeErrors) {
|
||||
stripeErrors.textContent = state === "error"
|
||||
? detailMessage || "We couldn’t load the secure payment form. Please try again or switch payment methods."
|
||||
: state === "awaiting-backend"
|
||||
? detailMessage || "Waiting for final confirmation from Stripe."
|
||||
: detailMessage || "No payment errors. Stripe validation and mount feedback will appear here if anything blocks checkout.";
|
||||
}
|
||||
|
||||
if (stripeRetry) {
|
||||
stripeRetry.hidden = state !== "error";
|
||||
}
|
||||
|
||||
updatePaymentAction();
|
||||
};
|
||||
|
||||
const waitForWebhookVerification = async (paymentIntentId, options = {}) => {
|
||||
const timeoutMs = options.timeoutMs || 25000;
|
||||
const intervalMs = options.intervalMs || 1500;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latestStatus = null;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
latestStatus = await requestPaymentStatus(paymentIntentId);
|
||||
|
||||
if (latestStatus?.verified && latestStatus?.webhookStatus === "succeeded") {
|
||||
return { outcome: "verified", status: latestStatus };
|
||||
}
|
||||
|
||||
if (["failed", "canceled"].includes(latestStatus?.webhookStatus) || ["canceled", "requires_payment_method"].includes(latestStatus?.stripeStatus)) {
|
||||
return { outcome: "failed", status: latestStatus };
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
return { outcome: "timeout", status: latestStatus };
|
||||
};
|
||||
|
||||
const handleVerificationCheck = async (paymentIntentId) => {
|
||||
paymentVerificationState = "pending";
|
||||
setPaymentState("awaiting-backend", "Payment submitted to Stripe. Waiting for final confirmation.");
|
||||
|
||||
try {
|
||||
const result = await waitForWebhookVerification(paymentIntentId);
|
||||
|
||||
if (result.outcome === "verified") {
|
||||
paymentVerificationState = "idle";
|
||||
setPaymentState("ready", result.status?.message || "Stripe confirmed your payment and updated the order record.");
|
||||
window.location.href = `/thank-you/?payment_intent=${encodeURIComponent(paymentIntentId)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.outcome === "failed") {
|
||||
paymentVerificationState = "idle";
|
||||
showPaymentError(result.status?.message || "Stripe did not verify the payment.");
|
||||
return;
|
||||
}
|
||||
|
||||
paymentVerificationState = "timed_out";
|
||||
setPaymentState("awaiting-backend", result.status?.message || "Still waiting for Stripe to finish webhook confirmation.");
|
||||
} catch (error) {
|
||||
paymentVerificationState = "timed_out";
|
||||
showPaymentError(error instanceof Error ? error.message : "We could not verify the Stripe payment status yet.");
|
||||
}
|
||||
};
|
||||
|
||||
const beginPaymentMount = async (tab) => {
|
||||
window.clearTimeout(paymentStateTimer);
|
||||
setPaymentTab(tab);
|
||||
paymentVerificationState = "idle";
|
||||
isConfirmingPayment = false;
|
||||
setPaymentState("loading");
|
||||
|
||||
try {
|
||||
const items = getRawCartItems();
|
||||
const cartSignature = getCartSignature(items);
|
||||
if (!items.length) {
|
||||
throw new Error("Add at least one Royal Pop kit before loading the Stripe payment form.");
|
||||
}
|
||||
|
||||
if (!stripePublishableKey) {
|
||||
throw new Error("Secure payment is not configured correctly right now. Please try again later.");
|
||||
}
|
||||
|
||||
const StripeConstructor = await ensureStripeJS();
|
||||
if (!StripeConstructor) {
|
||||
throw new Error("Stripe.js loaded, but the Stripe constructor was unavailable.");
|
||||
}
|
||||
|
||||
stripeInstance = stripeInstance || StripeConstructor(stripePublishableKey);
|
||||
if (!stripeInstance) {
|
||||
throw new Error("Stripe frontend initialization returned an empty instance.");
|
||||
}
|
||||
|
||||
if (cartSignature !== activeCartSignature) {
|
||||
resetPaymentIntentSession();
|
||||
activeCartSignature = cartSignature;
|
||||
}
|
||||
|
||||
if (activeCartSignature === cartSignature && activeClientSecret && activePaymentIntentId) {
|
||||
if (tab === "card") {
|
||||
ensureCardElementMounted(activeClientSecret);
|
||||
}
|
||||
|
||||
paymentStateTimer = window.setTimeout(() => {
|
||||
setPaymentState("ready", `Your secure payment session is ready for ${formatGBP(Number(activePaymentAmount || 0) / 100)}.`);
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
const intentData = await requestPaymentIntent(items, tab);
|
||||
if (!intentData?.clientSecret) {
|
||||
throw new Error("The backend response did not include a Stripe client secret.");
|
||||
}
|
||||
|
||||
activePaymentIntentId = intentData.paymentIntentId || "";
|
||||
activePaymentAmount = Number(intentData.amount || 0);
|
||||
activeSubmitEnabled = Boolean(intentData.submitEnabled);
|
||||
|
||||
if (tab === "card") {
|
||||
ensureCardElementMounted(intentData.clientSecret);
|
||||
}
|
||||
|
||||
paymentStateTimer = window.setTimeout(() => {
|
||||
setPaymentState("ready", `Your secure payment session is ready for ${formatGBP(Number(intentData.amount || 0) / 100)}.`);
|
||||
}, 400);
|
||||
} catch (error) {
|
||||
showPaymentError(error instanceof Error ? error.message : "We couldn’t initialize Stripe on the frontend.");
|
||||
}
|
||||
};
|
||||
|
||||
const showPaymentError = (message) => {
|
||||
window.clearTimeout(paymentStateTimer);
|
||||
setPaymentState("error", message);
|
||||
};
|
||||
|
||||
const renderCart = () => {
|
||||
const hasPricing = Number.isFinite(currentPricePerKit) && currentPricePerKit > 0;
|
||||
const hasRetailPricing = Number.isFinite(currentRetailPerKit) && currentRetailPerKit > 0;
|
||||
const cartItems = loadCart().map((item) => ({
|
||||
...item,
|
||||
quantity: Math.max(1, Number(item.quantity || 1)),
|
||||
colorway: colorwayMap.get(item.colorwayId) || fallbackColorway,
|
||||
finish: finishMap.get(item.finishId) || defaultFinish,
|
||||
}));
|
||||
const totalQuantity = cartItems.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const total = hasPricing ? currentPricePerKit * totalQuantity : 0;
|
||||
if (summaryColorway) summaryColorway.textContent = cartItems.map((item) => item.colorway.name).join(", ") || "No kits in cart";
|
||||
if (summaryQuantity) summaryQuantity.textContent = `${totalQuantity} ${totalQuantity === 1 ? "kit" : "kits"}`;
|
||||
if (summaryTotal) summaryTotal.textContent = hasPricing ? formatGBP(total) : "Updating…";
|
||||
summaryTotalInline.forEach((node) => {
|
||||
node.textContent = hasPricing ? formatGBP(total) : "Updating…";
|
||||
});
|
||||
|
||||
const isEmpty = totalQuantity === 0;
|
||||
|
||||
const nextCartSignature = getCartSignature(getRawCartItems());
|
||||
if (nextCartSignature !== activeCartSignature) {
|
||||
resetPaymentIntentSession();
|
||||
activeCartSignature = "";
|
||||
}
|
||||
|
||||
if (!cartItemsNode) return;
|
||||
|
||||
if (isEmpty) {
|
||||
cartItemsNode.innerHTML = `<div class="${cartItemEmptyClass}">Your cart is empty. Go back to Details or Configure to add a kit.</div>`;
|
||||
updatePaymentAction();
|
||||
return;
|
||||
}
|
||||
|
||||
cartItemsNode.innerHTML = cartItems
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="${cartItemClass}">
|
||||
<div class="${cartItemImageWrapClass}" style="background:${item.colorway.previewBackground}">
|
||||
<img src="${item.colorway.cardImage}" alt="${item.colorway.name} kit preview" />
|
||||
</div>
|
||||
<div class="${cartItemBodyClass}">
|
||||
<div>
|
||||
<strong class="${cartItemTitleClass}">${item.colorway.name}</strong>
|
||||
<p class="${cartItemSubtitleClass}">${item.colorway.subtitle}</p>
|
||||
<p class="${cartItemMetaClass}">${item.colorway.styleText}</p>
|
||||
<p class="${cartItemMetaClass}">${item.quantity} ${item.quantity === 1 ? "kit" : "kits"} · Matched configuration</p>
|
||||
</div>
|
||||
<div class="${cartItemFooterClass}">
|
||||
<strong class="${cartItemPriceClass}">${hasPricing ? formatGBP(currentPricePerKit * item.quantity) : "Updating…"}</strong>
|
||||
<button type="button" class="${cartItemRemoveClass}" data-remove-cart-item="${item.id}">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
updatePaymentAction();
|
||||
};
|
||||
|
||||
if (backLink) backLink.setAttribute("href", "/details");
|
||||
|
||||
const savedDetailsValid = hasValidSavedDetails();
|
||||
|
||||
if (cartItemsNode) {
|
||||
cartItemsNode.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-remove-cart-item]");
|
||||
if (!button) return;
|
||||
|
||||
const { removeCartItem: itemId } = button.dataset;
|
||||
if (!itemId) return;
|
||||
|
||||
removeCartItem(itemId);
|
||||
renderCart();
|
||||
|
||||
if (getCartCount() > 0) {
|
||||
beginPaymentMount(activePaymentTab);
|
||||
} else {
|
||||
showPaymentError("Add at least one Royal Pop kit before loading the Stripe payment form.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (thankYouButton) {
|
||||
thankYouButton.addEventListener("click", async () => {
|
||||
if (paymentVerificationState === "timed_out" && activePaymentIntentId) {
|
||||
await handleVerificationCheck(activePaymentIntentId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reviewConfirmationCheckbox?.checked) {
|
||||
showPaymentError("Please confirm that you have reviewed your configuration, quantity, and total before completing payment.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeSubmitEnabled) {
|
||||
showPaymentError("Payment confirmation is not available right now. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stripeInstance || !stripeElements || !paymentElement || !activePaymentIntentId) {
|
||||
showPaymentError("Stripe is not ready to confirm this payment yet. Reload the card form and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isConfirmingPayment = true;
|
||||
setPaymentState("loading", "Confirming your payment now.");
|
||||
|
||||
const submitResult = await stripeElements.submit();
|
||||
if (submitResult?.error) {
|
||||
throw new Error(submitResult.error.message || "Stripe could not validate the payment form.");
|
||||
}
|
||||
|
||||
const { error, paymentIntent } = await stripeInstance.confirmPayment({
|
||||
elements: stripeElements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/thank-you/`,
|
||||
},
|
||||
redirect: "if_required",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Stripe could not confirm the test payment.");
|
||||
}
|
||||
|
||||
activePaymentIntentId = paymentIntent?.id || activePaymentIntentId;
|
||||
isConfirmingPayment = false;
|
||||
await handleVerificationCheck(activePaymentIntentId);
|
||||
} catch (error) {
|
||||
isConfirmingPayment = false;
|
||||
paymentVerificationState = "idle";
|
||||
showPaymentError(error instanceof Error ? error.message : "Stripe could not confirm the payment.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (stripeRetry) {
|
||||
stripeRetry.addEventListener("click", () => {
|
||||
beginPaymentMount(activePaymentTab);
|
||||
});
|
||||
}
|
||||
|
||||
if (reviewConfirmationCheckbox) {
|
||||
reviewConfirmationCheckbox.checked = false;
|
||||
reviewConfirmationCheckbox.addEventListener("change", () => {
|
||||
updatePaymentAction();
|
||||
});
|
||||
}
|
||||
|
||||
const paymentPreviewState = new URLSearchParams(window.location.search).get("paymentState");
|
||||
|
||||
if (paymentPreviewState === "error") {
|
||||
setPaymentTab("card");
|
||||
showPaymentError("We couldn’t load the secure payment form. Check your Stripe publishable key, client secret, or network connection, then try again.");
|
||||
} else if (!savedDetailsValid) {
|
||||
setPaymentTab("card");
|
||||
showPaymentError("We couldn’t confirm your saved contact and shipping details. Please return to Details, review the form, and continue again.");
|
||||
} else {
|
||||
beginPaymentMount("card");
|
||||
}
|
||||
|
||||
renderCart();
|
||||
loadPricing();
|
||||
};
|
||||
|
||||
initCheckoutPage();
|
||||
document.addEventListener("astro:page-load", initCheckoutPage);
|
||||
</script>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,894 @@
|
||||
.clientPage {
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-bottom: 1px solid rgba(210, 210, 215, 0.9);
|
||||
}
|
||||
|
||||
.topbarInner {
|
||||
width: min(calc(100% - 64px), 1240px);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 220px;
|
||||
align-items: center;
|
||||
height: 52px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.backLink,
|
||||
.helpText,
|
||||
.ghostLink,
|
||||
.progress span,
|
||||
.logoutButton {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.progressActive {
|
||||
color: #1d1d1f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topbarActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.helpText {
|
||||
margin: 0;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.logoutButton {
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #d2d2d7;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 72px 32px 36px;
|
||||
}
|
||||
|
||||
.heroInner {
|
||||
width: min(100%, 1240px);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(340px, 0.9fr);
|
||||
gap: 32px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.sectionKicker {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
margin: 0;
|
||||
font-size: clamp(42px, 6vw, 72px);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.05em;
|
||||
max-width: 11ch;
|
||||
}
|
||||
|
||||
.heroBody {
|
||||
max-width: 62ch;
|
||||
margin: 22px 0 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.55;
|
||||
color: #424245;
|
||||
}
|
||||
|
||||
.heroMeta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
|
||||
div {
|
||||
padding: 18px 18px 20px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f9f9fb 100%);
|
||||
border: 1px solid #e5e5ea;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: #6e6e73;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardSection {
|
||||
padding: 28px 32px 72px;
|
||||
}
|
||||
|
||||
.dashboardShell {
|
||||
width: min(100%, 1240px);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.loginPanel,
|
||||
.dashboardPanel,
|
||||
.panel,
|
||||
.summaryCard,
|
||||
.stockCard,
|
||||
.orderCard {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e5ea;
|
||||
border-radius: 32px;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.loginPanel,
|
||||
.dashboardPanel,
|
||||
.panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #6e6e73;
|
||||
}
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.field,
|
||||
.inlineField {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #424245;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.loginActions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
background: #0071e3;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
border: 1px solid #d2d2d7;
|
||||
}
|
||||
|
||||
.formStatus,
|
||||
.dashboardStatus {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #6e6e73;
|
||||
|
||||
&[data-state="success"] {
|
||||
color: #1f7a36;
|
||||
}
|
||||
|
||||
&[data-state="error"] {
|
||||
color: #b42318;
|
||||
}
|
||||
}
|
||||
|
||||
.dangerPanel {
|
||||
padding: 24px;
|
||||
background: linear-gradient(180deg, #fff8f7 0%, #ffffff 100%);
|
||||
border: 1px solid rgba(220, 38, 38, 0.14);
|
||||
border-radius: 32px;
|
||||
box-shadow: 0 24px 60px rgba(127, 29, 29, 0.08);
|
||||
}
|
||||
|
||||
.dangerList {
|
||||
margin: 0 0 24px;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: #424245;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dangerAction {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
justify-items: flex-start;
|
||||
}
|
||||
|
||||
.dangerNote {
|
||||
margin: 0;
|
||||
max-width: 70ch;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #8a1c1c;
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.ghostLink {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: #6e6e73;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
|
||||
.summaryCard {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
strong {
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #6e6e73;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.searchControls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.6fr) auto;
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.searchActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.selectField,
|
||||
.textareaField {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #424245;
|
||||
}
|
||||
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 112px;
|
||||
}
|
||||
}
|
||||
|
||||
.ordersWorkspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.78fr) minmax(0, 1.22fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.orderResults,
|
||||
.stickyDetail {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stickyDetail {
|
||||
position: sticky;
|
||||
top: 84px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.statRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.resultCard {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid #e5e5ea;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.06);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #c8d8f8;
|
||||
box-shadow: 0 22px 52px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.resultCardActive {
|
||||
border-color: rgba(0, 113, 227, 0.24);
|
||||
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.08), 0 22px 52px rgba(15, 23, 42, 0.08);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
|
||||
}
|
||||
|
||||
.resultCustomer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
strong,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #6e6e73;
|
||||
}
|
||||
}
|
||||
|
||||
.resultMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f7;
|
||||
border: 1px solid #ececf1;
|
||||
font-size: 12px;
|
||||
color: #424245;
|
||||
}
|
||||
}
|
||||
|
||||
.resultSummary {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.resultFooter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
background: #fbfbfd;
|
||||
border: 1px solid #ececf1;
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
}
|
||||
}
|
||||
|
||||
.detailPanel {
|
||||
padding: 24px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e5ea;
|
||||
border-radius: 32px;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detailSection {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detailCallout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
div {
|
||||
padding: 16px 18px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
|
||||
border: 1px solid #e8eef9;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
div {
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
background: #f9f9fb;
|
||||
border: 1px solid #ececf1;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.detailSubgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detailAddress {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 24px;
|
||||
background: #f9f9fb;
|
||||
border: 1px solid #ececf1;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.detailItems {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
li {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #424245;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #6e6e73;
|
||||
}
|
||||
}
|
||||
|
||||
.detailForm {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.detailFormFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stockGrid,
|
||||
.ordersList {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stockCard {
|
||||
display: grid;
|
||||
grid-template-columns: 132px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stockCardMedia {
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(210, 210, 215, 0.72);
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 120px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.stockCardBody {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stockCardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.stockTitle,
|
||||
.stockSubtitle,
|
||||
.orderTitle,
|
||||
.orderSubtitle,
|
||||
.orderNotes,
|
||||
.stockMeta {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stockTitle,
|
||||
.orderTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.stockSubtitle,
|
||||
.orderSubtitle,
|
||||
.stockMeta,
|
||||
.orderNotes {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.stockBadge,
|
||||
.orderBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: #f5f5f7;
|
||||
border: 1px solid #e5e5ea;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orderBadge[data-status="paid"] {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #166534;
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.orderBadge[data-status="pending"],
|
||||
.orderBadge[data-status="processing"] {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #92400e;
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.orderBadge[data-status="packed"] {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #1d4ed8;
|
||||
border-color: rgba(59, 130, 246, 0.18);
|
||||
}
|
||||
|
||||
.orderBadge[data-status="shipped"] {
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: #6d28d9;
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
.orderBadge[data-status="delivered"] {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #047857;
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.orderBadge[data-status="failed"],
|
||||
.orderBadge[data-status="canceled"],
|
||||
.orderBadge[data-status="cancelled"] {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #991b1b;
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.stockCardFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.orderCard {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.orderCardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.orderMetaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
div {
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
background: #f9f9fb;
|
||||
border: 1px solid #ececf1;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.orderItems {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
li {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #424245;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
background: #f9f9fb;
|
||||
border: 1px dashed #d2d2d7;
|
||||
font-size: 14px;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.topbarInner,
|
||||
.heroInner,
|
||||
.dashboardShell {
|
||||
width: min(calc(100% - 32px), 100%);
|
||||
}
|
||||
|
||||
.heroInner,
|
||||
.dashboardGrid,
|
||||
.ordersWorkspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.searchControls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detailCallout,
|
||||
.detailSubgrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stickyDetail {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbarInner {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
padding: 14px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress,
|
||||
.topbarActions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.dashboardSection {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.heroMeta,
|
||||
.summaryGrid,
|
||||
.orderMetaGrid,
|
||||
.detailGrid,
|
||||
.detailCallout,
|
||||
.detailSubgrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stockCard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stockCardFooter,
|
||||
.orderCardHeader,
|
||||
.stockCardHeader,
|
||||
.detailFormFooter {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
---
|
||||
import SiteFooter from "../../components/royal-pop/SiteFooter.astro";
|
||||
import { colorways } from "../../data/royalPop";
|
||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||
import styles from "./client.module.scss";
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081");
|
||||
const secondaryCtaClass = styles.secondaryCta;
|
||||
const emptyStateClass = styles.emptyState;
|
||||
const orderCardClass = styles.orderCard;
|
||||
const orderCardHeaderClass = styles.orderCardHeader;
|
||||
const orderTitleClass = styles.orderTitle;
|
||||
const orderSubtitleClass = styles.orderSubtitle;
|
||||
const orderBadgeClass = styles.orderBadge;
|
||||
const orderMetaGridClass = styles.orderMetaGrid;
|
||||
const orderNotesClass = styles.orderNotes;
|
||||
const orderItemsClass = styles.orderItems;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Royal Pop Client Dashboard"
|
||||
description="Private dashboard for reviewing Royal Pop orders and fulfilment activity."
|
||||
noindex={true}
|
||||
>
|
||||
<main class={styles.clientPage} data-client-dashboard>
|
||||
<header class={styles.topbar}>
|
||||
<div class={styles.topbarInner}>
|
||||
<a href="/" class={styles.backLink}>Royal Pop</a>
|
||||
|
||||
<div class={styles.progress} aria-label="Client tools">
|
||||
<span class={styles.progressActive}>Client dashboard</span>
|
||||
<a href="/client/orders/" class={styles.ghostLink}>Orders</a>
|
||||
<a href="/client/stock/" class={styles.ghostLink}>Stock</a>
|
||||
</div>
|
||||
|
||||
<div class={styles.topbarActions}>
|
||||
<p class={styles.helpText}>Private access only</p>
|
||||
<button type="button" class={styles.logoutButton} data-client-logout hidden>Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class={styles.hero}>
|
||||
<div class={styles.heroInner}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>Client operations</p>
|
||||
<h1 class={styles.heroTitle}>Manage orders and stock without leaving the Royal Pop flow.</h1>
|
||||
<p class={styles.heroBody} data-dashboard-hero-body>
|
||||
Sign in to review customer orders, then jump into dedicated workspaces for fulfilment updates and inventory control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.heroMeta}>
|
||||
<div>
|
||||
<strong>Orders</strong>
|
||||
<span>Review recent customer submissions with totals, timestamps, and product details.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Fulfilment</strong>
|
||||
<span>Use the dedicated orders workspace to update statuses, tracking numbers, and shipping notes.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Stock</strong>
|
||||
<span>Open the dedicated stock workspace to adjust inventory counts that feed the public buy page.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.dashboardSection}>
|
||||
<div class={styles.dashboardShell}>
|
||||
<section class={styles.loginPanel} data-client-login-panel>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.sectionKicker}>Sign in</p>
|
||||
<h2>Open the client dashboard.</h2>
|
||||
<p>Use the simple dashboard credentials configured for this environment.</p>
|
||||
</div>
|
||||
|
||||
<form class={styles.loginForm} data-client-login-form>
|
||||
<label class={styles.field}>
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" autocomplete="username" required />
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
|
||||
<div class={styles.loginActions}>
|
||||
<button type="submit" class={styles.primaryCta} data-client-login-submit>Unlock dashboard</button>
|
||||
</div>
|
||||
|
||||
<p class={styles.formStatus} data-client-auth-feedback data-state="info">
|
||||
Sign in to load current orders and open the fulfilment workspace.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class={styles.dashboardPanel} data-client-dashboard-panel hidden>
|
||||
<div class={styles.summaryGrid}>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Total orders</p>
|
||||
<strong data-summary-total-orders>0</strong>
|
||||
<span>Recent customer orders currently visible in the dashboard.</span>
|
||||
</article>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Paid orders</p>
|
||||
<strong data-summary-paid-orders>0</strong>
|
||||
<span>Orders already confirmed by Stripe and persisted.</span>
|
||||
</article>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Processing</p>
|
||||
<strong data-summary-processing-orders>0</strong>
|
||||
<span>Orders already being prepared, packed, or actively worked on.</span>
|
||||
</article>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Shipped</p>
|
||||
<strong data-summary-shipped-orders>0</strong>
|
||||
<span>Orders already moved into shipped or delivered fulfilment states.</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.sectionKicker}>Orders</p>
|
||||
<h2>Recent orders</h2>
|
||||
<p>Latest customer submissions, totals, and fulfilment context from the checkout flow.</p>
|
||||
<div class={styles.loginActions}>
|
||||
<a href="/client/orders/" class={styles.secondaryCta}>Open dedicated orders page</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.ordersList} data-client-orders-list></div>
|
||||
</section>
|
||||
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.sectionKicker}>Stock</p>
|
||||
<h2>Inventory workspace</h2>
|
||||
<p>Use the separate stock page to update live availability while keeping the public buy page tied to current inventory.</p>
|
||||
<div class={styles.loginActions}>
|
||||
<a href="/client/stock/" class={styles.secondaryCta}>Open dedicated stock page</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class={styles.dashboardStatus} data-client-dashboard-feedback data-state="info">Dashboard ready.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
<script
|
||||
define:vars={{
|
||||
apiBaseUrl,
|
||||
colorways,
|
||||
secondaryCtaClass,
|
||||
emptyStateClass,
|
||||
orderCardClass,
|
||||
orderCardHeaderClass,
|
||||
orderTitleClass,
|
||||
orderSubtitleClass,
|
||||
orderBadgeClass,
|
||||
orderMetaGridClass,
|
||||
orderNotesClass,
|
||||
orderItemsClass,
|
||||
}}
|
||||
>
|
||||
const normalizedApiBaseUrl = String(apiBaseUrl || "").replace(/\/$/, "");
|
||||
|
||||
const initClientDashboard = () => {
|
||||
const root = document.querySelector("[data-client-dashboard]");
|
||||
if (!root || root.dataset.initialized === "true") return;
|
||||
root.dataset.initialized = "true";
|
||||
|
||||
const loginPanel = root.querySelector("[data-client-login-panel]");
|
||||
const dashboardPanel = root.querySelector("[data-client-dashboard-panel]");
|
||||
const loginForm = root.querySelector("[data-client-login-form]");
|
||||
const loginSubmit = root.querySelector("[data-client-login-submit]");
|
||||
const authFeedback = root.querySelector("[data-client-auth-feedback]");
|
||||
const dashboardFeedback = root.querySelector("[data-client-dashboard-feedback]");
|
||||
const ordersList = root.querySelector("[data-client-orders-list]");
|
||||
const logoutButton = root.querySelector("[data-client-logout]");
|
||||
const summaryTotalOrders = root.querySelector("[data-summary-total-orders]");
|
||||
const summaryPaidOrders = root.querySelector("[data-summary-paid-orders]");
|
||||
const summaryProcessingOrders = root.querySelector("[data-summary-processing-orders]");
|
||||
const summaryShippedOrders = root.querySelector("[data-summary-shipped-orders]");
|
||||
const heroBody = root.querySelector("[data-dashboard-hero-body]");
|
||||
const colorwayMap = new Map((colorways || []).map((colorway) => [colorway.id, colorway]));
|
||||
|
||||
const formatGBP = (value) =>
|
||||
new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(value || 0) / 100);
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return "—";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" });
|
||||
};
|
||||
|
||||
const setFeedback = (node, message, state = "info") => {
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.dataset.state = state;
|
||||
};
|
||||
|
||||
const updateSummary = (ordersEntries) => {
|
||||
const totalOrders = (ordersEntries || []).length;
|
||||
const paidOrders = (ordersEntries || []).filter((order) => order.status === "paid").length;
|
||||
const processingOrders = (ordersEntries || []).filter((order) => ["processing", "packed"].includes(order.fulfillmentStatus)).length;
|
||||
const shippedOrders = (ordersEntries || []).filter((order) => ["shipped", "delivered"].includes(order.fulfillmentStatus)).length;
|
||||
|
||||
if (summaryTotalOrders) summaryTotalOrders.textContent = String(totalOrders);
|
||||
if (summaryPaidOrders) summaryPaidOrders.textContent = String(paidOrders);
|
||||
if (summaryProcessingOrders) summaryProcessingOrders.textContent = String(processingOrders);
|
||||
if (summaryShippedOrders) summaryShippedOrders.textContent = String(shippedOrders);
|
||||
};
|
||||
|
||||
const renderOrders = (ordersEntries) => {
|
||||
if (!ordersList) return;
|
||||
if (!ordersEntries?.length) {
|
||||
ordersList.innerHTML = `<div class="${emptyStateClass}">No orders have been stored yet.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
ordersList.innerHTML = ordersEntries
|
||||
.map((order) => {
|
||||
const items = (order.items || [])
|
||||
.map((item) => {
|
||||
const colorway = colorwayMap.get(item.colorwayId);
|
||||
return `<li><strong>${colorway?.name || item.colorwayId}</strong> · ${item.style} · ${item.finishId} · ${item.quantity} × ${formatGBP(item.unitAmount)}</li>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<article class="${orderCardClass}">
|
||||
<div class="${orderCardHeaderClass}">
|
||||
<div>
|
||||
<p class="${orderTitleClass}">${order.firstName} ${order.lastName}</p>
|
||||
<p class="${orderSubtitleClass}">${order.email} · ${order.country}</p>
|
||||
</div>
|
||||
<span class="${orderBadgeClass}" data-status="${order.fulfillmentStatus || order.status}">${order.fulfillmentStatus || order.status}</span>
|
||||
</div>
|
||||
<div class="${orderMetaGridClass}">
|
||||
<div><span>Order ID</span><strong>${order.id}</strong></div>
|
||||
<div><span>Total</span><strong>${formatGBP(order.amount)}</strong></div>
|
||||
<div><span>Created</span><strong>${formatDateTime(order.createdAt)}</strong></div>
|
||||
<div><span>Tracking</span><strong>${order.trackingNumber || "Not added"}</strong></div>
|
||||
</div>
|
||||
${order.notes ? `<p class="${orderNotesClass}">${order.notes}</p>` : ""}
|
||||
<ul class="${orderItemsClass}">${items}</ul>
|
||||
<p><a href="/client/orders/" class="${secondaryCtaClass}">Open in orders workspace</a></p>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const setAuthenticatedUi = (authenticated, username = "") => {
|
||||
if (loginPanel) loginPanel.hidden = authenticated;
|
||||
if (dashboardPanel) dashboardPanel.hidden = !authenticated;
|
||||
if (logoutButton) logoutButton.hidden = !authenticated;
|
||||
if (heroBody) {
|
||||
heroBody.textContent = authenticated
|
||||
? `Signed in as ${username || "client user"}. Review live order activity, then jump into dedicated orders or stock workspaces whenever you need fulfilment or inventory changes.`
|
||||
: "Sign in to review recent customer orders and open the dedicated orders or stock workspaces.";
|
||||
}
|
||||
};
|
||||
|
||||
const loadDashboard = async () => {
|
||||
const ordersResponse = await fetch(`${normalizedApiBaseUrl}/v1/client/orders?limit=25`, { credentials: "include" });
|
||||
const ordersPayload = await ordersResponse.json().catch(() => null);
|
||||
|
||||
if (!ordersResponse.ok) {
|
||||
throw new Error(ordersPayload?.error?.message || "The client dashboard could not load recent orders.");
|
||||
}
|
||||
|
||||
const ordersEntries = ordersPayload?.data?.orders || [];
|
||||
renderOrders(ordersEntries);
|
||||
updateSummary(ordersEntries);
|
||||
};
|
||||
|
||||
const syncSession = async () => {
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/session`, { credentials: "include" });
|
||||
const payload = await response.json().catch(() => null);
|
||||
const session = payload?.data;
|
||||
|
||||
if (!response.ok || !session?.configured) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, session?.configured === false ? "Client dashboard credentials are not configured for this environment yet." : "Unable to verify dashboard access.", session?.configured === false ? "error" : "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.authenticated) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, "Sign in to load current orders and open the fulfilment workspace.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthenticatedUi(true, session.username);
|
||||
setFeedback(dashboardFeedback, "Dashboard ready.", "success");
|
||||
await loadDashboard();
|
||||
} catch (error) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, error instanceof Error ? error.message : "Unable to verify dashboard access.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
loginForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(loginForm);
|
||||
const username = String(formData.get("username") || "").trim();
|
||||
const password = String(formData.get("password") || "");
|
||||
|
||||
if (!username || !password) {
|
||||
setFeedback(authFeedback, "Enter both the username and password.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginSubmit) {
|
||||
loginSubmit.disabled = true;
|
||||
loginSubmit.textContent = "Unlocking…";
|
||||
}
|
||||
setFeedback(authFeedback, "Checking credentials…", "info");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/login`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The dashboard credentials were rejected.");
|
||||
}
|
||||
|
||||
setFeedback(authFeedback, "Access granted. Loading dashboard…", "success");
|
||||
loginForm.reset();
|
||||
await syncSession();
|
||||
} catch (error) {
|
||||
setFeedback(authFeedback, error instanceof Error ? error.message : "The dashboard credentials were rejected.", "error");
|
||||
} finally {
|
||||
if (loginSubmit) {
|
||||
loginSubmit.disabled = false;
|
||||
loginSubmit.textContent = "Unlock dashboard";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logoutButton?.addEventListener("click", async () => {
|
||||
await fetch(`${normalizedApiBaseUrl}/v1/client/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, "Signed out. Sign in again to reopen the dashboard.", "info");
|
||||
});
|
||||
|
||||
syncSession();
|
||||
};
|
||||
|
||||
initClientDashboard();
|
||||
document.addEventListener("astro:page-load", initClientDashboard);
|
||||
</script>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,715 @@
|
||||
---
|
||||
import SiteFooter from "../../../components/royal-pop/SiteFooter.astro";
|
||||
import { colorways } from "../../../data/royalPop";
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import styles from "../client.module.scss";
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081");
|
||||
const fulfillmentStatuses = [
|
||||
"pending",
|
||||
"paid",
|
||||
"processing",
|
||||
"packed",
|
||||
"shipped",
|
||||
"delivered",
|
||||
"cancelled",
|
||||
];
|
||||
const finishOptions = [
|
||||
{ id: "silver", label: "Silver" },
|
||||
{ id: "black-pvd", label: "Black PVD" },
|
||||
{ id: "rose-gold", label: "Rose Gold" },
|
||||
];
|
||||
|
||||
const resultCardClass = styles.resultCard;
|
||||
const resultCardActiveClass = styles.resultCardActive;
|
||||
const resultMetaClass = styles.resultMeta;
|
||||
const resultCustomerClass = styles.resultCustomer;
|
||||
const resultSummaryClass = styles.resultSummary;
|
||||
const detailPanelClass = styles.detailPanel;
|
||||
const detailSectionClass = styles.detailSection;
|
||||
const detailGridClass = styles.detailGrid;
|
||||
const detailAddressClass = styles.detailAddress;
|
||||
const detailItemsClass = styles.detailItems;
|
||||
const detailFormClass = styles.detailForm;
|
||||
const searchControlsClass = styles.searchControls;
|
||||
const toolbarClass = styles.toolbar;
|
||||
const ordersWorkspaceClass = styles.ordersWorkspace;
|
||||
const orderResultsClass = styles.orderResults;
|
||||
const stickyDetailClass = styles.stickyDetail;
|
||||
const selectFieldClass = styles.selectField;
|
||||
const textareaFieldClass = styles.textareaField;
|
||||
const emptyDetailClass = styles.emptyDetail;
|
||||
const ghostLinkClass = styles.ghostLink;
|
||||
const statRowClass = styles.statRow;
|
||||
const orderBadgeClass = styles.orderBadge;
|
||||
const orderTitleClass = styles.orderTitle;
|
||||
const orderSubtitleClass = styles.orderSubtitle;
|
||||
const orderCardHeaderClass = styles.orderCardHeader;
|
||||
const fieldClass = styles.field;
|
||||
const primaryCtaClass = styles.primaryCta;
|
||||
const secondaryCtaClass = styles.secondaryCta;
|
||||
const loginActionsClass = styles.loginActions;
|
||||
const sectionKickerClass = styles.sectionKicker;
|
||||
const searchActionsClass = styles.searchActions;
|
||||
const searchHintClass = styles.searchHint;
|
||||
const resultFooterClass = styles.resultFooter;
|
||||
const detailCalloutClass = styles.detailCallout;
|
||||
const detailSubgridClass = styles.detailSubgrid;
|
||||
const detailFormFooterClass = styles.detailFormFooter;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Royal Pop Client Orders"
|
||||
description="Private Royal Pop orders workspace for reviewing and updating customer orders."
|
||||
noindex={true}
|
||||
>
|
||||
<main class={styles.clientPage} data-client-orders-page>
|
||||
<header class={styles.topbar}>
|
||||
<div class={styles.topbarInner}>
|
||||
<a href="/client/" class={styles.backLink}>Royal Pop</a>
|
||||
|
||||
<div class={styles.progress} aria-label="Client tools">
|
||||
<a href="/client/" class={ghostLinkClass}>Client dashboard</a>
|
||||
<span class={styles.progressActive}>Orders</span>
|
||||
<a href="/client/stock/" class={ghostLinkClass}>Stock</a>
|
||||
</div>
|
||||
|
||||
<div class={styles.topbarActions}>
|
||||
<p class={styles.helpText}>Private access only</p>
|
||||
<button type="button" class={styles.logoutButton} data-client-logout hidden>Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class={styles.hero}>
|
||||
<div class={styles.heroInner}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>Client orders</p>
|
||||
<h1 class={styles.heroTitle}>Search orders, open full details, and manage fulfilment in one place.</h1>
|
||||
<p class={styles.heroBody} data-orders-hero-body>
|
||||
Review customer details, shipping addresses, line items, and update tracking without leaving the Royal Pop admin flow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.heroMeta}>
|
||||
<div>
|
||||
<strong>Search</strong>
|
||||
<span>Filter by order ID, customer name, email, or tracking number.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Detail</strong>
|
||||
<span>Open full shipping details, payment state, webhook notes, and order contents.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Fulfilment</strong>
|
||||
<span>Move orders through approved statuses and attach carrier + tracking updates.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.dashboardSection}>
|
||||
<div class={styles.dashboardShell}>
|
||||
<section class={styles.loginPanel} data-client-login-panel>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.sectionKicker}>Sign in</p>
|
||||
<h2>Open the orders console.</h2>
|
||||
<p>Use the same simple client credentials as the main dashboard.</p>
|
||||
</div>
|
||||
|
||||
<form class={styles.loginForm} data-client-login-form>
|
||||
<label class={styles.field}>
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" autocomplete="username" required />
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
|
||||
<div class={styles.loginActions}>
|
||||
<button type="submit" class={styles.primaryCta} data-client-login-submit>Unlock orders</button>
|
||||
</div>
|
||||
|
||||
<p class={styles.formStatus} data-client-auth-feedback data-state="info">
|
||||
Sign in to search and update customer orders.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class={styles.dashboardPanel} data-client-orders-panel hidden>
|
||||
<div class={toolbarClass}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.sectionKicker}>Orders workspace</p>
|
||||
<h2>Dedicated order management</h2>
|
||||
<p>Search your current order set and open any order to review the address, payment state, and fulfilment details.</p>
|
||||
</div>
|
||||
|
||||
<form class={searchControlsClass} data-orders-search-form>
|
||||
<label class={styles.field}>
|
||||
<span>Search</span>
|
||||
<input type="search" name="search" placeholder="Order ID, customer, email, or tracking" />
|
||||
</label>
|
||||
<label class={selectFieldClass}>
|
||||
<span>Status</span>
|
||||
<select name="status">
|
||||
<option value="">All fulfilment statuses</option>
|
||||
{fulfillmentStatuses.map((status) => (
|
||||
<option value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div class={searchActionsClass}>
|
||||
<button type="submit" class={styles.primaryCta}>Search orders</button>
|
||||
<button type="button" class={secondaryCtaClass} data-orders-reset>Clear</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class={searchHintClass}>Tip: use the detail panel to save fulfilment changes without losing your filtered order list.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.summaryGrid}>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Total orders</p>
|
||||
<strong data-summary-total-orders>0</strong>
|
||||
<span>Current results in this filtered view.</span>
|
||||
</article>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Open fulfilment</p>
|
||||
<strong data-summary-open-orders>0</strong>
|
||||
<span>Pending, paid, processing, or packed orders still moving forward.</span>
|
||||
</article>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Shipped</p>
|
||||
<strong data-summary-shipped-orders>0</strong>
|
||||
<span>Orders dispatched with tracking already attached.</span>
|
||||
</article>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Delivered</p>
|
||||
<strong data-summary-delivered-orders>0</strong>
|
||||
<span>Orders marked complete after successful arrival.</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class={ordersWorkspaceClass}>
|
||||
<section class={orderResultsClass}>
|
||||
<div class={statRowClass}>
|
||||
<p class={styles.helpText}>Results</p>
|
||||
<strong data-orders-count>0 orders</strong>
|
||||
</div>
|
||||
<div class={styles.ordersList} data-orders-results></div>
|
||||
</section>
|
||||
|
||||
<section class={stickyDetailClass}>
|
||||
<div data-order-detail></div>
|
||||
<p class={styles.dashboardStatus} data-order-detail-feedback data-state="info">Choose an order to inspect and update it.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p class={styles.dashboardStatus} data-client-orders-feedback data-state="info">Orders console ready.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
<script
|
||||
define:vars={{
|
||||
apiBaseUrl,
|
||||
colorways,
|
||||
finishOptions,
|
||||
fulfillmentStatuses,
|
||||
resultCardClass,
|
||||
resultCardActiveClass,
|
||||
resultMetaClass,
|
||||
resultCustomerClass,
|
||||
resultSummaryClass,
|
||||
detailPanelClass,
|
||||
detailSectionClass,
|
||||
detailGridClass,
|
||||
detailAddressClass,
|
||||
detailItemsClass,
|
||||
detailFormClass,
|
||||
searchControlsClass,
|
||||
toolbarClass,
|
||||
ordersWorkspaceClass,
|
||||
orderResultsClass,
|
||||
stickyDetailClass,
|
||||
selectFieldClass,
|
||||
textareaFieldClass,
|
||||
emptyDetailClass,
|
||||
ghostLinkClass,
|
||||
statRowClass,
|
||||
orderBadgeClass,
|
||||
orderTitleClass,
|
||||
orderSubtitleClass,
|
||||
orderCardHeaderClass,
|
||||
fieldClass,
|
||||
primaryCtaClass,
|
||||
secondaryCtaClass,
|
||||
loginActionsClass,
|
||||
sectionKickerClass,
|
||||
searchActionsClass,
|
||||
searchHintClass,
|
||||
resultFooterClass,
|
||||
detailCalloutClass,
|
||||
detailSubgridClass,
|
||||
detailFormFooterClass,
|
||||
}}
|
||||
>
|
||||
const normalizedApiBaseUrl = String(apiBaseUrl || "").replace(/\/$/, "");
|
||||
|
||||
const initClientOrdersPage = () => {
|
||||
const root = document.querySelector("[data-client-orders-page]");
|
||||
if (!root || root.dataset.initialized === "true") return;
|
||||
root.dataset.initialized = "true";
|
||||
|
||||
const loginPanel = root.querySelector("[data-client-login-panel]");
|
||||
const ordersPanel = root.querySelector("[data-client-orders-panel]");
|
||||
const loginForm = root.querySelector("[data-client-login-form]");
|
||||
const loginSubmit = root.querySelector("[data-client-login-submit]");
|
||||
const authFeedback = root.querySelector("[data-client-auth-feedback]");
|
||||
const ordersFeedback = root.querySelector("[data-client-orders-feedback]");
|
||||
const detailFeedback = root.querySelector("[data-order-detail-feedback]");
|
||||
const searchForm = root.querySelector("[data-orders-search-form]");
|
||||
const resetButton = root.querySelector("[data-orders-reset]");
|
||||
const resultsNode = root.querySelector("[data-orders-results]");
|
||||
const detailNode = root.querySelector("[data-order-detail]");
|
||||
const ordersCount = root.querySelector("[data-orders-count]");
|
||||
const summaryTotalOrders = root.querySelector("[data-summary-total-orders]");
|
||||
const summaryOpenOrders = root.querySelector("[data-summary-open-orders]");
|
||||
const summaryShippedOrders = root.querySelector("[data-summary-shipped-orders]");
|
||||
const summaryDeliveredOrders = root.querySelector("[data-summary-delivered-orders]");
|
||||
const heroBody = root.querySelector("[data-orders-hero-body]");
|
||||
const logoutButton = root.querySelector("[data-client-logout]");
|
||||
|
||||
const colorwayMap = new Map((colorways || []).map((colorway) => [colorway.id, colorway]));
|
||||
const finishMap = new Map((finishOptions || []).map((finish) => [finish.id, finish.label]));
|
||||
const state = {
|
||||
orders: [],
|
||||
selectedOrderId: "",
|
||||
selectedOrder: null,
|
||||
filters: {
|
||||
search: "",
|
||||
status: "",
|
||||
},
|
||||
};
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const formatGBP = (value) =>
|
||||
new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(value || 0) / 100);
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return "—";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleString("en-GB", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
};
|
||||
|
||||
const setFeedback = (node, message, stateName = "info") => {
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.dataset.state = stateName;
|
||||
};
|
||||
|
||||
const setAuthenticatedUi = (authenticated, username = "") => {
|
||||
if (loginPanel) loginPanel.hidden = authenticated;
|
||||
if (ordersPanel) ordersPanel.hidden = !authenticated;
|
||||
if (logoutButton) logoutButton.hidden = !authenticated;
|
||||
if (heroBody) {
|
||||
heroBody.textContent = authenticated
|
||||
? `Signed in as ${username || "client user"}. Search current orders, inspect addresses, and update fulfilment details.`
|
||||
: "Review customer details, shipping addresses, line items, and update tracking without leaving the Royal Pop admin flow.";
|
||||
}
|
||||
};
|
||||
|
||||
const updateSummary = () => {
|
||||
const total = state.orders.length;
|
||||
const open = state.orders.filter((order) => ["pending", "paid", "processing", "packed"].includes(order.fulfillmentStatus)).length;
|
||||
const shipped = state.orders.filter((order) => order.fulfillmentStatus === "shipped").length;
|
||||
const delivered = state.orders.filter((order) => order.fulfillmentStatus === "delivered").length;
|
||||
|
||||
if (summaryTotalOrders) summaryTotalOrders.textContent = String(total);
|
||||
if (summaryOpenOrders) summaryOpenOrders.textContent = String(open);
|
||||
if (summaryShippedOrders) summaryShippedOrders.textContent = String(shipped);
|
||||
if (summaryDeliveredOrders) summaryDeliveredOrders.textContent = String(delivered);
|
||||
};
|
||||
|
||||
const renderResults = () => {
|
||||
if (!resultsNode) return;
|
||||
if (!state.orders.length) {
|
||||
resultsNode.innerHTML = `<div class="${emptyDetailClass}">No orders matched this search yet.</div>`;
|
||||
if (ordersCount) ordersCount.textContent = "0 orders";
|
||||
updateSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ordersCount) {
|
||||
ordersCount.textContent = `${state.orders.length} ${state.orders.length === 1 ? "order" : "orders"}`;
|
||||
}
|
||||
|
||||
resultsNode.innerHTML = state.orders
|
||||
.map((order) => {
|
||||
const activeClass = order.id === state.selectedOrderId ? ` ${resultCardActiveClass}` : "";
|
||||
return `
|
||||
<button type="button" class="${resultCardClass}${activeClass}" data-order-select="${escapeHtml(order.id)}">
|
||||
<div class="${resultCustomerClass}">
|
||||
<div>
|
||||
<strong>${escapeHtml(order.firstName)} ${escapeHtml(order.lastName)}</strong>
|
||||
<p>${escapeHtml(order.email)}</p>
|
||||
</div>
|
||||
<span class="${orderBadgeClass}" data-status="${escapeHtml(order.fulfillmentStatus || order.status)}">${escapeHtml(order.fulfillmentStatus || order.status)}</span>
|
||||
</div>
|
||||
<div class="${resultMetaClass}">
|
||||
<span>${escapeHtml(order.id)}</span>
|
||||
<span>${formatGBP(order.amount)}</span>
|
||||
<span>${formatDateTime(order.createdAt)}</span>
|
||||
</div>
|
||||
<p class="${resultSummaryClass}">${escapeHtml(order.city || "—")}, ${escapeHtml(order.country || "—")} · ${Number(order.itemCount || 0)} items</p>
|
||||
<div class="${resultFooterClass}">
|
||||
<span>Tracking: ${escapeHtml(order.trackingNumber || "Not added")}</span>
|
||||
<span>Carrier: ${escapeHtml(order.shippingCarrier || "Not set")}</span>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
updateSummary();
|
||||
|
||||
Array.from(resultsNode.querySelectorAll("[data-order-select]"))?.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const orderID = button.getAttribute("data-order-select") || "";
|
||||
if (!orderID) return;
|
||||
await loadOrderDetail(orderID);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderDetail = () => {
|
||||
if (!detailNode) return;
|
||||
const order = state.selectedOrder;
|
||||
if (!order) {
|
||||
detailNode.innerHTML = `<div class="${emptyDetailClass}">Choose an order from the left to view the customer address, items, and fulfilment controls.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const addressParts = [order.addressLine1, order.addressLine2, order.city, order.region, order.postalCode, order.country]
|
||||
.filter(Boolean)
|
||||
.map((part) => `<span>${escapeHtml(part)}</span>`)
|
||||
.join("");
|
||||
const itemsMarkup = (order.items || [])
|
||||
.map((item) => {
|
||||
const colorway = colorwayMap.get(item.colorwayId);
|
||||
const finishLabel = finishMap.get(item.finishId) || item.finishId;
|
||||
return `<li><strong>${escapeHtml(colorway?.name || item.colorwayId)}</strong><span>${escapeHtml(item.style)} · ${escapeHtml(finishLabel)} · ${Number(item.quantity || 0)} × ${formatGBP(item.unitAmount)}</span></li>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
detailNode.innerHTML = `
|
||||
<article class="${detailPanelClass}">
|
||||
<div class="${orderCardHeaderClass}">
|
||||
<div>
|
||||
<p class="${orderTitleClass}">${escapeHtml(order.firstName)} ${escapeHtml(order.lastName)}</p>
|
||||
<p class="${orderSubtitleClass}">${escapeHtml(order.email)}${order.phone ? ` · ${escapeHtml(order.phone)}` : ""}</p>
|
||||
</div>
|
||||
<span class="${orderBadgeClass}" data-status="${escapeHtml(order.fulfillmentStatus)}">${escapeHtml(order.fulfillmentStatus)}</span>
|
||||
</div>
|
||||
|
||||
<section class="${detailSectionClass}">
|
||||
<div class="${detailCalloutClass}">
|
||||
<div>
|
||||
<span>Customer note</span>
|
||||
<strong>${escapeHtml(order.notes || "No customer note saved.")}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Tracking</span>
|
||||
<strong>${escapeHtml(order.trackingNumber || "Not attached yet")}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="${detailSectionClass}">
|
||||
<div class="${detailGridClass}">
|
||||
<div><span>Order ID</span><strong>${escapeHtml(order.id)}</strong></div>
|
||||
<div><span>Payment</span><strong>${escapeHtml(order.status)}</strong></div>
|
||||
<div><span>Total</span><strong>${formatGBP(order.amount)}</strong></div>
|
||||
<div><span>Webhook</span><strong>${escapeHtml(order.webhookStatus || "awaiting_webhook")}</strong></div>
|
||||
<div><span>Created</span><strong>${formatDateTime(order.createdAt)}</strong></div>
|
||||
<div><span>Shipped</span><strong>${formatDateTime(order.shippedAt)}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="${detailSectionClass}">
|
||||
<div class="${detailSubgridClass}">
|
||||
<div class="${detailAddressClass}">
|
||||
<p class="${sectionKickerClass}">Shipping address</p>
|
||||
${addressParts || "<span>No shipping address saved.</span>"}
|
||||
</div>
|
||||
<div class="${detailAddressClass}">
|
||||
<p class="${sectionKickerClass}">Shipping snapshot</p>
|
||||
<span>Carrier: ${escapeHtml(order.shippingCarrier || "Not set")}</span>
|
||||
<span>Tracking: ${escapeHtml(order.trackingNumber || "Not attached yet")}</span>
|
||||
<span>Updated: ${formatDateTime(order.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="${detailSectionClass}">
|
||||
<p class="${sectionKickerClass}">Items</p>
|
||||
<ul class="${detailItemsClass}">${itemsMarkup || "<li>No line items found.</li>"}</ul>
|
||||
</section>
|
||||
|
||||
<form class="${detailFormClass}" data-order-update-form>
|
||||
<label class="${selectFieldClass}">
|
||||
<span>Fulfilment status</span>
|
||||
<select name="fulfillmentStatus">
|
||||
${(fulfillmentStatuses || []).map((status) => `<option value="${escapeHtml(status)}" ${status === order.fulfillmentStatus ? "selected" : ""}>${escapeHtml(status)}</option>`).join("")}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="${fieldClass}">
|
||||
<span>Shipping carrier</span>
|
||||
<input type="text" name="shippingCarrier" value="${escapeHtml(order.shippingCarrier || "")}" placeholder="Royal Mail, DHL, UPS…" />
|
||||
</label>
|
||||
|
||||
<label class="${fieldClass}">
|
||||
<span>Tracking number</span>
|
||||
<input type="text" name="trackingNumber" value="${escapeHtml(order.trackingNumber || "")}" placeholder="Add the client-facing tracking reference" />
|
||||
</label>
|
||||
|
||||
<label class="${textareaFieldClass}">
|
||||
<span>Internal fulfilment notes</span>
|
||||
<textarea name="fulfillmentNotes" rows="4" placeholder="Packing notes, dispatch details, customer follow-up…">${escapeHtml(order.fulfillmentNotes || "")}</textarea>
|
||||
</label>
|
||||
|
||||
<div class="${detailFormFooterClass}">
|
||||
<p class="${searchHintClass}">Saving here updates the local dashboard backend immediately.</p>
|
||||
<button type="submit" class="${primaryCtaClass}">Save fulfilment update</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
`;
|
||||
|
||||
const updateForm = detailNode.querySelector("[data-order-update-form]");
|
||||
updateForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(updateForm);
|
||||
const nextStatus = String(formData.get("fulfillmentStatus") || "").trim();
|
||||
const currentlyShipped = Boolean(order.shippedAt);
|
||||
const shouldMarkShippedNow = nextStatus === "shipped" && !currentlyShipped;
|
||||
const shouldClearShippedAt = currentlyShipped && !["shipped", "delivered"].includes(nextStatus);
|
||||
|
||||
setFeedback(detailFeedback, `Saving ${order.id}…`, "info");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/orders/${encodeURIComponent(order.id)}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
fulfillmentStatus: nextStatus,
|
||||
shippingCarrier: String(formData.get("shippingCarrier") || ""),
|
||||
trackingNumber: String(formData.get("trackingNumber") || ""),
|
||||
fulfillmentNotes: String(formData.get("fulfillmentNotes") || ""),
|
||||
markShippedAtNow: shouldMarkShippedNow,
|
||||
clearShippedAt: shouldClearShippedAt,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The order update could not be saved.");
|
||||
}
|
||||
|
||||
state.selectedOrder = payload?.data?.order || null;
|
||||
setFeedback(detailFeedback, `Saved fulfilment update for ${order.id}.`, "success");
|
||||
await loadOrders(order.id, false);
|
||||
if (state.selectedOrder) renderDetail();
|
||||
} catch (error) {
|
||||
setFeedback(detailFeedback, error instanceof Error ? error.message : "The order update could not be saved.", "error");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadOrderDetail = async (orderID) => {
|
||||
state.selectedOrderId = orderID;
|
||||
renderResults();
|
||||
setFeedback(detailFeedback, `Loading ${orderID}…`, "info");
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/orders/${encodeURIComponent(orderID)}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The order details could not be loaded.");
|
||||
}
|
||||
state.selectedOrder = payload?.data?.order || null;
|
||||
renderDetail();
|
||||
setFeedback(detailFeedback, `Viewing ${orderID}.`, "success");
|
||||
} catch (error) {
|
||||
state.selectedOrder = null;
|
||||
renderDetail();
|
||||
setFeedback(detailFeedback, error instanceof Error ? error.message : "The order details could not be loaded.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrders = async (preferredOrderID = "", preserveDetail = true) => {
|
||||
setFeedback(ordersFeedback, "Loading orders…", "info");
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (state.filters.search) params.set("search", state.filters.search);
|
||||
if (state.filters.status) params.set("status", state.filters.status);
|
||||
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/orders?${params.toString()}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The orders list could not be loaded.");
|
||||
}
|
||||
|
||||
state.orders = payload?.data?.orders || [];
|
||||
const nextSelected = preferredOrderID || state.selectedOrderId || state.orders[0]?.id || "";
|
||||
state.selectedOrderId = nextSelected;
|
||||
renderResults();
|
||||
|
||||
if (!nextSelected) {
|
||||
state.selectedOrder = null;
|
||||
renderDetail();
|
||||
setFeedback(ordersFeedback, "No orders matched the current filters.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (preserveDetail && state.selectedOrder?.id === nextSelected) {
|
||||
renderDetail();
|
||||
setFeedback(ordersFeedback, "Orders list refreshed.", "success");
|
||||
return;
|
||||
}
|
||||
|
||||
await loadOrderDetail(nextSelected);
|
||||
setFeedback(ordersFeedback, "Orders loaded.", "success");
|
||||
};
|
||||
|
||||
const syncSession = async () => {
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/session`, { credentials: "include" });
|
||||
const payload = await response.json().catch(() => null);
|
||||
const session = payload?.data;
|
||||
|
||||
if (!response.ok || !session?.configured) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, session?.configured === false ? "Client dashboard credentials are not configured for this environment yet." : "Unable to verify orders access.", session?.configured === false ? "error" : "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.authenticated) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, "Sign in to search and update customer orders.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthenticatedUi(true, session.username);
|
||||
await loadOrders();
|
||||
} catch (error) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, error instanceof Error ? error.message : "Unable to verify orders access.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
loginForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(loginForm);
|
||||
const username = String(formData.get("username") || "").trim();
|
||||
const password = String(formData.get("password") || "");
|
||||
|
||||
if (!username || !password) {
|
||||
setFeedback(authFeedback, "Enter both the username and password.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginSubmit) {
|
||||
loginSubmit.disabled = true;
|
||||
loginSubmit.textContent = "Unlocking…";
|
||||
}
|
||||
setFeedback(authFeedback, "Checking credentials…", "info");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/login`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The orders credentials were rejected.");
|
||||
}
|
||||
loginForm.reset();
|
||||
setFeedback(authFeedback, "Access granted. Loading orders…", "success");
|
||||
await syncSession();
|
||||
} catch (error) {
|
||||
setFeedback(authFeedback, error instanceof Error ? error.message : "The orders credentials were rejected.", "error");
|
||||
} finally {
|
||||
if (loginSubmit) {
|
||||
loginSubmit.disabled = false;
|
||||
loginSubmit.textContent = "Unlock orders";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
searchForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(searchForm);
|
||||
state.filters.search = String(formData.get("search") || "").trim();
|
||||
state.filters.status = String(formData.get("status") || "").trim();
|
||||
try {
|
||||
await loadOrders();
|
||||
} catch (error) {
|
||||
setFeedback(ordersFeedback, error instanceof Error ? error.message : "The orders list could not be loaded.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
resetButton?.addEventListener("click", async () => {
|
||||
if (searchForm instanceof HTMLFormElement) {
|
||||
searchForm.reset();
|
||||
}
|
||||
state.filters.search = "";
|
||||
state.filters.status = "";
|
||||
try {
|
||||
await loadOrders();
|
||||
} catch (error) {
|
||||
setFeedback(ordersFeedback, error instanceof Error ? error.message : "The orders list could not be loaded.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
logoutButton?.addEventListener("click", async () => {
|
||||
await fetch(`${normalizedApiBaseUrl}/v1/client/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
state.orders = [];
|
||||
state.selectedOrder = null;
|
||||
state.selectedOrderId = "";
|
||||
setAuthenticatedUi(false);
|
||||
renderResults();
|
||||
renderDetail();
|
||||
setFeedback(authFeedback, "Signed out. Sign in again to reopen the orders console.", "info");
|
||||
});
|
||||
|
||||
renderDetail();
|
||||
syncSession();
|
||||
};
|
||||
|
||||
initClientOrdersPage();
|
||||
document.addEventListener("astro:page-load", initClientOrdersPage);
|
||||
</script>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,273 @@
|
||||
---
|
||||
import SiteFooter from "../../../components/royal-pop/SiteFooter.astro";
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import styles from "../client.module.scss";
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081");
|
||||
const dangerListClass = styles.dangerList;
|
||||
const dangerPanelClass = styles.dangerPanel;
|
||||
const dangerActionClass = styles.dangerAction;
|
||||
const dangerNoteClass = styles.dangerNote;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Royal Pop Client Reseed"
|
||||
description="Private Royal Pop reseed tool for backup and reset operations."
|
||||
noindex={true}
|
||||
>
|
||||
<main class={styles.clientPage} data-client-reseed-page>
|
||||
<header class={styles.topbar}>
|
||||
<div class={styles.topbarInner}>
|
||||
<a href="/client/" class={styles.backLink}>Royal Pop</a>
|
||||
|
||||
<div class={styles.progress} aria-label="Client tools">
|
||||
<span class={styles.progressActive}>Client reseed</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.topbarActions}>
|
||||
<p class={styles.helpText}>Local only</p>
|
||||
<button type="button" class={styles.logoutButton} data-client-logout hidden>Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class={styles.hero}>
|
||||
<div class={styles.heroInner}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>Client reseed</p>
|
||||
<h1 class={styles.heroTitle}>Back up the local database, then clear it back to empty.</h1>
|
||||
<p class={styles.heroBody} data-reseed-hero-body>
|
||||
This private tool creates a timestamped SQL backup first, then wipes orders, order items, and inventory rows while keeping the schema intact.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.heroMeta}>
|
||||
<div>
|
||||
<strong>Backup first</strong>
|
||||
<span>A SQL snapshot is written before anything is truncated so you have a recovery point.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>App data only</strong>
|
||||
<span>Orders, order items, and inventory rows are cleared. Tables, indexes, and routes remain in place.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>No shortcuts</strong>
|
||||
<span>Use this only when you truly want a clean local state for demos, testing, or re-seeding.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.dashboardSection}>
|
||||
<div class={styles.dashboardShell}>
|
||||
<section class={styles.loginPanel} data-client-login-panel>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.sectionKicker}>Sign in</p>
|
||||
<h2>Unlock the reseed tool.</h2>
|
||||
<p>Use the same private client credentials as the other dashboard pages.</p>
|
||||
</div>
|
||||
|
||||
<form class={styles.loginForm} data-client-login-form>
|
||||
<label class={styles.field}>
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" autocomplete="username" required />
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
|
||||
<div class={styles.loginActions}>
|
||||
<button type="submit" class={styles.primaryCta} data-client-login-submit>Unlock reseed</button>
|
||||
</div>
|
||||
|
||||
<p class={styles.formStatus} data-client-auth-feedback data-state="info">
|
||||
Sign in to create a backup and reset the local database.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class={dangerPanelClass} data-client-reseed-panel hidden>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.sectionKicker}>Danger zone</p>
|
||||
<h2>Backup and reseed the local database</h2>
|
||||
<p>This action writes a SQL backup file first, then empties the app tables so the local client pages come back up with a clean state.</p>
|
||||
</div>
|
||||
|
||||
<ul class={dangerListClass}>
|
||||
<li>Creates a timestamped SQL backup file under <strong>Backend/Backups/reseed/</strong>.</li>
|
||||
<li>Clears <strong>orders</strong>, <strong>order_items</strong>, and <strong>inventory_levels</strong>.</li>
|
||||
<li>Keeps the schema, migrations, and app code intact.</li>
|
||||
</ul>
|
||||
|
||||
<div class={dangerActionClass}>
|
||||
<button type="button" class={styles.primaryCta} data-reseed-trigger>Backup and reseed database</button>
|
||||
<p class={dangerNoteClass}>There is no second step on this page. Once you press the button, the local reset runs immediately after the backup file is written.</p>
|
||||
</div>
|
||||
|
||||
<p class={styles.dashboardStatus} data-client-reseed-feedback data-state="info">Reseed tool ready.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
<script define:vars={{ apiBaseUrl }}>
|
||||
const normalizedApiBaseUrl = String(apiBaseUrl || "").replace(/\/$/, "");
|
||||
|
||||
const initClientReseedPage = () => {
|
||||
const root = document.querySelector("[data-client-reseed-page]");
|
||||
if (!root || root.dataset.initialized === "true") return;
|
||||
root.dataset.initialized = "true";
|
||||
|
||||
const loginPanel = root.querySelector("[data-client-login-panel]");
|
||||
const reseedPanel = root.querySelector("[data-client-reseed-panel]");
|
||||
const loginForm = root.querySelector("[data-client-login-form]");
|
||||
const loginSubmit = root.querySelector("[data-client-login-submit]");
|
||||
const authFeedback = root.querySelector("[data-client-auth-feedback]");
|
||||
const reseedFeedback = root.querySelector("[data-client-reseed-feedback]");
|
||||
const reseedTrigger = root.querySelector("[data-reseed-trigger]");
|
||||
const logoutButton = root.querySelector("[data-client-logout]");
|
||||
const heroBody = root.querySelector("[data-reseed-hero-body]");
|
||||
|
||||
const setFeedback = (node, message, state = "info") => {
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.dataset.state = state;
|
||||
};
|
||||
|
||||
const setAuthenticatedUi = (authenticated, username = "") => {
|
||||
if (loginPanel) loginPanel.hidden = authenticated;
|
||||
if (reseedPanel) reseedPanel.hidden = !authenticated;
|
||||
if (logoutButton) logoutButton.hidden = !authenticated;
|
||||
if (heroBody) {
|
||||
heroBody.textContent = authenticated
|
||||
? `Signed in as ${username || "client user"}. This tool will create a backup file first and then reset the local database to an empty state.`
|
||||
: "Sign in to create a backup and reset the local database to an empty state.";
|
||||
}
|
||||
};
|
||||
|
||||
const syncSession = async () => {
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/session`, { credentials: "include" });
|
||||
const payload = await response.json().catch(() => null);
|
||||
const session = payload?.data;
|
||||
|
||||
if (!response.ok || !session?.configured) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, session?.configured === false ? "Client dashboard credentials are not configured for this environment yet." : "Unable to verify dashboard access.", session?.configured === false ? "error" : "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.authenticated) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, "Sign in to create a backup and reset the local database.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthenticatedUi(true, session.username);
|
||||
setFeedback(reseedFeedback, "Reseed tool ready.", "success");
|
||||
} catch (error) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, error instanceof Error ? error.message : "Unable to verify dashboard access.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
loginForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(loginForm);
|
||||
const username = String(formData.get("username") || "").trim();
|
||||
const password = String(formData.get("password") || "");
|
||||
|
||||
if (!username || !password) {
|
||||
setFeedback(authFeedback, "Enter both the username and password.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginSubmit) {
|
||||
loginSubmit.disabled = true;
|
||||
loginSubmit.textContent = "Unlocking…";
|
||||
}
|
||||
|
||||
setFeedback(authFeedback, "Checking credentials…", "info");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/login`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The client dashboard username or password is incorrect.");
|
||||
}
|
||||
|
||||
loginForm.reset();
|
||||
setAuthenticatedUi(true, payload?.data?.username || username);
|
||||
setFeedback(reseedFeedback, "Reseed tool ready.", "success");
|
||||
} catch (error) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, error instanceof Error ? error.message : "Unable to unlock the reseed tool.", "error");
|
||||
} finally {
|
||||
if (loginSubmit) {
|
||||
loginSubmit.disabled = false;
|
||||
loginSubmit.textContent = "Unlock reseed";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logoutButton?.addEventListener("click", async () => {
|
||||
logoutButton.disabled = true;
|
||||
try {
|
||||
await fetch(`${normalizedApiBaseUrl}/v1/client/logout`, { method: "POST", credentials: "include" });
|
||||
} finally {
|
||||
logoutButton.disabled = false;
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, "Signed out. Sign in again to use the reseed tool.", "info");
|
||||
}
|
||||
});
|
||||
|
||||
reseedTrigger?.addEventListener("click", async () => {
|
||||
if (!window.confirm("This will back up the local database and then clear orders, order items, and inventory. Continue?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
reseedTrigger.disabled = true;
|
||||
reseedTrigger.textContent = "Backing up and reseeding…";
|
||||
setFeedback(reseedFeedback, "Creating backup and clearing the local database…", "info");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/reseed`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The database backup or reset could not be completed.");
|
||||
}
|
||||
|
||||
const data = payload?.data || {};
|
||||
setFeedback(
|
||||
reseedFeedback,
|
||||
`Backup saved to ${data.backupPath || "Backups/reseed"}. Reset cleared ${data.ordersCount || 0} orders, ${data.orderItemsCount || 0} order items, and ${data.inventoryCount || 0} inventory rows.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
setFeedback(reseedFeedback, error instanceof Error ? error.message : "The database backup or reset could not be completed.", "error");
|
||||
} finally {
|
||||
reseedTrigger.disabled = false;
|
||||
reseedTrigger.textContent = "Backup and reseed database";
|
||||
}
|
||||
});
|
||||
|
||||
syncSession();
|
||||
};
|
||||
|
||||
initClientReseedPage();
|
||||
document.addEventListener("astro:page-load", initClientReseedPage);
|
||||
</script>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,507 @@
|
||||
---
|
||||
import SiteFooter from "../../../components/royal-pop/SiteFooter.astro";
|
||||
import { colorways } from "../../../data/royalPop";
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import styles from "../client.module.scss";
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081");
|
||||
const finishOptions = [{ id: "silver", label: "Silver" }];
|
||||
|
||||
const stockCardClass = styles.stockCard;
|
||||
const stockCardMediaClass = styles.stockCardMedia;
|
||||
const stockCardBodyClass = styles.stockCardBody;
|
||||
const stockCardHeaderClass = styles.stockCardHeader;
|
||||
const stockTitleClass = styles.stockTitle;
|
||||
const stockSubtitleClass = styles.stockSubtitle;
|
||||
const stockBadgeClass = styles.stockBadge;
|
||||
const inlineFieldClass = styles.inlineField;
|
||||
const stockCardFooterClass = styles.stockCardFooter;
|
||||
const stockMetaClass = styles.stockMeta;
|
||||
const emptyStateClass = styles.emptyState;
|
||||
const ghostLinkClass = styles.ghostLink;
|
||||
const toolbarClass = styles.toolbar;
|
||||
const searchControlsClass = styles.searchControls;
|
||||
const searchActionsClass = styles.searchActions;
|
||||
const searchHintClass = styles.searchHint;
|
||||
const selectFieldClass = styles.selectField;
|
||||
const textareaFieldClass = styles.textareaField;
|
||||
const primaryCtaClass = styles.primaryCta;
|
||||
const secondaryCtaClass = styles.secondaryCta;
|
||||
const sectionKickerClass = styles.sectionKicker;
|
||||
const statRowClass = styles.statRow;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Royal Pop Client Stock"
|
||||
description="Private Royal Pop stock workspace for reviewing and updating inventory levels."
|
||||
noindex={true}
|
||||
>
|
||||
<main class={styles.clientPage} data-client-stock-page>
|
||||
<header class={styles.topbar}>
|
||||
<div class={styles.topbarInner}>
|
||||
<a href="/client/" class={styles.backLink}>Royal Pop</a>
|
||||
|
||||
<div class={styles.progress} aria-label="Client tools">
|
||||
<a href="/client/" class={ghostLinkClass}>Client dashboard</a>
|
||||
<a href="/client/orders/" class={ghostLinkClass}>Orders</a>
|
||||
<span class={styles.progressActive}>Stock</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.topbarActions}>
|
||||
<p class={styles.helpText}>Private access only</p>
|
||||
<button type="button" class={styles.logoutButton} data-client-logout hidden>Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class={styles.hero}>
|
||||
<div class={styles.heroInner}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>Client stock</p>
|
||||
<h1 class={styles.heroTitle}>Keep storefront inventory aligned with the Royal Pop launch.</h1>
|
||||
<p class={styles.heroBody} data-stock-hero-body>
|
||||
Update inventory counts for the live buy page, keep preorder configurations open when needed, and leave notes for the next fulfilment pass.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.heroMeta}>
|
||||
<div>
|
||||
<strong>Live storefront</strong>
|
||||
<span>These counts feed the public buy page availability states so customers see current stock at a glance.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Pre-order rule</strong>
|
||||
<span>Any configuration at 0 stays open as pre-order available, so the catalog never hard-locks by mistake.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Focused control</strong>
|
||||
<span>Use this dedicated page for counts and notes while leaving the orders workspace focused on fulfilment.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.dashboardSection}>
|
||||
<div class={styles.dashboardShell}>
|
||||
<section class={styles.loginPanel} data-client-login-panel>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.sectionKicker}>Sign in</p>
|
||||
<h2>Open the stock console.</h2>
|
||||
<p>Use the same simple client credentials as the rest of the dashboard tools.</p>
|
||||
</div>
|
||||
|
||||
<form class={styles.loginForm} data-client-login-form>
|
||||
<label class={styles.field}>
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" autocomplete="username" required />
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
|
||||
<div class={styles.loginActions}>
|
||||
<button type="submit" class={styles.primaryCta} data-client-login-submit>Unlock stock</button>
|
||||
</div>
|
||||
|
||||
<p class={styles.formStatus} data-client-auth-feedback data-state="info">
|
||||
Sign in to review and update storefront inventory.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class={styles.dashboardPanel} data-client-stock-panel hidden>
|
||||
<div class={toolbarClass}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.sectionKicker}>Stock workspace</p>
|
||||
<h2>Dedicated inventory management</h2>
|
||||
<p>Review current storefront counts, filter by style, and save notes for the next batch without leaving the Royal Pop client area.</p>
|
||||
</div>
|
||||
|
||||
<form class={searchControlsClass} data-stock-filter-form>
|
||||
<label class={selectFieldClass}>
|
||||
<span>Style</span>
|
||||
<select name="styleFilter">
|
||||
<option value="">All styles</option>
|
||||
<option value="A">Style A</option>
|
||||
<option value="B">Style B</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class={searchActionsClass}>
|
||||
<button type="submit" class={primaryCtaClass}>Apply filter</button>
|
||||
<button type="button" class={secondaryCtaClass} data-stock-reset>Show all</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class={searchHintClass}>Tip: for now, the public buy page reflects the silver-finish rows shown here. Set quantity to 0 to keep a colorway open as pre-order available.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.summaryGrid}>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Tracked configurations</p>
|
||||
<strong data-summary-tracked-configs>0</strong>
|
||||
<span>Visible colorways currently listed in this inventory view.</span>
|
||||
</article>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Units on hand</p>
|
||||
<strong data-summary-total-units>0</strong>
|
||||
<span>Total quantity currently assigned across the visible rows.</span>
|
||||
</article>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Low stock</p>
|
||||
<strong data-summary-low-stock>0</strong>
|
||||
<span>Rows with only 1 to 3 units remaining before pre-order fallback.</span>
|
||||
</article>
|
||||
<article class={styles.summaryCard}>
|
||||
<p class={styles.sectionKicker}>Pre-order configs</p>
|
||||
<strong data-summary-preorder>0</strong>
|
||||
<span>Rows currently set to 0 and therefore shown as pre-order available on the store.</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class={styles.panel}>
|
||||
<div class={statRowClass}>
|
||||
<p class={styles.helpText}>Configurations</p>
|
||||
<strong data-stock-count>0 visible rows</strong>
|
||||
</div>
|
||||
<div class={styles.stockGrid} data-client-stock-grid></div>
|
||||
</section>
|
||||
|
||||
<p class={styles.dashboardStatus} data-client-stock-feedback data-state="info">Stock console ready.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
<script
|
||||
define:vars={{
|
||||
apiBaseUrl,
|
||||
colorways,
|
||||
finishOptions,
|
||||
stockCardClass,
|
||||
stockCardMediaClass,
|
||||
stockCardBodyClass,
|
||||
stockCardHeaderClass,
|
||||
stockTitleClass,
|
||||
stockSubtitleClass,
|
||||
stockBadgeClass,
|
||||
inlineFieldClass,
|
||||
stockCardFooterClass,
|
||||
stockMetaClass,
|
||||
emptyStateClass,
|
||||
toolbarClass,
|
||||
searchControlsClass,
|
||||
searchActionsClass,
|
||||
searchHintClass,
|
||||
selectFieldClass,
|
||||
textareaFieldClass,
|
||||
primaryCtaClass,
|
||||
secondaryCtaClass,
|
||||
sectionKickerClass,
|
||||
statRowClass,
|
||||
}}
|
||||
>
|
||||
const normalizedApiBaseUrl = String(apiBaseUrl || "").replace(/\/$/, "");
|
||||
|
||||
const initClientStockPage = () => {
|
||||
const root = document.querySelector("[data-client-stock-page]");
|
||||
if (!root || root.dataset.initialized === "true") return;
|
||||
root.dataset.initialized = "true";
|
||||
|
||||
const loginPanel = root.querySelector("[data-client-login-panel]");
|
||||
const stockPanel = root.querySelector("[data-client-stock-panel]");
|
||||
const loginForm = root.querySelector("[data-client-login-form]");
|
||||
const loginSubmit = root.querySelector("[data-client-login-submit]");
|
||||
const authFeedback = root.querySelector("[data-client-auth-feedback]");
|
||||
const stockFeedback = root.querySelector("[data-client-stock-feedback]");
|
||||
const stockGrid = root.querySelector("[data-client-stock-grid]");
|
||||
const logoutButton = root.querySelector("[data-client-logout]");
|
||||
const filterForm = root.querySelector("[data-stock-filter-form]");
|
||||
const resetButton = root.querySelector("[data-stock-reset]");
|
||||
const stockCount = root.querySelector("[data-stock-count]");
|
||||
const summaryTrackedConfigs = root.querySelector("[data-summary-tracked-configs]");
|
||||
const summaryTotalUnits = root.querySelector("[data-summary-total-units]");
|
||||
const summaryLowStock = root.querySelector("[data-summary-low-stock]");
|
||||
const summaryPreorder = root.querySelector("[data-summary-preorder]");
|
||||
const heroBody = root.querySelector("[data-stock-hero-body]");
|
||||
const colorwayMap = new Map((colorways || []).map((colorway) => [colorway.id, colorway]));
|
||||
const finishMap = new Map((finishOptions || []).map((finish) => [finish.id, finish]));
|
||||
const SILVER_FINISH_ID = "silver";
|
||||
|
||||
const state = {
|
||||
styleFilter: "",
|
||||
stock: [],
|
||||
};
|
||||
|
||||
const setFeedback = (node, message, stateValue = "info") => {
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.dataset.state = stateValue;
|
||||
};
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const setAuthenticatedUi = (authenticated, username = "") => {
|
||||
if (loginPanel) loginPanel.hidden = authenticated;
|
||||
if (stockPanel) stockPanel.hidden = !authenticated;
|
||||
if (logoutButton) logoutButton.hidden = !authenticated;
|
||||
if (heroBody) {
|
||||
heroBody.textContent = authenticated
|
||||
? `Signed in as ${username || "client user"}. Update stock counts here and the public buy page will reflect the latest availability after each save.`
|
||||
: "Sign in to review and update storefront inventory without leaving the Royal Pop client area.";
|
||||
}
|
||||
};
|
||||
|
||||
const mergeStockEntries = (entries) => {
|
||||
const entryMap = new Map(
|
||||
(entries || []).map((entry) => [`${entry.style}::${entry.colorwayId}::${entry.finishId}`, entry]),
|
||||
);
|
||||
|
||||
return (colorways || []).map((colorway) => {
|
||||
const key = `${colorway.style}::${colorway.id}::${SILVER_FINISH_ID}`;
|
||||
const existing = entryMap.get(key);
|
||||
return {
|
||||
style: colorway.style,
|
||||
colorwayId: colorway.id,
|
||||
finishId: existing?.finishId || SILVER_FINISH_ID,
|
||||
quantityOnHand: Number(existing?.quantityOnHand || 0),
|
||||
notes: existing?.notes || "",
|
||||
updatedAt: existing?.updatedAt || "",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getVisibleEntries = () => {
|
||||
return state.stock.filter((entry) => !state.styleFilter || entry.style === state.styleFilter);
|
||||
};
|
||||
|
||||
const updateSummary = (entries) => {
|
||||
const visibleEntries = entries || [];
|
||||
const trackedConfigs = visibleEntries.length;
|
||||
const totalUnits = visibleEntries.reduce((sum, entry) => sum + Number(entry.quantityOnHand || 0), 0);
|
||||
const lowStock = visibleEntries.filter((entry) => Number(entry.quantityOnHand || 0) > 0 && Number(entry.quantityOnHand || 0) <= 3).length;
|
||||
const preorderConfigs = visibleEntries.filter((entry) => Number(entry.quantityOnHand || 0) <= 0).length;
|
||||
|
||||
if (summaryTrackedConfigs) summaryTrackedConfigs.textContent = String(trackedConfigs);
|
||||
if (summaryTotalUnits) summaryTotalUnits.textContent = String(totalUnits);
|
||||
if (summaryLowStock) summaryLowStock.textContent = String(lowStock);
|
||||
if (summaryPreorder) summaryPreorder.textContent = String(preorderConfigs);
|
||||
if (stockCount) stockCount.textContent = `${trackedConfigs} visible row${trackedConfigs === 1 ? "" : "s"}`;
|
||||
};
|
||||
|
||||
const renderStock = () => {
|
||||
if (!stockGrid) return;
|
||||
const visibleEntries = getVisibleEntries();
|
||||
updateSummary(visibleEntries);
|
||||
|
||||
if (!visibleEntries.length) {
|
||||
stockGrid.innerHTML = `<div class="${emptyStateClass}">No stock rows match the current filter yet.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
stockGrid.innerHTML = visibleEntries
|
||||
.map((entry) => {
|
||||
const colorway = colorwayMap.get(entry.colorwayId);
|
||||
const finish = finishMap.get(entry.finishId);
|
||||
const availabilityLabel = Number(entry.quantityOnHand || 0) <= 0 ? "Sold Out/Pre-Order Available" : `${entry.quantityOnHand} available`;
|
||||
const updatedLabel = entry.updatedAt ? new Date(entry.updatedAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }) : "Not saved yet";
|
||||
return `
|
||||
<article class="${stockCardClass}">
|
||||
<div class="${stockCardMediaClass}" style="background:${escapeHtml(colorway?.previewBackground || "#f5f5f7")}">
|
||||
<img src="${escapeHtml(colorway?.cardImage || colorway?.previewImage || "")}" alt="${escapeHtml(colorway?.name || entry.colorwayId)}" loading="lazy" />
|
||||
</div>
|
||||
<div class="${stockCardBodyClass}">
|
||||
<div class="${stockCardHeaderClass}">
|
||||
<div>
|
||||
<p class="${stockTitleClass}">${escapeHtml(colorway?.name || entry.colorwayId)}</p>
|
||||
<p class="${stockSubtitleClass}">${escapeHtml(colorway?.subtitle || "Royal Pop configuration")}</p>
|
||||
<p class="${stockMetaClass}">${escapeHtml(colorway?.styleText || `Style ${entry.style}`)} · ${escapeHtml(finish?.label || entry.finishId)}</p>
|
||||
</div>
|
||||
<span class="${stockBadgeClass}">${escapeHtml(availabilityLabel)}</span>
|
||||
</div>
|
||||
|
||||
<form data-stock-form data-style="${escapeHtml(entry.style)}" data-colorway-id="${escapeHtml(entry.colorwayId)}" data-finish-id="${escapeHtml(entry.finishId)}">
|
||||
<div class="${inlineFieldClass}">
|
||||
<span>Quantity on hand</span>
|
||||
<input type="number" name="quantityOnHand" min="0" step="1" value="${escapeHtml(entry.quantityOnHand)}" />
|
||||
</div>
|
||||
<label class="${textareaFieldClass}">
|
||||
<span>Notes</span>
|
||||
<textarea name="notes" placeholder="Optional stock note for this configuration">${escapeHtml(entry.notes || "")}</textarea>
|
||||
</label>
|
||||
<div class="${stockCardFooterClass}">
|
||||
<p class="${stockMetaClass}">Last updated: ${escapeHtml(updatedLabel)}</p>
|
||||
<button type="submit" class="${primaryCtaClass}">Save stock</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const loadStock = async () => {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/stock`, { credentials: "include" });
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The stock workspace could not load inventory levels.");
|
||||
}
|
||||
|
||||
state.stock = mergeStockEntries(payload?.data?.stock || []);
|
||||
renderStock();
|
||||
};
|
||||
|
||||
const syncSession = async () => {
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/session`, { credentials: "include" });
|
||||
const payload = await response.json().catch(() => null);
|
||||
const session = payload?.data;
|
||||
|
||||
if (!response.ok || !session?.configured) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, session?.configured === false ? "Client dashboard credentials are not configured for this environment yet." : "Unable to verify dashboard access.", session?.configured === false ? "error" : "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.authenticated) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, "Sign in to review and update storefront inventory.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthenticatedUi(true, session.username);
|
||||
setFeedback(stockFeedback, "Stock console ready.", "success");
|
||||
await loadStock();
|
||||
} catch (error) {
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, error instanceof Error ? error.message : "Unable to verify dashboard access.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
loginForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(loginForm);
|
||||
const username = String(formData.get("username") || "").trim();
|
||||
const password = String(formData.get("password") || "");
|
||||
|
||||
if (!username || !password) {
|
||||
setFeedback(authFeedback, "Enter both the username and password.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginSubmit) {
|
||||
loginSubmit.disabled = true;
|
||||
loginSubmit.textContent = "Unlocking…";
|
||||
}
|
||||
setFeedback(authFeedback, "Checking credentials…", "info");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/login`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "The dashboard credentials were rejected.");
|
||||
}
|
||||
|
||||
setFeedback(authFeedback, "Access granted. Loading stock…", "success");
|
||||
loginForm.reset();
|
||||
await syncSession();
|
||||
} catch (error) {
|
||||
setFeedback(authFeedback, error instanceof Error ? error.message : "The dashboard credentials were rejected.", "error");
|
||||
} finally {
|
||||
if (loginSubmit) {
|
||||
loginSubmit.disabled = false;
|
||||
loginSubmit.textContent = "Unlock stock";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logoutButton?.addEventListener("click", async () => {
|
||||
await fetch(`${normalizedApiBaseUrl}/v1/client/logout`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
setAuthenticatedUi(false);
|
||||
setFeedback(authFeedback, "Signed out. Sign in again to reopen the stock console.", "info");
|
||||
});
|
||||
|
||||
filterForm?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(filterForm);
|
||||
state.styleFilter = String(formData.get("styleFilter") || "").trim().toUpperCase();
|
||||
renderStock();
|
||||
});
|
||||
|
||||
resetButton?.addEventListener("click", () => {
|
||||
state.styleFilter = "";
|
||||
filterForm?.reset();
|
||||
renderStock();
|
||||
});
|
||||
|
||||
stockGrid?.addEventListener("submit", async (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement) || !form.matches("[data-stock-form]")) return;
|
||||
event.preventDefault();
|
||||
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const formData = new FormData(form);
|
||||
const payload = {
|
||||
style: String(form.dataset.style || "").trim(),
|
||||
colorwayId: String(form.dataset.colorwayId || "").trim(),
|
||||
finishId: String(form.dataset.finishId || SILVER_FINISH_ID).trim(),
|
||||
quantityOnHand: Number(formData.get("quantityOnHand") || 0),
|
||||
notes: String(formData.get("notes") || "").trim(),
|
||||
};
|
||||
|
||||
if (submitButton instanceof HTMLButtonElement) {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = "Saving…";
|
||||
}
|
||||
setFeedback(stockFeedback, `Saving ${payload.colorwayId}…`, "info");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/client/stock`, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.error?.message || "The stock entry could not be saved.");
|
||||
}
|
||||
|
||||
setFeedback(stockFeedback, `Saved ${payload.colorwayId}.`, "success");
|
||||
await loadStock();
|
||||
} catch (error) {
|
||||
setFeedback(stockFeedback, error instanceof Error ? error.message : "The stock entry could not be saved.", "error");
|
||||
} finally {
|
||||
if (submitButton instanceof HTMLButtonElement) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = "Save stock";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
syncSession();
|
||||
};
|
||||
|
||||
initClientStockPage();
|
||||
document.addEventListener("astro:page-load", initClientStockPage);
|
||||
</script>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,449 @@
|
||||
---
|
||||
import SiteFooter from "../components/royal-pop/SiteFooter.astro";
|
||||
import { colorways, defaultColorway } from "../data/royalPop";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import styles from "./checkout.module.scss";
|
||||
|
||||
const finishOptions = [
|
||||
{ id: "silver", label: "Silver", note: "Classic brushed 316L steel" },
|
||||
{ id: "black-pvd", label: "Black PVD", note: "Stealth satin hardware" },
|
||||
{ id: "rose-gold", label: "Rose Gold", note: "Warm contrast finish" },
|
||||
];
|
||||
|
||||
const pricePerKit = 49.99;
|
||||
const retailPerKit = 89.99;
|
||||
const defaultFinish = finishOptions[0];
|
||||
const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081");
|
||||
const detailsStorageKey = "royal-pop-checkout-details";
|
||||
const countryOptions = [
|
||||
"Australia",
|
||||
"Austria",
|
||||
"Belgium",
|
||||
"Brazil",
|
||||
"Bulgaria",
|
||||
"Canada",
|
||||
"China",
|
||||
"Croatia",
|
||||
"Cyprus",
|
||||
"Czech Republic",
|
||||
"Denmark",
|
||||
"Estonia",
|
||||
"Finland",
|
||||
"France",
|
||||
"Germany",
|
||||
"Greece",
|
||||
"Hong Kong SAR",
|
||||
"Hungary",
|
||||
"Iceland",
|
||||
"India",
|
||||
"Indonesia",
|
||||
"Ireland",
|
||||
"Israel",
|
||||
"Italy",
|
||||
"Japan",
|
||||
"Latvia",
|
||||
"Lithuania",
|
||||
"Luxembourg",
|
||||
"Malaysia",
|
||||
"Malta",
|
||||
"Mexico",
|
||||
"Netherlands",
|
||||
"New Zealand",
|
||||
"Norway",
|
||||
"Philippines",
|
||||
"Poland",
|
||||
"Portugal",
|
||||
"Qatar",
|
||||
"Romania",
|
||||
"Saudi Arabia",
|
||||
"Singapore",
|
||||
"Slovakia",
|
||||
"Slovenia",
|
||||
"South Africa",
|
||||
"South Korea",
|
||||
"Spain",
|
||||
"Sweden",
|
||||
"Switzerland",
|
||||
"Taiwan",
|
||||
"Thailand",
|
||||
"Turkey",
|
||||
"United Arab Emirates",
|
||||
"United Kingdom",
|
||||
"United States",
|
||||
"Vietnam",
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Royal Pop Preorder Details"
|
||||
description="Enter your contact and shipping details to continue your Royal Pop preorder."
|
||||
noindex={true}
|
||||
>
|
||||
<main class={styles.checkoutPage} data-details-page>
|
||||
<header class={styles.topbar}>
|
||||
<div class={styles.topbarInner}>
|
||||
<a href="/buy" class={styles.backLink} data-back-link>Back to configuration</a>
|
||||
|
||||
<div class={styles.progress} aria-label="Purchase steps">
|
||||
<span>1. Configure</span>
|
||||
<span class={styles.progressActive}>2. Details</span>
|
||||
<span>3. Review & Pay</span>
|
||||
</div>
|
||||
|
||||
<p class={styles.helpText}>Contact & delivery details</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class={styles.hero}>
|
||||
<div class={styles.heroInner}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>Details</p>
|
||||
<h1 class={styles.heroTitle}>Add your contact and delivery details.</h1>
|
||||
<p class={styles.heroBody}>Add the contact and delivery details for this preorder so everything is ready for the final review.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.heroMeta}>
|
||||
<div>
|
||||
<strong>What happens here</strong>
|
||||
<span>Contact info, shipping address, and any useful notes before you move on.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>What we’ll use</strong>
|
||||
<span>Your details help keep your preorder, delivery preferences, and follow-up support aligned.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Next step</strong>
|
||||
<span>Review configuration, payment method, and submission on the final Review & Pay page.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.checkoutSection}>
|
||||
<div class={styles.checkoutGrid}>
|
||||
<aside class={styles.summaryRail}>
|
||||
<div class={styles.summarySticky}>
|
||||
<div class={styles.orderCard}>
|
||||
<div class={styles.orderHeader}>
|
||||
<div>
|
||||
<p class={styles.sectionKicker}>Order summary</p>
|
||||
<h2>Your cart</h2>
|
||||
</div>
|
||||
<p class={styles.priceNow} data-summary-total>£0.00</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.cartList} data-cart-items>
|
||||
<div class={styles.cartItemEmpty}>Your cart is empty.</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.orderRows}>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Kits in cart</span>
|
||||
<strong data-summary-quantity>0 kits</strong>
|
||||
</div>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Early Bird total</span>
|
||||
<strong data-summary-total-inline>£0.00</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class={styles.assuranceCard}>
|
||||
<h3>What we need from you</h3>
|
||||
<ul>
|
||||
<li>Primary contact for preorder confirmation and shipping updates</li>
|
||||
<li>Delivery address for shipping and customs guidance</li>
|
||||
<li>Optional fit or access notes before final review</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<form class={styles.formColumn} data-details-form>
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.stepCount}>Step 2A</p>
|
||||
<h2>Contact information</h2>
|
||||
<p>Where should we send your confirmation, shipping timeline, and install support?</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.fieldGrid}>
|
||||
<label class={styles.field}>
|
||||
<span>Email address</span>
|
||||
<input type="email" name="email" autocomplete="email" inputmode="email" placeholder="name@example.com" required data-details-input />
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
<span>Phone number</span>
|
||||
<input type="tel" name="phone" autocomplete="tel" inputmode="tel" placeholder="+44 7XXX XXX XXX" minlength="7" required data-details-input />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.stepCount}>Step 2B</p>
|
||||
<h2>Shipping address</h2>
|
||||
<p>Use your intended final delivery address so rollout logistics can be planned correctly.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.fieldGrid}>
|
||||
<label class={styles.field}>
|
||||
<span>First name</span>
|
||||
<input type="text" name="firstName" autocomplete="shipping given-name" placeholder="First name" minlength="2" required data-details-input />
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
<span>Last name</span>
|
||||
<input type="text" name="lastName" autocomplete="shipping family-name" placeholder="Last name" minlength="2" required data-details-input />
|
||||
</label>
|
||||
<label class={styles.field + " " + styles.fieldFull}>
|
||||
<span>Street address</span>
|
||||
<input type="text" name="addressLine1" autocomplete="shipping address-line1" placeholder="Street address" minlength="5" required data-details-input />
|
||||
</label>
|
||||
<label class={styles.field + " " + styles.fieldFull}>
|
||||
<span>Apartment, suite, etc. <em>(optional)</em></span>
|
||||
<input type="text" name="addressLine2" autocomplete="shipping address-line2" placeholder="Apartment, suite, unit, building, etc." data-details-input />
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
<span>City</span>
|
||||
<input type="text" name="city" autocomplete="shipping address-level2" placeholder="City" minlength="2" required data-details-input />
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
<span>State / county / region</span>
|
||||
<input type="text" name="region" autocomplete="shipping address-level1" placeholder="State, county, or region" minlength="2" required data-details-input />
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
<span>Postal code</span>
|
||||
<input type="text" name="postalCode" autocomplete="shipping postal-code" inputmode="text" placeholder="Postal code" minlength="3" required data-details-input />
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
<span>Country</span>
|
||||
<select name="country" autocomplete="shipping country-name" required data-details-input>
|
||||
<option value="" selected disabled>Select your country</option>
|
||||
{countryOptions.map((country) => (
|
||||
<option value={country}>{country}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.stepCount}>Step 2C</p>
|
||||
<h2>Delivery notes</h2>
|
||||
<p>Add anything helpful for shipping, fit questions, or handoff notes before you continue.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.stack}>
|
||||
<div class={styles.optionCard}>
|
||||
<div>
|
||||
<strong>Dispatch preference</strong>
|
||||
<span>Tracked worldwide shipping, with pre-orders expected to ship in around 1 month.</span>
|
||||
</div>
|
||||
<p>Standard</p>
|
||||
</div>
|
||||
|
||||
<label class={styles.field + " " + styles.fieldFull}>
|
||||
<span>Special notes</span>
|
||||
<textarea name="notes" rows="4" placeholder="Gate code, delivery notes, wrist-fit question, etc." data-details-input></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.stepCount}>Next</p>
|
||||
<h2>Continue to review & pay</h2>
|
||||
<p>From here you’ll review your configuration, choose payment, and send your preorder request.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.actions}>
|
||||
<a href="/buy" class={styles.secondaryCta}>Edit configuration</a>
|
||||
<button type="submit" class={styles.primaryCta} data-go-review>Continue to review & pay</button>
|
||||
</div>
|
||||
|
||||
<p class={styles.footnote}>Please double-check your email and delivery address before continuing.</p>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
<script is:inline src="/scripts/royal-pop-cart.js"></script>
|
||||
<script define:vars={{ colorways, finishOptions, pricePerKit, retailPerKit, apiBaseUrl, detailsStorageKey, cartItemClass: styles.cartItem, cartItemImageWrapClass: styles.cartItemImageWrap, cartItemBodyClass: styles.cartItemBody, cartItemTitleClass: styles.cartItemTitle, cartItemSubtitleClass: styles.cartItemSubtitle, cartItemMetaClass: styles.cartItemMeta, cartItemFooterClass: styles.cartItemFooter, cartItemPriceClass: styles.cartItemPrice, cartItemRemoveClass: styles.cartItemRemove, cartItemEmptyClass: styles.cartItemEmpty }}>
|
||||
const { loadCart, removeCartItem } = window.RoyalPopCart || {};
|
||||
const normalizedApiBaseUrl = String(apiBaseUrl || "").replace(/\/$/, "");
|
||||
const formatGBP = (value) => `£${Number(value || 0).toFixed(2)}`;
|
||||
|
||||
const initDetailsPage = () => {
|
||||
const root = document.querySelector("[data-details-page]");
|
||||
if (!root || root.dataset.initialized === "true") return;
|
||||
|
||||
root.dataset.initialized = "true";
|
||||
if (!loadCart || !removeCartItem) return;
|
||||
|
||||
const colorwayMap = new Map(colorways.map((colorway) => [colorway.id, colorway]));
|
||||
const finishMap = new Map(finishOptions.map((finish) => [finish.id, finish]));
|
||||
|
||||
const summaryQuantity = root.querySelector("[data-summary-quantity]");
|
||||
const summaryTotal = root.querySelector("[data-summary-total]");
|
||||
const summaryTotalInline = root.querySelector("[data-summary-total-inline]");
|
||||
const cartItemsNode = root.querySelector("[data-cart-items]");
|
||||
const backLink = root.querySelector("[data-back-link]");
|
||||
const detailsForm = root.querySelector("[data-details-form]");
|
||||
const reviewLink = root.querySelector("[data-go-review]");
|
||||
const detailsFields = Array.from(root.querySelectorAll("[data-details-input]"));
|
||||
let currentPricePerKit = Number(pricePerKit || 0);
|
||||
let currentRetailPerKit = Number(retailPerKit || 0);
|
||||
|
||||
const loadPricing = async () => {
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/storefront/pricing`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "Pricing is temporarily unavailable.");
|
||||
}
|
||||
|
||||
const pricing = payload?.data || {};
|
||||
const nextUnit = Number(pricing.unitAmount || 0) / 100;
|
||||
const nextRetail = Number(pricing.retailAmount || 0) / 100;
|
||||
|
||||
if (nextUnit > 0) currentPricePerKit = nextUnit;
|
||||
if (nextRetail > 0) currentRetailPerKit = nextRetail;
|
||||
} catch (_error) {
|
||||
// Keep baked fallback pricing when the public pricing endpoint is unavailable.
|
||||
} finally {
|
||||
renderCart();
|
||||
}
|
||||
};
|
||||
|
||||
const redirectToBuy = () => {
|
||||
window.location.replace("/buy");
|
||||
};
|
||||
|
||||
const loadStoredDetails = () => {
|
||||
try {
|
||||
return JSON.parse(window.sessionStorage.getItem(detailsStorageKey) || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveDetails = () => {
|
||||
if (!detailsForm) return;
|
||||
|
||||
const formData = new FormData(detailsForm);
|
||||
const payload = Object.fromEntries(formData.entries());
|
||||
window.sessionStorage.setItem(detailsStorageKey, JSON.stringify(payload));
|
||||
};
|
||||
|
||||
const prefillDetails = () => {
|
||||
const stored = loadStoredDetails();
|
||||
if (!stored) return;
|
||||
|
||||
detailsFields.forEach((field) => {
|
||||
const key = field.getAttribute("name");
|
||||
if (!key) return;
|
||||
const value = stored[key];
|
||||
if (typeof value !== "string") return;
|
||||
field.value = value;
|
||||
});
|
||||
};
|
||||
|
||||
const renderCart = () => {
|
||||
const rawCart = loadCart();
|
||||
|
||||
if (rawCart.length === 0) {
|
||||
redirectToBuy();
|
||||
return;
|
||||
}
|
||||
|
||||
const cartItems = rawCart.map((item) => ({
|
||||
...item,
|
||||
quantity: Math.max(1, Number(item.quantity || 1)),
|
||||
colorway: colorwayMap.get(item.colorwayId) || defaultColorway,
|
||||
finish: finishMap.get(item.finishId) || defaultFinish,
|
||||
}));
|
||||
const totalQuantity = cartItems.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const total = currentPricePerKit * totalQuantity;
|
||||
if (summaryQuantity) summaryQuantity.textContent = `${totalQuantity} ${totalQuantity === 1 ? "kit" : "kits"}`;
|
||||
if (summaryTotal) summaryTotal.textContent = formatGBP(total);
|
||||
if (summaryTotalInline) summaryTotalInline.textContent = formatGBP(total);
|
||||
|
||||
if (reviewLink) {
|
||||
const disabled = totalQuantity === 0;
|
||||
reviewLink.setAttribute("aria-disabled", String(disabled));
|
||||
reviewLink.disabled = disabled;
|
||||
}
|
||||
|
||||
if (!cartItemsNode) return;
|
||||
|
||||
if (cartItems.length === 0) {
|
||||
cartItemsNode.innerHTML = `<div class="${cartItemEmptyClass}">Your cart is empty. Go back to configuration to add a kit.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
cartItemsNode.innerHTML = cartItems
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="${cartItemClass}">
|
||||
<div class="${cartItemImageWrapClass}" style="background:${item.colorway.previewBackground}">
|
||||
<img src="${item.colorway.cardImage}" alt="${item.colorway.name} kit preview" />
|
||||
</div>
|
||||
<div class="${cartItemBodyClass}">
|
||||
<div>
|
||||
<strong class="${cartItemTitleClass}">${item.colorway.name}</strong>
|
||||
<p class="${cartItemSubtitleClass}">${item.colorway.subtitle}</p>
|
||||
<p class="${cartItemMetaClass}">${item.colorway.styleText}</p>
|
||||
<p class="${cartItemMetaClass}">${item.quantity} ${item.quantity === 1 ? "kit" : "kits"} · Matched configuration</p>
|
||||
</div>
|
||||
<div class="${cartItemFooterClass}">
|
||||
<strong class="${cartItemPriceClass}">${formatGBP(currentPricePerKit * item.quantity)}</strong>
|
||||
<button type="button" class="${cartItemRemoveClass}" data-remove-cart-item="${item.id}">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>`,
|
||||
)
|
||||
.join("");
|
||||
};
|
||||
|
||||
if (backLink) backLink.setAttribute("href", "/buy");
|
||||
|
||||
if (cartItemsNode) {
|
||||
cartItemsNode.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-remove-cart-item]");
|
||||
if (!button) return;
|
||||
|
||||
const { removeCartItem: itemId } = button.dataset;
|
||||
if (!itemId) return;
|
||||
|
||||
removeCartItem(itemId);
|
||||
renderCart();
|
||||
});
|
||||
}
|
||||
|
||||
if (detailsForm) {
|
||||
detailsForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!detailsForm.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveDetails();
|
||||
window.location.assign("/checkout");
|
||||
});
|
||||
}
|
||||
|
||||
prefillDetails();
|
||||
renderCart();
|
||||
loadPricing();
|
||||
};
|
||||
|
||||
initDetailsPage();
|
||||
document.addEventListener("astro:page-load", initDetailsPage);
|
||||
</script>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
// Path: Store/src/pages/index.astro
|
||||
import ColorwaysSection from "../components/royal-pop/ColorwaysSection.astro";
|
||||
import FeatureSections from "../components/royal-pop/FeatureSections.astro";
|
||||
import HeroSection from "../components/royal-pop/HeroSection.astro";
|
||||
import PreorderSection from "../components/royal-pop/PreorderSection.astro";
|
||||
import SiteFooter from "../components/royal-pop/SiteFooter.astro";
|
||||
import SpecsSection from "../components/royal-pop/SpecsSection.astro";
|
||||
import StatsBand from "../components/royal-pop/StatsBand.astro";
|
||||
import { colorways, heroStats, specRows } from "../data/royalPop";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081");
|
||||
const siteUrl = "https://royal-pop-accessory.com";
|
||||
const socialImage = "/images/png/pure-white.png";
|
||||
|
||||
const structuredData = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: "Royal Pop Accessory",
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/favicon/android-chrome-512x512.png`,
|
||||
image: `${siteUrl}${socialImage}`,
|
||||
description:
|
||||
"Royal Pop Accessory creates bold interchangeable watch strap accessories in standout colourways for collectors who want a fast visual transformation.",
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: "Royal Pop Accessory",
|
||||
url: siteUrl,
|
||||
description:
|
||||
"Upgrade your watch with Royal Pop Accessory — bold interchangeable strap accessories in standout colourways, built to change your look in seconds.",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Turn Your Strap Into a Statement"
|
||||
description="Upgrade your watch with Royal Pop Accessory — bold interchangeable strap accessories in standout colourways, built to change your look in seconds."
|
||||
socialTitle="Royal Pop Accessory | Turn Your Strap Into a Statement"
|
||||
socialImage={socialImage}
|
||||
structuredData={structuredData}
|
||||
>
|
||||
<main class={styles.homepage} data-home-page>
|
||||
<nav class={styles.nav} aria-label="Primary">
|
||||
<div class={styles.navInner}>
|
||||
<a class={styles.navLogo} href="#top">Royal Pop</a>
|
||||
<a href="/buy" class:list={[styles.navCta, styles.navCtaMobile]}>Pre-Order</a>
|
||||
|
||||
<div class={styles.navLinks}>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#colorways">Colorways</a>
|
||||
<a href="#specs">Specs</a>
|
||||
<a href="/buy" class={styles.navCta}>Pre-Order</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section id="top">
|
||||
<HeroSection />
|
||||
</section>
|
||||
|
||||
<section class={styles.taglineBand}>
|
||||
<div class={styles.taglineInner}>
|
||||
<p>From pocket to wrist.</p>
|
||||
<p><span>Without compromise.</span></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StatsBand items={heroStats} />
|
||||
<FeatureSections />
|
||||
<ColorwaysSection colorways={colorways} />
|
||||
<SpecsSection rows={specRows} />
|
||||
<PreorderSection />
|
||||
<SiteFooter />
|
||||
|
||||
<script define:vars={{ apiBaseUrl }}>
|
||||
const initHomePage = () => {
|
||||
const root = document.querySelector("[data-home-page]");
|
||||
if (!root) return;
|
||||
|
||||
const normalizedApiBaseUrl = String(apiBaseUrl || "").replace(/\/$/, "");
|
||||
const preorderBody = root.querySelector("[data-preorder-body]");
|
||||
const preorderPriceNow = root.querySelector("[data-preorder-price-now]");
|
||||
const preorderPriceRetail = root.querySelector("[data-preorder-price-retail]");
|
||||
const heroCta = root.querySelector("[data-hero-cta]");
|
||||
const earlyBirdStat = root.querySelector('[data-stat-id="early-bird-stat-value"]');
|
||||
|
||||
const reveals = Array.from(root.querySelectorAll(".reveal"));
|
||||
|
||||
const formatCurrency = (amount, currency = "gbp") =>
|
||||
new Intl.NumberFormat("en-GB", {
|
||||
style: "currency",
|
||||
currency: String(currency || "gbp").toUpperCase(),
|
||||
minimumFractionDigits: 2,
|
||||
}).format(Number(amount || 0));
|
||||
|
||||
const updatePricing = async () => {
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/storefront/pricing`);
|
||||
if (!response.ok) throw new Error(`pricing request failed: ${response.status}`);
|
||||
const payload = await response.json();
|
||||
const pricing = payload?.data;
|
||||
if (!pricing?.unitAmount) return;
|
||||
|
||||
const unitPrice = formatCurrency(Number(pricing.unitAmount) / 100, pricing.currency);
|
||||
const retailPrice = pricing.retailAmount
|
||||
? formatCurrency(Number(pricing.retailAmount) / 100, pricing.currency)
|
||||
: null;
|
||||
|
||||
if (preorderPriceNow) preorderPriceNow.textContent = unitPrice;
|
||||
if (preorderPriceRetail && retailPrice) preorderPriceRetail.textContent = retailPrice;
|
||||
if (earlyBirdStat) earlyBirdStat.textContent = unitPrice;
|
||||
if (preorderBody) {
|
||||
preorderBody.textContent = `Pre-orders typically ship in around 1 month. Each kit is matched to a specific Royal Pop colourway — lock in yours before the ${unitPrice} early bird closes.`;
|
||||
}
|
||||
if (heroCta) {
|
||||
heroCta.textContent = `Pre-Order · ${unitPrice} Early Bird`;
|
||||
}
|
||||
} catch {
|
||||
// Keep baked fallback pricing copy when pricing lookup fails.
|
||||
}
|
||||
};
|
||||
|
||||
if (reveals.length > 0) {
|
||||
if ("IntersectionObserver" in window) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("in");
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
);
|
||||
|
||||
reveals.forEach((element) => {
|
||||
if (element.classList.contains("in")) return;
|
||||
observer.observe(element);
|
||||
});
|
||||
} else {
|
||||
reveals.forEach((element) => element.classList.add("in"));
|
||||
}
|
||||
}
|
||||
|
||||
updatePricing();
|
||||
};
|
||||
|
||||
initHomePage();
|
||||
document.addEventListener("astro:page-load", initHomePage);
|
||||
</script>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,118 @@
|
||||
.homepage {
|
||||
background: #ffffff;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 1px solid rgba(210, 210, 215, 0.72);
|
||||
}
|
||||
|
||||
.navInner {
|
||||
width: min(calc(100% - 44px), 980px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 22px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.navLogo {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1d1d1f;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navLinks {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
|
||||
a {
|
||||
margin-left: 20px;
|
||||
font-size: 12px;
|
||||
color: #1d1d1f;
|
||||
text-decoration: none;
|
||||
opacity: 0.82;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include respond(tablet) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.navCta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
background: #0071e3;
|
||||
color: #ffffff !important;
|
||||
opacity: 1 !important;
|
||||
|
||||
&:hover {
|
||||
background: #0077ed;
|
||||
}
|
||||
}
|
||||
|
||||
.navCtaMobile {
|
||||
@include respond(tablet) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.taglineBand {
|
||||
padding: 80px 24px;
|
||||
background: #1d1d1f;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.taglineInner {
|
||||
width: min(100%, 980px);
|
||||
margin: 0 auto;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: clamp(32px, 4vw, 48px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.08;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #2997ff;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.reveal) {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
transition:
|
||||
opacity 0.8s ease,
|
||||
transform 0.8s ease;
|
||||
}
|
||||
|
||||
:global(.reveal.in) {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
---
|
||||
import SiteFooter from "../components/royal-pop/SiteFooter.astro";
|
||||
import { colorways, defaultColorway } from "../data/royalPop";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import styles from "./checkout.module.scss";
|
||||
|
||||
const pricePerKit = 49.99;
|
||||
const retailPerKit = 89.99;
|
||||
const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081");
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Royal Pop Thank You"
|
||||
description="Your Royal Pop preorder request has been received."
|
||||
noindex={true}
|
||||
>
|
||||
<main class={styles.checkoutPage} data-thank-you-page>
|
||||
<header class={styles.topbar}>
|
||||
<div class={styles.topbarInner}>
|
||||
<a href="/checkout" class={styles.backLink} data-back-link>Back to review & pay</a>
|
||||
|
||||
<div class={styles.progress} aria-label="Purchase steps">
|
||||
<span>1. Configure</span>
|
||||
<span>2. Details</span>
|
||||
<span class={styles.progressActive}>3. Review & Pay</span>
|
||||
</div>
|
||||
|
||||
<p class={styles.helpText}>Request received</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class={styles.hero}>
|
||||
<div class={styles.heroInner}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>Thank you</p>
|
||||
<h1 class={styles.heroTitle} data-thank-you-title>Your Royal Pop preorder request is in.</h1>
|
||||
<p class={styles.heroBody} data-thank-you-body>Your preorder summary and reference number are below so you can review the order exactly as submitted.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.heroMeta}>
|
||||
<div>
|
||||
<strong data-thank-you-meta-label="confirmation">Confirmation ready</strong>
|
||||
<span data-thank-you-meta-copy="confirmation">Your selected kit and request details are collected here in one place.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong data-thank-you-meta-label="next">What comes next</strong>
|
||||
<span data-thank-you-meta-copy="next">We’ll use this saved order record for payment confirmation, fulfilment updates, and future support.</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong data-thank-you-meta-label="reference">Reference ready</strong>
|
||||
<span data-thank-you-meta-copy="reference">Keep this page handy if you need to double-check your selected build.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.checkoutSection}>
|
||||
<div class={styles.checkoutGrid}>
|
||||
<aside class={styles.summaryRail}>
|
||||
<div class={styles.summarySticky}>
|
||||
<div class={styles.previewCard}>
|
||||
<p class={styles.sectionKicker}>Confirmed configuration</p>
|
||||
<div class={styles.previewFrame}>
|
||||
<img
|
||||
data-summary-image
|
||||
src={defaultColorway.previewImage}
|
||||
alt={`${defaultColorway.name} Royal Pop configuration preview`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles.previewCopy}>
|
||||
<p class={styles.previewTitle} data-summary-name>{defaultColorway.name}</p>
|
||||
<p class={styles.previewSubtitle} data-summary-subtitle>{defaultColorway.subtitle}</p>
|
||||
<p class={styles.previewSpec} data-summary-style>{defaultColorway.styleText}</p>
|
||||
<p class={styles.previewSpec} data-summary-crown>{defaultColorway.crownSpec}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.orderCard}>
|
||||
<div class={styles.orderHeader}>
|
||||
<div>
|
||||
<p class={styles.sectionKicker}>Request summary</p>
|
||||
<h2>Royal Pop cart</h2>
|
||||
</div>
|
||||
<p class={styles.priceNow} data-summary-total>£49.99</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.orderRows} data-cart-items>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Reference</span>
|
||||
<strong data-order-reference>RP-BSI-SPMC-01</strong>
|
||||
</div>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Quantity</span>
|
||||
<strong data-summary-quantity>1 kit</strong>
|
||||
</div>
|
||||
<div class={styles.orderRow}>
|
||||
<span>Early Bird total</span>
|
||||
<strong data-summary-total-inline>£49.99</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class={styles.assuranceCard}>
|
||||
<h3>What happens next</h3>
|
||||
<ul>
|
||||
<li>Your request summary stays attached to this reference.</li>
|
||||
<li>Zero-stock configurations remain open as preorder requests while the next batch is prepared.</li>
|
||||
<li>Install guidance and fit support can follow after confirmation.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class={styles.formColumn}>
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.panelHeader}>
|
||||
<p class={styles.stepCount}>Confirmation</p>
|
||||
<h2>Thank you for your preorder request.</h2>
|
||||
<p>Your request has been received. Use this page as your final summary for the preorder you just sent through.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.statusCard}>
|
||||
<p class={styles.statusPill} data-status-pill>Confirmation</p>
|
||||
<h3 class={styles.statusTitle} data-status-title>Royal Pop request received</h3>
|
||||
<p class={styles.statusBody} data-status-body>We’ve saved your selected build and quantity so you can review the order exactly as submitted.</p>
|
||||
<div class={styles.inlineMeta}>
|
||||
<div>
|
||||
<span>Reference</span>
|
||||
<strong data-order-reference>RP-BSI-SPMC-01</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong data-order-status>Request received</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
<script is:inline src="/scripts/royal-pop-cart.js"></script>
|
||||
<script define:vars={{ colorways, pricePerKit, retailPerKit, apiBaseUrl, orderRowClass: styles.orderRow, cartLineClass: styles.cartLine, cartLineMetaClass: styles.cartLineMeta }}>
|
||||
const { clearCart, getEffectiveCart, groupCartItems } = window.RoyalPopCart || {};
|
||||
const normalizedApiBaseUrl = String(apiBaseUrl || "").replace(/\/$/, "");
|
||||
const formatGBP = (value) => `£${Number(value || 0).toFixed(2)}`;
|
||||
|
||||
const initThankYouPage = () => {
|
||||
const root = document.querySelector("[data-thank-you-page]");
|
||||
if (!root || root.dataset.initialized === "true") return;
|
||||
|
||||
root.dataset.initialized = "true";
|
||||
if (!clearCart || !getEffectiveCart || !groupCartItems) return;
|
||||
let currentPricePerKit = Number(pricePerKit || 0);
|
||||
let currentRetailPerKit = Number(retailPerKit || 0);
|
||||
|
||||
const colorwayMap = new Map(colorways.map((colorway) => [colorway.id, colorway]));
|
||||
const summaryImage = root.querySelector("[data-summary-image]");
|
||||
const summaryName = root.querySelector("[data-summary-name]");
|
||||
const summarySubtitle = root.querySelector("[data-summary-subtitle]");
|
||||
const summaryStyle = root.querySelector("[data-summary-style]");
|
||||
const summaryCrown = root.querySelector("[data-summary-crown]");
|
||||
const summaryQuantity = root.querySelector("[data-summary-quantity]");
|
||||
const summaryTotal = root.querySelector("[data-summary-total]");
|
||||
const summaryTotalInline = root.querySelector("[data-summary-total-inline]");
|
||||
const referenceNodes = Array.from(root.querySelectorAll("[data-order-reference]"));
|
||||
const orderStatusNode = root.querySelector("[data-order-status]");
|
||||
const cartItemsNode = root.querySelector("[data-cart-items]");
|
||||
const backLink = root.querySelector("[data-back-link]");
|
||||
const thankYouTitle = root.querySelector("[data-thank-you-title]");
|
||||
const thankYouBody = root.querySelector("[data-thank-you-body]");
|
||||
const statusPill = root.querySelector("[data-status-pill]");
|
||||
const statusTitle = root.querySelector("[data-status-title]");
|
||||
const statusBody = root.querySelector("[data-status-body]");
|
||||
const metaLabels = {
|
||||
confirmation: root.querySelector('[data-thank-you-meta-label="confirmation"]'),
|
||||
next: root.querySelector('[data-thank-you-meta-label="next"]'),
|
||||
reference: root.querySelector('[data-thank-you-meta-label="reference"]'),
|
||||
};
|
||||
const metaCopies = {
|
||||
confirmation: root.querySelector('[data-thank-you-meta-copy="confirmation"]'),
|
||||
next: root.querySelector('[data-thank-you-meta-copy="next"]'),
|
||||
reference: root.querySelector('[data-thank-you-meta-copy="reference"]'),
|
||||
};
|
||||
|
||||
const loadPricing = async () => {
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/storefront/pricing`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || "Pricing is temporarily unavailable.");
|
||||
}
|
||||
|
||||
const pricing = payload?.data || {};
|
||||
const nextUnit = Number(pricing.unitAmount || 0) / 100;
|
||||
const nextRetail = Number(pricing.retailAmount || 0) / 100;
|
||||
|
||||
if (nextUnit > 0) currentPricePerKit = nextUnit;
|
||||
if (nextRetail > 0) currentRetailPerKit = nextRetail;
|
||||
} catch (_error) {
|
||||
// Keep baked fallback pricing when the public pricing endpoint is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const applyStatusContent = (orderStatus, message) => {
|
||||
if (orderStatus === "paid") {
|
||||
if (thankYouTitle) thankYouTitle.textContent = "Your Royal Pop preorder is confirmed.";
|
||||
if (thankYouBody) thankYouBody.textContent = "Your payment has been verified and your confirmed order summary is shown below.";
|
||||
if (statusPill) statusPill.textContent = "Payment confirmed";
|
||||
if (statusTitle) statusTitle.textContent = "Royal Pop preorder confirmed";
|
||||
if (statusBody) statusBody.textContent = message || "Stripe confirmed your payment and the backend order record has been updated.";
|
||||
if (metaLabels.confirmation) metaLabels.confirmation.textContent = "Payment verified";
|
||||
if (metaCopies.confirmation) metaCopies.confirmation.textContent = "Your backend order record and payment status now match.";
|
||||
if (metaLabels.next) metaLabels.next.textContent = "What happens next";
|
||||
if (metaCopies.next) metaCopies.next.textContent = "We can now use this verified order record for fulfilment, updates, and support.";
|
||||
if (metaLabels.reference) metaLabels.reference.textContent = "Order reference";
|
||||
if (metaCopies.reference) metaCopies.reference.textContent = "Keep this confirmed backend order reference handy for future support.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (orderStatus === "processing") {
|
||||
if (statusPill) statusPill.textContent = "Processing";
|
||||
if (statusTitle) statusTitle.textContent = "Your payment is still processing";
|
||||
if (statusBody) statusBody.textContent = message || "Stripe is still processing your payment. Refresh this page shortly for the latest update.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (orderStatus === "failed" || orderStatus === "canceled") {
|
||||
if (statusPill) statusPill.textContent = "Needs attention";
|
||||
if (statusTitle) statusTitle.textContent = "Your payment was not completed";
|
||||
if (statusBody) statusBody.textContent = message || "Stripe did not complete the payment. Return to checkout to try again.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusPill) statusPill.textContent = "Confirmation";
|
||||
if (statusTitle) statusTitle.textContent = "Royal Pop request received";
|
||||
if (statusBody) statusBody.textContent = message || "Your order record has been saved and is waiting for final Stripe confirmation.";
|
||||
};
|
||||
|
||||
const renderFromBackendOrder = (payload) => {
|
||||
const order = payload?.order;
|
||||
if (!order) return false;
|
||||
|
||||
const orderItems = Array.isArray(order.items) ? order.items : [];
|
||||
const mappedItems = orderItems.map((item) => ({
|
||||
...item,
|
||||
quantity: Number(item.quantity || 0),
|
||||
unitAmount: Number(item.unitAmount || 0),
|
||||
colorway: colorwayMap.get(item.colorwayId) || defaultColorway,
|
||||
}));
|
||||
const primaryItem = mappedItems[0] || { colorway: defaultColorway, quantity: 1, unitAmount: Math.round(currentPricePerKit * 100) };
|
||||
const totalQuantity = mappedItems.reduce((sum, item) => sum + item.quantity, 0) || 1;
|
||||
const total = Number(order.amount || 0) / 100;
|
||||
|
||||
if (summaryImage) {
|
||||
summaryImage.setAttribute("src", primaryItem.colorway.previewImage);
|
||||
summaryImage.setAttribute("alt", `${primaryItem.colorway.name} Royal Pop configuration preview`);
|
||||
}
|
||||
if (summaryName) summaryName.textContent = mappedItems.map((item) => item.colorway.name).join(" + ") || primaryItem.colorway.name;
|
||||
if (summarySubtitle) summarySubtitle.textContent = `${totalQuantity} ${totalQuantity === 1 ? "kit" : "kits"} confirmed`;
|
||||
if (summaryStyle) summaryStyle.textContent = mappedItems.length > 1 ? "Mixed configurations" : primaryItem.colorway.styleText;
|
||||
if (summaryCrown) summaryCrown.textContent = mappedItems.length > 1 ? "Multiple selections confirmed under one order." : primaryItem.colorway.crownSpec;
|
||||
if (summaryQuantity) summaryQuantity.textContent = `${totalQuantity} ${totalQuantity === 1 ? "kit" : "kits"}`;
|
||||
if (summaryTotal) summaryTotal.textContent = formatGBP(total);
|
||||
if (summaryTotalInline) summaryTotalInline.textContent = formatGBP(total);
|
||||
if (orderStatusNode) orderStatusNode.textContent = order.status || payload.orderStatus || "Received";
|
||||
|
||||
const reference = order.id || payload.orderId || "—";
|
||||
referenceNodes.forEach((node) => {
|
||||
node.textContent = reference;
|
||||
});
|
||||
|
||||
if (cartItemsNode) {
|
||||
cartItemsNode.innerHTML = `
|
||||
<div class="${orderRowClass}">
|
||||
<span>Reference</span>
|
||||
<strong>${reference}</strong>
|
||||
</div>
|
||||
${mappedItems.map((item) => `
|
||||
<div class="${orderRowClass}">
|
||||
<div class="${cartLineClass}">
|
||||
<span>${item.colorway.name}</span>
|
||||
<small class="${cartLineMetaClass}">${item.colorway.styleText} · ${item.quantity} ${item.quantity === 1 ? "kit" : "kits"}</small>
|
||||
</div>
|
||||
<strong>${formatGBP((item.unitAmount * item.quantity) / 100)}</strong>
|
||||
</div>`).join("")}
|
||||
<div class="${orderRowClass}">
|
||||
<span>Confirmed total</span>
|
||||
<strong>${formatGBP(total)}</strong>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
applyStatusContent(order.status || payload.orderStatus, payload.message || order.webhookMessage);
|
||||
clearCart();
|
||||
return true;
|
||||
};
|
||||
|
||||
const renderFromCartFallback = () => {
|
||||
const cartItems = getEffectiveCart();
|
||||
const groupedItems = groupCartItems(cartItems)
|
||||
.map((item) => ({ ...item, colorway: colorwayMap.get(item.colorwayId) || defaultColorway }))
|
||||
.filter(Boolean);
|
||||
const primaryItem = groupedItems[0] || { quantity: 1, colorway: defaultColorway };
|
||||
const totalQuantity = groupedItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0) || primaryItem.quantity || 1;
|
||||
const total = currentPricePerKit * totalQuantity;
|
||||
const colorCode = groupedItems.map((item) => item.colorway.name.slice(0, 2).toUpperCase()).join("").slice(0, 4) || primaryItem.colorway.name.slice(0, 2).toUpperCase();
|
||||
const quantityCode = String(totalQuantity).padStart(2, "0");
|
||||
const reference = `RP-${quantityCode}-${colorCode}`;
|
||||
|
||||
if (summaryImage) {
|
||||
summaryImage.setAttribute("src", primaryItem.colorway.previewImage);
|
||||
summaryImage.setAttribute("alt", `${primaryItem.colorway.name} Royal Pop configuration preview`);
|
||||
}
|
||||
if (summaryName) summaryName.textContent = groupedItems.map((item) => item.colorway.name).join(" + ") || primaryItem.colorway.name;
|
||||
if (summarySubtitle) summarySubtitle.textContent = `${totalQuantity} ${totalQuantity === 1 ? "kit" : "kits"} confirmed`;
|
||||
if (summaryStyle) summaryStyle.textContent = groupedItems.length > 1 ? "Mixed configurations" : primaryItem.colorway.styleText;
|
||||
if (summaryCrown) summaryCrown.textContent = groupedItems.length > 1 ? "Two selected kits saved under one request." : primaryItem.colorway.crownSpec;
|
||||
if (summaryQuantity) summaryQuantity.textContent = `${totalQuantity} ${totalQuantity === 1 ? "kit" : "kits"}`;
|
||||
if (summaryTotal) summaryTotal.textContent = formatGBP(total);
|
||||
if (summaryTotalInline) summaryTotalInline.textContent = formatGBP(total);
|
||||
referenceNodes.forEach((node) => {
|
||||
node.textContent = reference;
|
||||
});
|
||||
if (cartItemsNode) {
|
||||
cartItemsNode.innerHTML = `
|
||||
<div class="${orderRowClass}">
|
||||
<span>Reference</span>
|
||||
<strong>${reference}</strong>
|
||||
</div>
|
||||
${groupedItems.map((item) => `
|
||||
<div class="${orderRowClass}">
|
||||
<div class="${cartLineClass}">
|
||||
<span>${item.colorway.name}</span>
|
||||
<small class="${cartLineMetaClass}">${item.colorway.styleText} · ${item.quantity} ${item.quantity === 1 ? "kit" : "kits"}</small>
|
||||
</div>
|
||||
<strong>${formatGBP(item.quantity * currentPricePerKit)}</strong>
|
||||
</div>`).join("")}
|
||||
<div class="${orderRowClass}">
|
||||
<span>Early Bird total</span>
|
||||
<strong>${formatGBP(total)}</strong>
|
||||
</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
const loadVerifiedOrder = async () => {
|
||||
const paymentIntentID = new URLSearchParams(window.location.search).get("payment_intent");
|
||||
if (!paymentIntentID) {
|
||||
renderFromCartFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedApiBaseUrl}/v1/checkout/payment-intent/${encodeURIComponent(paymentIntentID)}`);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !payload?.data || !renderFromBackendOrder(payload.data)) {
|
||||
throw new Error(payload?.error?.message || "We could not load your confirmed order details.");
|
||||
}
|
||||
} catch (error) {
|
||||
renderFromCartFallback();
|
||||
applyStatusContent("pending", error instanceof Error ? error.message : "We could not load your confirmed order details.");
|
||||
}
|
||||
};
|
||||
|
||||
if (backLink) backLink.setAttribute("href", "/checkout");
|
||||
|
||||
loadPricing().finally(loadVerifiedOrder);
|
||||
};
|
||||
|
||||
initThankYouPage();
|
||||
document.addEventListener("astro:page-load", initThankYouPage);
|
||||
</script>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
Reference in New Issue
Block a user