wwwlayermeshusa/src/blocks/FDVpsCalculatorBlock/Component.tsx

382 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 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>
)
}