2
0

Version 1

This commit is contained in:
MangoPig
2026-06-23 18:50:37 +01:00
commit 55d9772272
133 changed files with 21515 additions and 0 deletions
@@ -0,0 +1,151 @@
---
// Path: Store/src/components/royal-pop/ColorwaysSection.astro
import type { RoyalPopColorway } from "../../data/royalPop";
import { defaultColorway } from "../../data/royalPop";
import styles from "./ColorwaysSection.module.scss";
interface Props {
colorways: RoyalPopColorway[];
}
const { colorways } = Astro.props;
const active = defaultColorway;
---
<section id="colorways" class:list={[styles.section, "reveal"]}>
<div class={styles.inner}>
<div class={styles.heading}>
<p class={styles.sectionLabel}>The Palette</p>
<h2 class={styles.sectionHeadline}>
Eight ways
<br />
to stand out.
</h2>
<p class={styles.sectionBody}>Each Royal Pop conversion kit ships as a complete cohesive system - strap, lug adapter, and matched case build - tied to your chosen colourway.</p>
</div>
<div class={styles.previewArea} data-colorways-root>
<div id="cw-preview" class={styles.previewCard} data-preview-surface style={`background:${active.previewBackground};`}>
<img class={styles.watchImage} src={active.previewImage} alt={`${active.name} Royal Pop preview`} data-colorway-preview loading="lazy" />
</div>
<div class={styles.previewText}>
<h3 class={styles.colorName} data-cw-name>{active.name}</h3>
<p class={styles.colorSubtitle} data-cw-sub>{active.subtitle}</p>
<div class={styles.styleBadge} data-cw-style-badge data-style={active.style.toLowerCase()}>
<span class={styles.dot}></span>
<span data-cw-style-text>{active.styleText}</span>
</div>
<p class={styles.crownDesc} data-cw-crown-desc>{active.crownDescription}</p>
</div>
<div class={styles.swatches} role="tablist" aria-label="Royal Pop colorways">
{
colorways.map((colorway) => (
<button
type="button"
class={styles.colorSwatch}
style={`background:${colorway.swatchColor};`}
data-colorway-trigger
data-colorway-id={colorway.id}
aria-label={`${colorway.name} - ${colorway.subtitle}`}
aria-pressed={String(colorway.id === active.id)}
/>
))
}
</div>
<div class={styles.cardGrid}>
{
colorways.map((colorway) => (
<button
type="button"
class={styles.gridCard}
data-colorway-card
data-colorway-id={colorway.id}
aria-label={`Select ${colorway.name} colorway`}
aria-pressed={String(colorway.id === active.id)}
>
<div class={styles.gridCardImageWrap}>
<img class={styles.watchImage} src={colorway.cardImage} alt={`${colorway.name} product view`} loading="lazy" />
</div>
<div class={styles.gridCardText}>
<strong>{colorway.name}</strong>
<span>{colorway.subtitle}</span>
</div>
</button>
))
}
</div>
</div>
</div>
<script define:vars={{ colorways }}>
const root = document.querySelector("[data-colorways-root]");
if (root) {
const preview = root.querySelector("[data-colorway-preview]");
const previewSurface = root.querySelector("[data-preview-surface]");
const name = root.querySelector("[data-cw-name]");
const subtitle = root.querySelector("[data-cw-sub]");
const badge = root.querySelector("[data-cw-style-badge]");
const badgeText = root.querySelector("[data-cw-style-text]");
const crownDesc = root.querySelector("[data-cw-crown-desc]");
const triggers = Array.from(root.querySelectorAll("[data-colorway-trigger]"));
const cards = Array.from(root.querySelectorAll("[data-colorway-card]"));
const crownStatValue = document.querySelector('[data-stat-id="crown-stat-value"]');
const crownStatLabel = document.querySelector('[data-stat-id="crown-stat-label"]');
const crownSpec = document.querySelector('[data-spec-id="crownPosition"]');
const applyColorway = (id) => {
const next = colorways.find((entry) => entry.id === id);
if (!next || !preview || !previewSurface || !name || !subtitle || !badge || !badgeText || !crownDesc) {
return;
}
preview.setAttribute("src", next.previewImage);
preview.setAttribute("alt", `${next.name} Royal Pop preview`);
previewSurface.setAttribute("style", `background:${next.previewBackground};`);
name.textContent = next.name;
subtitle.textContent = next.subtitle;
badge.setAttribute("data-style", next.style.toLowerCase());
badgeText.textContent = next.styleText;
crownDesc.textContent = next.crownDescription;
if (crownSpec) crownSpec.textContent = next.crownSpec;
if (crownStatValue) crownStatValue.textContent = next.crownStatValue;
if (crownStatLabel) crownStatLabel.textContent = next.crownStatLabel;
triggers.forEach((trigger) => {
const isActive = trigger.getAttribute("data-colorway-id") === next.id;
trigger.setAttribute("aria-pressed", String(isActive));
trigger.classList.toggle("is-active", isActive);
});
cards.forEach((card) => {
const isActive = card.getAttribute("data-colorway-id") === next.id;
card.setAttribute("aria-pressed", String(isActive));
card.classList.toggle("is-active", isActive);
});
};
triggers.forEach((trigger) => {
trigger.addEventListener("click", () => {
const id = trigger.getAttribute("data-colorway-id");
if (id) applyColorway(id);
});
});
cards.forEach((card) => {
card.addEventListener("click", () => {
const id = card.getAttribute("data-colorway-id");
if (id) applyColorway(id);
});
});
}
</script>
</section>
@@ -0,0 +1,236 @@
.section {
padding: 120px 24px;
background: #ffffff;
}
.inner {
width: min(100%, 980px);
margin: 0 auto;
}
.heading {
text-align: center;
}
.sectionLabel {
margin: 0 0 18px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6e6e73;
}
.sectionHeadline {
margin: 0;
font-size: clamp(36px, 5vw, 56px);
font-weight: 700;
line-height: 1.02;
letter-spacing: -0.05em;
color: #1d1d1f;
}
.sectionBody {
max-width: 620px;
margin: 20px auto 0;
font-size: 17px;
line-height: 1.7;
color: #6e6e73;
}
.previewArea {
margin-top: 56px;
text-align: center;
}
.previewCard {
max-width: 480px;
margin: 0 auto;
padding: 40px;
border-radius: 32px;
transition: background 0.25s ease;
}
.watchImage {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
.previewText {
margin-top: 28px;
display: flex;
flex-direction: column;
align-items: center;
}
.colorName {
margin: 0;
font-size: clamp(24px, 3vw, 32px);
font-weight: 700;
letter-spacing: -0.03em;
color: #1d1d1f;
}
.colorSubtitle {
margin: 6px 0 0;
font-size: 17px;
color: #6e6e73;
}
.styleBadge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 18px;
padding: 6px 14px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
&[data-style="a"] {
background: #e8f2ff;
color: #0071e3;
}
&[data-style="b"] {
background: #fdeced;
color: #d14d52;
}
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex: 0 0 auto;
}
.crownDesc {
max-width: 360px;
margin: 14px auto 0;
font-size: 13px;
line-height: 1.6;
color: #6e6e73;
}
.swatches {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
margin-top: 24px;
}
.colorSwatch {
width: 28px;
height: 28px;
padding: 0;
border: 2px solid transparent;
border-radius: 50%;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
&:hover {
transform: scale(1.08);
}
&[aria-pressed="true"],
&:global(.is-active) {
border-color: #1d1d1f;
box-shadow: 0 0 0 3px #ffffff, 0 0 0 4px #1d1d1f;
}
}
.cardGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 28px 20px;
margin-top: 56px;
@include respond(tablet) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.gridCard {
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: inherit;
text-align: center;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
text-align: center;
transition: transform 0.22s ease;
&:focus-visible {
outline: 2px solid #1d1d1f;
outline-offset: 6px;
border-radius: 20px;
}
&:hover .gridCardImageWrap {
transform: translateY(-4px) scale(1.015);
}
&[aria-pressed="true"] .gridCardImageWrap,
&:global(.is-active) .gridCardImageWrap {
box-shadow: 0 0 0 2px #1d1d1f;
transform: translateY(-4px) scale(1.015);
}
&[aria-pressed="true"] strong,
&:global(.is-active) strong {
color: #0071e3;
}
}
.gridCardImageWrap {
padding: 22px;
border-radius: 16px;
background: #f5f5f7;
transition: transform 0.22s ease;
}
.gridCardText {
margin-top: 12px;
strong,
span {
display: block;
}
strong {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
color: #1d1d1f;
}
span {
margin-top: 4px;
font-size: 14px;
color: #6e6e73;
}
}
@media (max-width: 734px) {
.section {
padding-top: 88px;
padding-bottom: 88px;
}
.previewCard {
padding: 28px;
}
}
@@ -0,0 +1,62 @@
---
import styles from "./FeatureSections.module.scss";
---
<section id="features" class:list={[styles.featureSection, "reveal"]}>
<div class={styles.inner}>
<div class={styles.splitGrid}>
<div class={styles.copyBlock}>
<p class={styles.sectionLabel}>Engineering</p>
<h2 class={styles.sectionHeadline}>
Built for the
<br />
Lépine crown.
</h2>
<p class={styles.sectionBody}>
The Royal Pop&apos;s Lépine-style pocket watch case wears its crown at 12 o&apos;clock - a design that demands a purpose-built conversion system. Our precision lug adapter is CNC-machined to align the octagonal case perfectly on
the wrist, ±0.1° crown-true, every time.
</p>
<div class={styles.metricRow}>
<div class={styles.metricItem}>
<strong>±0.1°</strong>
<span>Alignment tolerance</span>
</div>
<div class={styles.metricItem}>
<strong>CNC</strong>
<span>Machined lug adapter</span>
</div>
</div>
</div>
<div class={styles.mediaFrame}>
<img class={styles.watchImage} src="/images/royal-pop/editorial/scene-01.webp" alt="Royal Pop case shown inside the precision adapter" loading="lazy" />
</div>
</div>
</div>
</section>
<section class:list={[styles.featureSection, styles.materialSection, "reveal"]}>
<div class={styles.inner}>
<div class={styles.splitGrid}>
<div class={styles.mediaFrame}>
<img class={styles.detailImage} src="/images/royal-pop/editorial/scene-02.webp" alt="Royal Pop bioceramic case shell and silicone strap detail" loading="lazy" />
</div>
<div class={styles.copyBlock}>
<p class={styles.sectionLabel}>Materials</p>
<h2 class={styles.sectionHeadline}>
Built to the
<br />
same standard.
</h2>
<p class={styles.sectionBody}>
The conversion kit&apos;s case is formed from the same Premium Bioceramic compound Swatch uses in the Royal Pop itself - lightweight, scratch-resistant, and warm to the touch. The silicone strap is custom-moulded to the Royal
Pop&apos;s octagonal integrated lug geometry for a flush, factory-finished fit.
</p>
<p class={styles.sectionBody}>Every kit ships as a matched system: Bioceramic case shell, high-grade silicone strap, and Royal Pop-specific fitment - all matched to your chosen colourway.</p>
</div>
</div>
</div>
</section>
@@ -0,0 +1,411 @@
.featureSection {
padding: 120px 24px;
background: #ffffff;
}
.materialSection {
padding-top: 0;
}
.inner {
width: min(100%, 980px);
margin: 0 auto;
}
.splitGrid {
display: grid;
gap: 48px;
align-items: center;
@include respond(tablet) {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 80px;
}
}
.copyBlock {
min-width: 0;
}
.sectionLabel,
.sectionLabelDark {
margin: 0 0 18px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.sectionLabel {
color: #6e6e73;
}
.sectionLabelDark {
color: rgba(255, 255, 255, 0.62);
}
.sectionHeadline,
.sectionHeadlineDark {
margin: 0;
font-size: clamp(36px, 5vw, 56px);
font-weight: 700;
line-height: 1.02;
letter-spacing: -0.05em;
}
.sectionHeadline {
color: #1d1d1f;
}
.sectionHeadlineDark {
color: #ffffff;
}
.sectionBody,
.sectionBodyDark {
max-width: 480px;
margin: 20px 0 0;
font-size: 17px;
line-height: 1.7;
}
.sectionBody {
color: #6e6e73;
}
.sectionBodyDark {
color: rgba(255, 255, 255, 0.72);
}
.mediaFrame {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
border-radius: 32px;
border: 1px solid rgba(210, 210, 215, 0.7);
background: linear-gradient(180deg, #fbfbfd 0%, #f5f5f7 100%);
box-shadow: 0 18px 44px rgba(29, 29, 31, 0.06);
overflow: hidden;
}
.watchImage,
.detailImage {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
.metricRow {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 32px;
}
.metricItem,
.darkMetricItem {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 6px;
min-height: 118px;
padding: 20px;
border-radius: 24px;
}
.metricItem strong,
.darkMetricItem strong {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1;
}
.metricItem span,
.darkMetricItem span {
font-size: 13px;
line-height: 1.5;
}
.metricItem strong {
color: #1d1d1f;
}
.metricItem span {
color: #6e6e73;
}
.metricItem {
border: 1px solid rgba(210, 210, 215, 0.8);
background: linear-gradient(180deg, #fbfbfd 0%, #f5f5f7 100%);
box-shadow: 0 14px 32px rgba(29, 29, 31, 0.05);
}
.comparisonCard {
margin-top: 48px;
border: 1px solid #d2d2d7;
border-radius: 28px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 18px 44px rgba(29, 29, 31, 0.05);
}
.cardHeader,
.cardRow {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
gap: 16px;
align-items: center;
padding: 16px 24px;
}
.cardHeader {
background: linear-gradient(180deg, #f9f9fb 0%, #f3f3f5 100%);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6e6e73;
}
.cardRows {
background: #ffffff;
}
.cardRow {
padding: 14px 24px;
font-size: 15px;
color: #1d1d1f;
border-top: 1px solid #f0f0f0;
}
.cardRow span:first-child {
font-weight: 600;
}
.cardRowAlt {
background: #fafafa;
}
.darkBand {
padding: 120px 24px;
background: #1d1d1f;
}
.darkBandGrid {
display: grid;
gap: 40px;
align-items: center;
@include respond(tablet) {
grid-template-columns: minmax(0, 1fr) auto;
}
}
.darkMetrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.darkMetricItem strong {
color: #ffffff;
}
.darkMetricItem span {
color: rgba(255, 255, 255, 0.62);
}
.darkMetricItem {
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.03) 100%);
backdrop-filter: blur(8px);
}
@media (max-width: 900px) {
.comparisonCard {
margin-top: 36px;
border-radius: 24px;
}
.cardHeader {
display: none;
}
.cardRows {
display: grid;
gap: 12px;
padding: 14px;
background: linear-gradient(180deg, #fbfbfd 0%, #f4f4f6 100%);
}
.cardRow {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"label"
"ours"
"standard";
gap: 10px;
padding: 16px;
font-size: 13px;
line-height: 1.4;
border: 1px solid rgba(210, 210, 215, 0.8);
border-radius: 20px;
background: linear-gradient(180deg, #ffffff 0%, #fafafc 100%);
box-shadow: 0 10px 24px rgba(29, 29, 31, 0.05);
}
.cardRowAlt {
background: linear-gradient(180deg, #ffffff 0%, #fafafc 100%);
}
.cardRow span {
min-width: 0;
overflow-wrap: anywhere;
}
.cardRow span:first-child {
grid-area: label;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.cardRow span:nth-child(2),
.cardRow span:nth-child(3) {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 6px;
padding: 12px 14px;
border-radius: 14px;
background: #f5f5f7;
font-size: 14px;
line-height: 1.45;
}
.cardRow span:nth-child(2) {
grid-area: ours;
}
.cardRow span:nth-child(3) {
grid-area: standard;
}
.cardRow span:nth-child(2)::before,
.cardRow span:nth-child(3)::before {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6e6e73;
}
.cardRow span:nth-child(2)::before {
content: "Bioceramic (ours)";
}
.cardRow span:nth-child(3)::before {
content: "Standard plastic";
}
}
@media (max-width: 734px) {
.featureSection,
.darkBand {
padding-top: 72px;
padding-bottom: 72px;
}
.materialSection {
padding-top: 0;
}
.splitGrid {
gap: 28px;
}
.sectionLabel,
.sectionLabelDark {
margin-bottom: 14px;
font-size: 11px;
letter-spacing: 0.16em;
}
.sectionHeadline,
.sectionHeadlineDark {
font-size: clamp(31px, 9vw, 40px);
line-height: 1;
}
.sectionBody,
.sectionBodyDark {
margin-top: 16px;
font-size: 15px;
line-height: 1.55;
}
.mediaFrame {
padding: 24px;
border-radius: 24px;
}
.metricRow {
gap: 12px;
margin-top: 24px;
}
.darkMetrics {
gap: 12px;
}
.metricItem,
.darkMetricItem {
min-height: 100px;
padding: 16px;
border-radius: 20px;
}
.metricItem strong,
.darkMetricItem strong {
font-size: 24px;
}
.metricItem span,
.darkMetricItem span {
font-size: 12px;
line-height: 1.35;
}
.cardRow {
font-size: 13px;
line-height: 1.35;
}
.cardRow span:first-child {
font-size: 14px;
}
.cardRow span:nth-child(2),
.cardRow span:nth-child(3) {
font-size: 12px;
line-height: 1.4;
}
.cardRow span:nth-child(2)::before,
.cardRow span:nth-child(3)::before {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6e6e73;
}
.darkBandGrid {
gap: 24px;
}
}
@@ -0,0 +1,25 @@
---
// Path: Store/src/components/royal-pop/HeroSection.astro
import styles from "./HeroSection.module.scss";
---
<section class:list={[styles.heroSection, "reveal"]}>
<div class={styles.inner}>
<p class={styles.eyebrow}>Introducing the Conversion Kit</p>
<h1 class={styles.headline}>
Wear the Pop
<br />
on Your Wrist.
</h1>
<p class={styles.summary}>The first precision-engineered wrist conversion kit for your Swatch × AP Royal Pop. Eight vibrant pop-art colorways. One obsessive luxury standard.</p>
<div class={styles.actions}>
<a href="/buy" class={styles.primaryButton} data-hero-cta>Pre-Order - Early Bird</a>
<a href="#features" class={styles.secondaryButton}>Learn more </a>
</div>
<div class={styles.heroImageWrap}>
<img class={styles.watchImage} src="/images/royal-pop/gallery/ocho-negro-card.webp" alt="Royal Pop wrist conversion kit hero watch" loading="eager" />
</div>
</div>
</section>
@@ -0,0 +1,150 @@
@keyframes floatWatch {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.heroSection {
box-sizing: border-box;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: calc(env(safe-area-inset-top, 0px) + 72px) 24px 72px;
background: linear-gradient(180deg, #fbfbfd 0%, #f0f2f5 100%);
text-align: center;
}
.inner {
width: min(100%, 980px);
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
}
.eyebrow {
margin: 0 0 14px;
font-size: 14px;
font-weight: 600;
color: #0071e3;
}
.headline {
margin: 0;
max-width: 760px;
font-size: clamp(48px, 7vw, 80px);
font-weight: 700;
line-height: 0.96;
letter-spacing: -0.055em;
color: #1d1d1f;
}
.summary {
max-width: 560px;
margin: 22px 0 0;
font-size: clamp(18px, 2.5vw, 24px);
line-height: 1.45;
color: #6e6e73;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px;
margin-top: 28px;
}
.primaryButton,
.secondaryButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 22px;
border-radius: 980px;
font-size: 17px;
font-weight: 400;
text-decoration: none;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.primaryButton {
background: #0071e3;
color: #ffffff;
&:hover {
background: #0077ed;
text-decoration: none;
}
}
.secondaryButton {
color: #0071e3;
background: transparent;
&:hover {
background: rgba(0, 113, 227, 0.08);
text-decoration: none;
}
}
.heroImageWrap {
width: min(100%, 620px);
margin-top: 48px;
}
.watchImage {
display: block;
width: 100%;
height: auto;
object-fit: contain;
object-position: center;
filter: drop-shadow(0 24px 48px rgba(0, 0, 0, 0.18));
animation: floatWatch 5.5s ease-in-out infinite;
}
@media (max-width: 734px) {
.heroSection {
padding: calc(env(safe-area-inset-top, 0px) + 72px) 20px 36px;
}
.inner {
max-width: 420px;
}
.headline {
font-size: 36px;
line-height: 0.98;
}
.summary {
max-width: 34ch;
margin-top: 16px;
font-size: 18px;
line-height: 1.35;
}
.actions {
gap: 12px;
margin-top: 20px;
}
.primaryButton,
.secondaryButton {
padding: 11px 18px;
font-size: 16px;
}
.heroImageWrap {
width: min(100%, 320px);
margin-top: 28px;
}
}
@@ -0,0 +1,35 @@
---
// Path: Store/src/components/royal-pop/PreorderSection.astro
import styles from "./PreorderSection.module.scss";
---
<section id="preorder" class:list={[styles.section, "reveal"]}>
<div class={styles.banner}>
<p class={styles.sectionLabel}>Early Bird</p>
<h2 class={styles.sectionHeadline}>
Reserve
<br />
your kit.
</h2>
<p class={styles.sectionBody} data-preorder-body>Pre-orders typically ship in around 1 month. Each kit is matched to a specific Royal Pop colourway — lock in yours before the £49.99 early bird closes.</p>
<div class={styles.priceRow}>
<div class={styles.priceBlock}>
<strong data-preorder-price-now>£49.99</strong>
<span>Early Bird Price</span>
</div>
<span class={styles.vs}>vs</span>
<div class={styles.priceBlock}>
<strong class={styles.retailPrice} data-preorder-price-retail>£89.99</strong>
<span>Retail Price</span>
</div>
</div>
<div class={styles.actions}>
<a href="/buy" class={styles.primaryButton}>Pre-Order Now</a>
<a href="#colorways" class={styles.secondaryButton}>Choose your colour </a>
</div>
<p class={styles.footerNote}>Free worldwide shipping · 30-day returns · Secure checkout</p>
</div>
</section>
@@ -0,0 +1,137 @@
.section {
padding: 120px 24px;
background: #ffffff;
}
.banner {
width: min(100%, 980px);
margin: 0 auto;
padding: 120px 24px;
border-radius: 32px;
background: linear-gradient(180deg, #1f1f23 0%, #101113 100%);
text-align: center;
}
.sectionLabel {
margin: 0 0 18px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
}
.sectionHeadline {
margin: 0;
font-size: clamp(36px, 5vw, 56px);
font-weight: 700;
line-height: 1.02;
letter-spacing: -0.05em;
color: #ffffff;
}
.sectionBody {
max-width: 620px;
margin: 20px auto 0;
font-size: 17px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.72);
}
.priceRow {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 16px 24px;
margin-top: 34px;
}
.priceBlock {
display: flex;
flex-direction: column;
gap: 6px;
strong {
font-size: clamp(34px, 5vw, 44px);
font-weight: 700;
letter-spacing: -0.04em;
color: #ffffff;
}
span {
font-size: 14px;
color: rgba(255, 255, 255, 0.68);
}
}
.retailPrice {
opacity: 0.56;
text-decoration: line-through;
}
.vs {
font-size: 17px;
color: rgba(255, 255, 255, 0.52);
}
.actions {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
margin-top: 34px;
}
.primaryButton,
.secondaryButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 22px;
border-radius: 980px;
font-size: 17px;
text-decoration: none;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.primaryButton {
background: #0071e3;
color: #ffffff;
&:hover {
background: #0077ed;
text-decoration: none;
}
}
.secondaryButton {
color: #2997ff;
background: transparent;
&:hover {
background: rgba(41, 151, 255, 0.1);
text-decoration: none;
}
}
.footerNote {
margin: 28px 0 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.58);
}
@media (max-width: 734px) {
.section {
padding-top: 88px;
padding-bottom: 88px;
}
.banner {
padding-top: 88px;
padding-bottom: 88px;
}
}
@@ -0,0 +1,16 @@
---
// Path: Store/src/components/royal-pop/SiteFooter.astro
import styles from "./SiteFooter.module.scss";
---
<footer class={styles.footer}>
<div class={styles.inner}>
<div class={styles.topRow}>
<p class={styles.productLabel}>Royal Pop: Swatch × AP Wrist Conversion Kit</p>
</div>
<div class={styles.divider}></div>
<p class={styles.disclaimer}>Not affiliated with Swatch Group AG or Audemars Piguet SA.</p>
</div>
</footer>
@@ -0,0 +1,51 @@
.footer {
padding: 40px 24px;
background: #f5f5f7;
border-top: 1px solid #d2d2d7;
font-size: 12px;
color: #6e6e73;
}
.inner {
width: min(100%, 980px);
margin: 0 auto;
}
.topRow {
display: flex;
flex-direction: column;
gap: 16px;
@include respond(tablet) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.productLabel,
.disclaimer {
margin: 0;
line-height: 1.6;
}
.links {
display: flex;
flex-wrap: wrap;
gap: 16px;
a {
color: #6e6e73;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.divider {
height: 1px;
margin: 24px 0 16px;
background: #d2d2d7;
}
@@ -0,0 +1,41 @@
---
import type { SpecRow } from "../../data/royalPop";
import styles from "./SpecsSection.module.scss";
interface Props {
rows: SpecRow[];
}
const { rows } = Astro.props;
---
<section id="specs" class:list={[styles.section, "reveal"]}>
<div class={styles.inner}>
<div class={styles.grid}>
<div class={styles.copyBlock}>
<p class={styles.sectionLabel}>Details</p>
<h2 class={styles.sectionHeadline}>
Technical
<br />
specifications.
</h2>
<p class={styles.sectionBody}>
Every measurement exists for a reason. Zero compromises, zero guesswork.
</p>
</div>
<div class={styles.specRows} role="table" aria-label="Royal Pop technical specifications">
{
rows.map((row) => (
<div class={styles.specRow} role="row">
<span class={styles.specKey} role="rowheader">{row.label}</span>
<strong class={styles.specValue} role="cell" data-spec-id={row.dynamic}>
{row.value}
</strong>
</div>
))
}
</div>
</div>
</div>
</section>
@@ -0,0 +1,88 @@
.section {
padding: 120px 24px;
background: #ffffff;
}
.inner {
width: min(100%, 980px);
margin: 0 auto;
}
.grid {
display: grid;
gap: 48px;
@include respond(tablet) {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 80px;
}
}
.sectionLabel {
margin: 0 0 18px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6e6e73;
}
.sectionHeadline {
margin: 0;
font-size: clamp(36px, 5vw, 56px);
font-weight: 700;
line-height: 1.02;
letter-spacing: -0.05em;
color: #1d1d1f;
}
.sectionBody {
max-width: 360px;
margin: 20px 0 0;
font-size: 17px;
line-height: 1.7;
color: #6e6e73;
}
.specRows {
width: 100%;
}
.specRow {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px 0;
border-bottom: 1px solid #d2d2d7;
@include respond(mobile) {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 24px;
}
}
.specKey {
font-size: 15px;
color: #6e6e73;
}
.specValue {
font-size: 15px;
font-weight: 500;
color: #1d1d1f;
text-align: left;
@include respond(mobile) {
text-align: right;
}
}
@media (max-width: 734px) {
.section {
padding-top: 88px;
padding-bottom: 88px;
}
}
@@ -0,0 +1,25 @@
---
import type { StatItem } from "../../data/royalPop";
import styles from "./StatsBand.module.scss";
interface Props {
items: StatItem[];
}
const { items } = Astro.props;
---
<section class={styles.wrapper} aria-label="Key product stats">
<div class={styles.inner}>
<ul class={styles.grid}>
{
items.map((item) => (
<li class={styles.item}>
<strong data-stat-id={item.id ? `${item.id}-value` : undefined}>{item.value}</strong>
<span data-stat-id={item.id ? `${item.id}-label` : undefined}>{item.label}</span>
</li>
))
}
</ul>
</div>
</section>
@@ -0,0 +1,79 @@
.wrapper {
padding: 80px 24px;
background: #ffffff;
}
.inner {
width: min(100%, 980px);
margin: 0 auto;
}
.grid {
list-style: none;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 0;
margin: 0;
@include respond(tablet) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 132px;
padding: 20px 28px;
border: 1px solid rgba(210, 210, 215, 0.8);
border-radius: 24px;
background: linear-gradient(180deg, #fbfbfd 0%, #f5f5f7 100%);
box-shadow: 0 12px 30px rgba(29, 29, 31, 0.05);
text-align: center;
strong {
font-size: 36px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.05;
color: #1d1d1f;
}
span {
margin-top: 6px;
font-size: 12px;
font-weight: 500;
letter-spacing: -0.01em;
color: #6e6e73;
}
}
@media (max-width: 734px) {
.wrapper {
padding: 56px 20px 44px;
}
.grid {
gap: 12px;
}
.item {
min-height: 118px;
padding: 18px 14px;
border-radius: 20px;
strong {
font-size: clamp(28px, 8vw, 34px);
}
span {
margin-top: 5px;
font-size: 11px;
line-height: 1.35;
}
}
}
+196
View File
@@ -0,0 +1,196 @@
// Path: Store/src/data/royalPop.ts
export interface RoyalPopColorway {
id: string;
name: string;
subtitle: string;
style: "A" | "B";
styleText: string;
crownDescription: string;
crownSpec: string;
crownStatValue: string;
crownStatLabel: string;
swatchColor: string;
previewBackground: string;
previewImage: string;
cardImage: string;
}
export interface StatItem {
id?: string;
value: string;
label: string;
}
export interface ComparisonRow {
label: string;
royalPop: string;
standardBuild: string;
}
export interface SpecRow {
label: string;
value: string;
dynamic?: "crownPosition";
}
const styleADescription = "Traditional Lépine-style crown at 12 o'clock. A faithful pocket-watch orientation with the crown centered at the top of the wrist.";
const styleBDescription = "Standard sports-watch crown at 3 o'clock. Full ergonomic wrist comfort with direct time-setting access - no strap removal needed.";
export const colorways: RoyalPopColorway[] = [
{
id: "ocho-negro",
name: "ONYX",
subtitle: "All Black",
style: "A",
styleText: "Style A · 12 o'clock Crown",
crownDescription: styleADescription,
crownSpec: "Style A · 12 o'clock (Lépine crown)",
crownStatValue: "12°",
crownStatLabel: "Lépine Crown",
swatchColor: "#1a1a1a",
previewBackground: "linear-gradient(180deg, #f5f5f7 0%, #eeeeef 100%)",
previewImage: "/images/royal-pop/colorways/ocho-negro.webp",
cardImage: "/images/royal-pop/gallery/ocho-negro-card.webp",
},
{
id: "pure-white",
name: "BLANC",
subtitle: "All White",
style: "A",
styleText: "Style A · 12 o'clock Crown",
crownDescription: styleADescription,
crownSpec: "Style A · 12 o'clock (Lépine crown)",
crownStatValue: "12°",
crownStatLabel: "Lépine Crown",
swatchColor: "#e8e8e8",
previewBackground: "linear-gradient(180deg, #f7f7f8 0%, #ececef 100%)",
previewImage: "/images/royal-pop/colorways/pure-white.webp",
cardImage: "/images/royal-pop/gallery/pure-white-card.webp",
},
{
id: "pop-pink",
name: "SAKURA",
subtitle: "Pink · Red",
style: "A",
styleText: "Style A · 12 o'clock Crown",
crownDescription: styleADescription,
crownSpec: "Style A · 12 o'clock (Lépine crown)",
crownStatValue: "12°",
crownStatLabel: "Lépine Crown",
swatchColor: "#f4b8c8",
previewBackground: "linear-gradient(180deg, #f8f1f4 0%, #f1e4eb 100%)",
previewImage: "/images/royal-pop/colorways/pop-pink.webp",
cardImage: "/images/royal-pop/gallery/pop-pink-card.webp",
},
{
id: "racer-green",
name: "FOREST",
subtitle: "All Green",
style: "A",
styleText: "Style A · 12 o'clock Crown",
crownDescription: styleADescription,
crownSpec: "Style A · 12 o'clock (Lépine crown)",
crownStatValue: "12°",
crownStatLabel: "Lépine Crown",
swatchColor: "#2d9e48",
previewBackground: "linear-gradient(180deg, #edf5ef 0%, #e2efe5 100%)",
previewImage: "/images/royal-pop/colorways/racer-green.webp",
cardImage: "/images/royal-pop/gallery/racer-green-card.webp",
},
{
id: "lime-blue",
name: "SAGE",
subtitle: "Mint · Sky",
style: "A",
styleText: "Style A · 12 o'clock Crown",
crownDescription: styleADescription,
crownSpec: "Style A · 12 o'clock (Lépine crown)",
crownStatValue: "12°",
crownStatLabel: "Lépine Crown",
swatchColor: "#b8e8c0",
previewBackground: "linear-gradient(180deg, #eef6f5 0%, #e3eff2 100%)",
previewImage: "/images/royal-pop/colorways/lime-blue.webp",
cardImage: "/images/royal-pop/gallery/lime-blue-card.webp",
},
{
id: "deep-blue-orange",
name: "MIDNIGHT",
subtitle: "Navy · Orange",
style: "A",
styleText: "Style A · 12 o'clock Crown",
crownDescription: styleADescription,
crownSpec: "Style A · 12 o'clock (Lépine crown)",
crownStatValue: "12°",
crownStatLabel: "Lépine Crown",
swatchColor: "#0d1a3a",
previewBackground: "linear-gradient(180deg, #eef1f5 0%, #e4e7ee 100%)",
previewImage: "/images/royal-pop/colorways/deep-blue-orange.webp",
cardImage: "/images/royal-pop/gallery/deep-blue-orange-card.webp",
},
{
id: "light-blue-sprint",
name: "GLACIER",
subtitle: "Steel Blue",
style: "B",
styleText: "Style B · 3 o'clock Crown",
crownDescription: styleBDescription,
crownSpec: "Style B · 3 o'clock (Right-side crown)",
crownStatValue: "3°",
crownStatLabel: "Right Crown",
swatchColor: "#3a6ea8",
previewBackground: "linear-gradient(180deg, #edf3f7 0%, #e4ebf2 100%)",
previewImage: "/images/royal-pop/colorways/light-blue-sprint.webp",
cardImage: "/images/royal-pop/gallery/light-blue-sprint-card.webp",
},
{
id: "sorbet-pop-multi-color",
name: "SORBET",
subtitle: "Pink · Yellow",
style: "B",
styleText: "Style B · 3 o'clock Crown",
crownDescription: styleBDescription,
crownSpec: "Style B · 3 o'clock (Right-side crown)",
crownStatValue: "3°",
crownStatLabel: "Right Crown",
swatchColor: "#f5a0c0",
previewBackground: "linear-gradient(180deg, #f6f2f4 0%, #efe7eb 100%)",
previewImage: "/images/royal-pop/colorways/sorbet-pop-multi-color.webp",
cardImage: "/images/royal-pop/gallery/sorbet-pop-multi-color-card.webp",
},
];
export const defaultColorway = colorways.find((colorway) => colorway.id === "sorbet-pop-multi-color") ?? colorways[0];
export const heroStats: StatItem[] = [
{ id: "crown-stat", value: defaultColorway.crownStatValue, label: defaultColorway.crownStatLabel },
{ value: "BIO", label: "Bioceramic Case" },
{ value: "8", label: "Colorways" },
{ id: "early-bird-stat", value: "£49.99", label: "Early Bird" },
{ value: "50m", label: "Water Resistant" },
{ value: "~1 Mo", label: "Preorder Ship" },
];
export const comparisonRows: ComparisonRow[] = [
{ label: "Weight", royalPop: "Ultra-light ✓", standardBuild: "Heavier" },
{ label: "Scratch Resistance", royalPop: "Excellent ✓", standardBuild: "Moderate" },
{ label: "Skin Feel", royalPop: "Ceramic-warm ✓", standardBuild: "Plastic-cold" },
{ label: "Colour Stability", royalPop: "Permanent ✓", standardBuild: "Fades over time" },
{ label: "AP Royal Pop Match", royalPop: "Exact compound ✓", standardBuild: "Visual only" },
];
export const specRows: SpecRow[] = [
{ label: "Case Material", value: "Premium Bioceramic" },
{ label: "Strap Material", value: "High-grade Silicone" },
{ label: "Crown Configuration", value: defaultColorway.crownSpec, dynamic: "crownPosition" },
{ label: "Strap Width", value: "20 MM" },
{ label: "Lug Adapter", value: "44 MM integrated" },
{ label: "Long Band", value: "126.4 MM" },
{ label: "Short Band", value: "104.2 MM" },
{ label: "Wrist Fit", value: "5.6″ 8.5″ (14.2 21.6 cm)" },
{ label: "Compatibility", value: "Swatch × AP Royal Pop (all colourways)" },
{ label: "Available Colorways", value: "8 Royal Pop references" },
{ label: "Water Resistance", value: "50m" },
{ label: "Preorder Dispatch", value: "Around 1 month" },
];
+110
View File
@@ -0,0 +1,110 @@
---
// Path: Store/src/layouts/BaseLayout.astro
import "../styles/main.scss";
interface Props {
title?: string;
description?: string;
socialTitle?: string;
socialDescription?: string;
socialImage?: string;
noindex?: boolean;
structuredData?: Record<string, unknown> | Array<Record<string, unknown>>;
}
const siteTitle = "Royal Pop Accessory";
const siteUrl = "https://royal-pop-accessory.com";
const {
title = siteTitle,
description = "Upgrade your watch with Royal Pop Accessory — bold interchangeable strap accessories in standout colourways, built to change your look in seconds.",
socialTitle,
socialDescription,
socialImage = "/images/png/pure-white.png",
noindex = false,
structuredData,
} = Astro.props;
const documentTitle = title === siteTitle ? siteTitle : `${title} | ${siteTitle}`;
const metadataTitle = socialTitle ?? documentTitle;
const metadataDescription = socialDescription ?? description;
const canonicalUrl = new URL(Astro.url.pathname, siteUrl).toString();
const socialImageUrl = new URL(socialImage, siteUrl).toString();
const robotsContent = noindex ? "noindex, nofollow" : "index, follow";
const structuredDataItems = structuredData
? Array.isArray(structuredData)
? structuredData
: [structuredData]
: [];
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
<meta name="robots" content={robotsContent} />
<link rel="canonical" href={canonicalUrl} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content={siteTitle} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:title" content={metadataTitle} />
<meta property="og:description" content={metadataDescription} />
<meta property="og:image" content={socialImageUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={metadataTitle} />
<meta name="twitter:description" content={metadataDescription} />
<meta name="twitter:image" content={socialImageUrl} />
{
structuredDataItems.map((item) => (
<script type="application/ld+json" set:html={JSON.stringify(item)} />
))
}
<meta name="theme-color" content="#e9e9e9" />
<meta name="msapplication-TileColor" content="#e9e9e9" />
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon/favicon.ico" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon/favicon-48x48.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/favicon/site.webmanifest" />
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
<!-- Fonts Preload -->
<!-- TODO: Custom font, added later -->
<!-- <link rel="preload" href="/fonts/Geist.woff2" as="font" type="font/woff2" crossorigin="anonymous" /> -->
<!-- Helper Scripts -->
<script is:inline>
const getTheme = () => {
if (typeof localStorage !== "undefined" && localStorage.getItem("color-scheme")) {
return localStorage.getItem("color-scheme");
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
};
const applyTheme = () => {
const theme = getTheme();
document.documentElement.setAttribute("data-color-scheme", theme);
};
applyTheme();
document.addEventListener("astro:before-swap", (ev) => {
const currentTheme = document.documentElement.getAttribute("data-color-scheme");
ev.newDocument.documentElement.setAttribute("data-color-scheme", currentTheme);
});
</script>
<title>{documentTitle}</title>
</head>
<body>
<slot />
</body>
</html>
+896
View File
@@ -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 &amp; 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 youre 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 couldnt add that configuration right now. Please try again.", "error");
showCartToast("Couldnt 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 couldnt add that configuration right now. Please try again.", "error");
showCartToast("Couldnt 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
+877
View File
@@ -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 &amp; Pay</span>
</div>
<p class={styles.helpText}>Final review &amp; payment</p>
</div>
</header>
<section class={styles.hero}>
<div class={styles.heroInner}>
<div>
<p class={styles.eyebrow}>Review &amp; 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>Ive reviewed my configuration, quantity, and total.</span>
</label>
<label class={styles.checkboxRow}>
<input type="checkbox" />
<span>Im 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. Well update this page as soon as Stripe confirms the result."
: "Your secure payment form is ready.";
}
if (stripeErrors) {
stripeErrors.textContent = state === "error"
? detailMessage || "We couldnt 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 couldnt 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 couldnt 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 couldnt 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
+894
View File
@@ -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;
}
}
+370
View File
@@ -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>
+715
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
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>
+273
View File
@@ -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>
+507
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
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>
+449
View File
@@ -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 &amp; Pay</span>
</div>
<p class={styles.helpText}>Contact &amp; 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 well 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 &amp; 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 &amp; pay</h2>
<p>From here youll 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 &amp; 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>
+158
View File
@@ -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>
+118
View File
@@ -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);
}
+378
View File
@@ -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 &amp; pay</a>
<div class={styles.progress} aria-label="Purchase steps">
<span>1. Configure</span>
<span>2. Details</span>
<span class={styles.progressActive}>3. Review &amp; 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">Well 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>Weve 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>
+189
View File
@@ -0,0 +1,189 @@
export const ROYAL_POP_CART_KEY = "royal-pop-cart";
export const ROYAL_POP_BUILDER_KEY = "royal-pop-builder-selection";
export const ROYAL_POP_CART_LIMIT = Number.POSITIVE_INFINITY;
export interface RoyalPopSelection {
style: string;
colorwayId: string;
finishId: string;
quantity: number;
}
export interface RoyalPopCartItem {
id: string;
style: string;
colorwayId: string;
finishId: string;
quantity: number;
}
const isBrowser = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
const readJson = <T,>(key: string, fallback: T): T => {
if (!isBrowser()) return fallback;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
};
const writeJson = (key: string, value: unknown) => {
if (!isBrowser()) return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Ignore storage write failures.
}
};
const createItemId = () => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `rp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
};
const normalizeSelection = (selection: Partial<RoyalPopSelection>): RoyalPopSelection => ({
style: selection.style === "A" ? "A" : "B",
colorwayId: selection.colorwayId || "sorbet-pop-multi-color",
finishId: selection.finishId || "silver",
quantity: Math.max(1, Math.round(Number(selection.quantity) || 1)),
});
export const loadBuilderSelection = (): RoyalPopSelection | null => {
const selection = readJson<Partial<RoyalPopSelection> | null>(ROYAL_POP_BUILDER_KEY, null);
if (!selection) return null;
return normalizeSelection(selection);
};
export const saveBuilderSelection = (selection: Partial<RoyalPopSelection>) => {
writeJson(ROYAL_POP_BUILDER_KEY, normalizeSelection(selection));
};
export const loadCart = (): RoyalPopCartItem[] => {
const items = readJson<Partial<RoyalPopCartItem>[]>(ROYAL_POP_CART_KEY, []);
return items
.filter((item) => item && item.colorwayId && item.finishId)
.map((item) => ({
id: item.id || createItemId(),
style: item.style === "A" ? "A" : "B",
colorwayId: String(item.colorwayId),
finishId: String(item.finishId),
quantity: Math.max(1, Math.round(Number(item.quantity) || 1)),
}))
;
};
export const saveCart = (items: RoyalPopCartItem[]) => {
writeJson(
ROYAL_POP_CART_KEY,
items.map((item) => ({
...item,
quantity: Math.max(1, Math.round(Number(item.quantity) || 1)),
})),
);
};
export const getCartCount = () => loadCart().reduce((sum, item) => sum + Math.max(1, Number(item.quantity) || 1), 0);
export const addSelectionToCart = (selection: Partial<RoyalPopSelection>) => {
const normalized = normalizeSelection(selection);
const currentCart = loadCart();
const matchingIndex = currentCart.findIndex(
(item) =>
item.style === normalized.style &&
item.colorwayId === normalized.colorwayId &&
item.finishId === normalized.finishId,
);
let nextCart: RoyalPopCartItem[];
if (matchingIndex >= 0) {
nextCart = currentCart.map((item, index) =>
index === matchingIndex
? {
...item,
quantity: Math.max(1, Number(item.quantity) || 1) + normalized.quantity,
}
: item,
);
} else {
nextCart = [
...currentCart,
{
id: createItemId(),
style: normalized.style,
colorwayId: normalized.colorwayId,
finishId: normalized.finishId,
quantity: normalized.quantity,
},
];
}
saveCart(nextCart);
return {
cart: nextCart,
addedCount: normalized.quantity,
isFull: false,
};
};
export const getEffectiveCart = (): RoyalPopCartItem[] => {
const cart = loadCart();
if (cart.length > 0) return cart;
const selection = loadBuilderSelection();
if (!selection) return [];
return [
{
id: createItemId(),
style: selection.style,
colorwayId: selection.colorwayId,
finishId: selection.finishId,
quantity: selection.quantity,
},
];
};
export const groupCartItems = (items: RoyalPopCartItem[]) => {
const groups = new Map<string, { style: string; colorwayId: string; finishId: string; quantity: number }>();
items.forEach((item) => {
const key = `${item.style}::${item.colorwayId}::${item.finishId}`;
const current = groups.get(key);
const itemQuantity = Math.max(1, Math.round(Number(item.quantity) || 1));
if (current) {
current.quantity += itemQuantity;
return;
}
groups.set(key, {
style: item.style,
colorwayId: item.colorwayId,
finishId: item.finishId,
quantity: itemQuantity,
});
});
return Array.from(groups.values());
};
export const removeCartItem = (itemId: string) => {
const nextCart = loadCart().filter((item) => item.id !== itemId);
saveCart(nextCart);
return nextCart;
};
export const clearCart = () => {
if (!isBrowser()) return;
window.localStorage.removeItem(ROYAL_POP_CART_KEY);
};
+31
View File
@@ -0,0 +1,31 @@
/* Path: Store/src/styles/_fonts.scss */
// @font-face {
// font-family: "CaveatBrush";
// src: url("/fonts/CaveatBrush/CaveatBrush-Regular.woff2") format("woff2");
// font-display: swap;
// }
// @font-face {
// font-family: "Poppins";
// src: url("/fonts/Poppins/Poppins-Light.woff2") format("woff2");
// font-weight: 300;
// font-style: normal;
// font-display: swap;
// }
// @font-face {
// font-family: "Poppins";
// src: url("/fonts/Poppins/Poppins-Regular.woff2") format("woff2");
// font-weight: 400;
// font-style: normal;
// font-display: swap;
// }
// @font-face {
// font-family: "Poppins";
// src: url("/fonts/Poppins/Poppins-Medium.woff2") format("woff2");
// font-weight: 500;
// font-style: normal;
// font-display: swap;
// }
+52
View File
@@ -0,0 +1,52 @@
/* Path: Store/src/styles/_reset.scss */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html,
body {
height: 100%;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
#root,
#__next {
isolation: isolate;
}
+89
View File
@@ -0,0 +1,89 @@
/* Path: Store/src/styles/main.scss */
@use "./reset" as *;
@use "./fonts" as *;
html {
background-color: var(--bg);
// TODO: Transition to be added
transition:
background-color var(--transition-speed) var(--transition-ease),
color var(--transition-speed) var(--transition-ease);
color: var(--text);
font-family:
"Geist",
// TODO: Custom font, added later
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
overflow-x: hidden;
scroll-behavior: smooth;
}
body {
background-color: var(--bg);
}
h1 {
@include text-largest;
}
h2 {
@include text-large;
}
h3 {
@include text-medium;
}
h4 {
@include text-small;
}
h5 {
@include text-smaller;
}
p,
span,
button,
input,
textarea,
sub,
a {
@include text-smallest;
}
a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
h1,
h2,
h3,
h4,
h5,
p,
span,
a,
button,
input,
textarea {
transition: color var(--transition-speed) var(--transition-ease);
}
+185
View File
@@ -0,0 +1,185 @@
/* Path: Store/src/styles/vars.scss */
:root {
--transition-speed: 0.5s;
--transition-ease: ease-in-out;
--gray-50: hsl(40 25% 85%);
--gray-100: hsl(40 23% 80%);
--gray-200: hsl(40 20% 77%);
--gray-300: hsl(40 20% 70%);
--gray-400: hsl(40 10% 50%);
--gray-500: hsl(40 5% 40%);
--gray-600: hsl(40 5% 30%);
--gray-700: hsl(40 5% 25%);
--gray-800: hsl(40 5% 20%);
--gray-900: hsl(40 5% 15%);
--brown-300: hsl(27 39.2% 86.5%);
--brown-400: hsl(27 39.2% 76.5%);
--brown-500: hsl(27 39.2% 66.5%);
--brown-600: hsl(27 39.2% 56.5%);
--brown-700: hsl(27 39.2% 46.5%);
--brown-800: hsl(27 39.2% 36.5%);
--green-300: hsl(115 43.1% 70%);
--green-400: hsl(115 43.1% 70%);
--green-500: hsl(115 43.1% 60%);
--green-600: hsl(115 43.1% 50%);
--green-700: hsl(115 43.1% 40%);
--red-500: hsl(359 46.6% 50.8%);
--red-600: hsl(359 46.6% 40.8%);
}
:root {
// Background and Text Colors
--bg: var(--gray-50);
--text: var(--gray-800);
--text-muted: var(--gray-700);
// Accent
--primary: var(--brown-700);
--primary-hover: var(--brown-800);
--secondary: var(--green-500);
--secondary-hover: var(--green-600);
--warning: var(--red-500);
--warning-hover: var(--red-600);
// Typical Card Button Colors
--bg-1: var(--gray-100);
--bg-1-hover: var(--gray-200);
--bg-2: var(--gray-200);
--bg-2-hover: var(--gray-300);
--bg-3: var(--gray-300);
--bg-3-hover: var(--gray-400);
--box-shadow: 0 8px 16px hsl(0 0% 0% / 0.1);
}
[data-color-scheme="dark"] {
// Background and Text Colors
--bg: var(--gray-900);
--text: var(--gray-50);
--text-muted: var(--gray-400);
// Accent
--primary: var(--brown-700);
--primary-hover: var(--brown-600);
--secondary: var(--green-700);
--secondary-hover: var(--green-600);
// Typical Card Button Colors
--bg-1: var(--gray-800);
--bg-1-hover: var(--gray-700);
--bg-2: var(--gray-700);
--bg-2-hover: var(--gray-600);
--bg-3: var(--gray-600);
--bg-3-hover: var(--gray-500);
}
// Breakpoints
$mobile-narrow: 519px;
$phablet: 640px;
$compact: 720px;
$mobile: 768px;
$wide: 880px;
$desktop-sm: 960px;
$desktop-md: 980px;
$tablet: 1024px;
$workspace: 1080px;
$desktop-lg: 1120px;
$desktop: 1440px;
// The Mixin: Now checks for MIN-width
@mixin respond($breakpoint) {
@if $breakpoint == phablet {
@media (min-width: $phablet) {
@content;
}
} @else if $breakpoint == compact {
@media (min-width: $compact) {
@content;
}
} @else if $breakpoint == mobile {
@media (min-width: $mobile) {
@content;
}
} @else if $breakpoint == wide {
@media (min-width: $wide) {
@content;
}
} @else if $breakpoint == desktop-sm {
@media (min-width: $desktop-sm) {
@content;
}
} @else if $breakpoint == desktop-md {
@media (min-width: $desktop-md) {
@content;
}
} @else if $breakpoint == tablet {
@media (min-width: $tablet) {
@content;
}
} @else if $breakpoint == workspace {
@media (min-width: $workspace) {
@content;
}
} @else if $breakpoint == desktop-lg {
@media (min-width: $desktop-lg) {
@content;
}
} @else if $breakpoint == desktop {
@media (min-width: $desktop) {
@content;
}
}
}
@mixin respond-max($breakpoint) {
@if $breakpoint == mobile-narrow {
@media (max-width: $mobile-narrow) {
@content;
}
}
}
@mixin text-smallest {
font-size: 1rem;
font-size: clamp(1rem, 0.9295774647887324rem + 0.300469483568075vw, 1.2rem);
font-weight: 300;
}
@mixin text-smaller {
font-size: 1.1rem;
font-size: clamp(1.1rem, 0.959154929577465rem + 0.60093896713615vw, 1.5rem);
font-weight: 300;
}
@mixin text-small {
font-size: 1.25rem;
font-size: clamp(1.25rem, 0.9859154929577465rem + 1.1267605633802815vw, 2rem);
font-weight: 300;
}
@mixin text-medium {
font-size: 1.5rem;
font-size: clamp(1.5rem, 1.147887323943662rem + 1.5023474178403755vw, 2.5rem);
font-weight: 400;
}
@mixin text-large {
font-size: 1.75rem;
font-size: clamp(1.75rem, 1.3098591549295775rem + 1.8779342723004695vw, 3rem);
font-weight: 400;
}
@mixin text-largest {
font-size: 2rem;
font-size: clamp(2rem, 1.295774647887324rem + 3.004694835680751vw, 4rem);
font-weight: 400;
}