- Delete 5 orphaned blocks: ArchiveBlock, CallToAction, Content, Form, RelatedPosts - Remove Form import from RenderBlocks - Delete Media-BACKUP.txt - Extract fdCardRadius, fdCardRadiusSm to fdTheme — adopted across 15 blocks - Extract fdContainer to fdTheme — adopted across 29 blocks - Add fdSepiaOverlay, fd-sepia CSS token — replaces hardcoded #8B7D3C - Replace #153350 with via-fd-navy-700 in navyGradient - Replace #e2e8f0 with fd-gray-light in calculator blocks
373 lines
17 KiB
TypeScript
373 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useMemo } from 'react'
|
||
import type { FDServiceCalculatorBlock as Props } from '@/payload-types'
|
||
import { fdCardRadius as cardRadius } from '@/utilities/fdTheme'
|
||
|
||
const formatKr = (n: number) => Math.round(n).toLocaleString('sv-SE') + ' kr'
|
||
|
||
|
||
/* ── Toggle switch ─────────────────────────────────────────────────────── */
|
||
function Toggle({ active, onToggle, label }: { active: boolean; onToggle: () => void; label: string }) {
|
||
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-fd-gray-light dark:bg-white/20'}`}
|
||
role="switch"
|
||
aria-checked={active}
|
||
aria-label={label}
|
||
>
|
||
<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>
|
||
)
|
||
}
|
||
|
||
/* ── +/- 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
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-between py-4 border-b border-fd-navy/10 dark:border-white/10">
|
||
<div className="flex flex-col">
|
||
<span className="font-joey-bold text-fd-body-lg text-fd-navy dark:text-white">{label}</span>
|
||
<span className="font-joey text-fd-body text-fd-navy/50 dark:text-white/50">{priceLabel}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(Math.max(min, value - step))}
|
||
className="w-11 h-11 rounded-full border-2 flex items-center justify-center font-joey-bold text-lg transition-colors border-fd-navy/20 text-fd-navy hover:border-fd-navy/60 dark:border-white/30 dark:text-white dark:hover:border-white/70"
|
||
aria-label={`Minska ${label}`}
|
||
>−</button>
|
||
<input
|
||
type="number"
|
||
value={value}
|
||
onChange={(e) => onChange(Math.max(min, Math.min(max, Number(e.target.value) || 0)))}
|
||
className="w-20 text-center font-joey-medium text-fd-body-lg rounded-full px-2 py-1.5 border-2 bg-fd-surface-alt border-fd-navy/15 text-fd-navy dark:bg-white/10 dark:border-white/20 dark:text-white [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||
style={{ appearance: 'textfield' }}
|
||
aria-label={label}
|
||
/>
|
||
<span className="hidden sm:inline font-joey text-fd-body w-8 text-fd-navy/40 dark:text-white/40">{unit}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(Math.min(max, value + step))}
|
||
className="w-11 h-11 rounded-full border-2 flex items-center justify-center font-joey-bold text-lg transition-colors border-fd-navy/20 text-fd-navy hover:border-fd-navy/60 dark:border-white/30 dark:text-white dark:hover:border-white/70"
|
||
aria-label={`Öka ${label}`}
|
||
>+</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ── Main component ────────────────────────────────────────────────────── */
|
||
export const FDServiceCalculatorBlockComponent: React.FC<Props> = ({
|
||
heading = 'Beräkna din kostnad',
|
||
description,
|
||
summaryHeading = 'Kostnadsöversikt',
|
||
totalLabel = 'Totalt per månad',
|
||
totalSuffix = 'exkl. moms',
|
||
orderCtaText = 'Beställ',
|
||
orderCtaLink = '/kontakt',
|
||
contactCtaText = 'Frågor? Kontakta oss',
|
||
contactCtaLink = '/kontakt',
|
||
sectionBackground = 'white',
|
||
optionGroups = [],
|
||
resources = [],
|
||
addons = [],
|
||
fixedFees = [],
|
||
discountPercent,
|
||
discountLabel,
|
||
anchorId,
|
||
}) => {
|
||
/* ── State: one selected index per option group ──────────────────────── */
|
||
const [selectedOptions, setSelectedOptions] = useState<Record<number, number>>(() => {
|
||
const initial: Record<number, number> = {}
|
||
;(optionGroups ?? []).forEach((_, i) => { initial[i] = 0 })
|
||
return initial
|
||
})
|
||
|
||
/* ── State: one number per resource slider ───────────────────────────── */
|
||
const [resourceValues, setResourceValues] = useState<Record<number, number>>(() => {
|
||
const initial: Record<number, number> = {}
|
||
;(resources ?? []).forEach((r, i) => { initial[i] = r.defaultValue ?? 0 })
|
||
return initial
|
||
})
|
||
|
||
/* ── State: toggle map for addons ────────────────────────────────────── */
|
||
const [addonToggles, setAddonToggles] = useState<Record<number, boolean>>({})
|
||
const toggleAddon = (i: number) => setAddonToggles((p) => ({ ...p, [i]: !p[i] }))
|
||
|
||
const discount = (discountPercent ?? 0) / 100
|
||
const disc = (v: number) => v * (1 - discount)
|
||
|
||
/* ── Derived costs ───────────────────────────────────────────────────── */
|
||
const costs = useMemo(() => {
|
||
// Option groups
|
||
const optionCosts = (optionGroups ?? []).map((group, gi) => {
|
||
const selectedIdx = selectedOptions[gi] ?? 0
|
||
const option = group.options?.[selectedIdx]
|
||
return disc(option?.price ?? 0)
|
||
})
|
||
|
||
// Resources
|
||
const resourceCosts = (resources ?? []).map((r, i) => {
|
||
return disc((resourceValues[i] ?? 0) * (r.pricePerUnit ?? 0))
|
||
})
|
||
|
||
// Addons
|
||
const addonCosts = (addons ?? []).map((a, i) => {
|
||
return addonToggles[i] ? disc(a.price ?? 0) : 0
|
||
})
|
||
|
||
// Fixed fees (not discounted)
|
||
const fixedTotal = (fixedFees ?? []).reduce((sum, f) => sum + (f.amount ?? 0), 0)
|
||
|
||
const total =
|
||
optionCosts.reduce((a, b) => a + b, 0) +
|
||
resourceCosts.reduce((a, b) => a + b, 0) +
|
||
addonCosts.reduce((a, b) => a + b, 0) +
|
||
fixedTotal
|
||
|
||
return { optionCosts, resourceCosts, addonCosts, fixedTotal, total }
|
||
}, [selectedOptions, resourceValues, addonToggles, optionGroups, resources, addons, fixedFees, discount])
|
||
|
||
/* ── Theme ───────────────────────────────────────────────────────────── */
|
||
const isDark = sectionBackground === 'navy'
|
||
const bgClass = isDark ? 'bg-fd-navy' : sectionBackground === 'gray' ? 'bg-fd-surface-alt dark:bg-fd-navy' : 'bg-white dark:bg-fd-navy'
|
||
|
||
const cardClass = isDark
|
||
? `bg-white/5 border-[5px] border-white/10 ${cardRadius}`
|
||
: `bg-white border-[5px] border-fd-gray-light ${cardRadius} dark:bg-white/5 dark:border-white/10`
|
||
|
||
const headingColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
|
||
const descColor = isDark ? 'text-white/60' : 'text-fd-navy/60 dark:text-white/60'
|
||
const sectionLabel = isDark ? 'text-white/40' : 'text-fd-navy/35 dark:text-white/40'
|
||
const summaryLabel = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
|
||
const categoryLabel = isDark ? 'text-white/40' : 'text-fd-navy/40 dark:text-white/40'
|
||
const dividerClass = isDark ? 'border-white/10' : 'border-fd-navy/8 dark:border-white/10'
|
||
|
||
const optActiveClass = 'bg-fd-yellow text-fd-navy border-fd-yellow font-joey-bold'
|
||
const optInactiveClass = 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 dark:text-white/70 dark:border-white/20 dark:hover:border-white/50'
|
||
|
||
const discStr = discount > 0 ? ` (${discountPercent}% rabatt)` : ''
|
||
|
||
const hasAddons = (addons ?? []).length > 0
|
||
const hasFixedFees = (fixedFees ?? []).length > 0
|
||
|
||
/* ── Build summary rows ──────────────────────────────────────────────── */
|
||
const summaryRows: { label: string; cost: number; category?: string }[] = []
|
||
|
||
// Option group costs
|
||
;(optionGroups ?? []).forEach((group, gi) => {
|
||
const selectedIdx = selectedOptions[gi] ?? 0
|
||
const option = group.options?.[selectedIdx]
|
||
if (option && (option.price ?? 0) > 0) {
|
||
summaryRows.push({
|
||
label: `${group.groupLabel} (${option.label})`,
|
||
cost: costs.optionCosts[gi],
|
||
})
|
||
}
|
||
})
|
||
|
||
// Resource costs
|
||
;(resources ?? []).forEach((r, i) => {
|
||
const val = resourceValues[i] ?? 0
|
||
if (val > 0) {
|
||
const template = r.summaryTemplate || `${r.label} ({value} {unit})`
|
||
const label = template
|
||
.replace('{value}', String(val))
|
||
.replace('{unit}', r.unit || '')
|
||
summaryRows.push({ label, cost: costs.resourceCosts[i] })
|
||
}
|
||
})
|
||
|
||
// Fixed fees
|
||
;(fixedFees ?? []).forEach((f) => {
|
||
summaryRows.push({ label: f.label, cost: f.amount ?? 0 })
|
||
})
|
||
|
||
// Active addons
|
||
const addonSummaryRows = (addons ?? []).flatMap((a, i) =>
|
||
addonToggles[i] ? [{ label: a.label, cost: costs.addonCosts[i] }] : [],
|
||
)
|
||
|
||
const resolvedDiscountLabel = discountLabel
|
||
? discountLabel.replace('{percent}', String(discountPercent ?? 0))
|
||
: `${discountPercent}% rabatt på alla resurser`
|
||
|
||
return (
|
||
<section id={anchorId || undefined} className={`fd-section ${bgClass}`}>
|
||
<div className="fd-container">
|
||
|
||
{/* ── Header ──────────────────────────────────────────────────── */}
|
||
{(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">
|
||
{resolvedDiscountLabel}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 min-[820px]:grid-cols-5 gap-6 min-[820px]:gap-8">
|
||
|
||
{/* ── Left: Configuration ─────────────────────────────────── */}
|
||
<div className={`min-[820px]:col-span-3 p-7 md:p-9 ${cardClass}`}>
|
||
|
||
{/* Option groups */}
|
||
{(optionGroups ?? []).map((group, gi) => (
|
||
<div key={gi} className={gi > 0 ? 'mt-7' : ''}>
|
||
<p className={`font-joey-medium text-fd-body tracking-widest uppercase mb-3 ${sectionLabel}`}>
|
||
{group.groupLabel}
|
||
</p>
|
||
<div className="flex gap-3 mb-6 flex-wrap">
|
||
{group.options?.map((opt, oi) => (
|
||
<button
|
||
key={oi}
|
||
type="button"
|
||
onClick={() => setSelectedOptions((p) => ({ ...p, [gi]: oi }))}
|
||
className={`flex-1 min-w-[120px] py-3 rounded-full text-fd-body-lg border-2 transition-all duration-200 ${
|
||
selectedOptions[gi] === oi ? optActiveClass : optInactiveClass
|
||
}`}
|
||
>
|
||
{opt.label}
|
||
{(opt.price ?? 0) > 0 && (
|
||
<span className="ml-1 opacity-70">(+{opt.price}kr)</span>
|
||
)}
|
||
{(opt.price ?? 0) === 0 && group.options && group.options.some((o) => (o.price ?? 0) > 0) && (
|
||
<span className="ml-1 opacity-70">(gratis)</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Resource sliders */}
|
||
{(resources ?? []).length > 0 && (
|
||
<div>
|
||
{(resources ?? []).map((r, i) => (
|
||
<ResourceRow
|
||
key={i}
|
||
label={r.label}
|
||
value={resourceValues[i] ?? 0}
|
||
onChange={(v) => setResourceValues((p) => ({ ...p, [i]: v }))}
|
||
min={r.min ?? 0}
|
||
max={r.max ?? 1000}
|
||
step={r.step ?? 1}
|
||
unit={r.unit || ''}
|
||
priceLabel={`${r.pricePerUnit} kr/${r.unit}${discStr}`}
|
||
isDark={isDark}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Fixed fees (display only) */}
|
||
{hasFixedFees && (
|
||
<div className="mt-2">
|
||
{(fixedFees ?? []).map((f, i) => (
|
||
<div key={i} className={`flex items-center justify-between py-4 border-b ${dividerClass}`}>
|
||
<span className={`font-joey-bold text-fd-body-lg ${summaryLabel}`}>{f.label}</span>
|
||
<span className={`font-joey-medium text-fd-body-lg ${categoryLabel}`}>{formatKr(f.amount ?? 0)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Toggle addons */}
|
||
{hasAddons && (
|
||
<div className="mt-7">
|
||
<p className={`font-joey-medium text-fd-body tracking-widest uppercase mb-1 ${sectionLabel}`}>
|
||
Tillvalstjänster
|
||
</p>
|
||
{(addons ?? []).map((addon, i) => (
|
||
<div key={i} className={`flex items-center gap-4 py-4 border-b ${dividerClass}`}>
|
||
<div className="flex-1 min-w-0">
|
||
<span className={`font-joey-bold text-fd-body-lg ${summaryLabel}`}>{addon.label}</span>
|
||
{addon.price != null && (
|
||
<span className={`block font-joey text-fd-body-lg ${categoryLabel}`}>{addon.price} kr/mån</span>
|
||
)}
|
||
{addon.description && (
|
||
<span className={`block font-joey text-fd-body-lg ${categoryLabel} mt-0.5`}>{addon.description}</span>
|
||
)}
|
||
</div>
|
||
<div className="flex-shrink-0 w-14">
|
||
<Toggle active={!!addonToggles[i]} onToggle={() => toggleAddon(i)} label={addon.label} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Right: Summary ──────────────────────────────────────── */}
|
||
<div className={`min-[820px]:col-span-2 p-7 md:p-9 flex flex-col ${cardClass}`}>
|
||
<h3 className={`font-joey-heavy text-fd-h2 mb-6 ${headingColor}`}>{summaryHeading}</h3>
|
||
|
||
<div className="flex-1 space-y-0">
|
||
{summaryRows.map((row, i) => (
|
||
<div key={i} className={`flex justify-between items-center py-2.5 border-b ${dividerClass}`}>
|
||
<span className={`font-joey text-fd-body-lg ${summaryLabel}`}>{row.label}</span>
|
||
<span className={`font-joey-bold text-fd-body-lg ${summaryLabel}`}>{formatKr(row.cost)}</span>
|
||
</div>
|
||
))}
|
||
|
||
{addonSummaryRows.length > 0 && (
|
||
<>
|
||
<div className="pt-4 pb-1">
|
||
<span className={`font-joey text-fd-body-lg ${categoryLabel}`}>Tillval</span>
|
||
</div>
|
||
{addonSummaryRows.map((row, i) => (
|
||
<div key={i} className={`flex justify-between items-center py-2.5 border-b ${dividerClass}`}>
|
||
<span className={`font-joey text-fd-body-lg ${summaryLabel}`}>{row.label}</span>
|
||
<span className={`font-joey-bold text-fd-body-lg ${summaryLabel}`}>{formatKr(row.cost)}</span>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{summaryRows.length === 0 && addonSummaryRows.length === 0 && (
|
||
<p className={`font-joey text-fd-body-lg py-4 opacity-50 ${summaryLabel}`}>
|
||
Konfigurera din tjänst för att se kostnaden.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className={`mt-6 pt-5 border-t-2 ${dividerClass}`}>
|
||
<div className="flex justify-between items-baseline">
|
||
<span className={`font-joey text-fd-body-lg ${summaryLabel}`}>{totalLabel}</span>
|
||
<span className={`font-joey-heavy text-fd-h2 min-[820px]:text-fd-h2 lg:text-fd-h1 ${headingColor}`}>{formatKr(costs.total)}</span>
|
||
</div>
|
||
{totalSuffix && (
|
||
<p className={`text-right font-joey text-fd-body-lg mt-1 ${categoryLabel}`}>{totalSuffix}</p>
|
||
)}
|
||
</div>
|
||
|
||
<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 dark:fd-btn-secondary-dark'} w-full justify-center`}
|
||
>
|
||
{contactCtaText}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|