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