diff --git a/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/ConfigSectionRail.vue b/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/ConfigSectionRail.vue
new file mode 100644
index 0000000..3f506f0
--- /dev/null
+++ b/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/ConfigSectionRail.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
diff --git a/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue b/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue
index 60d1726..c673b83 100644
--- a/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue
+++ b/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue
@@ -1,6 +1,7 @@
@@ -94,7 +149,12 @@ watch(() => props.plan?.id, () => {
-
+
props.plan?.id, () => {
:cycle="store.cycle"
@update:selected="(v: string) => onSelectionChange(group.name, v)"
/>
-
+
@@ -147,6 +207,13 @@ watch(() => props.plan?.id, () => {
backdrop-filter: blur(12px);
}
+// Each section has its own anchor target. scroll-margin-top is used by the
+// browser-native "scrollIntoView({behavior: smooth})" fallback to keep the
+// section title clear of the sticky navbar (matches our 92px goTo offset).
+.dedicated-configurator__section {
+ scroll-margin-top: 92px;
+}
+
.dedicated-configurator__footer-wrap {
margin-top: 8px;
}
diff --git a/website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue b/website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue
index d2535b9..421b09e 100644
--- a/website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue
+++ b/website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue
@@ -4,6 +4,7 @@ import { computed } from 'vue'
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
import DedicatedConfigurator from '@/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue'
import BuildSummary from '@/Components/Marketing/Dedicated/DedicatedConfigurator/BuildSummary.vue'
+import ConfigSectionRail from '@/Components/Marketing/Dedicated/DedicatedConfigurator/ConfigSectionRail.vue'
import { useDedicatedConfiguratorStore } from '@/stores/dedicatedConfigurator'
import { crossDomainUrl } from '@/utils/resolvers'
import type { DedicatedPlan, DedicatedConfigGroup } from '@/stores/dedicatedConfigurator'
@@ -117,6 +118,9 @@ const store = useDedicatedConfiguratorStore()
+
+
+
>({})
const cycle = ref('monthly')
+ // Tracks which configurator section is currently in view; updated by an
+ // IntersectionObserver in the configurator and read by ConfigSectionRail
+ // to highlight the active anchor.
+ const activeAnchorId = ref('')
+
function init(catalog: {
plan: DedicatedPlan
configGroups: DedicatedConfigGroup[]
@@ -197,6 +214,23 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
const monthlyEffective = computed(() => cycleSubtotal.value / CYCLE_MONTHS[cycle.value])
+ // A section is "touched" when the customer has moved off the seeded
+ // default. Drive bay groups also need a non-zero quantity to count.
+ function isGroupTouched(groupName: string): boolean {
+ const sel = selections.value[groupName]
+ if (sel === undefined) return false
+ const group = findGroup(groupName)
+ if (!group) return false
+
+ if (isDriveBaySelection(sel)) {
+ return sel.drive !== 'none' && sel.quantity > 0
+ }
+
+ const opt = group.options[0]
+ const def = opt?.values.find(v => v.is_default)?.value ?? opt?.values[0]?.value
+ return sel !== def
+ }
+
const isSetupFeeWaived = computed(() => {
if (!plan.value) return true
const fee = parseFloat(String(plan.value.setup_fee ?? 0))
@@ -348,6 +382,7 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
accountUrl,
selections,
cycle,
+ activeAnchorId,
baselinePrice,
addOnsTotal,
driveBayCost,
@@ -366,5 +401,6 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
findGroup,
findValue,
pickCyclePrice,
+ isGroupTouched,
}
})