382 lines
16 KiB
TypeScript
382 lines
16 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useMemo } from 'react'
|
||
import type { FDVpsCalculatorBlock as FDVpsCalculatorBlockProps } from '@/payload-types'
|
||
|
||
const DEFAULT_PRICING = {
|
||
windows: 250,
|
||
cpuPerCore: 120,
|
||
ramPerGb: 100,
|
||
ssdPerGb: 4,
|
||
hddPerGb: 1,
|
||
adminFee: 200,
|
||
}
|
||
|
||
const formatKr = (n: number) => {
|
||
// Format like "2 495 kr" with space as thousands separator
|
||
return Math.round(n).toLocaleString('sv-SE') + ' kr'
|
||
}
|
||
|
||
// ─── Toggle switch ───────────────────────────────────────────────────────────
|
||
function Toggle({ active, onToggle }: { active: boolean; onToggle: () => void }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
style={{ display: 'block', width: '3.5rem', height: '2rem', flexShrink: 0 }}
|
||
className={`relative rounded-full transition-all duration-300 ${
|
||
active ? 'bg-fd-yellow' : 'bg-[#e2e8f0]'
|
||
}`}
|
||
role="switch"
|
||
aria-checked={active}
|
||
>
|
||
<div
|
||
className={`absolute top-1 w-6 h-6 rounded-full bg-white shadow-sm transition-all duration-300 ${
|
||
active ? 'left-7' : 'left-1'
|
||
}`}
|
||
/>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ─── Number stepper row ──────────────────────────────────────────────────────
|
||
function ResourceRow({
|
||
label,
|
||
value,
|
||
onChange,
|
||
min = 0,
|
||
max = 999,
|
||
step = 1,
|
||
unit,
|
||
priceLabel,
|
||
isDark,
|
||
}: {
|
||
label: string
|
||
value: number
|
||
onChange: (v: number) => void
|
||
min?: number
|
||
max?: number
|
||
step?: number
|
||
unit: string
|
||
priceLabel: string
|
||
isDark: boolean
|
||
}) {
|
||
const borderColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(14,35,56,0.08)'
|
||
const labelColor = isDark ? 'text-white' : 'text-fd-navy'
|
||
const subColor = isDark ? 'text-white/50' : 'text-fd-navy/50'
|
||
const btnColor = isDark
|
||
? 'border-white/30 text-white hover:border-white/70'
|
||
: 'border-fd-navy/20 text-fd-navy hover:border-fd-navy/60'
|
||
const inputColor = isDark
|
||
? 'bg-white/10 border-white/20 text-white'
|
||
: 'bg-fd-surface-alt border-fd-navy/15 text-fd-navy'
|
||
const unitColor = isDark ? 'text-white/40' : 'text-fd-navy/40'
|
||
|
||
return (
|
||
<div
|
||
className="flex items-center justify-between py-4"
|
||
style={{ borderBottom: `1px solid ${borderColor}` }}
|
||
>
|
||
<div className="flex flex-col">
|
||
<span className={`font-joey-bold text-fd-body ${labelColor}`}>{label}</span>
|
||
<span className={`font-joey text-fd-small ${subColor}`}>{priceLabel}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(Math.max(min, value - step))}
|
||
className={`w-9 h-9 rounded-full border-2 flex items-center justify-center font-joey-bold text-base transition-colors ${btnColor}`}
|
||
>
|
||
−
|
||
</button>
|
||
<input
|
||
type="number"
|
||
value={value}
|
||
onChange={(e) => onChange(Math.max(min, Math.min(max, Number(e.target.value) || 0)))}
|
||
className={`w-16 text-center font-joey-medium text-fd-body rounded-full px-2 py-1.5 border-2 ${inputColor} [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none`}
|
||
style={{ appearance: 'textfield' }}
|
||
/>
|
||
<span className={`font-joey text-fd-small w-7 ${unitColor}`}>{unit}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(Math.min(max, value + step))}
|
||
className={`w-9 h-9 rounded-full border-2 flex items-center justify-center font-joey-bold text-base transition-colors ${btnColor}`}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Main component ──────────────────────────────────────────────────────────
|
||
export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps> = ({
|
||
heading = 'Virtuell server — kalkylator',
|
||
description,
|
||
contactCtaText = 'Frågor? Kontakta oss',
|
||
contactCtaLink = '/kontakt',
|
||
orderCtaText = 'Beställ',
|
||
orderCtaLink = '/kontakt?subject=vps-bestallning',
|
||
sectionBackground = 'white',
|
||
pricingCpuPerCore,
|
||
pricingRamPerGb,
|
||
pricingSsdPerGb,
|
||
pricingHddPerGb,
|
||
pricingWindowsLicense,
|
||
discountPercent,
|
||
showAdminFee,
|
||
adminFeeAmount,
|
||
additionalServices = [],
|
||
}) => {
|
||
const pricing = {
|
||
windows: pricingWindowsLicense ?? DEFAULT_PRICING.windows,
|
||
cpuPerCore: pricingCpuPerCore ?? DEFAULT_PRICING.cpuPerCore,
|
||
ramPerGb: pricingRamPerGb ?? DEFAULT_PRICING.ramPerGb,
|
||
ssdPerGb: pricingSsdPerGb ?? DEFAULT_PRICING.ssdPerGb,
|
||
hddPerGb: pricingHddPerGb ?? DEFAULT_PRICING.hddPerGb,
|
||
}
|
||
const feeAmount = adminFeeAmount ?? DEFAULT_PRICING.adminFee
|
||
const discount = (discountPercent ?? 0) / 100
|
||
|
||
const [os, setOs] = useState<'linux' | 'windows'>('linux')
|
||
const [cpuCores, setCpuCores] = useState(2)
|
||
const [ramGb, setRamGb] = useState(4)
|
||
const [ssdGb, setSsdGb] = useState(50)
|
||
const [hddGb, setHddGb] = useState(0)
|
||
const [extraToggles, setExtraToggles] = useState<Record<number, boolean>>({})
|
||
const toggleExtra = (i: number) => setExtraToggles((p) => ({ ...p, [i]: !p[i] }))
|
||
|
||
const costs = useMemo(() => {
|
||
const disc = (v: number) => v * (1 - discount)
|
||
const licenseCost = os === 'windows' ? disc(pricing.windows) : 0
|
||
const cpuCost = disc(cpuCores * pricing.cpuPerCore)
|
||
const ramCost = disc(ramGb * pricing.ramPerGb)
|
||
const ssdCost = disc(ssdGb * pricing.ssdPerGb)
|
||
const hddCost = disc(hddGb * pricing.hddPerGb)
|
||
// Admin fee is fixed — always on if enabled in CMS, not customer-controlled
|
||
const feeCost = showAdminFee ? feeAmount : 0
|
||
const extraCosts = (additionalServices ?? []).map((svc, i) =>
|
||
extraToggles[i] ? (svc.price ?? 0) : 0
|
||
)
|
||
const extraTotal = extraCosts.reduce((a, b) => a + b, 0)
|
||
const total = licenseCost + cpuCost + ramCost + ssdCost + hddCost + feeCost + extraTotal
|
||
return { licenseCost, cpuCost, ramCost, ssdCost, hddCost, feeCost, extraCosts, total }
|
||
}, [os, cpuCores, ramGb, ssdGb, hddGb, extraToggles, pricing, discount, feeAmount, showAdminFee, additionalServices])
|
||
|
||
const isDark = sectionBackground === 'navy'
|
||
const bgClass = isDark ? 'bg-fd-navy' : sectionBackground === 'gray' ? 'bg-fd-surface-alt' : 'bg-white'
|
||
|
||
// Card styling
|
||
const cardBg = isDark ? 'rgba(255,255,255,0.05)' : '#ffffff'
|
||
const cardBorder = isDark ? 'rgba(255,255,255,0.12)' : '#e2e8f0'
|
||
const cardStyle: React.CSSProperties = {
|
||
background: cardBg,
|
||
border: `6px solid ${cardBorder}`,
|
||
borderRadius: 'clamp(28px, 4vw, 60px)',
|
||
}
|
||
|
||
// Colors
|
||
const headingColor = isDark ? 'text-white' : 'text-fd-navy'
|
||
const descColor = isDark ? 'text-white/60' : 'text-fd-navy/60'
|
||
const sectionLabelColor = isDark ? 'text-white/40' : 'text-fd-navy/35'
|
||
const dividerColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(14,35,56,0.08)'
|
||
const summaryLabelColor = isDark ? 'text-white' : 'text-fd-navy'
|
||
const summaryValueColor = isDark ? 'text-white' : 'text-fd-navy'
|
||
const categoryLabelColor = isDark ? 'text-white/40' : 'text-fd-navy/40'
|
||
|
||
// OS toggle
|
||
const osActiveClass = 'bg-fd-yellow text-fd-navy border-fd-yellow font-joey-bold'
|
||
const osInactiveClass = isDark
|
||
? 'bg-transparent text-white/70 border-white/20 hover:border-white/50 font-joey'
|
||
: 'bg-transparent text-fd-navy/70 border-fd-navy/20 hover:border-fd-navy/50 font-joey'
|
||
|
||
const discStr = discount > 0 ? ` (${discountPercent}% rabatt)` : ''
|
||
|
||
// Summary rows
|
||
const baseRows = [
|
||
...(os === 'windows' ? [{ label: 'Licens (Windows)', cost: costs.licenseCost }] : []),
|
||
{ label: `CPU (${cpuCores} ${cpuCores === 1 ? 'kärna' : 'kärnor'})`, cost: costs.cpuCost },
|
||
{ label: `RAM (${ramGb} GB)`, cost: costs.ramCost },
|
||
{ label: `SSD NVMe (${ssdGb} GB)`, cost: costs.ssdCost },
|
||
...(hddGb > 0 ? [{ label: `HDD (${hddGb} GB)`, cost: costs.hddCost }] : []),
|
||
...(showAdminFee ? [{ label: 'Adminavgift', cost: costs.feeCost }] : []),
|
||
]
|
||
|
||
const tillvalRows = (additionalServices ?? []).flatMap((svc, i) =>
|
||
extraToggles[i] ? [{ label: svc.label ?? 'Tilläggstjänst', cost: costs.extraCosts[i] ?? 0 }] : []
|
||
)
|
||
|
||
const hasTillval = (additionalServices ?? []).length > 0
|
||
|
||
return (
|
||
<section className={`fd-section ${bgClass}`}>
|
||
<div className="fd-container">
|
||
|
||
{/* Section heading */}
|
||
{(heading || description) && (
|
||
<div className="text-center mb-10 md:mb-14">
|
||
{heading && (
|
||
<h2 className={`font-joey-heavy text-fd-h1 mb-3 ${headingColor}`}>{heading}</h2>
|
||
)}
|
||
{description && (
|
||
<p className={`font-joey text-fd-body-lg max-w-[600px] mx-auto ${descColor}`}>
|
||
{description}
|
||
</p>
|
||
)}
|
||
{discount > 0 && (
|
||
<span className="inline-block mt-3 px-4 py-1 bg-fd-mint text-fd-navy font-joey-bold text-fd-small rounded-full">
|
||
{discountPercent}% rabatt på alla resurser
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6 lg:gap-8">
|
||
|
||
{/* ── Left: Config ── */}
|
||
<div className="lg:col-span-3 p-7 md:p-9" style={cardStyle}>
|
||
|
||
{/* OS */}
|
||
<p className={`font-joey-medium text-fd-small tracking-widest uppercase mb-3 ${sectionLabelColor}`}>
|
||
Operativsystem
|
||
</p>
|
||
<div className="flex gap-3 mb-6">
|
||
{(['linux', 'windows'] as const).map((opt) => (
|
||
<button
|
||
key={opt}
|
||
type="button"
|
||
onClick={() => setOs(opt)}
|
||
className={`flex-1 py-3 rounded-full text-fd-body border-2 transition-all duration-200 ${
|
||
os === opt ? osActiveClass : osInactiveClass
|
||
}`}
|
||
>
|
||
{opt === 'linux' ? 'Linux (gratis)' : `Windows (+${pricing.windows}kr)`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Resources */}
|
||
<div>
|
||
<ResourceRow label="CPU-kärnor" value={cpuCores} onChange={setCpuCores} min={1} max={32} unit="st" priceLabel={`${pricing.cpuPerCore} kr/kärna${discStr}`} isDark={isDark} />
|
||
<ResourceRow label="RAM" value={ramGb} onChange={setRamGb} min={1} max={256} unit="GB" priceLabel={`${pricing.ramPerGb} kr/GB${discStr}`} isDark={isDark} />
|
||
<ResourceRow label="SSD NVMe" value={ssdGb} onChange={setSsdGb} min={0} max={4000} step={10} unit="GB" priceLabel={`${pricing.ssdPerGb} kr/GB${discStr}`} isDark={isDark} />
|
||
<ResourceRow label="HDD" value={hddGb} onChange={setHddGb} min={0} max={10000} step={100} unit="GB" priceLabel={`${pricing.hddPerGb} kr/GB${discStr}`} isDark={isDark} />
|
||
</div>
|
||
|
||
{/* Admin fee — fixed line, not customer-controlled */}
|
||
{showAdminFee && (
|
||
<div className="flex items-center justify-between py-4" style={{ borderBottom: `1px solid ${dividerColor}` }}>
|
||
<span className={`font-joey-bold text-fd-body ${isDark ? 'text-white' : 'text-fd-navy'}`}>
|
||
Adminavgift
|
||
</span>
|
||
<span className={`font-joey-medium text-fd-body ${isDark ? 'text-white/70' : 'text-fd-navy/70'}`}>
|
||
{formatKr(feeAmount)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tillvalstjänster */}
|
||
{hasTillval && (
|
||
<div className="mt-7">
|
||
<p className={`font-joey-medium text-fd-small tracking-widest uppercase mb-1 ${sectionLabelColor}`}>
|
||
Tillvalstjänster
|
||
</p>
|
||
{(additionalServices ?? []).map((svc, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex items-center gap-4 py-4"
|
||
style={{ borderBottom: `1px solid ${dividerColor}` }}
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
<span className={`font-joey-bold text-fd-body ${isDark ? 'text-white' : 'text-fd-navy'}`}>
|
||
{svc.label}
|
||
</span>
|
||
{svc.price != null && (
|
||
<span className={`block font-joey text-fd-small ${isDark ? 'text-white/50' : 'text-fd-navy/50'}`}>
|
||
{svc.price} kr/mån
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex-shrink-0 w-14">
|
||
<Toggle active={!!extraToggles[i]} onToggle={() => toggleExtra(i)} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Right: Summary ── */}
|
||
<div className="lg:col-span-2 p-7 md:p-9 flex flex-col" style={cardStyle}>
|
||
<h3 className={`font-joey-heavy text-fd-h2 mb-6 ${headingColor}`}>
|
||
Kostnadsöversikt
|
||
</h3>
|
||
|
||
{/* Base cost rows */}
|
||
<div className="flex-1 space-y-0">
|
||
{baseRows.map((row, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex justify-between items-center py-2.5"
|
||
style={{ borderBottom: `1px solid ${dividerColor}` }}
|
||
>
|
||
<span className={`font-joey text-fd-body ${summaryLabelColor}`}>{row.label}</span>
|
||
<span className={`font-joey-bold text-fd-body ${summaryValueColor}`}>
|
||
{formatKr(row.cost)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* Tillval section in summary */}
|
||
{tillvalRows.length > 0 && (
|
||
<>
|
||
<div className="pt-4 pb-1">
|
||
<span className={`font-joey text-fd-small ${categoryLabelColor}`}>Tillval</span>
|
||
</div>
|
||
{tillvalRows.map((row, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex justify-between items-center py-2.5"
|
||
style={{ borderBottom: `1px solid ${dividerColor}` }}
|
||
>
|
||
<span className={`font-joey text-fd-body ${summaryLabelColor}`}>{row.label}</span>
|
||
<span className={`font-joey-bold text-fd-body ${summaryValueColor}`}>
|
||
{formatKr(row.cost)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Total */}
|
||
<div className="mt-6 pt-5" style={{ borderTop: `2px solid ${dividerColor}` }}>
|
||
<div className="flex justify-between items-baseline">
|
||
<span className={`font-joey text-fd-body ${summaryLabelColor}`}>Totalt per månad</span>
|
||
<span className={`font-joey-heavy text-fd-h1 ${isDark ? 'text-white' : 'text-fd-navy'}`}>
|
||
{formatKr(costs.total)}
|
||
</span>
|
||
</div>
|
||
<p className={`text-right font-joey text-fd-small mt-1 ${categoryLabelColor}`}>exkl. moms</p>
|
||
</div>
|
||
|
||
{/* CTAs */}
|
||
<div className="mt-7 space-y-3">
|
||
<a href={orderCtaLink || '#'} className="fd-btn-primary w-full justify-center">
|
||
{orderCtaText}
|
||
</a>
|
||
<a
|
||
href={contactCtaLink || '#'}
|
||
className={`${isDark ? 'fd-btn-secondary-dark' : 'fd-btn-secondary'} w-full justify-center`}
|
||
>
|
||
{contactCtaText}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|