wwwfiberdirekt/src/blocks/FDServiceCalculatorBlock/Component.tsx
Jeffrey 15c3194eb6 refactor: codebase audit — remove orphaned blocks, unify design tokens
- 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
2026-03-13 13:22:16 +01:00

373 lines
17 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 { 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>
)
}