wwwlayermeshusa/src/blocks/FDServiceCalculatorBlock/Component.tsx

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'
const formatKr = (n: number) => Math.round(n).toLocaleString('sv-SE') + ' kr'
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
/* ── 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-[#e2e8f0] 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="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-[#e2e8f0] ${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-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>
)
}