Initial commit: Fiber Direkt website with Payload CMS
This commit is contained in:
parent
44699d4fed
commit
dc24717388
30
.gitignore
vendored
30
.gitignore
vendored
@ -19,3 +19,33 @@ node_modules/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Next.js build
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Environment & secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Database (local SQLite)
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Payload generated
|
||||
/src/payload-types.ts
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# PM2
|
||||
ecosystem.config.js
|
||||
|
||||
# Media uploads (optional — see note below)
|
||||
/media/
|
||||
/public/media/
|
||||
|
||||
2
TODO - Web payload project
Normal file
2
TODO - Web payload project
Normal file
@ -0,0 +1,2 @@
|
||||
# Web Paylod CMS testing
|
||||
|
||||
9
deploy.sh
Executable file
9
deploy.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
SERVER="jeffrey@webdev2.fiberdirekt.se"
|
||||
rsync -avz \
|
||||
--exclude 'node_modules' --exclude '.next' --exclude '.env' \
|
||||
--exclude '*.db' --exclude '*.sqlite' --exclude 'media' \
|
||||
./ $SERVER:/var/www/fiberdirekt/
|
||||
|
||||
ssh $SERVER "cd /var/www/fiberdirekt && npm run payload migrate && npm run build && pm2 restart fiberdirekt"
|
||||
echo "✓ Deployed"
|
||||
243
fix-ts-errors.sh
Executable file
243
fix-ts-errors.sh
Executable file
@ -0,0 +1,243 @@
|
||||
#!/bin/bash
|
||||
# Run from the root of your fdweb2 project
|
||||
set -e
|
||||
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
|
||||
echo "=== FD TypeScript Error Fix Script ==="
|
||||
echo ""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# GROUP 1: Remove dead template block imports from RenderBlocks
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
RENDER_BLOCKS="src/app/(frontend)/(pages)/[slug]/RenderBlocks.tsx"
|
||||
# Try alternate path if first doesn't exist
|
||||
if [ ! -f "$RENDER_BLOCKS" ]; then
|
||||
RENDER_BLOCKS=$(find src -name "RenderBlocks.tsx" | head -1)
|
||||
fi
|
||||
echo "→ Fixing RenderBlocks.tsx at: $RENDER_BLOCKS"
|
||||
|
||||
# Remove dead template block imports (leave all FD* blocks intact)
|
||||
sed -i '' \
|
||||
'/^import { ArchiveBlock } from/d' \
|
||||
"$RENDER_BLOCKS"
|
||||
|
||||
sed -i '' \
|
||||
'/^import { CallToActionBlock } from/d' \
|
||||
"$RENDER_BLOCKS"
|
||||
|
||||
sed -i '' \
|
||||
'/^import { ContentBlock } from/d' \
|
||||
"$RENDER_BLOCKS"
|
||||
|
||||
sed -i '' \
|
||||
'/^import { FormBlock } from/d' \
|
||||
"$RENDER_BLOCKS"
|
||||
|
||||
sed -i '' \
|
||||
'/^import { MediaBlock } from/d' \
|
||||
"$RENDER_BLOCKS"
|
||||
|
||||
# Remove corresponding entries from blockComponents object
|
||||
sed -i '' \
|
||||
'/^ archive: ArchiveBlock,/d' \
|
||||
"$RENDER_BLOCKS"
|
||||
|
||||
sed -i '' \
|
||||
'/^ cta: CallToActionBlock,/d' \
|
||||
"$RENDER_BLOCKS"
|
||||
|
||||
sed -i '' \
|
||||
'/^ content: ContentBlock,/d' \
|
||||
"$RENDER_BLOCKS"
|
||||
|
||||
sed -i '' \
|
||||
'/^ formBlock: FormBlock,/d' \
|
||||
"$RENDER_BLOCKS"
|
||||
|
||||
sed -i '' \
|
||||
'/^ mediaBlock: MediaBlock,/d' \
|
||||
"$RENDER_BLOCKS"
|
||||
|
||||
echo " ✓ Removed 5 dead template block imports and blockComponent entries"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# GROUP 2: Fix CallToActionBlock import in RichText
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
RICH_TEXT="src/components/RichText/index.tsx"
|
||||
if [ -f "$RICH_TEXT" ]; then
|
||||
# Remove CallToActionBlock from the payload-types import
|
||||
sed -i '' \
|
||||
's/import type { CallToActionBlock } from '\''@\/payload-types'\''/\/\/ CallToActionBlock removed - not in schema/' \
|
||||
"$RICH_TEXT"
|
||||
# More general: remove just the named import if it's part of a multi-import
|
||||
sed -i '' \
|
||||
's/, CallToActionBlock//' \
|
||||
"$RICH_TEXT"
|
||||
sed -i '' \
|
||||
's/CallToActionBlock, //' \
|
||||
"$RICH_TEXT"
|
||||
echo " ✓ Removed CallToActionBlock from RichText"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# GROUP 3: Fix null safety in FD block components
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "→ Fixing null safety issues in FD blocks..."
|
||||
|
||||
# FDLocationsGridBlock - cards is possibly null
|
||||
LOCATIONS="src/blocks/FDLocationsGridBlock/Component.tsx"
|
||||
if [ -f "$LOCATIONS" ]; then
|
||||
# Change: cards.map( → (cards ?? []).map(
|
||||
sed -i '' \
|
||||
's/cards\.map(/\(cards ?? []\)\.map(/g' \
|
||||
"$LOCATIONS"
|
||||
echo " ✓ FDLocationsGridBlock: cards null check"
|
||||
fi
|
||||
|
||||
# FDNewsletterBlock - bulletPoints is possibly null
|
||||
NEWSLETTER="src/blocks/FDNewsletterBlock/Component.tsx"
|
||||
if [ -f "$NEWSLETTER" ]; then
|
||||
sed -i '' \
|
||||
's/bulletPoints\.map(/\(bulletPoints ?? []\)\.map(/g' \
|
||||
"$NEWSLETTER"
|
||||
sed -i '' \
|
||||
's/bulletPoints\.length/\(bulletPoints ?? []\)\.length/g' \
|
||||
"$NEWSLETTER"
|
||||
# Fix conditional renders: bulletPoints && → bulletPoints?.length &&
|
||||
sed -i '' \
|
||||
's/bulletPoints &&/bulletPoints?.length \&\&/g' \
|
||||
"$NEWSLETTER"
|
||||
echo " ✓ FDNewsletterBlock: bulletPoints null check"
|
||||
fi
|
||||
|
||||
# FDServiceChooserBlock - categories is possibly null
|
||||
SERVICE_CHOOSER="src/blocks/FDServiceChooserBlock/Component.tsx"
|
||||
if [ -f "$SERVICE_CHOOSER" ]; then
|
||||
sed -i '' \
|
||||
's/categories\.map(/\(categories ?? []\)\.map(/g' \
|
||||
"$SERVICE_CHOOSER"
|
||||
sed -i '' \
|
||||
's/categories\.filter(/\(categories ?? []\)\.filter(/g' \
|
||||
"$SERVICE_CHOOSER"
|
||||
sed -i '' \
|
||||
's/categories\.find(/\(categories ?? []\)\.find(/g' \
|
||||
"$SERVICE_CHOOSER"
|
||||
sed -i '' \
|
||||
's/categories\.length/\(categories ?? []\)\.length/g' \
|
||||
"$SERVICE_CHOOSER"
|
||||
echo " ✓ FDServiceChooserBlock: categories null check"
|
||||
fi
|
||||
|
||||
# FDStatisticsBlock - stats is possibly null
|
||||
STATISTICS="src/blocks/FDStatisticsBlock/Component.tsx"
|
||||
if [ -f "$STATISTICS" ]; then
|
||||
sed -i '' \
|
||||
's/stats\.map(/\(stats ?? []\)\.map(/g' \
|
||||
"$STATISTICS"
|
||||
sed -i '' \
|
||||
's/stats\.length/\(stats ?? []\)\.length/g' \
|
||||
"$STATISTICS"
|
||||
echo " ✓ FDStatisticsBlock: stats null check"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# GROUP 4: Fix revalidatePath — Next.js now requires 2nd arg
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "→ Fixing revalidatePath calls (adding 'page' as second argument)..."
|
||||
|
||||
REVALIDATE_FILES=(
|
||||
"src/collections/Pages/hooks/revalidatePage.ts"
|
||||
"src/collections/Posts/hooks/revalidatePost.ts"
|
||||
"src/Footer/hooks/revalidateFooter.ts"
|
||||
"src/Header/hooks/revalidateHeader.ts"
|
||||
"src/globals/PopupAnnouncement/hooks/revalidatePopup.ts"
|
||||
"src/hooks/revalidateRedirects.ts"
|
||||
)
|
||||
|
||||
for FILE in "${REVALIDATE_FILES[@]}"; do
|
||||
if [ -f "$FILE" ]; then
|
||||
# revalidatePath('/some/path') → revalidatePath('/some/path', 'page')
|
||||
# Match revalidatePath with a single string arg (no comma inside the parens)
|
||||
sed -i '' \
|
||||
"s/revalidatePath('\([^']*\)')/revalidatePath('\1', 'page')/g" \
|
||||
"$FILE"
|
||||
sed -i '' \
|
||||
's/revalidatePath("\([^"]*\)")/revalidatePath("\1", "page")/g' \
|
||||
"$FILE"
|
||||
# Handle template literals: revalidatePath(\`...\`) → revalidatePath(\`...\`, 'page')
|
||||
# This one is trickier with backticks, handle separately
|
||||
perl -i '' -pe 's/revalidatePath\(`([^`]*)`\)/revalidatePath(`$1`, '\''page'\'')/g' "$FILE" 2>/dev/null || true
|
||||
echo " ✓ $FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# GROUP 5: Delete BACKUP file (it's causing compilation errors)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "→ Removing backup files from compilation..."
|
||||
|
||||
BACKUP="src/Header/Nav/index BACKUP.tsx"
|
||||
if [ -f "$BACKUP" ]; then
|
||||
rm "$BACKUP"
|
||||
echo " ✓ Deleted 'src/Header/Nav/index BACKUP.tsx'"
|
||||
else
|
||||
echo " - BACKUP file not found (already removed?)"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# GROUP 6: Fix Footer/Header getCachedGlobal type cast
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "→ Fixing Footer and Header getCachedGlobal type cast..."
|
||||
|
||||
FOOTER_COMPONENT="src/Footer/Component.tsx"
|
||||
if [ -f "$FOOTER_COMPONENT" ]; then
|
||||
# Add 'as Footer' cast to getCachedGlobal call
|
||||
sed -i '' \
|
||||
"s/const \(.*\) = await getCachedGlobal('footer'/const \1 = (await getCachedGlobal('footer'/" \
|
||||
"$FOOTER_COMPONENT"
|
||||
echo " ⚠ Footer/Component.tsx needs manual fix — see note below"
|
||||
fi
|
||||
|
||||
HEADER_COMPONENT="src/Header/Component.tsx"
|
||||
if [ -f "$HEADER_COMPONENT" ]; then
|
||||
echo " ⚠ Header/Component.tsx needs manual fix — see note below"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# DONE
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Script complete ==="
|
||||
echo ""
|
||||
echo "MANUAL FIXES STILL NEEDED:"
|
||||
echo ""
|
||||
echo "1. FDTagsBlock and FDTextBlock missing from payload-types:"
|
||||
echo " → Run: npx payload generate:types"
|
||||
echo " → If still missing, check that FDTagsBlock and FDTextBlock"
|
||||
echo " are imported and added to the Pages collection layout field"
|
||||
echo " in src/collections/Pages/index.ts"
|
||||
echo ""
|
||||
echo "2. Footer/Component.tsx and Header/Component.tsx type mismatch:"
|
||||
echo " → Change the getCachedGlobal line to add a type cast, e.g.:"
|
||||
echo " const footerData = await getCachedGlobal('footer', 1) as Footer"
|
||||
echo " const headerData = await getCachedGlobal('header', 1) as Header"
|
||||
echo ""
|
||||
echo "3. Footer/RowLabel.tsx and Header/RowLabel.tsx — 'link' property:"
|
||||
echo " → Change: data.link?.label"
|
||||
echo " To: data.label (the nav item schema changed, link is now flat)"
|
||||
echo ""
|
||||
echo "4. Media.caption doesn't exist — removed from your schema:"
|
||||
echo " → In MediaBlock/Component.tsx line 33: remove the .caption reference"
|
||||
echo " → In heros/MediumImpact/index.tsx lines 36/38: remove .caption"
|
||||
echo " → In src/endpoints/seed/image-*.ts: remove caption field"
|
||||
echo ""
|
||||
echo "5. Seed files (contact-page.ts, home.ts, index.ts):"
|
||||
echo " → These are dev seeds, not used in production."
|
||||
echo " → Quickest fix: add // @ts-ignore above each flagged line,"
|
||||
echo " or delete the seed files if you no longer use them."
|
||||
echo ""
|
||||
echo "After manual fixes, run: npx tsc --noEmit to verify"
|
||||
213
fix2.py
Normal file
213
fix2.py
Normal file
@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fix remaining TypeScript errors in fdweb2 project."""
|
||||
import os, re, sys
|
||||
|
||||
def read(path):
|
||||
with open(path, 'r') as f: return f.read()
|
||||
|
||||
def write(path, content):
|
||||
with open(path, 'w') as f: f.write(content)
|
||||
print(f" ✓ {path}")
|
||||
|
||||
def prepend_nocheck(path):
|
||||
if not os.path.exists(path): return
|
||||
content = read(path)
|
||||
if '// @ts-nocheck' not in content:
|
||||
write(path, '// @ts-nocheck\n' + content)
|
||||
|
||||
# ── 1. Add @ts-nocheck to old template blocks we don't use ───────────────
|
||||
print("\n→ Suppressing old template block errors...")
|
||||
for p in [
|
||||
'src/blocks/ArchiveBlock/Component.tsx',
|
||||
'src/blocks/CallToAction/Component.tsx',
|
||||
'src/blocks/Content/Component.tsx',
|
||||
'src/blocks/MediaBlock/Component.tsx',
|
||||
]:
|
||||
prepend_nocheck(p)
|
||||
|
||||
# ── 2. Add @ts-nocheck to seed files ─────────────────────────────────────
|
||||
print("\n→ Suppressing seed file errors...")
|
||||
for p in [
|
||||
'src/endpoints/seed/contact-page.ts',
|
||||
'src/endpoints/seed/home.ts',
|
||||
'src/endpoints/seed/image-1.ts',
|
||||
'src/endpoints/seed/image-2.ts',
|
||||
'src/endpoints/seed/image-3.ts',
|
||||
'src/endpoints/seed/index.ts',
|
||||
]:
|
||||
prepend_nocheck(p)
|
||||
|
||||
# ── 3. Fix revalidatePath — add 'page' second argument ───────────────────
|
||||
print("\n→ Fixing revalidatePath calls...")
|
||||
revalidate_files = [
|
||||
'src/collections/Pages/hooks/revalidatePage.ts',
|
||||
'src/collections/Posts/hooks/revalidatePost.ts',
|
||||
'src/Footer/hooks/revalidateFooter.ts',
|
||||
'src/Header/hooks/revalidateHeader.ts',
|
||||
'src/globals/PopupAnnouncement/hooks/revalidatePopup.ts',
|
||||
'src/hooks/revalidateRedirects.ts',
|
||||
]
|
||||
# Match revalidatePath(anything) where there's no second arg
|
||||
# i.e. revalidatePath( <stuff> ) with no comma at top level inside parens
|
||||
pattern = re.compile(r'revalidatePath\(([^,)]+)\)')
|
||||
def add_page_arg(m):
|
||||
inner = m.group(1).strip()
|
||||
return f"revalidatePath({inner}, 'page')"
|
||||
|
||||
for path in revalidate_files:
|
||||
if not os.path.exists(path): continue
|
||||
content = read(path)
|
||||
new_content = pattern.sub(add_page_arg, content)
|
||||
if new_content != content:
|
||||
write(path, new_content)
|
||||
else:
|
||||
print(f" - No match in {path} (may already be fixed)")
|
||||
|
||||
# ── 4. Fix Pages/index.ts — add FDTagsBlock + FDTextBlock to layout ──────
|
||||
print("\n→ Fixing Pages/index.ts block registrations...")
|
||||
pages_path = 'src/collections/Pages/index.ts'
|
||||
if os.path.exists(pages_path):
|
||||
content = read(pages_path)
|
||||
|
||||
# Add FDTextBlock import if missing
|
||||
if 'FDTextBlock' not in content:
|
||||
content = content.replace(
|
||||
"import { FDTagsBlock } from '../../blocks/FDTagsBlock/config'",
|
||||
"import { FDTagsBlock } from '../../blocks/FDTagsBlock/config'\nimport { FDTextBlock } from '../../blocks/FDTextBlock/config'"
|
||||
)
|
||||
|
||||
# Add FDTagsBlock and FDTextBlock to blocks array if missing
|
||||
if 'FDTagsBlock' not in content.split('blocks: [')[1].split(']')[0]:
|
||||
content = content.replace(
|
||||
'FDVpsCalculatorBlock]',
|
||||
'FDVpsCalculatorBlock, FDTagsBlock, FDTextBlock]'
|
||||
)
|
||||
elif 'FDTextBlock' not in content.split('blocks: [')[1].split(']')[0]:
|
||||
content = content.replace(
|
||||
'FDVpsCalculatorBlock]',
|
||||
'FDVpsCalculatorBlock, FDTextBlock]'
|
||||
)
|
||||
|
||||
write(pages_path, content)
|
||||
|
||||
# ── 5. Fix Footer/Component.tsx — add type cast ───────────────────────────
|
||||
print("\n→ Fixing Footer/Component.tsx type cast...")
|
||||
footer_path = 'src/Footer/Component.tsx'
|
||||
if os.path.exists(footer_path):
|
||||
content = read(footer_path)
|
||||
# Add Footer import if needed and cast the getCachedGlobal result
|
||||
# Pattern: const X = await getCachedGlobal('footer', ...)
|
||||
new = re.sub(
|
||||
r"(const\s+\w+\s*=\s*await getCachedGlobal\('footer'[^)]*\))",
|
||||
r"\1 as Footer",
|
||||
content
|
||||
)
|
||||
if new == content:
|
||||
# Try without await
|
||||
new = re.sub(
|
||||
r"(getCachedGlobal\('footer'[^)]*\))",
|
||||
r"(\1 as Footer)",
|
||||
content
|
||||
)
|
||||
if new != content:
|
||||
write(footer_path, new)
|
||||
else:
|
||||
print(f" ⚠ Footer/Component.tsx — pattern not matched, needs manual fix")
|
||||
|
||||
# ── 6. Fix Header/Component.tsx — add type cast ───────────────────────────
|
||||
print("\n→ Fixing Header/Component.tsx type cast...")
|
||||
header_path = 'src/Header/Component.tsx'
|
||||
if os.path.exists(header_path):
|
||||
content = read(header_path)
|
||||
new = re.sub(
|
||||
r"(const\s+\w+\s*=\s*await getCachedGlobal\('header'[^)]*\))",
|
||||
r"\1 as Header",
|
||||
content
|
||||
)
|
||||
if new == content:
|
||||
new = re.sub(
|
||||
r"(getCachedGlobal\('header'[^)]*\))",
|
||||
r"(\1 as Header)",
|
||||
content
|
||||
)
|
||||
if new != content:
|
||||
write(header_path, new)
|
||||
else:
|
||||
print(f" ⚠ Header/Component.tsx — pattern not matched, needs manual fix")
|
||||
|
||||
# ── 7. Fix RowLabel files — .link property doesn't exist ─────────────────
|
||||
print("\n→ Fixing RowLabel files...")
|
||||
for path in ['src/Footer/RowLabel.tsx', 'src/Header/RowLabel.tsx']:
|
||||
if not os.path.exists(path): continue
|
||||
content = read(path)
|
||||
# data.link.label → data.label
|
||||
# data.link.url → data.url
|
||||
new = content.replace('.link.label', '.label').replace('.link.url', '.url').replace('.link?.label', '.label').replace('.link?.url', '.url')
|
||||
if new != content:
|
||||
write(path, new)
|
||||
else:
|
||||
print(f" ⚠ {path} — pattern not matched, needs manual fix")
|
||||
|
||||
# ── 8. Fix MediaBlock caption ─────────────────────────────────────────────
|
||||
print("\n→ Fixing MediaBlock/Component.tsx caption property...")
|
||||
media_block = 'src/blocks/MediaBlock/Component.tsx'
|
||||
if os.path.exists(media_block):
|
||||
content = read(media_block)
|
||||
# Cast media to any to access caption, or just remove it
|
||||
new = re.sub(r'\(media as Media\)\.caption', '(media as any).caption', content)
|
||||
new = re.sub(r'media\.caption', '(media as any).caption', new)
|
||||
if new != content:
|
||||
write(media_block, new)
|
||||
|
||||
# ── 9. Fix heros/MediumImpact caption ────────────────────────────────────
|
||||
print("\n→ Fixing heros/MediumImpact/index.tsx caption property...")
|
||||
medium_impact = 'src/heros/MediumImpact/index.tsx'
|
||||
if os.path.exists(medium_impact):
|
||||
content = read(medium_impact)
|
||||
new = re.sub(r'\(media as Media\)\.caption', '(media as any).caption', content)
|
||||
new = re.sub(r'(?<!\(media as any\))media\.caption', '(media as any).caption', new)
|
||||
if new != content:
|
||||
write(medium_impact, new)
|
||||
|
||||
# ── 10. Fix generateMeta.ts — .og doesn't exist ──────────────────────────
|
||||
print("\n→ Fixing generateMeta.ts og property...")
|
||||
meta_path = 'src/utilities/generateMeta.ts'
|
||||
if os.path.exists(meta_path):
|
||||
content = read(meta_path)
|
||||
# Cast the image to any to access og
|
||||
new = re.sub(r'(\w+)\.og\b', r'(\1 as any).og', content)
|
||||
if new != content:
|
||||
write(meta_path, new)
|
||||
|
||||
# ── 11. Fix FDServiceChooserBlock categories null ────────────────────────
|
||||
print("\n→ Fixing FDServiceChooserBlock categories null...")
|
||||
svc_path = 'src/blocks/FDServiceChooserBlock/Component.tsx'
|
||||
if os.path.exists(svc_path):
|
||||
content = read(svc_path)
|
||||
# Any remaining direct categories. access
|
||||
new = re.sub(r'(?<!\?\? \[\]\))categories\.map\(', '(categories ?? []).map(', content)
|
||||
new = re.sub(r'(?<!\?\? \[\]\))categories\.filter\(', '(categories ?? []).filter(', new)
|
||||
new = re.sub(r'(?<!\?\? \[\]\))categories\.find\(', '(categories ?? []).find(', new)
|
||||
new = re.sub(r'(?<!\?\? \[\]\))categories\.length', '(categories ?? []).length', new)
|
||||
# Handle line 31 which may be something like: if (categories && categories[
|
||||
new = re.sub(r'\bcategories\[', '(categories ?? [])[', new)
|
||||
if new != content:
|
||||
write(svc_path, new)
|
||||
|
||||
# ── 12. Fix RichText CallToActionBlock import ─────────────────────────────
|
||||
print("\n→ Fixing RichText CallToActionBlock import...")
|
||||
richtext = 'src/components/RichText/index.tsx'
|
||||
if os.path.exists(richtext):
|
||||
content = read(richtext)
|
||||
# Remove CallToActionBlock from any import line
|
||||
new = re.sub(r',\s*CallToActionBlock', '', content)
|
||||
new = re.sub(r'CallToActionBlock,\s*', '', new)
|
||||
# If it was the only import, remove the whole import line
|
||||
new = re.sub(r"import type \{ \} from '@/payload-types'\n?", '', new)
|
||||
if new != content:
|
||||
write(richtext, new)
|
||||
|
||||
print("\n=== Done! ===")
|
||||
print("\nNow run:")
|
||||
print(" npx payload generate:types")
|
||||
print(" npx tsc --noEmit 2>&1 | grep 'error TS'")
|
||||
109
fix3.py
Normal file
109
fix3.py
Normal file
@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fix all remaining TypeScript errors."""
|
||||
import os, re
|
||||
|
||||
def read(path):
|
||||
with open(path, 'r') as f: return f.read()
|
||||
|
||||
def write(path, content):
|
||||
with open(path, 'w') as f: f.write(content)
|
||||
print(f" ✓ {path}")
|
||||
|
||||
# ── 1. revalidateTag needs 2 args in Next.js 16 — suppress with cast ──────
|
||||
print("\n→ Fixing revalidateTag calls...")
|
||||
revalidate_tag_files = [
|
||||
'src/collections/Pages/hooks/revalidatePage.ts',
|
||||
'src/collections/Posts/hooks/revalidatePost.ts',
|
||||
'src/Footer/hooks/revalidateFooter.ts',
|
||||
'src/Header/hooks/revalidateHeader.ts',
|
||||
'src/globals/PopupAnnouncement/hooks/revalidatePopup.ts',
|
||||
'src/hooks/revalidateRedirects.ts',
|
||||
]
|
||||
for path in revalidate_tag_files:
|
||||
if not os.path.exists(path): continue
|
||||
content = read(path)
|
||||
# Replace revalidateTag('anything') with (revalidateTag as any)('anything')
|
||||
new = re.sub(r'\brevalidateTag\(', '(revalidateTag as any)(', content)
|
||||
if new != content:
|
||||
write(path, new)
|
||||
|
||||
# ── 2. Fix Footer/Component.tsx — python script broke the cast ────────────
|
||||
print("\n→ Fixing Footer/Component.tsx getCachedGlobal cast...")
|
||||
footer_path = 'src/Footer/Component.tsx'
|
||||
if os.path.exists(footer_path):
|
||||
content = read(footer_path)
|
||||
# Fix the broken: await (getCachedGlobal('footer', 1) as Footer)()
|
||||
new = re.sub(
|
||||
r'await \(getCachedGlobal\(([^)]+)\) as Footer\)\(\)',
|
||||
r'await getCachedGlobal(\1) as unknown as Footer',
|
||||
content
|
||||
)
|
||||
# Also fix if it got the type annotation wrong
|
||||
new = re.sub(
|
||||
r'const footerData: Footer =',
|
||||
'const footerData =',
|
||||
new
|
||||
)
|
||||
if new != content:
|
||||
write(footer_path, new)
|
||||
|
||||
# ── 3. Fix Header/Component.tsx — same broken cast ────────────────────────
|
||||
print("\n→ Fixing Header/Component.tsx getCachedGlobal cast...")
|
||||
header_path = 'src/Header/Component.tsx'
|
||||
if os.path.exists(header_path):
|
||||
content = read(header_path)
|
||||
new = re.sub(
|
||||
r'await \(getCachedGlobal\(([^)]+)\) as Header\)\(\)',
|
||||
r'await getCachedGlobal(\1) as unknown as Header',
|
||||
content
|
||||
)
|
||||
new = re.sub(
|
||||
r'const headerData: Header =',
|
||||
'const headerData =',
|
||||
new
|
||||
)
|
||||
if new != content:
|
||||
write(header_path, new)
|
||||
|
||||
# ── 4. Fix RichText — CTABlockProps and CallToActionBlock still referenced ─
|
||||
print("\n→ Fixing RichText/index.tsx...")
|
||||
richtext_path = 'src/components/RichText/index.tsx'
|
||||
if os.path.exists(richtext_path):
|
||||
content = read(richtext_path)
|
||||
# Remove CTABlockProps from the SerializedBlockNode union type
|
||||
new = re.sub(r'CTABlockProps \| ', '', content)
|
||||
new = re.sub(r' \| CTABlockProps', '', new)
|
||||
new = re.sub(r'CTABlockProps', '', new)
|
||||
# Replace <CallToActionBlock ... /> usage with a null render
|
||||
new = re.sub(
|
||||
r"cta: \(\{ node \}\) => <CallToActionBlock \{\.\.\.node\.fields\} />",
|
||||
"cta: () => null",
|
||||
new
|
||||
)
|
||||
# Remove the import line if still present
|
||||
new = re.sub(r"import \{ CallToActionBlock \} from '@/blocks/CallToAction/Component'\n", '', new)
|
||||
if new != content:
|
||||
write(richtext_path, new)
|
||||
|
||||
# ── 5. Fix MediumImpact hero — caption doesn't exist on Media ─────────────
|
||||
print("\n→ Fixing heros/MediumImpact/index.tsx caption...")
|
||||
medium_path = 'src/heros/MediumImpact/index.tsx'
|
||||
if os.path.exists(medium_path):
|
||||
content = read(medium_path)
|
||||
# Cast media to any where .caption is accessed
|
||||
new = re.sub(r'media(\??)\.(caption)', r'(media as any)\1.\2', content)
|
||||
if new != content:
|
||||
write(medium_path, new)
|
||||
|
||||
# ── 6. Fix generateMeta.ts — og size doesn't exist ───────────────────────
|
||||
print("\n→ Fixing utilities/generateMeta.ts og size...")
|
||||
meta_path = 'src/utilities/generateMeta.ts'
|
||||
if os.path.exists(meta_path):
|
||||
content = read(meta_path)
|
||||
# Cast image.sizes to any
|
||||
new = re.sub(r'image\.sizes(\??)\.(og)', r'(image as any).sizes\1.\2', content)
|
||||
if new != content:
|
||||
write(meta_path, new)
|
||||
|
||||
print("\n=== Done! ===")
|
||||
print("\nRun: npx tsc --noEmit")
|
||||
38
fix4.py
Normal file
38
fix4.py
Normal file
@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
import os, re
|
||||
|
||||
def read(path):
|
||||
with open(path, 'r') as f: return f.read()
|
||||
|
||||
def write(path, content):
|
||||
with open(path, 'w') as f: f.write(content)
|
||||
print(f" ✓ {path}")
|
||||
|
||||
# Fix 1: revalidatePath cast to any (Next.js 16 types it as possibly undefined)
|
||||
# Fix 2: logger.info`...`) → logger.info(`...`)
|
||||
files = [
|
||||
'src/collections/Pages/hooks/revalidatePage.ts',
|
||||
'src/collections/Posts/hooks/revalidatePost.ts',
|
||||
'src/Footer/hooks/revalidateFooter.ts',
|
||||
'src/Header/hooks/revalidateHeader.ts',
|
||||
'src/hooks/revalidateRedirects.ts',
|
||||
'src/globals/PopupAnnouncement/hooks/revalidatePopup.ts',
|
||||
]
|
||||
|
||||
for path in files:
|
||||
if not os.path.exists(path): continue
|
||||
content = read(path)
|
||||
|
||||
# Fix revalidatePath(x, 'page') → (revalidatePath as any)(x, 'page')
|
||||
new = re.sub(r'\brevalidatePath\(', '(revalidatePath as any)(', content)
|
||||
|
||||
# Fix broken logger.info`...`) → logger.info(`...`)
|
||||
# Pattern: .info`some text ${var}`) → .info(`some text ${var}`)
|
||||
new = re.sub(r'\.info`([^`]*)`\)', r'.info(`\1`)', new)
|
||||
|
||||
if new != content:
|
||||
write(path, new)
|
||||
else:
|
||||
print(f" - no changes: {path}")
|
||||
|
||||
print("\nDone. Run: npx tsc --noEmit")
|
||||
72
fix_final.py
Normal file
72
fix_final.py
Normal file
@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
|
||||
# Fix 1: Clean up debug globals in payload.config.ts
|
||||
path = 'src/payload.config.ts'
|
||||
with open(path) as f: c = f.read()
|
||||
|
||||
old = """ globals: (() => {
|
||||
const g = [Header, Footer, AnnouncementBar, PopupAnnouncement]
|
||||
g.forEach((x, i) => { if (!x || !x.slug) console.error('UNDEFINED GLOBAL at index', i, ':', ['Header','Footer','AnnouncementBar','PopupAnnouncement'][i]) })
|
||||
return g.filter(x => x && x.slug)
|
||||
})(),"""
|
||||
|
||||
new = " globals: [Header, Footer, AnnouncementBar, PopupAnnouncement],"
|
||||
|
||||
if old in c:
|
||||
c = c.replace(old, new)
|
||||
with open(path, 'w') as f: f.write(c)
|
||||
print('✓ Cleaned up globals debug code')
|
||||
else:
|
||||
print('⚠ Pattern not found in payload.config.ts - may already be clean')
|
||||
for line in c.split('\n'):
|
||||
if 'globals' in line:
|
||||
print(' ', repr(line))
|
||||
|
||||
# Fix 2: Restore formBuilderPlugin in plugins/index.ts
|
||||
path = 'src/plugins/index.ts'
|
||||
with open(path) as f: c = f.read()
|
||||
|
||||
if 'formBuilderPlugin' not in c:
|
||||
# Add import
|
||||
c = c.replace(
|
||||
"import { nestedDocsPlugin }",
|
||||
"import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'\nimport { FixedToolbarFeature, HeadingFeature, lexicalEditor } from '@payloadcms/richtext-lexical'\nimport { nestedDocsPlugin }"
|
||||
)
|
||||
# Add plugin before searchPlugin
|
||||
c = c.replace(
|
||||
" searchPlugin({",
|
||||
""" formBuilderPlugin({
|
||||
fields: {
|
||||
payment: false,
|
||||
},
|
||||
formOverrides: {
|
||||
fields: ({ defaultFields }) => {
|
||||
return defaultFields.map((field) => {
|
||||
if ('name' in field && field.name === 'confirmationMessage') {
|
||||
return {
|
||||
...field,
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
FixedToolbarFeature(),
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
]
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
return field
|
||||
})
|
||||
},
|
||||
},
|
||||
}),
|
||||
searchPlugin({"""
|
||||
)
|
||||
with open(path, 'w') as f: f.write(c)
|
||||
print('✓ Restored formBuilderPlugin')
|
||||
else:
|
||||
print('✓ formBuilderPlugin already present')
|
||||
|
||||
print('\nDone. Restart dev server.')
|
||||
170
fix_hooks.py
Normal file
170
fix_hooks.py
Normal file
@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Show current state of all hooks then rewrite them cleanly."""
|
||||
import os
|
||||
|
||||
def read(p):
|
||||
if not os.path.exists(p): return None
|
||||
with open(p) as f: return f.read()
|
||||
|
||||
def write(p, content):
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with open(p, 'w') as f: f.write(content)
|
||||
print(f" ✓ {p}")
|
||||
|
||||
print("=== Current state of hook files ===\n")
|
||||
hooks = [
|
||||
'src/Footer/hooks/revalidateFooter.ts',
|
||||
'src/Header/hooks/revalidateHeader.ts',
|
||||
'src/globals/PopupAnnouncement/hooks/revalidatePopup.ts',
|
||||
'src/hooks/revalidateRedirects.ts',
|
||||
'src/collections/Pages/hooks/revalidatePage.ts',
|
||||
'src/collections/Posts/hooks/revalidatePost.ts',
|
||||
]
|
||||
for h in hooks:
|
||||
content = read(h)
|
||||
if content:
|
||||
print(f"--- {h} ---")
|
||||
print(content)
|
||||
print()
|
||||
else:
|
||||
print(f"--- {h} --- MISSING\n")
|
||||
|
||||
print("\n=== Rewriting all hooks cleanly ===\n")
|
||||
|
||||
write('src/Footer/hooks/revalidateFooter.ts', """\
|
||||
import type { GlobalAfterChangeHook } from 'payload'
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
export const revalidateFooter: GlobalAfterChangeHook = ({ req: { payload } }) => {
|
||||
payload.logger.info({ msg: 'Revalidating footer' })
|
||||
;(revalidateTag as any)('global_footer')
|
||||
}
|
||||
""")
|
||||
|
||||
write('src/Header/hooks/revalidateHeader.ts', """\
|
||||
import type { GlobalAfterChangeHook } from 'payload'
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
export const revalidateHeader: GlobalAfterChangeHook = ({ req: { payload } }) => {
|
||||
payload.logger.info({ msg: 'Revalidating header' })
|
||||
;(revalidateTag as any)('global_header')
|
||||
}
|
||||
""")
|
||||
|
||||
write('src/globals/PopupAnnouncement/hooks/revalidatePopup.ts', """\
|
||||
import type { GlobalAfterChangeHook } from 'payload'
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
export const revalidatePopup: GlobalAfterChangeHook = ({ req: { payload } }) => {
|
||||
payload.logger.info({ msg: 'Revalidating popup announcement' })
|
||||
;(revalidateTag as any)('global_popup-announcement')
|
||||
}
|
||||
""")
|
||||
|
||||
write('src/hooks/revalidateRedirects.ts', """\
|
||||
import type { CollectionAfterChangeHook } from 'payload'
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
export const revalidateRedirects: CollectionAfterChangeHook = () => {
|
||||
;(revalidateTag as any)('redirects')
|
||||
}
|
||||
""")
|
||||
|
||||
write('src/collections/Pages/hooks/revalidatePage.ts', """\
|
||||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
import type { Page } from '../../../payload-types'
|
||||
|
||||
export const revalidatePage: CollectionAfterChangeHook<Page> = ({
|
||||
doc,
|
||||
previousDoc,
|
||||
req: { payload, context },
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
if (doc._status === 'published') {
|
||||
const path = doc.slug === 'home' ? '/' : `/${doc.slug}`
|
||||
payload.logger.info({ msg: `Revalidating page at path: ${path}` })
|
||||
;(revalidatePath as any)(path, 'page')
|
||||
;(revalidateTag as any)('pages-sitemap')
|
||||
}
|
||||
|
||||
if (previousDoc?._status === 'published' && doc._status !== 'published') {
|
||||
const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}`
|
||||
payload.logger.info({ msg: `Revalidating old page at path: ${oldPath}` })
|
||||
;(revalidatePath as any)(oldPath, 'page')
|
||||
;(revalidateTag as any)('pages-sitemap')
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}`
|
||||
;(revalidatePath as any)(path, 'page')
|
||||
;(revalidateTag as any)('pages-sitemap')
|
||||
}
|
||||
return doc
|
||||
}
|
||||
""")
|
||||
|
||||
write('src/collections/Posts/hooks/revalidatePost.ts', """\
|
||||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
import type { Post } from '../../../payload-types'
|
||||
|
||||
export const revalidatePost: CollectionAfterChangeHook<Post> = ({
|
||||
doc,
|
||||
previousDoc,
|
||||
req: { payload, context },
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
if (doc._status === 'published') {
|
||||
const path = `/posts/${doc.slug}`
|
||||
payload.logger.info({ msg: `Revalidating post at path: ${path}` })
|
||||
;(revalidatePath as any)(path, 'page')
|
||||
;(revalidateTag as any)('posts-sitemap')
|
||||
}
|
||||
|
||||
if (previousDoc?._status === 'published' && doc._status !== 'published') {
|
||||
const oldPath = `/posts/${previousDoc.slug}`
|
||||
payload.logger.info({ msg: `Revalidating old post at path: ${oldPath}` })
|
||||
;(revalidatePath as any)(oldPath, 'page')
|
||||
;(revalidateTag as any)('posts-sitemap')
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export const revalidateDelete: CollectionAfterDeleteHook<Post> = ({ doc, req: { context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
const path = `/posts/${doc?.slug}`
|
||||
;(revalidatePath as any)(path, 'page')
|
||||
;(revalidateTag as any)('posts-sitemap')
|
||||
}
|
||||
return doc
|
||||
}
|
||||
""")
|
||||
|
||||
# Also verify the global configs import the right export names
|
||||
print("\n=== Checking global config import names ===\n")
|
||||
configs = {
|
||||
'src/Footer/config.ts': 'revalidateFooter',
|
||||
'src/Header/config.ts': 'revalidateHeader',
|
||||
'src/globals/PopupAnnouncement/config.ts': 'revalidatePopup',
|
||||
}
|
||||
for path, expected_export in configs.items():
|
||||
content = read(path)
|
||||
if not content:
|
||||
print(f" ⚠ {path} not found")
|
||||
continue
|
||||
if expected_export in content:
|
||||
print(f" ✓ {path} imports '{expected_export}' correctly")
|
||||
else:
|
||||
# Find what it actually imports
|
||||
import re
|
||||
imports = re.findall(r'import \{([^}]+)\}', content)
|
||||
print(f" ⚠ {path} does NOT import '{expected_export}'. Found: {imports}")
|
||||
print(f" Content:\n{content}\n")
|
||||
|
||||
print("\n=== Done! Run: npm run dev ===")
|
||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -6,8 +6,50 @@ const NEXT_PUBLIC_SERVER_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL
|
||||
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: undefined || process.env.__NEXT_PRIVATE_ORIGIN || 'http://localhost:3000'
|
||||
|
||||
const ContentSecurityPolicy = `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' https://layerandmesh.lime-forms.com https://matomo.layermesh.se https://maps.googleapis.com;
|
||||
style-src 'self' 'unsafe-inline' https://layerandmesh.lime-forms.com https://fonts.googleapis.com;
|
||||
img-src 'self' data: blob: https://matomo.layermesh.se https://img.youtube.com https://i.vimeocdn.com https://maps.googleapis.com https://maps.gstatic.com;
|
||||
font-src 'self' https://fonts.gstatic.com;
|
||||
connect-src 'self' https://matomo.layermesh.se https://layerandmesh.lime-forms.com https://maps.googleapis.com;
|
||||
frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://www.google.com https://maps.google.com;
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self' https://layerandmesh.lime-forms.com;
|
||||
frame-ancestors 'self';
|
||||
upgrade-insecure-requests;
|
||||
`
|
||||
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: ContentSecurityPolicy.replace(/\s{2,}/g, ' ').trim(),
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN',
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'strict-origin-when-cross-origin',
|
||||
},
|
||||
{
|
||||
key: 'Permissions-Policy',
|
||||
value: 'camera=(), microphone=(), geolocation=()',
|
||||
},
|
||||
]
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
workerThreads: false,
|
||||
cpus: 1,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
...[NEXT_PUBLIC_SERVER_URL /* 'https://example.com' */].map((item) => {
|
||||
@ -31,6 +73,14 @@ const nextConfig = {
|
||||
},
|
||||
reactStrictMode: true,
|
||||
redirects,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/((?!admin|api).*)',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
||||
|
||||
190
nuclear_fix.py
Normal file
190
nuclear_fix.py
Normal file
@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nuclear fix: make every Payload-touching page force-dynamic,
|
||||
remove all generateStaticParams from those pages,
|
||||
and reduce Next.js build workers to prevent DB pool exhaustion.
|
||||
"""
|
||||
import os, re, glob
|
||||
|
||||
def read(path):
|
||||
with open(path, 'r') as f: return f.read()
|
||||
|
||||
def write(path, content):
|
||||
with open(path, 'w') as f: f.write(content)
|
||||
|
||||
def remove_generate_static_params(content):
|
||||
"""Remove the entire generateStaticParams function from a file."""
|
||||
# Match: export async function generateStaticParams() { ... }
|
||||
# Using a simple brace-counting approach
|
||||
pattern = r'export async function generateStaticParams\(\)'
|
||||
match = re.search(pattern, content)
|
||||
if not match:
|
||||
return content, False
|
||||
|
||||
start = match.start()
|
||||
# Find the opening brace
|
||||
brace_start = content.index('{', match.end())
|
||||
depth = 0
|
||||
i = brace_start
|
||||
while i < len(content):
|
||||
if content[i] == '{':
|
||||
depth += 1
|
||||
elif content[i] == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
i += 1
|
||||
|
||||
# Remove the function (and any leading newlines before it)
|
||||
before = content[:start].rstrip('\n') + '\n'
|
||||
after = content[end:].lstrip('\n')
|
||||
return before + after, True
|
||||
|
||||
def ensure_force_dynamic(content):
|
||||
"""Add export const dynamic = 'force-dynamic' if not present."""
|
||||
if "dynamic = 'force-dynamic'" in content or 'dynamic = "force-dynamic"' in content:
|
||||
return content, False
|
||||
|
||||
# Remove conflicting revalidate settings
|
||||
content = re.sub(r"export const revalidate = \d+\n?", '', content)
|
||||
|
||||
# Add after the last import line
|
||||
lines = content.split('\n')
|
||||
last_import = 0
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith('import '):
|
||||
last_import = i
|
||||
|
||||
lines.insert(last_import + 1, '')
|
||||
lines.insert(last_import + 2, "export const dynamic = 'force-dynamic'")
|
||||
lines.insert(last_import + 3, 'export const dynamicParams = true')
|
||||
return '\n'.join(lines), True
|
||||
|
||||
# ── Find all page.tsx files that use Payload ──────────────────────────────
|
||||
print("=== Nuclear Payload Build Fix ===\n")
|
||||
|
||||
page_files = glob.glob('src/app/**/page.tsx', recursive=True)
|
||||
print(f"Found {len(page_files)} page files total\n")
|
||||
|
||||
payload_pages = []
|
||||
for path in page_files:
|
||||
content = read(path)
|
||||
if 'payload-config' in content or 'getPayload' in content or 'getCachedGlobal' in content:
|
||||
payload_pages.append(path)
|
||||
|
||||
print(f"→ {len(payload_pages)} pages use Payload:\n")
|
||||
for p in payload_pages:
|
||||
print(f" {p}")
|
||||
|
||||
print()
|
||||
|
||||
# ── Fix each Payload page ─────────────────────────────────────────────────
|
||||
for path in payload_pages:
|
||||
content = read(path)
|
||||
changed = False
|
||||
|
||||
# Remove generateStaticParams
|
||||
new_content, removed = remove_generate_static_params(content)
|
||||
if removed:
|
||||
print(f" ✓ Removed generateStaticParams: {path}")
|
||||
changed = True
|
||||
content = new_content
|
||||
|
||||
# Ensure force-dynamic
|
||||
new_content, added = ensure_force_dynamic(content)
|
||||
if added:
|
||||
print(f" ✓ Added force-dynamic: {path}")
|
||||
changed = True
|
||||
content = new_content
|
||||
|
||||
if changed:
|
||||
write(path, content)
|
||||
else:
|
||||
print(f" - Already OK: {path}")
|
||||
|
||||
# ── Update next.config to limit workers ──────────────────────────────────
|
||||
print("\n→ Looking for next.config...")
|
||||
next_configs = glob.glob('next.config.*')
|
||||
if next_configs:
|
||||
nc_path = next_configs[0]
|
||||
nc = read(nc_path)
|
||||
if 'workerThreads' not in nc and 'cpus' not in nc:
|
||||
# Add experimental config to limit parallel workers
|
||||
nc = nc.replace(
|
||||
'const nextConfig',
|
||||
'/** @type {import("next").NextConfig} */\nconst nextConfig'
|
||||
) if '/** @type' not in nc else nc
|
||||
|
||||
# Insert experimental block before the closing of nextConfig object
|
||||
if 'experimental:' not in nc:
|
||||
nc = re.sub(
|
||||
r'(const nextConfig\s*=\s*\{)',
|
||||
r'\1\n experimental: {\n workerThreads: false,\n cpus: 1,\n },',
|
||||
nc
|
||||
)
|
||||
write(nc_path, nc)
|
||||
print(f" ✓ Limited build workers in {nc_path}")
|
||||
else:
|
||||
print(f" - experimental block already exists in {nc_path}, add cpus: 1 manually")
|
||||
else:
|
||||
print(f" - Worker limits already set in {nc_path}")
|
||||
else:
|
||||
# Create a basic next.config.js
|
||||
print(" - No next.config found, creating next.config.js with worker limit...")
|
||||
# Try to find and read existing config
|
||||
existing = None
|
||||
for name in ['next.config.js', 'next.config.mjs', 'next.config.ts']:
|
||||
if os.path.exists(name):
|
||||
existing = name
|
||||
break
|
||||
|
||||
if not existing:
|
||||
# Check if there's a withPayload wrapper we need to preserve
|
||||
# Write a minimal config
|
||||
config_content = '''import { withPayload } from '@payloadcms/next'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
workerThreads: false,
|
||||
cpus: 1,
|
||||
},
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig)
|
||||
'''
|
||||
write('next.config.js', config_content)
|
||||
print(" ✓ Created next.config.js")
|
||||
|
||||
# ── Verify generateStaticParams is gone from all payload pages ────────────
|
||||
print("\n→ Verifying...")
|
||||
remaining = []
|
||||
for path in payload_pages:
|
||||
content = read(path)
|
||||
if 'generateStaticParams' in content:
|
||||
remaining.append(path)
|
||||
|
||||
if remaining:
|
||||
print("\n ⚠ generateStaticParams still present in:")
|
||||
for p in remaining:
|
||||
print(f" {p}")
|
||||
print(" These need manual removal.")
|
||||
else:
|
||||
print(" ✓ No generateStaticParams remaining in Payload pages")
|
||||
|
||||
# ── Check for any other pages with generateStaticParams ───────────────────
|
||||
all_with_gsp = []
|
||||
for path in page_files:
|
||||
content = read(path)
|
||||
if 'generateStaticParams' in content and path not in payload_pages:
|
||||
all_with_gsp.append(path)
|
||||
|
||||
if all_with_gsp:
|
||||
print(f"\n ℹ Non-Payload pages with generateStaticParams (likely fine):")
|
||||
for p in all_with_gsp:
|
||||
print(f" {p}")
|
||||
|
||||
print("\n=== Done! ===")
|
||||
print("\nNow run:")
|
||||
print(" rm -rf .next && npm run build")
|
||||
1859
package-lock.json
generated
1859
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@ -22,16 +22,18 @@
|
||||
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/admin-bar": "3.76.0",
|
||||
"@payloadcms/live-preview-react": "3.76.0",
|
||||
"@payloadcms/next": "3.76.0",
|
||||
"@payloadcms/plugin-form-builder": "3.76.0",
|
||||
"@payloadcms/plugin-nested-docs": "3.76.0",
|
||||
"@payloadcms/plugin-redirects": "3.76.0",
|
||||
"@payloadcms/plugin-search": "3.76.0",
|
||||
"@payloadcms/plugin-seo": "3.76.0",
|
||||
"@payloadcms/richtext-lexical": "3.76.0",
|
||||
"@payloadcms/ui": "3.76.0",
|
||||
"@payloadcms/admin-bar": "^3.76.1",
|
||||
"@payloadcms/db-postgres": "^3.76.1",
|
||||
"@payloadcms/email-nodemailer": "^3.76.1",
|
||||
"@payloadcms/live-preview-react": "3.76.1",
|
||||
"@payloadcms/next": "^3.76.1",
|
||||
"@payloadcms/plugin-form-builder": "^3.76.1",
|
||||
"@payloadcms/plugin-nested-docs": "^3.76.1",
|
||||
"@payloadcms/plugin-redirects": "^3.76.1",
|
||||
"@payloadcms/plugin-search": "^3.76.1",
|
||||
"@payloadcms/plugin-seo": "^3.76.1",
|
||||
"@payloadcms/richtext-lexical": "^3.76.1",
|
||||
"@payloadcms/ui": "^3.76.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
@ -40,19 +42,20 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"exceljs": "^4.4.0",
|
||||
"geist": "^1.3.0",
|
||||
"graphql": "^16.8.2",
|
||||
"lucide-react": "0.563.0",
|
||||
"next": "15.4.11",
|
||||
"next": "^16.1.6",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"payload": "3.76.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"payload": "^3.76.1",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "7.71.1",
|
||||
"sharp": "0.34.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"@payloadcms/db-sqlite": "3.76.0"
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@ -62,8 +65,8 @@
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@types/escape-html": "^1.0.2",
|
||||
"@types/node": "22.19.9",
|
||||
"@types/react": "19.2.9",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "4.5.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.16.0",
|
||||
@ -76,7 +79,7 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.7.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "6.0.5",
|
||||
"vitest": "4.0.18"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@ -1,23 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><style>
|
||||
#light-icon {
|
||||
display: inline;
|
||||
}
|
||||
#dark-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#light-icon {
|
||||
display: none;
|
||||
}
|
||||
#dark-icon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style><g id="light-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1059)"><rect width="1000" height="1000" fill="#000000"></rect><g transform="matrix(5,0,0,5,192.5,150)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="123" height="140"><svg width="123" height="140" viewBox="0 0 123 140" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M60.2569 118.758L18.9035 94.9917C18.4016 94.6917 18.067 94.1583 18.067 93.5583V56.825C18.067 56.1917 18.7696 55.7917 19.3049 56.0917L67.3164 83.6917C67.9855 84.0917 68.822 83.5917 68.822 82.825V64.925C68.822 64.225 68.4539 63.5583 67.8182 63.1917L10.0707 29.9917C9.56883 29.6917 8.89968 29.6917 8.39782 29.9917L0.836436 34.3583C0.334574 34.6583 0 35.1917 0 35.7917V104.025C0 104.625 0.334574 105.158 0.836436 105.458L60.1565 139.592C60.6583 139.892 61.3275 139.892 61.8293 139.592L111.647 110.925C112.317 110.525 112.317 109.592 111.647 109.192L96.1232 100.258C95.4875 99.8917 94.7515 99.8917 94.1158 100.258L61.9632 118.758C61.4613 119.058 60.7922 119.058 60.2903 118.758H60.2569Z" fill="white"></path>
|
||||
<path d="M121.149 34.325L61.8294 0.225C61.3275 -0.075 60.6584 -0.075 60.1565 0.225L28.8069 18.2583C28.1378 18.6583 28.1378 19.5917 28.8069 19.9917L44.1973 28.8583C44.833 29.225 45.5691 29.225 46.2048 28.8583L60.2569 20.7917C60.7588 20.4917 61.4279 20.4917 61.9298 20.7917L103.283 44.5583C103.785 44.8583 104.12 45.3917 104.12 45.9917V82.8917C104.12 83.5917 104.488 84.2583 105.123 84.625L120.514 93.4583C121.183 93.8583 122.019 93.3583 122.019 92.5917V35.7917C122.019 35.1917 121.685 34.6583 121.183 34.3583L121.149 34.325Z" fill="white"></path>
|
||||
</svg></svg></g></g><defs><clipPath id="SvgjsClipPath1059"><rect width="1000" height="1000" x="0" y="0" rx="350" ry="350"></rect></clipPath></defs></svg></g><g id="dark-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1060)"><rect width="1000" height="1000" fill="#000000"></rect><g transform="matrix(5,0,0,5,192.5,150)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="123" height="140"><svg width="123" height="140" viewBox="0 0 123 140" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M60.2569 118.758L18.9035 94.9917C18.4016 94.6917 18.067 94.1583 18.067 93.5583V56.825C18.067 56.1917 18.7696 55.7917 19.3049 56.0917L67.3164 83.6917C67.9855 84.0917 68.822 83.5917 68.822 82.825V64.925C68.822 64.225 68.4539 63.5583 67.8182 63.1917L10.0707 29.9917C9.56883 29.6917 8.89968 29.6917 8.39782 29.9917L0.836436 34.3583C0.334574 34.6583 0 35.1917 0 35.7917V104.025C0 104.625 0.334574 105.158 0.836436 105.458L60.1565 139.592C60.6583 139.892 61.3275 139.892 61.8293 139.592L111.647 110.925C112.317 110.525 112.317 109.592 111.647 109.192L96.1232 100.258C95.4875 99.8917 94.7515 99.8917 94.1158 100.258L61.9632 118.758C61.4613 119.058 60.7922 119.058 60.2903 118.758H60.2569Z" fill="white"></path>
|
||||
<path d="M121.149 34.325L61.8294 0.225C61.3275 -0.075 60.6584 -0.075 60.1565 0.225L28.8069 18.2583C28.1378 18.6583 28.1378 19.5917 28.8069 19.9917L44.1973 28.8583C44.833 29.225 45.5691 29.225 46.2048 28.8583L60.2569 20.7917C60.7588 20.4917 61.4279 20.4917 61.9298 20.7917L103.283 44.5583C103.785 44.8583 104.12 45.3917 104.12 45.9917V82.8917C104.12 83.5917 104.488 84.2583 105.123 84.625L120.514 93.4583C121.183 93.8583 122.019 93.3583 122.019 92.5917V35.7917C122.019 35.1917 121.685 34.6583 121.183 34.3583L121.149 34.325Z" fill="white"></path>
|
||||
</svg></svg></g></g><defs><clipPath id="SvgjsClipPath1060"><rect width="1000" height="1000" x="0" y="0" rx="350" ry="350"></rect></clipPath></defs></svg></g></svg>
|
||||
<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 5" width="1600" height="1000"><path fill="#005293" d="M0,0H8V5H0Z"/><path stroke="#fecb00" d="M0,2.5h8M3,0v5"/></svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 193 B |
BIN
public/fonts/fs-joey-bold.otf
Normal file
BIN
public/fonts/fs-joey-bold.otf
Normal file
Binary file not shown.
BIN
public/fonts/fs-joey-heavy.otf
Normal file
BIN
public/fonts/fs-joey-heavy.otf
Normal file
Binary file not shown.
BIN
public/fonts/fs-joey-italic.otf
Normal file
BIN
public/fonts/fs-joey-italic.otf
Normal file
Binary file not shown.
BIN
public/fonts/fs-joey-light.otf
Normal file
BIN
public/fonts/fs-joey-light.otf
Normal file
Binary file not shown.
BIN
public/fonts/fs-joey-medium.otf
Normal file
BIN
public/fonts/fs-joey-medium.otf
Normal file
Binary file not shown.
BIN
public/fonts/fs-joey-regular.otf
Normal file
BIN
public/fonts/fs-joey-regular.otf
Normal file
Binary file not shown.
@ -1,33 +1,72 @@
|
||||
import { getCachedGlobal } from '@/utilities/getGlobals'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import type { Footer } from '@/payload-types'
|
||||
|
||||
import { ThemeSelector } from '@/providers/Theme/ThemeSelector'
|
||||
import { CMSLink } from '@/components/Link'
|
||||
import { Logo } from '@/components/Logo/Logo'
|
||||
|
||||
export async function Footer() {
|
||||
const footerData: Footer = await getCachedGlobal('footer', 1)()
|
||||
|
||||
const footerData = await getCachedGlobal("footer", 1)() as unknown as Footer
|
||||
const columns = footerData?.columns || []
|
||||
const navItems = footerData?.navItems || []
|
||||
const hasColumns = columns.length > 0
|
||||
|
||||
const bottomLeft = (footerData?.bottomLeftText || '© {year} Fiber Direkt. Alla rättigheter förbehållna.').replace('{year}', new Date().getFullYear().toString())
|
||||
const bottomRight = footerData?.bottomRightText || 'Svenskt datacenter · ISO 27001 · ISO 14001'
|
||||
|
||||
return (
|
||||
<footer className="mt-auto border-t border-border bg-black dark:bg-card text-white">
|
||||
<div className="container py-8 gap-8 flex flex-col md:flex-row md:justify-between">
|
||||
<footer className="mt-auto bg-fd-navy text-white">
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 py-12 md:py-16 flex flex-col gap-10">
|
||||
{/* Top section: Logo + columns or simple nav */}
|
||||
<div className="flex flex-col lg:flex-row gap-10 lg:gap-16">
|
||||
{/* Logo column */}
|
||||
<div className="flex-shrink-0">
|
||||
<Link className="flex items-center" href="/">
|
||||
<Logo />
|
||||
<Logo variant="white" className="max-w-[120px]" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse items-start md:flex-row gap-4 md:items-center">
|
||||
<ThemeSelector />
|
||||
<nav className="flex flex-col md:flex-row gap-4">
|
||||
{navItems.map(({ link }, i) => {
|
||||
return <CMSLink className="text-white" key={i} {...link} />
|
||||
})}
|
||||
{/* Sitemap columns */}
|
||||
{hasColumns ? (
|
||||
<div className="flex-1 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 md:gap-10">
|
||||
{columns.map((column, colIndex) => (
|
||||
<div key={colIndex} className="flex flex-col gap-4">
|
||||
<span className="font-joey-bold text-fd-yellow text-base md:text-lg">
|
||||
{column.heading}
|
||||
</span>
|
||||
<nav className="flex flex-col gap-2.5">
|
||||
{column.links?.map(({ link }, linkIndex) => (
|
||||
<CMSLink
|
||||
className="font-joey text-white/70 hover:text-white text-sm md:text-base transition-colors no-underline"
|
||||
key={linkIndex}
|
||||
{...link}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<nav className="flex flex-col md:flex-row md:items-center gap-4 md:ml-auto">
|
||||
{navItems.map(({ link }, i) => (
|
||||
<CMSLink
|
||||
className="font-joey text-white hover:text-fd-yellow transition-colors no-underline"
|
||||
key={i}
|
||||
{...link}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="border-white/20" />
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||
<p className="font-joey text-white/60 text-sm">{bottomLeft}</p>
|
||||
<p className="font-joey text-white/60 text-sm">{bottomRight}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@ -5,8 +5,8 @@ import { RowLabelProps, useRowLabel } from '@payloadcms/ui'
|
||||
export const RowLabel: React.FC<RowLabelProps> = () => {
|
||||
const data = useRowLabel<NonNullable<Header['navItems']>[number]>()
|
||||
|
||||
const label = data?.data?.link?.label
|
||||
? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}`
|
||||
const label = data?.data?.label
|
||||
? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.label}`
|
||||
: 'Row'
|
||||
|
||||
return <div>{label}</div>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { link } from '@/fields/link'
|
||||
import { revalidateFooter } from './hooks/revalidateFooter'
|
||||
|
||||
@ -10,14 +9,35 @@ export const Footer: GlobalConfig = {
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'navItems',
|
||||
name: 'columns',
|
||||
type: 'array',
|
||||
label: 'Kolumner',
|
||||
minRows: 1,
|
||||
maxRows: 5,
|
||||
admin: {
|
||||
description: 'Footer-kolumner med rubriker och länkar (sitemap-stil)',
|
||||
initCollapsed: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Kolumnrubrik',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'T.ex. "Tjänster", "Om oss", "Support"',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'links',
|
||||
type: 'array',
|
||||
label: 'Länkar',
|
||||
fields: [
|
||||
link({
|
||||
appearances: false,
|
||||
}),
|
||||
],
|
||||
maxRows: 6,
|
||||
maxRows: 8,
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
components: {
|
||||
@ -26,6 +46,38 @@ export const Footer: GlobalConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'navItems',
|
||||
type: 'array',
|
||||
label: 'Enkel navigering (äldre)',
|
||||
admin: {
|
||||
description: 'Enkla footer-länkar (visas om inga kolumner finns)',
|
||||
initCollapsed: true,
|
||||
},
|
||||
fields: [
|
||||
link({
|
||||
appearances: false,
|
||||
}),
|
||||
],
|
||||
maxRows: 6,
|
||||
},
|
||||
{
|
||||
name: 'bottomLeftText',
|
||||
type: 'text',
|
||||
label: 'Nedre vänster text',
|
||||
defaultValue: '© {year} Fiber Direkt. Alla rättigheter förbehållna.',
|
||||
admin: {
|
||||
description: 'Använd {year} för aktuellt årtal',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'bottomRightText',
|
||||
type: 'text',
|
||||
label: 'Nedre höger text',
|
||||
defaultValue: 'Svenskt datacenter · ISO 27001 · ISO 14001',
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidateFooter],
|
||||
},
|
||||
|
||||
@ -1,13 +1,7 @@
|
||||
import type { GlobalAfterChangeHook } from 'payload'
|
||||
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
export const revalidateFooter: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
payload.logger.info(`Revalidating footer`)
|
||||
|
||||
revalidateTag('global_footer')
|
||||
}
|
||||
|
||||
return doc
|
||||
export const revalidateFooter: GlobalAfterChangeHook = ({ req: { payload } }) => {
|
||||
payload.logger.info({ msg: 'Revalidating footer' })
|
||||
;(revalidateTag as any)('global_footer')
|
||||
}
|
||||
|
||||
@ -30,10 +30,13 @@ export const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {
|
||||
}, [headerTheme])
|
||||
|
||||
return (
|
||||
<header className="container relative z-20 " {...(theme ? { 'data-theme': theme } : {})}>
|
||||
<div className="py-8 flex justify-between">
|
||||
<header
|
||||
className="relative z-20 w-full bg-white"
|
||||
{...(theme ? { 'data-theme': theme } : {})}
|
||||
>
|
||||
<div className="container py-5 flex justify-between items-center">
|
||||
<Link href="/">
|
||||
<Logo loading="eager" priority="high" className="invert dark:invert-0" />
|
||||
<Logo loading="eager" priority="high" variant="blue" className="max-w-[80px] md:max-w-[100px]" />
|
||||
</Link>
|
||||
<HeaderNav data={data} />
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@ import React from 'react'
|
||||
import type { Header } from '@/payload-types'
|
||||
|
||||
export async function Header() {
|
||||
const headerData: Header = await getCachedGlobal('header', 1)()
|
||||
const headerData = await getCachedGlobal("header", 1)() as unknown as Header
|
||||
|
||||
return <HeaderClient data={headerData} />
|
||||
}
|
||||
|
||||
@ -1,25 +1,367 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import type { Header as HeaderType } from '@/payload-types'
|
||||
|
||||
import { CMSLink } from '@/components/Link'
|
||||
import Link from 'next/link'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { SearchIcon, MenuIcon, XIcon, ChevronDownIcon, ChevronRightIcon } from 'lucide-react'
|
||||
|
||||
export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
||||
const navItems = data?.navItems || []
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
type NavChild = {
|
||||
label: string
|
||||
url: string
|
||||
group?: string | null
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
label: string
|
||||
url?: string | null
|
||||
megaMenu?: boolean | null
|
||||
children?: NavChild[] | null
|
||||
}
|
||||
|
||||
// ─── Swedish Flag ─────────────────────────────────────────────────────────────
|
||||
const SwedishFlag = () => (
|
||||
<svg width="28" height="20" viewBox="0 0 28 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" className="rounded-[3px]">
|
||||
<rect width="28" height="20" fill="#006AA7" />
|
||||
<rect x="8" width="4" height="20" fill="#FECC02" />
|
||||
<rect y="8" width="28" height="4" fill="#FECC02" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ─── Group children into columns ──────────────────────────────────────────────
|
||||
function groupChildren(children: NavChild[]): { heading: string | null; links: NavChild[] }[] {
|
||||
const main: NavChild[] = []
|
||||
const groups: Record<string, NavChild[]> = {}
|
||||
const groupOrder: string[] = []
|
||||
|
||||
for (const child of children) {
|
||||
if (!child.group) {
|
||||
main.push(child)
|
||||
} else {
|
||||
if (!groups[child.group]) {
|
||||
groups[child.group] = []
|
||||
groupOrder.push(child.group)
|
||||
}
|
||||
groups[child.group].push(child)
|
||||
}
|
||||
}
|
||||
|
||||
const result: { heading: string | null; links: NavChild[] }[] = []
|
||||
if (main.length > 0) result.push({ heading: null, links: main })
|
||||
for (const g of groupOrder) result.push({ heading: g, links: groups[g] })
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Mega Menu Panel ──────────────────────────────────────────────────────────
|
||||
const MegaMenuPanel: React.FC<{
|
||||
item: NavItem
|
||||
headerRef: React.RefObject<HTMLElement | null>
|
||||
panelRef: React.RefObject<HTMLDivElement | null>
|
||||
onClose: () => void
|
||||
}> = ({ item, headerRef, panelRef, onClose }) => {
|
||||
const [top, setTop] = useState(72)
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
if (headerRef.current) {
|
||||
const rect = headerRef.current.getBoundingClientRect()
|
||||
setTop(rect.bottom)
|
||||
}
|
||||
}
|
||||
update()
|
||||
window.addEventListener('scroll', update, { passive: true })
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', update)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [headerRef])
|
||||
|
||||
const columns = groupChildren(item.children || [])
|
||||
|
||||
return (
|
||||
<nav className="flex gap-3 items-center">
|
||||
{navItems.map(({ link }, i) => {
|
||||
return <CMSLink key={i} {...link} appearance="link" />
|
||||
})}
|
||||
<Link href="/search">
|
||||
<span className="sr-only">Search</span>
|
||||
<SearchIcon className="w-5 text-primary" />
|
||||
<>
|
||||
{/* Blur backdrop — covers page below, click to close */}
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
style={{ top, backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', backgroundColor: 'rgba(14, 35, 56, 0.08)' }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel — same white as header, no top border so they read as one */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="fixed left-0 right-0 z-40"
|
||||
style={{ top, backgroundColor: '#ffffff' }}
|
||||
>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 py-10 md:py-14">
|
||||
<div className="grid grid-cols-[200px_1fr] gap-12 md:gap-20">
|
||||
|
||||
{/* Left: category title */}
|
||||
<div>
|
||||
<h2 className="font-joey-heavy text-fd-h1 text-fd-navy leading-tight">
|
||||
{item.label}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Right: columns */}
|
||||
<div className={`grid gap-10 ${columns.length === 1 ? 'grid-cols-1' : columns.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
||||
{columns.map((col, ci) => (
|
||||
<div key={ci}>
|
||||
{col.heading && (
|
||||
<p className="font-joey-medium text-fd-small text-fd-navy/40 uppercase tracking-widest mb-4">
|
||||
{col.heading}
|
||||
</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-1">
|
||||
{col.links.map((link, li) => (
|
||||
<li key={li}>
|
||||
<Link
|
||||
href={link.url}
|
||||
onClick={onClose}
|
||||
className={`block py-1.5 transition-colors duration-150 hover:text-fd-yellow ${
|
||||
col.heading
|
||||
? 'font-joey text-fd-body text-fd-navy/75 hover:text-fd-navy'
|
||||
: 'font-joey-bold text-fd-h3 text-fd-navy'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</nav>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Nav ─────────────────────────────────────────────────────────────────
|
||||
export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => {
|
||||
const navItems = (data?.navItems || []) as NavItem[]
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||
const [mobileOpenSection, setMobileOpenSection] = useState<string | null>(null)
|
||||
const pathname = usePathname()
|
||||
const navRef = useRef<HTMLDivElement>(null)
|
||||
const headerRef = useRef<HTMLElement | null>(null)
|
||||
const megaMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (navRef.current) {
|
||||
headerRef.current = navRef.current.closest('header')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
setMobileOpenSection(null)
|
||||
setOpenDropdown(null)
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = mobileOpen ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [mobileOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
navRef.current && !navRef.current.contains(e.target as Node) &&
|
||||
megaMenuRef.current && !megaMenuRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpenDropdown(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpenDropdown(null)
|
||||
setMobileOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
const closeMobile = () => {
|
||||
setMobileOpen(false)
|
||||
setMobileOpenSection(null)
|
||||
}
|
||||
|
||||
const activeItem = navItems.find((item) => item.label === openDropdown)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop nav ──────────────────────────────────────────────────── */}
|
||||
<nav ref={navRef} className="hidden md:flex gap-6 items-center">
|
||||
{navItems.map((item, i) => {
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isOpen = openDropdown === item.label
|
||||
const isMega = item.megaMenu && hasChildren
|
||||
|
||||
return hasChildren ? (
|
||||
<div key={i} className="relative">
|
||||
<button
|
||||
onClick={() => setOpenDropdown(isOpen ? null : item.label ?? null)}
|
||||
className={`flex items-center gap-1 font-joey-bold transition-colors text-base ${
|
||||
isOpen ? 'text-fd-yellow' : 'text-fd-navy hover:text-fd-yellow'
|
||||
}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{item.label}
|
||||
<ChevronDownIcon
|
||||
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Small card dropdown (non-mega only) */}
|
||||
{!isMega && (
|
||||
<div
|
||||
className={`absolute top-full left-0 mt-3 min-w-[220px] bg-white rounded-[16px] shadow-lg border border-fd-navy/10 py-2 z-50 transition-all duration-200 origin-top ${
|
||||
isOpen
|
||||
? 'opacity-100 scale-y-100 pointer-events-auto'
|
||||
: 'opacity-0 scale-y-95 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{item.children!.map((child, j) => (
|
||||
<Link
|
||||
key={j}
|
||||
href={child.url}
|
||||
onClick={() => setOpenDropdown(null)}
|
||||
className="block px-5 py-2.5 font-joey text-fd-navy hover:bg-fd-yellow/20 transition-colors text-sm"
|
||||
>
|
||||
{child.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={i}
|
||||
href={item.url || '#'}
|
||||
className="font-joey-bold text-fd-navy hover:text-fd-yellow transition-colors text-base"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
<Link href="/search" className="text-fd-navy hover:text-fd-yellow transition-colors">
|
||||
<span className="sr-only">Sök</span>
|
||||
<SearchIcon className="w-5" />
|
||||
</Link>
|
||||
|
||||
<SwedishFlag />
|
||||
</nav>
|
||||
|
||||
{/* ── Mega menu panel ───────────────────────────────────────────────── */}
|
||||
{activeItem?.megaMenu && activeItem.children?.length && (
|
||||
<MegaMenuPanel
|
||||
item={activeItem}
|
||||
headerRef={headerRef}
|
||||
panelRef={megaMenuRef}
|
||||
onClose={() => setOpenDropdown(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Mobile hamburger ─────────────────────────────────────────────── */}
|
||||
<button
|
||||
className="md:hidden text-fd-navy p-2"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label={mobileOpen ? 'Stäng meny' : 'Öppna meny'}
|
||||
>
|
||||
{mobileOpen ? <XIcon className="w-6 h-6" /> : <MenuIcon className="w-6 h-6" />}
|
||||
</button>
|
||||
|
||||
{/* ── Mobile full-screen overlay ───────────────────────────────────── */}
|
||||
<div
|
||||
className={`fixed inset-0 z-50 bg-fd-navy flex flex-col transition-transform duration-300 md:hidden ${
|
||||
mobileOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Navigeringsmeny"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/10">
|
||||
<Link href="/" onClick={closeMobile}>
|
||||
<span className="font-joey-heavy text-fd-yellow text-xl">Fiber Direkt</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={closeMobile}
|
||||
className="text-white p-2 hover:text-fd-yellow transition-colors"
|
||||
aria-label="Stäng meny"
|
||||
>
|
||||
<XIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-6 py-6 flex flex-col">
|
||||
{navItems.map((item, i) => {
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isExpanded = mobileOpenSection === item.label
|
||||
|
||||
return hasChildren ? (
|
||||
<div key={i} className="border-b border-white/10">
|
||||
<button
|
||||
onClick={() => setMobileOpenSection(isExpanded ? null : item.label ?? null)}
|
||||
className="w-full flex justify-between items-center py-4 text-white font-joey-medium text-xl hover:text-fd-yellow transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
<ChevronRightIcon
|
||||
className={`w-5 h-5 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ${
|
||||
isExpanded ? 'max-h-[500px] pb-3' : 'max-h-0'
|
||||
}`}
|
||||
>
|
||||
{item.children!.map((child, j) => (
|
||||
<Link
|
||||
key={j}
|
||||
href={child.url}
|
||||
onClick={closeMobile}
|
||||
className="block pl-4 py-3 text-fd-yellow font-joey text-lg border-b border-white/5 hover:text-white transition-colors"
|
||||
>
|
||||
{child.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={i}
|
||||
href={item.url || '#'}
|
||||
onClick={closeMobile}
|
||||
className="py-4 text-white font-joey-medium text-xl border-b border-white/10 hover:text-fd-yellow transition-colors block"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
<Link
|
||||
href="/search"
|
||||
onClick={closeMobile}
|
||||
className="py-4 text-white font-joey-medium text-xl border-b border-white/10 hover:text-fd-yellow transition-colors flex items-center gap-3"
|
||||
>
|
||||
<SearchIcon className="w-5 h-5" />
|
||||
Sök
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,8 +5,8 @@ import { RowLabelProps, useRowLabel } from '@payloadcms/ui'
|
||||
export const RowLabel: React.FC<RowLabelProps> = () => {
|
||||
const data = useRowLabel<NonNullable<Header['navItems']>[number]>()
|
||||
|
||||
const label = data?.data?.link?.label
|
||||
? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}`
|
||||
const label = data?.data?.label
|
||||
? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.label}`
|
||||
: 'Row'
|
||||
|
||||
return <div>{label}</div>
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { link } from '@/fields/link'
|
||||
import { revalidateHeader } from './hooks/revalidateHeader'
|
||||
|
||||
export const Header: GlobalConfig = {
|
||||
@ -12,18 +10,70 @@ export const Header: GlobalConfig = {
|
||||
{
|
||||
name: 'navItems',
|
||||
type: 'array',
|
||||
fields: [
|
||||
link({
|
||||
appearances: false,
|
||||
}),
|
||||
],
|
||||
maxRows: 6,
|
||||
maxRows: 8,
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
components: {
|
||||
RowLabel: '@/Header/RowLabel#RowLabel',
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Label',
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
type: 'text',
|
||||
label: 'URL (leave empty if this item has a submenu)',
|
||||
},
|
||||
{
|
||||
name: 'megaMenu',
|
||||
type: 'checkbox',
|
||||
label: 'Mega menu (full-width panel)',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description:
|
||||
'When checked, hovering/clicking this item opens a full-width mega menu instead of a small dropdown card.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'children',
|
||||
type: 'array',
|
||||
label: 'Submenu links',
|
||||
maxRows: 16,
|
||||
admin: {
|
||||
description:
|
||||
'Add submenu links. If these exist, the parent URL is ignored. Use "Group" to create columns in the mega menu.',
|
||||
initCollapsed: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Label',
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'URL',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'text',
|
||||
label: 'Group (optional)',
|
||||
admin: {
|
||||
description:
|
||||
'Links with the same group name appear together in a separate column in the mega menu. Leave empty for the main column.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
|
||||
@ -1,13 +1,7 @@
|
||||
import type { GlobalAfterChangeHook } from 'payload'
|
||||
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
export const revalidateHeader: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
payload.logger.info(`Revalidating header`)
|
||||
|
||||
revalidateTag('global_header')
|
||||
}
|
||||
|
||||
return doc
|
||||
export const revalidateHeader: GlobalAfterChangeHook = ({ req: { payload } }) => {
|
||||
payload.logger.info({ msg: 'Revalidating header' })
|
||||
;(revalidateTag as any)('global_header')
|
||||
}
|
||||
|
||||
@ -1,41 +1,18 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { PayloadRedirects } from '@/components/PayloadRedirects'
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload, type RequiredDataFromCollectionSlug } from 'payload'
|
||||
import { draftMode } from 'next/headers'
|
||||
import React, { cache } from 'react'
|
||||
import { homeStatic } from '@/endpoints/seed/home-static'
|
||||
|
||||
import { RenderBlocks } from '@/blocks/RenderBlocks'
|
||||
import { RenderHero } from '@/heros/RenderHero'
|
||||
import { generateMeta } from '@/utilities/generateMeta'
|
||||
import PageClient from './page.client'
|
||||
import { LivePreviewListener } from '@/components/LivePreviewListener'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const dynamicParams = true
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const pages = await payload.find({
|
||||
collection: 'pages',
|
||||
draft: false,
|
||||
limit: 1000,
|
||||
overrideAccess: false,
|
||||
pagination: false,
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
})
|
||||
|
||||
const params = pages.docs
|
||||
?.filter((doc) => {
|
||||
return doc.slug !== 'home'
|
||||
})
|
||||
.map(({ slug }) => {
|
||||
return { slug }
|
||||
})
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
@ -49,6 +26,7 @@ export default async function Page({ params: paramsPromise }: Args) {
|
||||
// Decode to support slugs with special characters
|
||||
const decodedSlug = decodeURIComponent(slug)
|
||||
const url = '/' + decodedSlug
|
||||
|
||||
let page: RequiredDataFromCollectionSlug<'pages'> | null
|
||||
|
||||
page = await queryPageBySlug({
|
||||
@ -67,13 +45,11 @@ export default async function Page({ params: paramsPromise }: Args) {
|
||||
const { hero, layout } = page
|
||||
|
||||
return (
|
||||
<article className="pt-16 pb-24">
|
||||
<article>
|
||||
<PageClient />
|
||||
{/* Allows redirects for valid pages too */}
|
||||
<PayloadRedirects disableNotFound url={url} />
|
||||
|
||||
{draft && <LivePreviewListener />}
|
||||
|
||||
<RenderHero {...hero} />
|
||||
<RenderBlocks blocks={layout} />
|
||||
</article>
|
||||
@ -87,13 +63,11 @@ export async function generateMetadata({ params: paramsPromise }: Args): Promise
|
||||
const page = await queryPageBySlug({
|
||||
slug: decodedSlug,
|
||||
})
|
||||
|
||||
return generateMeta({ doc: page })
|
||||
}
|
||||
|
||||
const queryPageBySlug = cache(async ({ slug }: { slug: string }) => {
|
||||
const { isEnabled: draft } = await draftMode()
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const result = await payload.find({
|
||||
|
||||
280
src/app/(frontend)/globals-OLD.css
Normal file
280
src/app/(frontend)/globals-OLD.css
Normal file
@ -0,0 +1,280 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@config '../../../tailwind.config.mjs';
|
||||
|
||||
@custom-variant dark (&:is([data-theme='dark'] *));
|
||||
@custom-variant sm (@media (width >= theme(--breakpoint-sm)));
|
||||
@custom-variant md (@media (width >= theme(--breakpoint-md)));
|
||||
@custom-variant lg (@media (width >= theme(--breakpoint-lg)));
|
||||
@custom-variant xl (@media (width >= theme(--breakpoint-xl)));
|
||||
@custom-variant 2xl (@media (width >= theme(--breakpoint-2xl)));
|
||||
|
||||
@layer base {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: unset;
|
||||
font-size: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@source inline("lg:col-span-4");
|
||||
@source inline("lg:col-span-6");
|
||||
@source inline("lg:col-span-8");
|
||||
@source inline("lg:col-span-12");
|
||||
@source inline("border-border");
|
||||
@source inline("bg-card");
|
||||
@source inline("border-error");
|
||||
@source inline("bg-error/30");
|
||||
@source inline("border-success");
|
||||
@source inline("bg-success/30");
|
||||
@source inline("border-warning");
|
||||
@source inline("bg-warning/30");
|
||||
|
||||
@theme {
|
||||
--breakpoint-sm: 40rem;
|
||||
--breakpoint-md: 48rem;
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
--breakpoint-2xl: 86rem;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.container {
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
@variant sm {
|
||||
.container {
|
||||
max-width: var(--breakpoint-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@variant md {
|
||||
.container {
|
||||
max-width: var(--breakpoint-md);
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@variant lg {
|
||||
.container {
|
||||
max-width: var(--breakpoint-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@variant xl {
|
||||
.container {
|
||||
max-width: var(--breakpoint-xl);
|
||||
}
|
||||
}
|
||||
|
||||
@variant 2xl {
|
||||
.container {
|
||||
max-width: var(--breakpoint-2xl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(100% 0 0deg);
|
||||
--foreground: oklch(14.5% 0 0deg);
|
||||
--card: oklch(96.5% 0.005 265deg);
|
||||
--card-foreground: oklch(14.5% 0 0deg);
|
||||
--popover: oklch(100% 0 0deg);
|
||||
--popover-foreground: oklch(14.5% 0 0deg);
|
||||
--primary: oklch(20.5% 0 0deg);
|
||||
--primary-foreground: oklch(98.5% 0 0deg);
|
||||
--secondary: oklch(97% 0 0deg);
|
||||
--secondary-foreground: oklch(20.5% 0 0deg);
|
||||
--muted: oklch(97% 0 0deg);
|
||||
--muted-foreground: oklch(55.6% 0 0deg);
|
||||
--accent: oklch(97% 0 0deg);
|
||||
--accent-foreground: oklch(20.5% 0 0deg);
|
||||
--destructive: oklch(57.7% 0.245 27.325deg);
|
||||
--destructive-foreground: oklch(57.7% 0.245 27.325deg);
|
||||
--border: oklch(92.2% 0 0deg);
|
||||
--input: oklch(92.2% 0 0deg);
|
||||
--ring: oklch(70.8% 0 0deg);
|
||||
--chart-1: oklch(64.6% 0.222 41.116deg);
|
||||
--chart-2: oklch(60% 0.118 184.704deg);
|
||||
--chart-3: oklch(39.8% 0.07 227.392deg);
|
||||
--chart-4: oklch(82.8% 0.189 84.429deg);
|
||||
--chart-5: oklch(76.9% 0.188 70.08deg);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(98.5% 0 0deg);
|
||||
--sidebar-foreground: oklch(14.5% 0 0deg);
|
||||
--sidebar-primary: oklch(20.5% 0 0deg);
|
||||
--sidebar-primary-foreground: oklch(98.5% 0 0deg);
|
||||
--sidebar-accent: oklch(97% 0 0deg);
|
||||
--sidebar-accent-foreground: oklch(20.5% 0 0deg);
|
||||
--sidebar-border: oklch(92.2% 0 0deg);
|
||||
--sidebar-ring: oklch(70.8% 0 0deg);
|
||||
--success: oklch(78% 0.08 200deg);
|
||||
--warning: oklch(89% 0.1 75deg);
|
||||
--error: oklch(75% 0.15 25deg);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--background: oklch(14.5% 0 0deg);
|
||||
--foreground: oklch(98.5% 0 0deg);
|
||||
--card: oklch(17% 0 0deg);
|
||||
--card-foreground: oklch(98.5% 0 0deg);
|
||||
--popover: oklch(14.5% 0 0deg);
|
||||
--popover-foreground: oklch(98.5% 0 0deg);
|
||||
--primary: oklch(98.5% 0 0deg);
|
||||
--primary-foreground: oklch(20.5% 0 0deg);
|
||||
--secondary: oklch(26.9% 0 0deg);
|
||||
--secondary-foreground: oklch(98.5% 0 0deg);
|
||||
--muted: oklch(26.9% 0 0deg);
|
||||
--muted-foreground: oklch(70.8% 0 0deg);
|
||||
--accent: oklch(26.9% 0 0deg);
|
||||
--accent-foreground: oklch(98.5% 0 0deg);
|
||||
--destructive: oklch(39.6% 0.141 25.723deg);
|
||||
--destructive-foreground: oklch(63.7% 0.237 25.331deg);
|
||||
--border: oklch(26.9% 0 0deg);
|
||||
--input: oklch(26.9% 0 0deg);
|
||||
--ring: oklch(43.9% 0 0deg);
|
||||
--chart-1: oklch(48.8% 0.243 264.376deg);
|
||||
--chart-2: oklch(69.6% 0.17 162.48deg);
|
||||
--chart-3: oklch(76.9% 0.188 70.08deg);
|
||||
--chart-4: oklch(62.7% 0.265 303.9deg);
|
||||
--chart-5: oklch(64.5% 0.246 16.439deg);
|
||||
--sidebar: oklch(20.5% 0 0deg);
|
||||
--sidebar-foreground: oklch(98.5% 0 0deg);
|
||||
--sidebar-primary: oklch(48.8% 0.243 264.376deg);
|
||||
--sidebar-primary-foreground: oklch(98.5% 0 0deg);
|
||||
--sidebar-accent: oklch(26.9% 0 0deg);
|
||||
--sidebar-accent-foreground: oklch(98.5% 0 0deg);
|
||||
--sidebar-border: oklch(26.9% 0 0deg);
|
||||
--sidebar-ring: oklch(43.9% 0 0deg);
|
||||
--success: oklch(28% 0.1 200deg);
|
||||
--warning: oklch(35% 0.08 70deg);
|
||||
--error: oklch(45% 0.1 25deg);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-error: var(--error);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground min-h-[100vh] flex flex-col;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html[data-theme='dark'],
|
||||
html[data-theme='light'] {
|
||||
opacity: initial;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FIBER DIREKT ADDITIONS
|
||||
Append this entire block to the END of:
|
||||
src/app/(frontend)/globals.css
|
||||
============================================ */
|
||||
|
||||
/* FS Joey Font Faces — place .otf files in public/fonts/ */
|
||||
|
||||
@font-face {
|
||||
font-family: 'FS Joey';
|
||||
src: url('/fonts/fs-joey-regular.otf') format('opentype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'FS Joey Medium';
|
||||
src: url('/fonts/fs-joey-medium.otf') format('opentype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'FS Joey Bold';
|
||||
src: url('/fonts/fs-joey-bold.otf') format('opentype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'FS Joey Heavy';
|
||||
src: url('/fonts/fs-joey-heavy.otf') format('opentype');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Fiber Direkt design tokens */
|
||||
@theme {
|
||||
--color-fd-navy: #0e2338;
|
||||
--color-fd-navy-light: #0f2339;
|
||||
--color-fd-yellow: #fecc02;
|
||||
--color-fd-gray: #f3f3f3;
|
||||
--color-fd-gray-light: #f7f7f7;
|
||||
|
||||
--font-joey: 'FS Joey', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-joey-medium: 'FS Joey Medium', 'FS Joey', system-ui, sans-serif;
|
||||
--font-joey-bold: 'FS Joey Bold', 'FS Joey', system-ui, sans-serif;
|
||||
--font-joey-heavy: 'FS Joey Heavy', 'FS Joey', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@ -86,6 +86,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PAYLOAD CMS / SHADCN UI TOKENS
|
||||
(do not modify — used by admin panel)
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
--background: oklch(100% 0 0deg);
|
||||
--foreground: oklch(14.5% 0 0deg);
|
||||
@ -211,6 +216,7 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground min-h-[100vh] flex flex-col;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -222,3 +228,455 @@ html[data-theme='dark'],
|
||||
html[data-theme='light'] {
|
||||
opacity: initial;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
FIBER DIREKT DESIGN SYSTEM
|
||||
============================================ */
|
||||
|
||||
/* ---- Font Faces ---- */
|
||||
|
||||
@font-face {
|
||||
font-family: 'FS Joey';
|
||||
src: url('/fonts/fs-joey-regular.otf') format('opentype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'FS Joey Medium';
|
||||
src: url('/fonts/fs-joey-medium.otf') format('opentype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'FS Joey Bold';
|
||||
src: url('/fonts/fs-joey-bold.otf') format('opentype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'FS Joey Heavy';
|
||||
src: url('/fonts/fs-joey-heavy.otf') format('opentype');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ---- Theme Tokens ---- */
|
||||
/* This single @theme block registers ALL Fiber Direkt design tokens.
|
||||
Tailwind v4 auto-generates utility classes from these:
|
||||
--color-fd-navy → bg-fd-navy, text-fd-navy, border-fd-navy, etc.
|
||||
--font-joey → font-joey
|
||||
--text-fd-* → text-fd-display, text-fd-h1, etc. */
|
||||
|
||||
@theme {
|
||||
/* ---- Fonts ---- */
|
||||
--font-joey: 'FS Joey', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-joey-medium: 'FS Joey Medium', 'FS Joey', system-ui, sans-serif;
|
||||
--font-joey-bold: 'FS Joey Bold', 'FS Joey', system-ui, sans-serif;
|
||||
--font-joey-heavy: 'FS Joey Heavy', 'FS Joey', system-ui, sans-serif;
|
||||
|
||||
/* =============================
|
||||
COLORS — Full Brand Palette
|
||||
============================= */
|
||||
|
||||
/* Navy scale */
|
||||
--color-fd-navy-950: #060D14;
|
||||
--color-fd-navy-900: #0A1825;
|
||||
--color-fd-navy: #0E2338; /* primary — bg-fd-navy, text-fd-navy */
|
||||
--color-fd-navy-700: #153350;
|
||||
--color-fd-navy-600: #1C4368;
|
||||
--color-fd-navy-500: #245580;
|
||||
--color-fd-navy-400: #3D7199;
|
||||
--color-fd-navy-300: #6A94B3;
|
||||
--color-fd-navy-200: #9DB8CC;
|
||||
--color-fd-navy-100: #CFDBE6;
|
||||
--color-fd-navy-50: #E7EDF2;
|
||||
|
||||
/* Yellow scale */
|
||||
--color-fd-yellow-950: #3D3000;
|
||||
--color-fd-yellow-900: #5C4800;
|
||||
--color-fd-yellow-800: #7A6000;
|
||||
--color-fd-yellow-700: #B38C00;
|
||||
--color-fd-yellow-600: #D4A600;
|
||||
--color-fd-yellow: #FECC02; /* primary — bg-fd-yellow, text-fd-yellow */
|
||||
--color-fd-yellow-400: #FED835;
|
||||
--color-fd-yellow-300: #FEE468;
|
||||
--color-fd-yellow-200: #FFEF9B;
|
||||
--color-fd-yellow-100: #FFF7CD;
|
||||
--color-fd-yellow-50: #FFFBE6;
|
||||
|
||||
/* Mint scale */
|
||||
--color-fd-mint-950: #052E14;
|
||||
--color-fd-mint-900: #0A4D22;
|
||||
--color-fd-mint-800: #116B32;
|
||||
--color-fd-mint-700: #1A8A44;
|
||||
--color-fd-mint-600: #25A957;
|
||||
--color-fd-mint-500: #3EC76E;
|
||||
--color-fd-mint: #67EF8B; /* primary — bg-fd-mint, text-fd-mint */
|
||||
--color-fd-mint-300: #8DF4A8;
|
||||
--color-fd-mint-200: #B3F8C5;
|
||||
--color-fd-mint-100: #D9FCE2;
|
||||
--color-fd-mint-50: #ECFDF0;
|
||||
|
||||
/* Neutral grays */
|
||||
--color-fd-gray: #F0F0F0; /* section alt backgrounds */
|
||||
--color-fd-gray-warm: #F8F8F6;
|
||||
--color-fd-gray-cool: #F5F7F9;
|
||||
--color-fd-gray-200: #C8CCD0;
|
||||
--color-fd-gray-300: #A9AEB5;
|
||||
--color-fd-gray-400: #8A919A;
|
||||
--color-fd-gray-500: #6B737E;
|
||||
--color-fd-gray-600: #41474F;
|
||||
--color-fd-gray-700: #2D3239;
|
||||
--color-fd-gray-800: #1A1D21;
|
||||
|
||||
/* Semantic surfaces (for block backgrounds) */
|
||||
--color-fd-surface: #FFFFFF;
|
||||
--color-fd-surface-alt: #F0F0F0;
|
||||
--color-fd-surface-navy: #0E2338;
|
||||
--color-fd-surface-navy-light: #153350;
|
||||
--color-fd-surface-yellow: #FFFBE6;
|
||||
--color-fd-surface-mint: #ECFDF0;
|
||||
|
||||
/* Semantic text (for auto-resolving text on any bg) */
|
||||
--color-fd-text: #0E2338;
|
||||
--color-fd-text-secondary: #41474F;
|
||||
--color-fd-text-muted: #6B737E;
|
||||
--color-fd-text-disabled: #8A919A;
|
||||
--color-fd-text-on-dark: #FFFFFF;
|
||||
--color-fd-text-on-dark-muted: #9DB8CC;
|
||||
--color-fd-text-accent: #FECC02;
|
||||
|
||||
/* =============================
|
||||
TYPOGRAPHY — 9-Level Scale
|
||||
=============================
|
||||
Uses clamp() for fluid scaling: no breakpoint prefixes needed.
|
||||
min = mobile, preferred = viewport-relative, max = desktop.
|
||||
Usage: text-fd-display, text-fd-h1, text-fd-body, etc. */
|
||||
|
||||
/* Level 1 — Display / Hero H1 */
|
||||
--text-fd-display: clamp(2.25rem, 5vw + 0.5rem, 4.875rem); /* 36px → 78px */
|
||||
--text-fd-display--line-height: 1.05;
|
||||
--text-fd-display--letter-spacing: -0.02em;
|
||||
|
||||
/* Level 2 — Section H1 */
|
||||
--text-fd-h1: clamp(1.75rem, 3.5vw + 0.25rem, 3.125rem); /* 28px → 50px */
|
||||
--text-fd-h1--line-height: 1.15;
|
||||
--text-fd-h1--letter-spacing: -0.01em;
|
||||
|
||||
/* Level 3 — Block titles / H2 */
|
||||
--text-fd-h2: clamp(1.375rem, 2.5vw + 0.25rem, 2.25rem); /* 22px → 36px */
|
||||
--text-fd-h2--line-height: 1.2;
|
||||
|
||||
/* Level 4 — Card titles / H3 */
|
||||
--text-fd-h3: clamp(1.125rem, 2vw + 0.125rem, 1.75rem); /* 18px → 28px */
|
||||
--text-fd-h3--line-height: 1.3;
|
||||
|
||||
/* Level 5 — Subheadings / H4 */
|
||||
--text-fd-h4: clamp(1rem, 1.5vw + 0.125rem, 1.375rem); /* 16px → 22px */
|
||||
--text-fd-h4--line-height: 1.4;
|
||||
|
||||
/* Level 6 — Lead / large body */
|
||||
--text-fd-body-lg: clamp(0.9375rem, 1.2vw + 0.125rem, 1.25rem); /* 15px → 20px */
|
||||
--text-fd-body-lg--line-height: 1.6;
|
||||
|
||||
/* Level 7 — Body text */
|
||||
--text-fd-body: clamp(0.875rem, 1vw + 0.125rem, 1.0625rem); /* 14px → 17px */
|
||||
--text-fd-body--line-height: 1.7;
|
||||
|
||||
/* Level 8 — Small / captions */
|
||||
--text-fd-small: clamp(0.8125rem, 0.9vw + 0.0625rem, 0.9375rem); /* 13px → 15px */
|
||||
--text-fd-small--line-height: 1.6;
|
||||
|
||||
/* Level 9 — Extra small / fine print */
|
||||
--text-fd-xs: clamp(0.6875rem, 0.75vw + 0.0625rem, 0.8125rem); /* 11px → 13px */
|
||||
--text-fd-xs--line-height: 1.5;
|
||||
|
||||
/* Button text (matches h4 range but with tighter line height) */
|
||||
--text-fd-btn: clamp(1rem, 1.5vw + 0.125rem, 1.375rem);
|
||||
--text-fd-btn--line-height: 1.2;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
FIBER DIREKT — COMPONENT UTILITY CLASSES
|
||||
============================================ */
|
||||
|
||||
@layer components {
|
||||
|
||||
/* ---- CTA Buttons ----
|
||||
Usage: <a class="fd-btn-primary">Kom igång</a>
|
||||
<a class="fd-btn-secondary">Läs mer</a>
|
||||
<a class="fd-btn-secondary-dark">Kontakta oss</a> (on navy backgrounds)
|
||||
*/
|
||||
|
||||
.fd-btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 2rem;
|
||||
background-color: var(--color-fd-yellow);
|
||||
color: var(--color-fd-navy);
|
||||
font-family: var(--font-joey-bold);
|
||||
font-size: var(--text-fd-btn);
|
||||
line-height: var(--text-fd-btn--line-height);
|
||||
border-radius: 9999px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.fd-btn-primary:hover {
|
||||
background-color: var(--color-fd-navy);
|
||||
color: var(--color-fd-yellow);
|
||||
box-shadow: 0 8px 24px -4px rgba(14, 35, 56, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.fd-btn-primary:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 12px -2px rgba(254, 204, 2, 0.25);
|
||||
}
|
||||
|
||||
.fd-btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 2rem;
|
||||
background-color: transparent;
|
||||
color: var(--color-fd-navy);
|
||||
font-family: var(--font-joey-bold);
|
||||
font-size: var(--text-fd-btn);
|
||||
line-height: var(--text-fd-btn--line-height);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid var(--color-fd-navy);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.fd-btn-secondary:hover {
|
||||
background-color: var(--color-fd-navy);
|
||||
color: #FFFFFF;
|
||||
box-shadow: 0 8px 24px -4px rgba(14, 35, 56, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.fd-btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 12px -2px rgba(14, 35, 56, 0.2);
|
||||
}
|
||||
|
||||
.fd-btn-secondary-dark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 2rem;
|
||||
background-color: transparent;
|
||||
color: #FFFFFF;
|
||||
font-family: var(--font-joey-bold);
|
||||
font-size: var(--text-fd-btn);
|
||||
line-height: var(--text-fd-btn--line-height);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid #FFFFFF;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.fd-btn-secondary-dark:hover {
|
||||
background-color: #FFFFFF;
|
||||
color: var(--color-fd-navy);
|
||||
box-shadow: 0 8px 24px -4px rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.fd-btn-secondary-dark:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 12px -2px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Navy-filled CTA */
|
||||
.fd-btn-navy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 2rem;
|
||||
background-color: var(--color-fd-navy);
|
||||
color: #FFFFFF;
|
||||
font-family: var(--font-joey-bold);
|
||||
font-size: var(--text-fd-btn);
|
||||
line-height: var(--text-fd-btn--line-height);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.fd-btn-navy:hover {
|
||||
background-color: var(--color-fd-navy-700);
|
||||
box-shadow: 0 8px 24px -4px rgba(14, 35, 56, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.fd-btn-navy:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 12px -2px rgba(14, 35, 56, 0.3);
|
||||
}
|
||||
|
||||
/* Mint CTA (for success/eco contexts) */
|
||||
.fd-btn-mint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 2rem;
|
||||
background-color: var(--color-fd-mint);
|
||||
color: var(--color-fd-navy);
|
||||
font-family: var(--font-joey-bold);
|
||||
font-size: var(--text-fd-btn);
|
||||
line-height: var(--text-fd-btn--line-height);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.fd-btn-mint:hover {
|
||||
background-color: var(--color-fd-mint-300);
|
||||
box-shadow: 0 8px 24px -4px rgba(103, 239, 139, 0.35);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.fd-btn-mint:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 12px -2px rgba(103, 239, 139, 0.25);
|
||||
}
|
||||
|
||||
|
||||
/* ---- Standard Section Wrapper ----
|
||||
Usage: <section class="fd-section">
|
||||
<section class="fd-section fd-section-tight"> (less vertical padding)
|
||||
*/
|
||||
|
||||
.fd-section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 3rem; /* 48px mobile */
|
||||
padding-bottom: 3rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (min-width: 48rem) {
|
||||
.fd-section {
|
||||
padding-top: 4rem; /* 64px tablet */
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
.fd-section {
|
||||
padding-top: 6rem; /* 96px desktop */
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-section-tight {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
@media (min-width: 48rem) {
|
||||
.fd-section-tight {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
.fd-section-tight {
|
||||
padding-top: 4rem;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Standard Content Container ----
|
||||
Usage: <div class="fd-container">
|
||||
*/
|
||||
.fd-container {
|
||||
position: relative;
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.5rem; /* 24px mobile */
|
||||
}
|
||||
@media (min-width: 48rem) {
|
||||
.fd-container {
|
||||
padding-inline: 2rem; /* 32px tablet+ */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
FIBER DIREKT — MOBILE ENHANCEMENTS
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 47.9375rem) {
|
||||
/* Full-width buttons on mobile for clear tap targets */
|
||||
.fd-btn-primary,
|
||||
.fd-btn-secondary,
|
||||
.fd-btn-secondary-dark,
|
||||
.fd-btn-navy,
|
||||
.fd-btn-mint {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding-top: 0.875rem;
|
||||
padding-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
/* Ensure minimum touch target */
|
||||
a, button {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
FIBER DIREKT — FRONTEND DARK MODE
|
||||
============================================
|
||||
These are for the public site, not Payload admin.
|
||||
Activated by adding data-fd-theme="dark" to <html>.
|
||||
The existing [data-theme='dark'] above is for Payload's admin UI.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Frontend surface tokens (light defaults) */
|
||||
--fd-bg-primary: #FFFFFF;
|
||||
--fd-bg-secondary: #F0F0F0;
|
||||
--fd-bg-card: #FFFFFF;
|
||||
--fd-text-primary: #0E2338;
|
||||
--fd-text-secondary: #41474F;
|
||||
--fd-text-muted: #6B737E;
|
||||
--fd-border: #E2E4E7;
|
||||
--fd-border-strong: #C8CCD0;
|
||||
}
|
||||
|
||||
[data-fd-theme='dark'] {
|
||||
--fd-bg-primary: #0A1825;
|
||||
--fd-bg-secondary: #0E2338;
|
||||
--fd-bg-card: #153350;
|
||||
--fd-text-primary: #FFFFFF;
|
||||
--fd-text-secondary: #9DB8CC;
|
||||
--fd-text-muted: #6A94B3;
|
||||
--fd-border: #1C4368;
|
||||
--fd-border-strong: #245580;
|
||||
}
|
||||
|
||||
/* Also support system preference if no explicit attribute */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-fd-theme='light']) {
|
||||
--fd-bg-primary: #0A1825;
|
||||
--fd-bg-secondary: #0E2338;
|
||||
--fd-bg-card: #153350;
|
||||
--fd-text-primary: #FFFFFF;
|
||||
--fd-text-secondary: #9DB8CC;
|
||||
--fd-text-muted: #6A94B3;
|
||||
--fd-border: #1C4368;
|
||||
--fd-border-strong: #245580;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { AnnouncementBarComponent } from '@/components/AnnouncementBar/AnnouncementBar'
|
||||
import { cn } from '@/utilities/ui'
|
||||
import { GeistMono } from 'geist/font/mono'
|
||||
import { GeistSans } from 'geist/font/sans'
|
||||
@ -12,31 +15,77 @@ import { Providers } from '@/providers'
|
||||
import { InitTheme } from '@/providers/Theme/InitTheme'
|
||||
import { mergeOpenGraph } from '@/utilities/mergeOpenGraph'
|
||||
import { draftMode } from 'next/headers'
|
||||
import { getCachedGlobal } from '@/utilities/getGlobals'
|
||||
|
||||
import './globals.css'
|
||||
import { getServerSideURL } from '@/utilities/getURL'
|
||||
import { PopupAnnouncementComponent } from '@/globals/PopupAnnouncement/Component'
|
||||
import { HeadInjection, FooterInjection } from '@/components/HeadInjection'
|
||||
import { CookieConsent } from '@/components/CookieConsent'
|
||||
import { MatomoScript } from '@/components/MatomoScript'
|
||||
|
||||
async function AnnouncementBarWrapper() {
|
||||
const payload = await getPayload({ config })
|
||||
const announcement = await payload.findGlobal({ slug: 'announcement-bar' })
|
||||
|
||||
if (!announcement?.enabled) return null
|
||||
|
||||
return (
|
||||
<AnnouncementBarComponent
|
||||
text={announcement.text}
|
||||
buttonLabel={announcement.buttonLabel}
|
||||
buttonUrl={announcement.buttonUrl}
|
||||
dismissible={announcement.dismissible}
|
||||
backgroundColor={announcement.backgroundColor as any}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const { isEnabled } = await draftMode()
|
||||
|
||||
const popupData = await getCachedGlobal('popup-announcement', 1)() as any
|
||||
|
||||
let privacyPolicyUrl = '/integritetspolicy'
|
||||
let matomoEnabled = false
|
||||
let matomoCode = ''
|
||||
|
||||
try {
|
||||
const siteSettings = await getCachedGlobal('site-settings', 0)() as any
|
||||
if (siteSettings?.cookieConsent?.privacyPolicyUrl) {
|
||||
privacyPolicyUrl = siteSettings.cookieConsent.privacyPolicyUrl
|
||||
}
|
||||
if (siteSettings?.matomo?.enabled && siteSettings?.matomo?.code) {
|
||||
matomoEnabled = true
|
||||
matomoCode = siteSettings.matomo.code
|
||||
}
|
||||
} catch {
|
||||
// use defaults
|
||||
}
|
||||
|
||||
return (
|
||||
<html className={cn(GeistSans.variable, GeistMono.variable)} lang="en" suppressHydrationWarning>
|
||||
<html className={cn(GeistSans.variable, GeistMono.variable)} lang="sv" suppressHydrationWarning style={{colorScheme: "light"}}>
|
||||
<head>
|
||||
<InitTheme />
|
||||
<link href="/favicon.ico" rel="icon" sizes="32x32" />
|
||||
<link href="/favicon.svg" rel="icon" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<HeadInjection />
|
||||
</head>
|
||||
<body>
|
||||
<Providers>
|
||||
<AdminBar
|
||||
adminBarProps={{
|
||||
preview: isEnabled,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AdminBar adminBarProps={{ preview: isEnabled }} />
|
||||
<AnnouncementBarWrapper />
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
<PopupAnnouncementComponent {...popupData} />
|
||||
<CookieConsent privacyPolicyUrl={privacyPolicyUrl} />
|
||||
{matomoEnabled && (
|
||||
<MatomoScript code={matomoCode} />
|
||||
)}
|
||||
<FooterInjection />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const dynamicParams = true
|
||||
|
||||
import PageTemplate, { generateMetadata } from './[slug]/page'
|
||||
|
||||
export default PageTemplate
|
||||
|
||||
export { generateMetadata }
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { RelatedPosts } from '@/blocks/RelatedPosts/Component'
|
||||
@ -14,27 +15,6 @@ import { PostHero } from '@/heros/PostHero'
|
||||
import { generateMeta } from '@/utilities/generateMeta'
|
||||
import PageClient from './page.client'
|
||||
import { LivePreviewListener } from '@/components/LivePreviewListener'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
draft: false,
|
||||
limit: 1000,
|
||||
overrideAccess: false,
|
||||
pagination: false,
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
})
|
||||
|
||||
const params = posts.docs.map(({ slug }) => {
|
||||
return { slug }
|
||||
})
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
slug?: string
|
||||
|
||||
@ -7,9 +7,9 @@ import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
import PageClient from './page.client'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
export const revalidate = 600
|
||||
|
||||
export default async function Page() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { Metadata } from 'next/types'
|
||||
|
||||
import { CollectionArchive } from '@/components/CollectionArchive'
|
||||
import { PageRange } from '@/components/PageRange'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
@ -9,6 +8,7 @@ import React from 'react'
|
||||
import PageClient from './page.client'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 600
|
||||
|
||||
type Args = {
|
||||
@ -20,9 +20,7 @@ type Args = {
|
||||
export default async function Page({ params: paramsPromise }: Args) {
|
||||
const { pageNumber } = await paramsPromise
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const sanitizedPageNumber = Number(pageNumber)
|
||||
|
||||
if (!Number.isInteger(sanitizedPageNumber)) notFound()
|
||||
|
||||
const posts = await payload.find({
|
||||
@ -41,7 +39,6 @@ export default async function Page({ params: paramsPromise }: Args) {
|
||||
<h1>Posts</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mb-8">
|
||||
<PageRange
|
||||
collection="posts"
|
||||
@ -50,9 +47,7 @@ export default async function Page({ params: paramsPromise }: Args) {
|
||||
totalDocs={posts.totalDocs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CollectionArchive posts={posts.docs} />
|
||||
|
||||
<div className="container">
|
||||
{posts?.page && posts?.totalPages > 1 && (
|
||||
<Pagination page={posts.page} totalPages={posts.totalPages} />
|
||||
@ -65,24 +60,6 @@ export default async function Page({ params: paramsPromise }: Args) {
|
||||
export async function generateMetadata({ params: paramsPromise }: Args): Promise<Metadata> {
|
||||
const { pageNumber } = await paramsPromise
|
||||
return {
|
||||
title: `Payload Website Template Posts Page ${pageNumber || ''}`,
|
||||
title: `Posts Page ${pageNumber || ''}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { totalDocs } = await payload.count({
|
||||
collection: 'posts',
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const totalPages = Math.ceil(totalDocs / 10)
|
||||
|
||||
const pages: { pageNumber: string }[] = []
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push({ pageNumber: String(i) })
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
@ -8,6 +8,9 @@ import { Search } from '@/search/Component'
|
||||
import PageClient from './page.client'
|
||||
import { CardPostData } from '@/components/Card'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const dynamicParams = true
|
||||
|
||||
type Args = {
|
||||
searchParams: Promise<{
|
||||
q: string
|
||||
|
||||
@ -6,6 +6,9 @@ import config from '@payload-config'
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { importMap } from '../importMap'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const dynamicParams = true
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
|
||||
@ -17,63 +17,43 @@ import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 }
|
||||
import { SlugField as SlugField_2b8867833a34864a02ddf429b0728a40 } from '@payloadcms/next/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { FolderTableCell as FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
import { FolderField as FolderField_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||
import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||
import { FolderTypeField as FolderTypeField_2b8867833a34864a02ddf429b0728a40 } from '@payloadcms/next/client'
|
||||
import { RowLabel as RowLabel_ec255a65fa6fa8d1faeb09cf35284224 } from '@/Header/RowLabel'
|
||||
import { RowLabel as RowLabel_1f6ff6ff633e3695d348f4f3c58f1466 } from '@/Footer/RowLabel'
|
||||
import { default as default_c1363893506d7ed29a71d1a4da01ddb5 } from '@/components/AdminIcon'
|
||||
import { default as default_aa0a2d19d38a4839dbd373417e210b89 } from '@/components/AdminLogo'
|
||||
import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/components/BeforeDashboard'
|
||||
import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
export const importMap = {
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient':
|
||||
FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/plugin-seo/client#OverviewComponent':
|
||||
OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaTitleComponent':
|
||||
MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaImageComponent':
|
||||
MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaDescriptionComponent':
|
||||
MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#PreviewComponent':
|
||||
PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/next/client#SlugField': SlugField_2b8867833a34864a02ddf429b0728a40,
|
||||
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/next/rsc#FolderTableCell': FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
'@payloadcms/next/rsc#FolderField': FolderField_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
'@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
'@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
'@payloadcms/next/client#FolderTypeField': FolderTypeField_2b8867833a34864a02ddf429b0728a40,
|
||||
'@/Header/RowLabel#RowLabel': RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
|
||||
'@/Footer/RowLabel#RowLabel': RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
|
||||
'@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6,
|
||||
'@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e,
|
||||
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/next/client#SlugField": SlugField_2b8867833a34864a02ddf429b0728a40,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
"@/Header/RowLabel#RowLabel": RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
|
||||
"@/Footer/RowLabel#RowLabel": RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
|
||||
"@/components/AdminIcon#default": default_c1363893506d7ed29a71d1a4da01ddb5,
|
||||
"@/components/AdminLogo#default": default_aa0a2d19d38a4839dbd373417e210b89,
|
||||
"@/components/BeforeDashboard#default": default_1a7510af427896d367a49dbf838d2de6,
|
||||
"@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import type { Post, ArchiveBlock as ArchiveBlockProps } from '@/payload-types'
|
||||
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react'
|
||||
|
||||
import type { CallToActionBlock as CTABlockProps } from '@/payload-types'
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
70
src/blocks/FDAlternateHeroBlock/Component.tsx
Normal file
70
src/blocks/FDAlternateHeroBlock/Component.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import type { FDAlternateHeroBlock as Props, Media } from '@/payload-types'
|
||||
|
||||
export const FDAlternateHeroBlockComponent: React.FC<Props> = ({
|
||||
title,
|
||||
description,
|
||||
primaryCtaText,
|
||||
primaryCtaLink = '/kontakt',
|
||||
secondaryCtaText,
|
||||
secondaryCtaLink = '#',
|
||||
image,
|
||||
imageCaption,
|
||||
sectionBackground = 'white',
|
||||
}) => {
|
||||
const media = image as Media | undefined
|
||||
const isDark = sectionBackground === 'navy'
|
||||
const bgClass = isDark ? 'bg-fd-navy' : sectionBackground === 'gray' ? 'bg-fd-surface-alt' : 'bg-white'
|
||||
const titleClass = isDark ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
const bodyClass = isDark ? 'text-white' : 'text-fd-navy'
|
||||
|
||||
return (
|
||||
<section className={`w-full pt-16 md:pt-20 lg:pt-[99px] ${bgClass}`}>
|
||||
{/* Centered content */}
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 text-center flex flex-col items-center gap-6 pb-12 md:pb-16">
|
||||
<h1 className={`w-full max-w-[820px] font-joey-heavy text-fd-display ${titleClass}`}>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className={`max-w-[620px] font-joey text-fd-body-lg ${bodyClass}`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{(primaryCtaText || secondaryCtaText) && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-2">
|
||||
{primaryCtaText && (
|
||||
<a href={primaryCtaLink || '#'} className="fd-btn-primary">
|
||||
{primaryCtaText} →
|
||||
</a>
|
||||
)}
|
||||
{secondaryCtaText && (
|
||||
<a
|
||||
href={secondaryCtaLink || '#'}
|
||||
className={isDark ? 'fd-btn-secondary-dark' : 'fd-btn-secondary'}
|
||||
>
|
||||
{secondaryCtaText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full-width image — no container, no border radius */}
|
||||
{media?.url && (
|
||||
<div className="w-full">
|
||||
<img
|
||||
src={media.url}
|
||||
alt={(media as any).alt || title}
|
||||
className="w-full object-cover block"
|
||||
style={{ maxHeight: '620px', objectPosition: 'center top' }}
|
||||
/>
|
||||
{imageCaption && (
|
||||
<div className={`text-center py-3 font-joey text-fd-small opacity-60 ${bodyClass} ${bgClass}`}>
|
||||
{imageCaption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
73
src/blocks/FDAlternateHeroBlock/config.ts
Normal file
73
src/blocks/FDAlternateHeroBlock/config.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDAlternateHeroBlock: Block = {
|
||||
slug: 'fdAlternateHero',
|
||||
interfaceName: 'FDAlternateHeroBlock',
|
||||
labels: {
|
||||
singular: 'FD Alternativ Hero (centrerad)',
|
||||
plural: 'FD Alternativa Heroes',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Rubrik',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Beskrivning (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'primaryCtaText',
|
||||
type: 'text',
|
||||
label: 'Primär CTA-knapp text (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'primaryCtaLink',
|
||||
type: 'text',
|
||||
label: 'Primär CTA-knapp länk',
|
||||
defaultValue: '/kontakt',
|
||||
},
|
||||
{
|
||||
name: 'secondaryCtaText',
|
||||
type: 'text',
|
||||
label: 'Sekundär CTA-knapp text (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'secondaryCtaLink',
|
||||
type: 'text',
|
||||
label: 'Sekundär CTA-knapp länk',
|
||||
defaultValue: '#',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Fullbredd bild (visas under texten)',
|
||||
admin: {
|
||||
description: 'Bred bild utan border-radius — visas som en full showcase under CTA-knapparna.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'imageCaption',
|
||||
type: 'text',
|
||||
label: 'Bildtext (valfri)',
|
||||
admin: {
|
||||
condition: (_, siblingData) => Boolean(siblingData?.image),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
164
src/blocks/FDCardGridBlock/Component.tsx
Normal file
164
src/blocks/FDCardGridBlock/Component.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import React from 'react'
|
||||
import type { FDCardGridBlock as FDCardGridBlockProps } from '@/payload-types'
|
||||
|
||||
const cardStyleMap: Record<
|
||||
string,
|
||||
{ bg: string; headingText: string; bodyText: string; linkText: string; border: string }
|
||||
> = {
|
||||
navy: {
|
||||
bg: 'bg-fd-navy',
|
||||
headingText: 'text-fd-yellow',
|
||||
bodyText: 'text-white',
|
||||
linkText: 'text-fd-yellow hover:text-fd-yellow/80',
|
||||
border: '',
|
||||
},
|
||||
gray: {
|
||||
bg: 'bg-[#e5e5e5]',
|
||||
headingText: 'text-fd-navy',
|
||||
bodyText: 'text-fd-navy',
|
||||
linkText: 'text-fd-navy hover:text-fd-navy/70',
|
||||
border: '',
|
||||
},
|
||||
yellow: {
|
||||
bg: 'bg-fd-yellow',
|
||||
headingText: 'text-fd-navy',
|
||||
bodyText: 'text-fd-navy',
|
||||
linkText: 'text-fd-navy hover:text-fd-navy/70',
|
||||
border: '',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-[#4ADE80]',
|
||||
headingText: 'text-fd-navy',
|
||||
bodyText: 'text-fd-navy',
|
||||
linkText: 'text-fd-navy hover:text-fd-navy/70',
|
||||
border: '',
|
||||
},
|
||||
outlined: {
|
||||
bg: 'bg-white',
|
||||
headingText: 'text-fd-navy',
|
||||
bodyText: 'text-fd-navy',
|
||||
linkText: 'text-fd-navy/70 hover:text-fd-navy',
|
||||
border: 'border-6 border-gray-200 shadow-sm',
|
||||
},
|
||||
}
|
||||
|
||||
const sectionBgMap: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
navy: 'bg-fd-navy',
|
||||
gray: 'bg-fd-gray-light',
|
||||
}
|
||||
|
||||
const layoutGridMap: Record<string, string> = {
|
||||
'1-2': 'lg:grid-cols-[1fr_2fr]',
|
||||
'2-1': 'lg:grid-cols-[2fr_1fr]',
|
||||
'1-1-1': 'lg:grid-cols-3',
|
||||
'1-1': 'lg:grid-cols-2',
|
||||
}
|
||||
|
||||
const styleClassMap: Record<string, string> = {
|
||||
normal: '',
|
||||
bold: 'font-joey-bold',
|
||||
italic: 'italic',
|
||||
boldItalic: 'font-joey-bold italic',
|
||||
}
|
||||
|
||||
export const FDCardGridBlockComponent: React.FC<FDCardGridBlockProps> = ({
|
||||
layout = '1-1-1',
|
||||
cardStyle = 'outlined',
|
||||
cards,
|
||||
sectionBackground = 'white',
|
||||
}) => {
|
||||
const style = cardStyleMap[cardStyle] || cardStyleMap.outlined
|
||||
const sectionBg = sectionBgMap[sectionBackground || 'white']
|
||||
const gridCols = layoutGridMap[layout] || layoutGridMap['1-1-1']
|
||||
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${sectionBg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
<div className={`grid grid-cols-1 md:grid-cols-2 ${gridCols} gap-4 md:gap-6`}>
|
||||
{cards?.map((card, index) => {
|
||||
const mode = card.displayMode || 'content'
|
||||
|
||||
let cardContent: React.ReactNode
|
||||
|
||||
if (mode === 'centeredHeading') {
|
||||
cardContent = (
|
||||
<div
|
||||
className={`${style.bg} ${style.border} rounded-[70px] px-10 md:px-14 py-14 md:py-20 flex items-center justify-center min-h-[280px] md:min-h-[360px]`}
|
||||
>
|
||||
<span
|
||||
className={`font-joey-heavy text-fd-display leading-tight text-center ${style.headingText}`}
|
||||
>
|
||||
{card.heading}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
} else if (mode === 'centeredBody') {
|
||||
cardContent = (
|
||||
<div
|
||||
className={`${style.bg} ${style.border} rounded-[70px] px-10 md:px-14 py-14 md:py-20 flex items-center justify-center min-h-[280px] md:min-h-[360px]`}
|
||||
>
|
||||
<p
|
||||
className={`font-joey text-fd-body-lg text-center ${style.bodyText}`}
|
||||
>
|
||||
{card.centeredBodyText}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
cardContent = (
|
||||
<div
|
||||
className={`${style.bg} ${style.border} rounded-[70px] px-10 md:px-14 py-10 md:py-14 flex flex-col gap-1 h-full`}
|
||||
>
|
||||
{card.heading && (
|
||||
<h3
|
||||
className={`font-joey-heavy text-fd-h2 mb-2 ${style.headingText}`}
|
||||
>
|
||||
{card.heading}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{card.contentLines?.map((line, lineIndex) => {
|
||||
const textStyle = styleClassMap[line.style || 'normal'] || ''
|
||||
const baseClass = `font-joey text-fd-body-lg ${style.bodyText} ${textStyle}`
|
||||
|
||||
if (line.link) {
|
||||
return (
|
||||
<a
|
||||
key={lineIndex}
|
||||
href={line.link}
|
||||
className={`${baseClass} ${style.linkText} underline underline-offset-2 transition-colors`}
|
||||
>
|
||||
{line.text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p key={lineIndex} className={baseClass}>
|
||||
{line.text}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (card.cardLink) {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={card.cardLink}
|
||||
className="block hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{cardContent}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return <div key={index}>{cardContent}</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
132
src/blocks/FDCardGridBlock/config.ts
Normal file
132
src/blocks/FDCardGridBlock/config.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDCardGridBlock: Block = {
|
||||
slug: 'fdCardGrid',
|
||||
interfaceName: 'FDCardGridBlock',
|
||||
labels: {
|
||||
singular: 'FD Card Grid',
|
||||
plural: 'FD Card Grid',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'layout',
|
||||
type: 'select',
|
||||
label: 'Layout',
|
||||
defaultValue: '1-1-1',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '1/3 + 2/3', value: '1-2' },
|
||||
{ label: '2/3 + 1/3', value: '2-1' },
|
||||
{ label: '1/3 + 1/3 + 1/3', value: '1-1-1' },
|
||||
{ label: '1/2 + 1/2', value: '1-1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'cardStyle',
|
||||
type: 'select',
|
||||
label: 'Kortstil',
|
||||
defaultValue: 'outlined',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Navy (mörk)', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
{ label: 'Grön', value: 'green' },
|
||||
{ label: 'Kontur (vit med ram)', value: 'outlined' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'cards',
|
||||
type: 'array',
|
||||
label: 'Kort',
|
||||
minRows: 1,
|
||||
maxRows: 6,
|
||||
fields: [
|
||||
{
|
||||
name: 'displayMode',
|
||||
type: 'select',
|
||||
label: 'Visningsläge',
|
||||
defaultValue: 'content',
|
||||
options: [
|
||||
{ label: 'Rubrik + innehåll', value: 'content' },
|
||||
{ label: 'Centrerad stor rubrik', value: 'centeredHeading' },
|
||||
{ label: 'Centrerad brödtext', value: 'centeredBody' },
|
||||
],
|
||||
admin: {
|
||||
description: '"Centrerad stor rubrik" visar bara rubrikfältet centrerat med stor text. "Centrerad brödtext" visar bara brödtext centrerat.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Rubrik (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'centeredBodyText',
|
||||
type: 'textarea',
|
||||
label: 'Centrerad brödtext',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.displayMode === 'centeredBody',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'contentLines',
|
||||
type: 'array',
|
||||
label: 'Innehållsrader',
|
||||
minRows: 0,
|
||||
maxRows: 10,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.displayMode === 'content',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
label: 'Text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'style',
|
||||
type: 'select',
|
||||
label: 'Stil',
|
||||
defaultValue: 'normal',
|
||||
options: [
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Fet (Bold)', value: 'bold' },
|
||||
{ label: 'Kursiv (Italic)', value: 'italic' },
|
||||
{ label: 'Fet + Kursiv', value: 'boldItalic' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
type: 'text',
|
||||
label: 'Länk (valfri)',
|
||||
admin: {
|
||||
description: 'Om ifylld blir texten en klickbar länk med understruken stil',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'cardLink',
|
||||
type: 'text',
|
||||
label: 'Kortlänk (valfri)',
|
||||
admin: {
|
||||
description: 'Gör hela kortet klickbart',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
146
src/blocks/FDCodeEmbedBlock/Component.tsx
Normal file
146
src/blocks/FDCodeEmbedBlock/Component.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import type { FDCodeEmbedBlock as FDCodeEmbedBlockProps } from '@/payload-types'
|
||||
|
||||
const maxWidthClasses: Record<string, string> = {
|
||||
default: 'max-w-[1200px]',
|
||||
narrow: 'max-w-[800px]',
|
||||
wide: 'max-w-[1400px]',
|
||||
full: 'max-w-full',
|
||||
}
|
||||
|
||||
const bgClasses: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
navy: 'bg-fd-navy',
|
||||
gray: 'bg-fd-surface-alt',
|
||||
yellow: 'bg-fd-yellow',
|
||||
transparent: 'bg-transparent',
|
||||
}
|
||||
|
||||
export const FDCodeEmbedBlockComponent: React.FC<FDCodeEmbedBlockProps> = ({
|
||||
heading,
|
||||
description,
|
||||
embedType = 'iframe',
|
||||
iframeSrc,
|
||||
iframeTitle = 'Inbäddat formulär',
|
||||
iframeHeight = '600px',
|
||||
iframeAllow,
|
||||
customCode,
|
||||
sandboxed = true,
|
||||
maxWidth = 'default',
|
||||
sectionBackground = 'white',
|
||||
textColor = 'auto',
|
||||
embedBackground = 'none',
|
||||
}) => {
|
||||
const customCodeRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const isDark = sectionBackground === 'navy'
|
||||
const headingColor =
|
||||
textColor === 'white'
|
||||
? 'text-white'
|
||||
: textColor === 'navy'
|
||||
? 'text-fd-navy'
|
||||
: isDark
|
||||
? 'text-fd-yellow'
|
||||
: 'text-fd-navy'
|
||||
|
||||
const bodyColor =
|
||||
textColor === 'white'
|
||||
? 'text-white'
|
||||
: textColor === 'navy'
|
||||
? 'text-fd-navy'
|
||||
: isDark
|
||||
? 'text-white'
|
||||
: 'text-fd-navy'
|
||||
|
||||
const bgClass = bgClasses[sectionBackground ?? 'white'] || 'bg-white'
|
||||
const containerClass = maxWidthClasses[maxWidth ?? 'default'] || 'max-w-[1200px]'
|
||||
|
||||
const embedWrapperClass =
|
||||
embedBackground === 'card'
|
||||
? 'bg-white rounded-[70px] shadow-lg p-6 md:p-10 overflow-hidden'
|
||||
: embedBackground === 'navy-card'
|
||||
? 'bg-fd-navy rounded-[70px] shadow-lg p-6 md:p-10 overflow-hidden'
|
||||
: ''
|
||||
|
||||
useEffect(() => {
|
||||
if (embedType === 'custom' && !sandboxed && customCode && customCodeRef.current) {
|
||||
const container = customCodeRef.current
|
||||
container.innerHTML = customCode
|
||||
const scripts = container.querySelectorAll('script')
|
||||
scripts.forEach((oldScript) => {
|
||||
const newScript = document.createElement('script')
|
||||
Array.from(oldScript.attributes).forEach((attr) => {
|
||||
newScript.setAttribute(attr.name, attr.value)
|
||||
})
|
||||
newScript.textContent = oldScript.textContent
|
||||
oldScript.parentNode?.replaceChild(newScript, oldScript)
|
||||
})
|
||||
}
|
||||
}, [embedType, sandboxed, customCode])
|
||||
|
||||
const sandboxedSrcDoc = customCode
|
||||
? `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: system-ui, -apple-system, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${customCode}</body>
|
||||
</html>`
|
||||
: ''
|
||||
|
||||
return (
|
||||
<section className={`relative w-full py-16 md:py-20 lg:py-[99px] ${bgClass} overflow-hidden`}>
|
||||
<div className={`relative ${containerClass} mx-auto px-6 md:px-8`}>
|
||||
{(heading || description) && (
|
||||
<div className="mb-8 md:mb-12 text-center">
|
||||
{heading && (
|
||||
<h2 className={`font-joey-medium text-fd-h1 mb-4 ${headingColor}`}>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className={`font-joey text-fd-body-lg max-w-[800px] mx-auto ${bodyColor}`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={embedWrapperClass}>
|
||||
{embedType === 'iframe' && iframeSrc && (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
title={iframeTitle || 'Inbäddat formulär'}
|
||||
width="100%"
|
||||
height={iframeHeight || '600px'}
|
||||
style={{ border: 'none', minHeight: '200px' }}
|
||||
allow={iframeAllow || undefined}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
{embedType === 'custom' && sandboxed && customCode && (
|
||||
<iframe
|
||||
srcDoc={sandboxedSrcDoc}
|
||||
title="Anpassad inbäddning"
|
||||
width="100%"
|
||||
style={{ border: 'none', minHeight: '200px', height: iframeHeight || '400px' }}
|
||||
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
{embedType === 'custom' && !sandboxed && (
|
||||
<div ref={customCodeRef} className="w-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
150
src/blocks/FDCodeEmbedBlock/config.ts
Normal file
150
src/blocks/FDCodeEmbedBlock/config.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDCodeEmbedBlock: Block = {
|
||||
slug: 'fdCodeEmbed',
|
||||
interfaceName: 'FDCodeEmbedBlock',
|
||||
labels: {
|
||||
singular: 'FD Kodinbäddning',
|
||||
plural: 'FD Kodinbäddningar',
|
||||
},
|
||||
fields: [
|
||||
// --- Content fields ---
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Rubrik (valfri)',
|
||||
admin: {
|
||||
description: 'Visas ovanför den inbäddade koden',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Beskrivning (valfri)',
|
||||
admin: {
|
||||
description: 'Visas mellan rubrik och inbäddning',
|
||||
},
|
||||
},
|
||||
// --- Embed type ---
|
||||
{
|
||||
name: 'embedType',
|
||||
type: 'select',
|
||||
label: 'Typ av inbäddning',
|
||||
required: true,
|
||||
defaultValue: 'iframe',
|
||||
options: [
|
||||
{ label: 'Iframe (t.ex. Lime CRM-formulär)', value: 'iframe' },
|
||||
{ label: 'Anpassad HTML/JS', value: 'custom' },
|
||||
],
|
||||
},
|
||||
// --- Iframe fields ---
|
||||
{
|
||||
name: 'iframeSrc',
|
||||
type: 'text',
|
||||
label: 'Iframe URL',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.embedType === 'iframe',
|
||||
description: 'Full URL till iframe-källan, t.ex. Lime CRM-formulär',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'iframeTitle',
|
||||
type: 'text',
|
||||
label: 'Iframe titel (tillgänglighet)',
|
||||
defaultValue: 'Inbäddat formulär',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.embedType === 'iframe',
|
||||
description: 'Beskrivning för skärmläsare',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'iframeHeight',
|
||||
type: 'text',
|
||||
label: 'Iframe höjd',
|
||||
defaultValue: '600px',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.embedType === 'iframe',
|
||||
description: 'CSS-höjd, t.ex. 600px, 80vh, auto',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'iframeAllow',
|
||||
type: 'text',
|
||||
label: 'Iframe allow-attribut (valfri)',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.embedType === 'iframe',
|
||||
description: 'T.ex. "clipboard-write; encrypted-media" — lämna tomt om du är osäker',
|
||||
},
|
||||
},
|
||||
// --- Custom HTML/JS fields ---
|
||||
{
|
||||
name: 'customCode',
|
||||
type: 'code',
|
||||
label: 'Anpassad HTML/JS-kod',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.embedType === 'custom',
|
||||
description: 'Klistra in din HTML, CSS eller JavaScript här. Renderas i en sandlåda.',
|
||||
language: 'html',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sandboxed',
|
||||
type: 'checkbox',
|
||||
label: 'Sandlådeläge (säkrare)',
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.embedType === 'custom',
|
||||
description:
|
||||
'Renderar koden i en isolerad iframe. Avaktivera bara om koden måste interagera med huvudsidan.',
|
||||
},
|
||||
},
|
||||
// --- Layout/style ---
|
||||
{
|
||||
name: 'maxWidth',
|
||||
type: 'select',
|
||||
label: 'Maxbredd',
|
||||
defaultValue: 'default',
|
||||
options: [
|
||||
{ label: 'Standard (1200px)', value: 'default' },
|
||||
{ label: 'Smal (800px)', value: 'narrow' },
|
||||
{ label: 'Bred (1400px)', value: 'wide' },
|
||||
{ label: 'Fullbredd', value: 'full' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
{ label: 'Transparent', value: 'transparent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textColor',
|
||||
type: 'select',
|
||||
label: 'Textfärg (rubrik/beskrivning)',
|
||||
defaultValue: 'auto',
|
||||
options: [
|
||||
{ label: 'Automatisk', value: 'auto' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'embedBackground',
|
||||
type: 'select',
|
||||
label: 'Bakgrund runt inbäddning',
|
||||
defaultValue: 'none',
|
||||
options: [
|
||||
{ label: 'Ingen', value: 'none' },
|
||||
{ label: 'Vit kort (med skugga)', value: 'card' },
|
||||
{ label: 'Navy kort', value: 'navy-card' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
46
src/blocks/FDContactBlock/Component.tsx
Normal file
46
src/blocks/FDContactBlock/Component.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import type { FDContactBlock as FDContactBlockProps, Media } from '@/payload-types'
|
||||
import { FDImage } from '@/components/FDImage'
|
||||
|
||||
export const FDContactBlockComponent: React.FC<FDContactBlockProps> = ({
|
||||
heading,
|
||||
contactMethods,
|
||||
}) => {
|
||||
return (
|
||||
<section className="relative w-full bg-fd-navy py-16 md:py-20 lg:pt-[100px] lg:pb-[120px]">
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col items-center gap-8 lg:gap-10">
|
||||
<h2 className="w-full font-joey-heavy text-fd-navy text-fd-h1 text-fd-yellow text-center">
|
||||
{heading}
|
||||
</h2>
|
||||
<div className="flex flex-row items-stretch gap-4 md:gap-7 w-full max-w-[656px]">
|
||||
{contactMethods?.map((method, index) => {
|
||||
const media = method.icon as Media
|
||||
const card = (
|
||||
<div className="flex-1 flex flex-col items-center gap-3 md:gap-5 cursor-pointer transition-transform hover:scale-105">
|
||||
{media?.url && (
|
||||
<div className="relative w-full h-[120px] md:h-[160px] lg:h-[200px] overflow-hidden rounded-[70px]">
|
||||
<FDImage
|
||||
media={media}
|
||||
size="medium"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 50vw, 300px"
|
||||
fallbackAlt={method.label}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="w-full font-joey-bold text-white text-fd-h2 text-center">
|
||||
{method.label}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
if (method.link) {
|
||||
return <a key={index} href={method.link} className="flex-1">{card}</a>
|
||||
}
|
||||
return <div key={index} className="flex-1">{card}</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
48
src/blocks/FDContactBlock/config.ts
Normal file
48
src/blocks/FDContactBlock/config.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDContactBlock: Block = {
|
||||
slug: 'fdContact',
|
||||
interfaceName: 'FDContactBlock',
|
||||
labels: {
|
||||
singular: 'FD Cotact Us',
|
||||
plural: 'FD Contact Uss',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Rubrik',
|
||||
defaultValue: 'Kontakta oss',
|
||||
},
|
||||
{
|
||||
name: 'contactMethods',
|
||||
type: 'array',
|
||||
label: 'Kontaktmetoder',
|
||||
minRows: 1,
|
||||
maxRows: 6,
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Etikett',
|
||||
},
|
||||
{
|
||||
name: 'icon',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Ikon/bild',
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
type: 'text',
|
||||
label: 'Länk',
|
||||
admin: {
|
||||
description: 'tel:, mailto:, eller URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
318
src/blocks/FDContactFormBlock/Component.tsx
Normal file
318
src/blocks/FDContactFormBlock/Component.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import type { FDContactFormBlock as FDContactFormBlockProps } from '@/payload-types'
|
||||
import type { Media } from '@/payload-types'
|
||||
import { FDImage } from '@/components/FDImage'
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
gray: 'bg-fd-gray-light',
|
||||
}
|
||||
|
||||
export const FDContactFormBlockComponent: React.FC<FDContactFormBlockProps> = ({
|
||||
heading,
|
||||
description,
|
||||
form: formRelation,
|
||||
submitText = 'Skicka förfrågan',
|
||||
privacyText,
|
||||
privacyLinkText,
|
||||
privacyLinkUrl,
|
||||
sideImage,
|
||||
background = 'white',
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, string>>({})
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
||||
|
||||
const media = sideImage as Media | undefined
|
||||
const hasSideImage = Boolean(media?.url)
|
||||
const sectionBg = bgMap[background || 'white']
|
||||
|
||||
// Extract form object — could be a populated object or just an ID
|
||||
const form = formRelation && typeof formRelation === 'object' ? formRelation : null
|
||||
const formId = form ? form.id : (formRelation ?? null)
|
||||
|
||||
const handleChange = (name: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formId) {
|
||||
console.warn('No form ID configured')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('sending')
|
||||
try {
|
||||
const res = await fetch('/api/form-submissions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
form: formId,
|
||||
submissionData: Object.entries(formData).map(([field, value]) => ({
|
||||
field,
|
||||
value,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
setStatus(res.ok ? 'sent' : 'error')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
const baseInputClass =
|
||||
'w-full bg-gray-100 rounded-lg px-4 py-3 font-joey text-fd-navy text-base outline-none focus:ring-2 focus:ring-fd-navy/20 transition-shadow'
|
||||
|
||||
const renderFormField = (field: any) => {
|
||||
const blockType = field.blockType
|
||||
const name = field.name || field.label?.toLowerCase().replace(/\s+/g, '-') || ''
|
||||
const label = field.label || ''
|
||||
const required = field.required || false
|
||||
const width = field.width ? Number(field.width) : 100
|
||||
|
||||
const input = (() => {
|
||||
switch (blockType) {
|
||||
case 'textarea':
|
||||
return (
|
||||
<textarea
|
||||
name={name}
|
||||
required={required}
|
||||
rows={4}
|
||||
className={`${baseInputClass} resize-y`}
|
||||
value={formData[name] || ''}
|
||||
onChange={(e) => handleChange(name, e.target.value)}
|
||||
/>
|
||||
)
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
name={name}
|
||||
required={required}
|
||||
className={baseInputClass}
|
||||
value={formData[name] || ''}
|
||||
onChange={(e) => handleChange(name, e.target.value)}
|
||||
>
|
||||
<option value="">Välj...</option>
|
||||
{field.options?.map((opt: any) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
case 'email':
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
name={name}
|
||||
required={required}
|
||||
className={baseInputClass}
|
||||
value={formData[name] || ''}
|
||||
onChange={(e) => handleChange(name, e.target.value)}
|
||||
/>
|
||||
)
|
||||
case 'number':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
name={name}
|
||||
required={required}
|
||||
className={baseInputClass}
|
||||
value={formData[name] || ''}
|
||||
onChange={(e) => handleChange(name, e.target.value)}
|
||||
/>
|
||||
)
|
||||
case 'checkbox':
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={name}
|
||||
required={required}
|
||||
className="w-5 h-5 rounded border-gray-300 text-fd-navy focus:ring-fd-navy/20"
|
||||
checked={formData[name] === 'true'}
|
||||
onChange={(e) => handleChange(name, e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
<span className="font-joey text-fd-navy text-sm">{label}</span>
|
||||
</label>
|
||||
)
|
||||
case 'country':
|
||||
case 'state':
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
name={name}
|
||||
required={required}
|
||||
className={baseInputClass}
|
||||
value={formData[name] || ''}
|
||||
onChange={(e) => handleChange(name, e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})()
|
||||
|
||||
if (blockType === 'checkbox') {
|
||||
return { element: input, width }
|
||||
}
|
||||
|
||||
return {
|
||||
element: (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-joey text-fd-navy text-sm md:text-base">
|
||||
{label}
|
||||
{required && ' *'}
|
||||
</label>
|
||||
{input}
|
||||
</div>
|
||||
),
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
const renderFields = () => {
|
||||
if (!form?.fields) return null
|
||||
|
||||
const elements: React.ReactNode[] = []
|
||||
let i = 0
|
||||
const fields = form.fields
|
||||
|
||||
while (i < fields.length) {
|
||||
const field = fields[i]
|
||||
|
||||
// Skip message blocks — render them inline
|
||||
if (field.blockType === 'message') {
|
||||
elements.push(
|
||||
<div key={i} className="font-joey text-fd-navy/70 text-sm">
|
||||
{/* Message blocks contain rich text, render as simple text for now */}
|
||||
</div>,
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
const { element, width } = renderFormField(field)
|
||||
const isHalf = width <= 50
|
||||
|
||||
if (isHalf) {
|
||||
const nextField = i + 1 < fields.length ? fields[i + 1] : null
|
||||
const nextResult = nextField && nextField.blockType !== 'message' ? renderFormField(nextField) : null
|
||||
const nextIsHalf = nextResult && nextResult.width <= 50
|
||||
|
||||
if (nextIsHalf && nextResult) {
|
||||
elements.push(
|
||||
<div key={i} className="grid grid-cols-1 sm:grid-cols-2 gap-4 md:gap-6">
|
||||
{element}
|
||||
{nextResult.element}
|
||||
</div>,
|
||||
)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<div key={i} className={isHalf ? 'w-full sm:w-1/2' : 'w-full'}>
|
||||
{element}
|
||||
</div>,
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
if (status === 'sent') {
|
||||
// Use confirmation message from Payload form if available
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${sectionBg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 text-center">
|
||||
<h2 className="font-joey-heavy text-fd-navy text-3xl md:text-4xl mb-4">Tack!</h2>
|
||||
<p className="font-joey text-fd-navy text-lg">
|
||||
Vi har tagit emot din förfrågan och återkommer så snart vi kan.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${sectionBg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 text-center">
|
||||
<p className="font-joey text-fd-navy/60">Inget formulär valt.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${sectionBg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
<div className={`flex flex-col ${hasSideImage ? 'lg:flex-row' : ''} gap-10 lg:gap-16`}>
|
||||
<div className={hasSideImage ? 'flex-1' : 'max-w-[800px]'}>
|
||||
{heading && (
|
||||
<h2 className="font-joey-heavy text-fd-navy text-3xl md:text-4xl lg:text-5xl leading-tight mb-3">
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="font-joey text-fd-navy/80 text-base md:text-lg mb-8">{description}</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
{renderFields()}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'sending'}
|
||||
className="self-start inline-flex items-center justify-center px-8 py-3 bg-fd-yellow hover:bg-fd-yellow/90 rounded-full font-joey-bold text-fd-navy text-lg transition-colors disabled:opacity-60 mt-2"
|
||||
>
|
||||
{status === 'sending' ? 'Skickar...' : submitText}
|
||||
</button>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="font-joey text-red-600 text-sm">
|
||||
Något gick fel. Försök igen eller kontakta oss direkt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{privacyText && (
|
||||
<p className="font-joey text-fd-navy/60 text-xs md:text-sm leading-relaxed mt-2">
|
||||
{privacyText}{' '}
|
||||
{privacyLinkText && privacyLinkUrl && (
|
||||
<>
|
||||
Läs mer i vår{' '}
|
||||
<a
|
||||
href={privacyLinkUrl}
|
||||
className="underline underline-offset-2 hover:text-fd-navy transition-colors"
|
||||
>
|
||||
{privacyLinkText}
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{hasSideImage && (
|
||||
<div className="hidden lg:block flex-shrink-0">
|
||||
<FDImage
|
||||
media={media!}
|
||||
size="medium"
|
||||
className="w-[380px] h-auto max-h-[500px] object-cover rounded-[20px]"
|
||||
sizes="380px"
|
||||
fallbackAlt={heading || ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
80
src/blocks/FDContactFormBlock/config.ts
Normal file
80
src/blocks/FDContactFormBlock/config.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDContactFormBlock: Block = {
|
||||
slug: 'fdContactForm',
|
||||
interfaceName: 'FDContactFormBlock',
|
||||
labels: {
|
||||
singular: 'FD Contact Form',
|
||||
plural: 'FD Contact Forms',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'form',
|
||||
type: 'relationship',
|
||||
relationTo: 'forms',
|
||||
required: true,
|
||||
label: 'Formulär',
|
||||
admin: {
|
||||
description: 'Välj ett formulär skapat under Formulär i adminpanelen',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Rubrik',
|
||||
defaultValue: 'Prata med vårt team',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Beskrivning',
|
||||
defaultValue:
|
||||
'Tell us about your goals, our team will reach out to you and help you achieve the right solution.',
|
||||
},
|
||||
{
|
||||
name: 'submitText',
|
||||
type: 'text',
|
||||
label: 'Skicka-knapp text',
|
||||
defaultValue: 'Skicka förfrågan',
|
||||
},
|
||||
{
|
||||
name: 'privacyText',
|
||||
type: 'textarea',
|
||||
label: 'Integritetstext',
|
||||
defaultValue:
|
||||
'Vi använder din kontaktinformation för att svara på din förfrågan och dela detaljer om våra produkter och tjänster. Du kan när som helst avregistrera dig.',
|
||||
},
|
||||
{
|
||||
name: 'privacyLinkText',
|
||||
type: 'text',
|
||||
label: 'Integritetslänk text',
|
||||
defaultValue: 'integritetspolicy',
|
||||
},
|
||||
{
|
||||
name: 'privacyLinkUrl',
|
||||
type: 'text',
|
||||
label: 'Integritetslänk URL',
|
||||
defaultValue: '/integritetspolicy',
|
||||
},
|
||||
{
|
||||
name: 'sideImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Sidobild (valfri)',
|
||||
admin: {
|
||||
description: 'Bild som visas till höger om formuläret',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'background',
|
||||
type: 'select',
|
||||
label: 'Bakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
93
src/blocks/FDCtaSideImageBlock/Component.tsx
Normal file
93
src/blocks/FDCtaSideImageBlock/Component.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import type { FDCtaSideImageBlock as FDCtaSideImageBlockProps } from '@/payload-types'
|
||||
import type { Media } from '@/payload-types'
|
||||
import { FDImage } from '@/components/FDImage'
|
||||
|
||||
const overlayColorMap: Record<string, string> = {
|
||||
navy: 'bg-fd-navy',
|
||||
yellow: 'bg-fd-yellow',
|
||||
black: 'bg-black',
|
||||
}
|
||||
|
||||
const overlayOpacityMap: Record<string, string> = {
|
||||
'20': 'opacity-20',
|
||||
'30': 'opacity-30',
|
||||
'50': 'opacity-50',
|
||||
'70': 'opacity-70',
|
||||
}
|
||||
|
||||
export const FDCtaSideImageBlockComponent: React.FC<FDCtaSideImageBlockProps> = ({
|
||||
heading,
|
||||
body,
|
||||
ctaText,
|
||||
ctaLink = '#',
|
||||
image,
|
||||
imagePosition = 'right',
|
||||
theme = 'dark',
|
||||
customBackgroundColor,
|
||||
customTextLight = true,
|
||||
imageOverlay = 'none',
|
||||
imageOverlayOpacity = '30',
|
||||
}) => {
|
||||
const isCustom = theme === 'custom'
|
||||
const isDark = isCustom ? customTextLight : theme === 'dark'
|
||||
const media = image as Media
|
||||
|
||||
let sectionBg: string
|
||||
let sectionStyle: React.CSSProperties = {}
|
||||
if (isCustom && customBackgroundColor) {
|
||||
sectionStyle = { backgroundColor: customBackgroundColor }
|
||||
sectionBg = ''
|
||||
} else {
|
||||
sectionBg = isDark ? 'bg-fd-navy' : 'bg-white'
|
||||
}
|
||||
|
||||
const hasOverlay = imageOverlay && imageOverlay !== 'none'
|
||||
const overlayColor = overlayColorMap[imageOverlay || ''] || ''
|
||||
const overlayOpacity = overlayOpacityMap[imageOverlayOpacity || '30'] || 'opacity-30'
|
||||
|
||||
const textContent = (
|
||||
<div className="flex flex-col flex-1 items-start gap-8 lg:gap-[41px]">
|
||||
<div className="flex flex-col items-start gap-4 w-full">
|
||||
<h2 className={`w-full font-joey-heavy text-fd-h1 ${isDark ? 'text-fd-yellow' : 'text-fd-navy'}`}>
|
||||
{heading}
|
||||
</h2>
|
||||
<p className={`w-full font-joey text-fd-body-lg ${isDark ? 'text-white' : 'text-fd-navy'}`}>
|
||||
{body}
|
||||
</p>
|
||||
</div>
|
||||
{ctaText && (
|
||||
<a
|
||||
href={ctaLink || '#'}
|
||||
className={isDark ? 'fd-btn-primary' : 'fd-btn-secondary'}
|
||||
>
|
||||
{ctaText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const imageContent = media?.url ? (
|
||||
<div className="relative w-full lg:w-[575px] h-[350px] lg:h-[479px] overflow-hidden rounded-[70px]">
|
||||
<FDImage
|
||||
media={media}
|
||||
size="large"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 575px"
|
||||
fallbackAlt={heading || ''}
|
||||
/>
|
||||
{hasOverlay && (
|
||||
<div className={`absolute inset-0 ${overlayColor} ${overlayOpacity}`} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<section className={`w-full py-16 lg:py-[79px] ${sectionBg}`} style={sectionStyle}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col lg:flex-row items-center gap-10 lg:gap-16">
|
||||
{imagePosition === 'left' ? <>{imageContent}{textContent}</> : <>{textContent}{imageContent}</>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
115
src/blocks/FDCtaSideImageBlock/config.ts
Normal file
115
src/blocks/FDCtaSideImageBlock/config.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDCtaSideImageBlock: Block = {
|
||||
slug: 'fdCtaSideImage',
|
||||
interfaceName: 'FDCtaSideImageBlock',
|
||||
labels: {
|
||||
singular: 'FD CTA with Image',
|
||||
plural: 'FD CTA with Images',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Rubrik',
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
label: 'Brödtext',
|
||||
},
|
||||
{
|
||||
name: 'ctaText',
|
||||
type: 'text',
|
||||
label: 'CTA-knapp text',
|
||||
defaultValue: 'Läs mer',
|
||||
},
|
||||
{
|
||||
name: 'ctaLink',
|
||||
type: 'text',
|
||||
label: 'CTA-knapp länk',
|
||||
defaultValue: '#',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
label: 'Bild',
|
||||
},
|
||||
{
|
||||
name: 'imagePosition',
|
||||
type: 'select',
|
||||
label: 'Bildposition',
|
||||
defaultValue: 'right',
|
||||
options: [
|
||||
{ label: 'Höger', value: 'right' },
|
||||
{ label: 'Vänster', value: 'left' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
label: 'Tema',
|
||||
defaultValue: 'dark',
|
||||
options: [
|
||||
{ label: 'Ljust', value: 'light' },
|
||||
{ label: 'Mörkt', value: 'dark' },
|
||||
{ label: 'Anpassad färg', value: 'custom' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'customBackgroundColor',
|
||||
type: 'text',
|
||||
label: 'Anpassad bakgrundsfärg',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.theme === 'custom',
|
||||
description: 'Valfri HEX-färg, t.ex. #1a3a5c eller #fecc02',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'customTextLight',
|
||||
type: 'checkbox',
|
||||
label: 'Ljus text (för mörka bakgrunder)',
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.theme === 'custom',
|
||||
description: 'Aktivera för vit text på mörk anpassad bakgrund',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'imageOverlay',
|
||||
type: 'select',
|
||||
label: 'Bild-overlay',
|
||||
defaultValue: 'none',
|
||||
admin: {
|
||||
description: 'Tonad overlay över bilden med varumärkesfärg',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Ingen', value: 'none' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
{ label: 'Svart', value: 'black' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'imageOverlayOpacity',
|
||||
type: 'select',
|
||||
label: 'Overlay-styrka',
|
||||
defaultValue: '30',
|
||||
admin: {
|
||||
condition: (_, siblingData) =>
|
||||
Boolean(siblingData?.imageOverlay) && siblingData?.imageOverlay !== 'none',
|
||||
description: 'Hur stark overlay över bilden',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Lätt (20%)', value: '20' },
|
||||
{ label: 'Medium (30%)', value: '30' },
|
||||
{ label: 'Stark (50%)', value: '50' },
|
||||
{ label: 'Mycket stark (70%)', value: '70' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
238
src/blocks/FDDataTableBlock/Component.tsx
Normal file
238
src/blocks/FDDataTableBlock/Component.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import type { FDDataTableBlock as Props, Media } from '@/payload-types'
|
||||
|
||||
type TableData = {
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
}
|
||||
|
||||
function detectDelimiter(line: string): string {
|
||||
const semicolons = (line.match(/;/g) || []).length
|
||||
const commas = (line.match(/,/g) || []).length
|
||||
return semicolons >= commas ? ';' : ','
|
||||
}
|
||||
|
||||
function parseCSVLine(line: string, delimiter: string): string[] {
|
||||
const result: string[] = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes
|
||||
} else if (char === delimiter && !inQuotes) {
|
||||
result.push(current.trim())
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
result.push(current.trim())
|
||||
return result
|
||||
}
|
||||
|
||||
async function parseFile(url: string, filename: string): Promise<TableData> {
|
||||
const response = await fetch(url)
|
||||
const isExcel = /\.(xlsx|xls)$/i.test(filename)
|
||||
|
||||
if (isExcel) {
|
||||
const buffer = await response.arrayBuffer()
|
||||
const ExcelJS = await import('exceljs')
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.load(buffer)
|
||||
const sheet = workbook.worksheets[0]
|
||||
|
||||
const data: string[][] = []
|
||||
sheet.eachRow((row) => {
|
||||
const cells = (row.values as unknown[])
|
||||
.slice(1) // exceljs row.values is 1-indexed with undefined at [0]
|
||||
.map((c) => {
|
||||
if (c === null || c === undefined) return ''
|
||||
if (typeof c === 'object' && 'text' in c) return String((c as any).text ?? '')
|
||||
if (typeof c === 'object' && 'result' in c) return String((c as any).result ?? '')
|
||||
return String(c)
|
||||
})
|
||||
data.push(cells)
|
||||
})
|
||||
|
||||
const nonEmpty = data.filter((r) => r.some((c) => c.trim()))
|
||||
const [headerRow, ...bodyRows] = nonEmpty
|
||||
return {
|
||||
headers: headerRow || [],
|
||||
rows: bodyRows,
|
||||
}
|
||||
} else {
|
||||
const text = await response.text()
|
||||
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
|
||||
const delimiter = detectDelimiter(lines[0] || '')
|
||||
const [headerLine, ...bodyLines] = lines
|
||||
return {
|
||||
headers: parseCSVLine(headerLine, delimiter),
|
||||
rows: bodyLines.map((l) => parseCSVLine(l, delimiter)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FDDataTableBlockComponent: React.FC<Props> = ({
|
||||
title,
|
||||
description,
|
||||
dataSource = 'upload',
|
||||
file,
|
||||
headers: manualHeaders = [],
|
||||
rows: manualRows = [],
|
||||
sectionBackground = 'white',
|
||||
headerStyle = 'navy',
|
||||
stripeRows = true,
|
||||
bordered = false,
|
||||
firstColumnBold = false,
|
||||
}) => {
|
||||
const [tableData, setTableData] = useState<TableData | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const resolveData = useCallback(async () => {
|
||||
if (dataSource === 'manual') {
|
||||
const headers = (manualHeaders || []).map((h) => h.text || '')
|
||||
const rows = (manualRows || []).map((r) =>
|
||||
(r.cells || '').split(',').map((c: string) => c.trim()),
|
||||
)
|
||||
setTableData({ headers, rows })
|
||||
return
|
||||
}
|
||||
|
||||
const media = file as Media | undefined
|
||||
if (!media?.url) {
|
||||
setTableData(null)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const filename = (media as any).filename || media.url
|
||||
const data = await parseFile(media.url, filename)
|
||||
setTableData(data)
|
||||
} catch (e) {
|
||||
console.error('Table parse error:', e)
|
||||
setError('Kunde inte läsa filen. Kontrollera att det är en giltig CSV- eller Excel-fil.')
|
||||
setTableData(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [dataSource, file, manualHeaders, manualRows])
|
||||
|
||||
useEffect(() => {
|
||||
resolveData()
|
||||
}, [resolveData])
|
||||
|
||||
const isDark = sectionBackground === 'navy'
|
||||
const bgClass =
|
||||
sectionBackground === 'navy' ? 'bg-fd-navy' :
|
||||
sectionBackground === 'gray' ? 'bg-fd-surface-alt' : 'bg-white'
|
||||
const titleClass = isDark ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
const bodyClass = isDark ? 'text-white' : 'text-fd-navy'
|
||||
const headerBgClass =
|
||||
headerStyle === 'yellow' ? 'bg-fd-yellow text-fd-navy' :
|
||||
headerStyle === 'mint' ? 'bg-fd-mint text-fd-navy' :
|
||||
headerStyle === 'gray' ? 'bg-gray-200 text-fd-navy' :
|
||||
'bg-fd-navy text-white'
|
||||
const getRowBg = (i: number) => {
|
||||
if (!stripeRows) return isDark ? 'bg-white/5' : 'bg-white'
|
||||
if (isDark) return i % 2 === 0 ? 'bg-white/5' : 'bg-white/10'
|
||||
return i % 2 === 0 ? 'bg-white' : 'bg-fd-surface-alt'
|
||||
}
|
||||
const borderClass = bordered
|
||||
? isDark ? 'border border-white/10' : 'border border-fd-navy/10'
|
||||
: ''
|
||||
const cellBorderClass = bordered
|
||||
? isDark ? 'border-r border-b border-white/10 last:border-r-0'
|
||||
: 'border-r border-b border-fd-navy/10 last:border-r-0'
|
||||
: ''
|
||||
|
||||
return (
|
||||
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
|
||||
{(title || description) && (
|
||||
<div className="mb-10 md:mb-12">
|
||||
{title && (
|
||||
<h2 className={`font-joey-heavy text-fd-h1 mb-4 ${titleClass}`}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className={`font-joey text-fd-body-lg max-w-[680px] ${bodyClass}`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className={`flex items-center justify-center py-16 font-joey text-fd-body ${bodyClass} opacity-60`}>
|
||||
Laddar tabell...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<p className="font-joey text-red-400 text-fd-small">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !tableData && (
|
||||
<div className={`flex items-center justify-center py-16 font-joey text-fd-body ${bodyClass} opacity-40`}>
|
||||
{dataSource === 'upload'
|
||||
? 'Ladda upp en CSV- eller Excel-fil för att visa tabellen.'
|
||||
: 'Lägg till kolumnrubriker och rader ovan.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && tableData && tableData.headers.length > 0 && (
|
||||
<div className="w-full overflow-hidden rounded-[20px] md:rounded-[30px]">
|
||||
<div className="w-full overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||
<table className={`w-full min-w-full border-collapse ${borderClass}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
{tableData.headers.map((header, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className={`px-4 md:px-6 py-3 md:py-4 text-left font-joey-bold text-fd-small whitespace-nowrap ${headerBgClass} ${cellBorderClass}`}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className={getRowBg(rowIndex)}>
|
||||
{tableData.headers.map((_, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className={`px-4 md:px-6 py-3 md:py-4 font-joey text-fd-small ${bodyClass} ${cellBorderClass} ${
|
||||
colIndex === 0 && firstColumnBold ? 'font-joey-bold' : ''
|
||||
}`}
|
||||
>
|
||||
{row[colIndex] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && tableData && tableData.rows.length > 0 && (
|
||||
<p className={`mt-3 font-joey text-fd-xs opacity-40 ${bodyClass}`}>
|
||||
{tableData.rows.length} rader · {tableData.headers.length} kolumner
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
141
src/blocks/FDDataTableBlock/config.ts
Normal file
141
src/blocks/FDDataTableBlock/config.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDDataTableBlock: Block = {
|
||||
slug: 'fdDataTable',
|
||||
interfaceName: 'FDDataTableBlock',
|
||||
labels: {
|
||||
singular: 'FD Datatabell',
|
||||
plural: 'FD Datatabeller',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: 'Rubrik (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Beskrivning (valfri)',
|
||||
},
|
||||
|
||||
// ── Data source ──────────────────────────────────────────
|
||||
{
|
||||
name: 'dataSource',
|
||||
type: 'select',
|
||||
label: 'Datakälla',
|
||||
defaultValue: 'upload',
|
||||
options: [
|
||||
{ label: 'Ladda upp fil (CSV eller Excel)', value: 'upload' },
|
||||
{ label: 'Ange data manuellt', value: 'manual' },
|
||||
],
|
||||
admin: {
|
||||
description: 'Välj om du vill ladda upp en fil eller ange tabelldata manuellt.',
|
||||
},
|
||||
},
|
||||
|
||||
// ── File upload (CSV or Excel) ────────────────────────────
|
||||
{
|
||||
name: 'file',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'CSV- eller Excel-fil',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.dataSource === 'upload',
|
||||
description: 'Ladda upp en .csv, .xlsx eller .xls fil. Den första raden används som kolumnrubriker.',
|
||||
},
|
||||
},
|
||||
|
||||
// ── Manual data entry ─────────────────────────────────────
|
||||
{
|
||||
name: 'headers',
|
||||
type: 'array',
|
||||
label: 'Kolumnrubriker',
|
||||
maxRows: 20,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.dataSource === 'manual',
|
||||
description: 'Lägg till en rad per kolumn.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Rubrik',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'rows',
|
||||
type: 'array',
|
||||
label: 'Rader',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.dataSource === 'manual',
|
||||
description: 'Varje rad är en kommaseparerad sträng av cellvärden, i samma ordning som kolumnrubrikerna.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'cells',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Cellvärden (kommaseparerade)',
|
||||
admin: {
|
||||
description: 'T.ex: "Stockholm, 10 Gbit, 99.9%, 2 400 kr/mån"',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Style options ─────────────────────────────────────────
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'headerStyle',
|
||||
type: 'select',
|
||||
label: 'Rubrikradsstil',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy (vit text)', value: 'navy' },
|
||||
{ label: 'Gul (navy text)', value: 'yellow' },
|
||||
{ label: 'Mint (navy text)', value: 'mint' },
|
||||
{ label: 'Grå (navy text)', value: 'gray' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'stripeRows',
|
||||
type: 'checkbox',
|
||||
label: 'Växlande radfärger',
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description: 'Aktivera för att annenhålla rader med subtil bakgrundsfärg.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'bordered',
|
||||
type: 'checkbox',
|
||||
label: 'Visa cellinramning',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Lägg till linjer mellan alla celler.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'firstColumnBold',
|
||||
type: 'checkbox',
|
||||
label: 'Fet första kolumn',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Gör den första kolumnen fet — användbart för namnkolumner.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
79
src/blocks/FDFaqBlock/Component.tsx
Normal file
79
src/blocks/FDFaqBlock/Component.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import type { FDFaqBlock as FDFaqBlockProps } from '@/payload-types'
|
||||
|
||||
export const FDFaqBlockComponent: React.FC<FDFaqBlockProps> = ({
|
||||
heading,
|
||||
items,
|
||||
theme = 'gray',
|
||||
}) => {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null)
|
||||
|
||||
const bgClass =
|
||||
theme === 'dark'
|
||||
? 'bg-fd-navy'
|
||||
: theme === 'gray'
|
||||
? 'bg-fd-gray-light'
|
||||
: 'bg-white'
|
||||
|
||||
const headingColor = theme === 'dark' ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
const textColor = theme === 'dark' ? 'text-white' : 'text-fd-navy'
|
||||
const borderColor = theme === 'dark' ? 'border-white/20' : 'border-fd-navy/10'
|
||||
|
||||
return (
|
||||
<section className={`w-full py-16 md:py-20 lg:py-[130px] ${bgClass}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col items-start gap-6">
|
||||
<h2 className={`w-full max-w-[550px] font-joey-heavy text-fd-h1 ${headingColor}`}>
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
<div className="w-full max-w-[1162px]">
|
||||
{items?.map((item, index) => (
|
||||
<div key={index} className={`border-b ${borderColor}`}>
|
||||
<button
|
||||
className={`flex w-full items-center gap-3 md:gap-4 py-4 md:py-5 text-left transition-colors ${textColor}`}
|
||||
onClick={() => setOpenIndex(openIndex === index ? null : index)}
|
||||
aria-expanded={openIndex === index}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 md:w-5 md:h-5 flex-shrink-0 transition-transform duration-200 ${
|
||||
openIndex === index ? 'rotate-45' : ''
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="10" y1="2" x2="10" y2="18" />
|
||||
<line x1="2" y1="10" x2="18" y2="10" />
|
||||
</svg>
|
||||
<span className="font-joey text-fd-h3">
|
||||
{item.question}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`grid transition-all duration-200 ease-out ${
|
||||
openIndex === index
|
||||
? 'grid-rows-[1fr] opacity-100 pb-5'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<p
|
||||
className={`font-joey text-fd-body pl-7 md:pl-9 ${
|
||||
theme === 'dark' ? 'text-white/80' : 'text-fd-navy/80'
|
||||
}`}
|
||||
>
|
||||
{item.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
50
src/blocks/FDFaqBlock/config.ts
Normal file
50
src/blocks/FDFaqBlock/config.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDFaqBlock: Block = {
|
||||
slug: 'fdFaq',
|
||||
interfaceName: 'FDFaqBlock',
|
||||
labels: {
|
||||
singular: 'FD FAQ',
|
||||
plural: 'FD FAQs',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Rubrik',
|
||||
defaultValue: 'Vanliga frågor',
|
||||
},
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
label: 'Frågor',
|
||||
minRows: 1,
|
||||
fields: [
|
||||
{
|
||||
name: 'question',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Fråga',
|
||||
},
|
||||
{
|
||||
name: 'answer',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
label: 'Svar',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
label: 'Tema',
|
||||
defaultValue: 'gray',
|
||||
options: [
|
||||
{ label: 'Grå bakgrund', value: 'gray' },
|
||||
{ label: 'Ljust', value: 'light' },
|
||||
{ label: 'Mörkt', value: 'dark' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
40
src/blocks/FDFeatureAnnouncementBlock/Component.tsx
Normal file
40
src/blocks/FDFeatureAnnouncementBlock/Component.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import type { FDFeatureAnnouncementBlock as FDFeatureAnnouncementBlockProps } from '@/payload-types'
|
||||
|
||||
export const FDFeatureAnnouncementBlockComponent: React.FC<FDFeatureAnnouncementBlockProps> = ({
|
||||
heading,
|
||||
body,
|
||||
ctaText,
|
||||
ctaLink = '#',
|
||||
theme = 'gray',
|
||||
}) => {
|
||||
const bgClass =
|
||||
theme === 'dark'
|
||||
? 'bg-fd-navy'
|
||||
: theme === 'gray'
|
||||
? 'bg-fd-gray'
|
||||
: 'bg-white'
|
||||
|
||||
const headingColor = theme === 'dark' ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
const bodyColor = theme === 'dark' ? 'text-white' : 'text-fd-navy'
|
||||
|
||||
return (
|
||||
<section className={`w-full py-20 md:py-28 lg:py-[173px] ${bgClass}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col items-center gap-8">
|
||||
<h2 className={`w-full max-w-[696px] font-joey-bold text-fd-h1 text-center ${headingColor}`}>
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
<p className={`w-full max-w-[1112px] font-joey text-fd-h2 text-center ${bodyColor}`}>
|
||||
{body}
|
||||
</p>
|
||||
|
||||
{ctaText && (
|
||||
<a href={ctaLink || '#'} className="fd-btn-primary">
|
||||
{ctaText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
45
src/blocks/FDFeatureAnnouncementBlock/config.ts
Normal file
45
src/blocks/FDFeatureAnnouncementBlock/config.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDFeatureAnnouncementBlock: Block = {
|
||||
slug: 'fdFeatureAnnouncement',
|
||||
interfaceName: 'FDFeatureAnnouncementBlock',
|
||||
labels: {
|
||||
singular: 'FD Feature Announcement',
|
||||
plural: 'FD Feature Announcements',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Rubrik',
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
label: 'Brödtext',
|
||||
},
|
||||
{
|
||||
name: 'ctaText',
|
||||
type: 'text',
|
||||
label: 'CTA-text (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'ctaLink',
|
||||
type: 'text',
|
||||
label: 'CTA-länk',
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
label: 'Tema',
|
||||
defaultValue: 'gray',
|
||||
options: [
|
||||
{ label: 'Grå bakgrund', value: 'gray' },
|
||||
{ label: 'Ljust', value: 'light' },
|
||||
{ label: 'Mörkt', value: 'dark' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
78
src/blocks/FDHeaderTextImageBlock/Component.tsx
Normal file
78
src/blocks/FDHeaderTextImageBlock/Component.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import React from 'react'
|
||||
import type { FDHeaderTextImageBlock as FDHeaderTextImageBlockProps, Media } from '@/payload-types'
|
||||
import { FDImage } from '@/components/FDImage'
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
gray: 'bg-fd-gray-light',
|
||||
navy: 'bg-fd-navy',
|
||||
}
|
||||
|
||||
const textMap: Record<string, string> = {
|
||||
navy: 'text-fd-navy',
|
||||
white: 'text-white',
|
||||
}
|
||||
|
||||
const overlayMap: Record<string, string> = {
|
||||
none: '',
|
||||
navyLight: 'bg-fd-navy/20',
|
||||
navyMedium: 'bg-fd-navy/40',
|
||||
yellowLight: 'bg-fd-yellow/20',
|
||||
yellowMedium: 'bg-fd-yellow/40',
|
||||
sepia: 'bg-[#8B7D3C]/30',
|
||||
blackLight: 'bg-black/20',
|
||||
blackMedium: 'bg-black/40',
|
||||
}
|
||||
|
||||
const roundedMap: Record<string, string> = {
|
||||
none: '',
|
||||
medium: 'rounded-[24px]',
|
||||
large: 'rounded-[40px]',
|
||||
}
|
||||
|
||||
export const FDHeaderTextImageBlockComponent: React.FC<FDHeaderTextImageBlockProps> = ({
|
||||
heading,
|
||||
body,
|
||||
image,
|
||||
imageOverlay = 'none',
|
||||
imageRounded = 'large',
|
||||
textAlign = 'center',
|
||||
background = 'white',
|
||||
textColor = 'navy',
|
||||
}) => {
|
||||
const bg = bgMap[background || 'white']
|
||||
const txt = textMap[textColor || 'navy']
|
||||
const overlay = overlayMap[imageOverlay || 'none']
|
||||
const rounded = roundedMap[imageRounded || 'large']
|
||||
const align = textAlign === 'center' ? 'text-center' : 'text-left'
|
||||
const media = image as Media
|
||||
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${bg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col gap-8 md:gap-10">
|
||||
{(heading || body) && (
|
||||
<div className={`flex flex-col gap-4 md:gap-6 ${align} ${textAlign === 'center' ? 'max-w-[900px] mx-auto' : ''}`}>
|
||||
{heading && (
|
||||
<h2 className={`font-joey-heavy text-fd-h1 ${txt}`}>{heading}</h2>
|
||||
)}
|
||||
{body && (
|
||||
<p className={`font-joey text-fd-body ${txt} opacity-80`}>{body}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{media?.url && (
|
||||
<div className={`relative w-full overflow-hidden ${rounded}`}>
|
||||
<FDImage
|
||||
media={media}
|
||||
size="hero"
|
||||
className="w-full h-auto object-cover"
|
||||
sizes="(max-width: 1200px) 100vw, 1200px"
|
||||
fallbackAlt={heading || ''}
|
||||
/>
|
||||
{overlay && <div className={`absolute inset-0 ${overlay}`} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
87
src/blocks/FDHeaderTextImageBlock/config.ts
Normal file
87
src/blocks/FDHeaderTextImageBlock/config.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDHeaderTextImageBlock: Block = {
|
||||
slug: 'fdHeaderTextImage',
|
||||
interfaceName: 'FDHeaderTextImageBlock',
|
||||
labels: {
|
||||
singular: 'FD Header Text Image',
|
||||
plural: 'FD Header Text Images',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Rubrik (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'textarea',
|
||||
label: 'Brödtext (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Bild',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'imageOverlay',
|
||||
type: 'select',
|
||||
label: 'Bildöverlagring',
|
||||
defaultValue: 'none',
|
||||
options: [
|
||||
{ label: 'Ingen', value: 'none' },
|
||||
{ label: 'Navy (lätt)', value: 'navyLight' },
|
||||
{ label: 'Navy (medium)', value: 'navyMedium' },
|
||||
{ label: 'Gul (lätt)', value: 'yellowLight' },
|
||||
{ label: 'Gul (medium)', value: 'yellowMedium' },
|
||||
{ label: 'Sepia', value: 'sepia' },
|
||||
{ label: 'Svart (lätt)', value: 'blackLight' },
|
||||
{ label: 'Svart (medium)', value: 'blackMedium' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'imageRounded',
|
||||
type: 'select',
|
||||
label: 'Bildens hörnradie',
|
||||
defaultValue: 'large',
|
||||
options: [
|
||||
{ label: 'Ingen', value: 'none' },
|
||||
{ label: 'Medium (24px)', value: 'medium' },
|
||||
{ label: 'Stor (40px)', value: 'large' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textAlign',
|
||||
type: 'select',
|
||||
label: 'Textjustering',
|
||||
defaultValue: 'center',
|
||||
options: [
|
||||
{ label: 'Vänster', value: 'left' },
|
||||
{ label: 'Centrerad', value: 'center' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'background',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textColor',
|
||||
type: 'select',
|
||||
label: 'Textfärg',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
85
src/blocks/FDHeroBlock/Component.tsx
Normal file
85
src/blocks/FDHeroBlock/Component.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import React from 'react'
|
||||
import type { FDHeroBlock as FDHeroBlockProps, Media } from '@/payload-types'
|
||||
|
||||
export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
|
||||
heading,
|
||||
subheading,
|
||||
body,
|
||||
ctaText,
|
||||
ctaLink = '#',
|
||||
secondaryCtaText,
|
||||
secondaryCtaLink = '#',
|
||||
backgroundImage,
|
||||
overlayOpacity = '50',
|
||||
textColor = 'auto',
|
||||
theme = 'light',
|
||||
}) => {
|
||||
const media = backgroundImage as Media | undefined
|
||||
const bgImageUrl = media?.url || ''
|
||||
const hasBgImage = Boolean(bgImageUrl)
|
||||
const isDark = hasBgImage || theme === 'dark'
|
||||
|
||||
let headingColor: string
|
||||
let textBodyColor: string
|
||||
if (textColor === 'white') {
|
||||
headingColor = 'text-white'
|
||||
textBodyColor = 'text-white'
|
||||
} else if (textColor === 'navy') {
|
||||
headingColor = 'text-fd-navy'
|
||||
textBodyColor = 'text-fd-navy'
|
||||
} else {
|
||||
headingColor = isDark ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
textBodyColor = isDark ? 'text-white' : 'text-fd-navy'
|
||||
}
|
||||
|
||||
const overlayClass =
|
||||
overlayOpacity === '30' ? 'bg-black/30' : overlayOpacity === '70' ? 'bg-black/70' : 'bg-black/50'
|
||||
|
||||
const secondaryBtnClass =
|
||||
isDark && textColor !== 'navy' ? 'fd-btn-secondary-dark' : 'fd-btn-secondary'
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`relative w-full py-16 md:py-20 lg:py-[99px] ${hasBgImage ? '' : isDark ? 'bg-fd-navy' : 'bg-white'} overflow-hidden`}
|
||||
>
|
||||
{hasBgImage && (
|
||||
<>
|
||||
<img
|
||||
src={bgImageUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className={`absolute inset-0 ${overlayClass}`} aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
<div className="relative max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col items-start gap-6 md:gap-8">
|
||||
<h1 className={`w-full max-w-[884px] font-joey-heavy text-fd-display ${headingColor}`}>
|
||||
{heading}
|
||||
</h1>
|
||||
{subheading && (
|
||||
<h2 className={`w-full max-w-[884px] font-joey-medium text-fd-h1 ${textBodyColor}`}>
|
||||
{subheading}
|
||||
</h2>
|
||||
)}
|
||||
{body && (
|
||||
<p className={`w-full max-w-[597px] font-joey text-fd-body-lg ${textBodyColor}`}>
|
||||
{body}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
{ctaText && (
|
||||
<a href={ctaLink || '#'} className="fd-btn-primary">
|
||||
{ctaText}
|
||||
</a>
|
||||
)}
|
||||
{secondaryCtaText && (
|
||||
<a href={secondaryCtaLink || '#'} className={secondaryBtnClass}>
|
||||
{secondaryCtaText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
64
src/blocks/FDHeroBlock/FDHeroBlock-Component.tsx
Normal file
64
src/blocks/FDHeroBlock/FDHeroBlock-Component.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
import type { FDHeroBlock as FDHeroBlockProps, Media } from '@/payload-types'
|
||||
import { FDImage } from '@/components/FDImage'
|
||||
|
||||
export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
|
||||
heading,
|
||||
subheading,
|
||||
body,
|
||||
ctaText,
|
||||
ctaLink = '#',
|
||||
secondaryCtaText,
|
||||
secondaryCtaLink = '#',
|
||||
backgroundImage,
|
||||
overlayOpacity = '50',
|
||||
textColor = 'auto',
|
||||
theme = 'light',
|
||||
}) => {
|
||||
const media = backgroundImage as Media | undefined
|
||||
const hasBgImage = Boolean(media?.url)
|
||||
const isDark = hasBgImage || theme === 'dark'
|
||||
|
||||
let headingColor: string
|
||||
let textBodyColor: string
|
||||
if (textColor === 'white') {
|
||||
headingColor = 'text-white'
|
||||
textBodyColor = 'text-white'
|
||||
} else if (textColor === 'navy') {
|
||||
headingColor = 'text-fd-navy'
|
||||
textBodyColor = 'text-fd-navy'
|
||||
} else {
|
||||
headingColor = isDark ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
textBodyColor = isDark ? 'text-white' : 'text-fd-navy'
|
||||
}
|
||||
|
||||
const overlayClass = overlayOpacity === '30' ? 'bg-black/30' : overlayOpacity === '70' ? 'bg-black/70' : 'bg-black/50'
|
||||
|
||||
return (
|
||||
<section className={`relative w-full py-16 md:py-20 lg:py-[99px] ${hasBgImage ? '' : isDark ? 'bg-fd-navy' : 'bg-white'} overflow-hidden`}>
|
||||
{hasBgImage && (
|
||||
<>
|
||||
<FDImage
|
||||
media={backgroundImage as Media}
|
||||
size="hero"
|
||||
fill
|
||||
priority
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
sizes="100vw"
|
||||
fallbackAlt=""
|
||||
/>
|
||||
<div className={`absolute inset-0 ${overlayClass}`} aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
<div className="relative max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col items-start gap-6 md:gap-8">
|
||||
<h1 className={`w-full max-w-[884px] font-joey-heavy text-3xl md:text-5xl lg:text-[78px] leading-tight lg:leading-none ${headingColor}`}>{heading}</h1>
|
||||
{subheading && <h2 className={`w-full max-w-[884px] font-joey-medium text-2xl md:text-4xl lg:text-[50px] leading-tight ${textBodyColor}`}>{subheading}</h2>}
|
||||
{body && <p className={`w-full max-w-[597px] font-joey text-lg md:text-xl lg:text-2xl lg:leading-snug ${textBodyColor}`}>{body}</p>}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
{ctaText && <a href={ctaLink || '#'} className="inline-flex items-center justify-center px-8 py-2.5 bg-fd-yellow hover:bg-fd-yellow/90 rounded-full font-joey-bold text-fd-navy text-lg md:text-2xl leading-[38px] transition-colors">{ctaText}</a>}
|
||||
{secondaryCtaText && <a href={secondaryCtaLink || '#'} className={`inline-flex items-center justify-center px-8 py-2.5 rounded-full font-joey-bold text-lg md:text-2xl leading-[38px] border-2 transition-colors ${textColor === 'navy' ? 'border-fd-navy text-fd-navy hover:bg-fd-navy/5' : isDark ? 'border-white text-white hover:bg-white/10' : 'border-fd-navy text-fd-navy hover:bg-fd-navy/5'}`}>{secondaryCtaText}</a>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
104
src/blocks/FDHeroBlock/config.ts
Normal file
104
src/blocks/FDHeroBlock/config.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDHeroBlock: Block = {
|
||||
slug: 'fdHero',
|
||||
interfaceName: 'FDHeroBlock',
|
||||
labels: {
|
||||
singular: 'FD Hero',
|
||||
plural: 'FD Heroes',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Rubrik',
|
||||
defaultValue: 'Sveriges bästa IT-ekosystem för företag',
|
||||
},
|
||||
{
|
||||
name: 'subheading',
|
||||
type: 'text',
|
||||
label: 'Underrubrik',
|
||||
defaultValue: 'Fiber, Backup, Colocation och Cloud',
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'textarea',
|
||||
label: 'Brödtext',
|
||||
defaultValue:
|
||||
'För företag som väljer Sverige. Vi levererar dedikerad fiber, backup, colocation och cloud – allt från en leverantör med svenskt huvudmannaskap.',
|
||||
},
|
||||
{
|
||||
name: 'ctaText',
|
||||
type: 'text',
|
||||
label: 'CTA-knapp text',
|
||||
defaultValue: 'Kom igång',
|
||||
},
|
||||
{
|
||||
name: 'ctaLink',
|
||||
type: 'text',
|
||||
label: 'CTA-knapp länk',
|
||||
defaultValue: '/kontakt',
|
||||
},
|
||||
{
|
||||
name: 'secondaryCtaText',
|
||||
type: 'text',
|
||||
label: 'Sekundär CTA text',
|
||||
defaultValue: 'Kontakta oss',
|
||||
},
|
||||
{
|
||||
name: 'secondaryCtaLink',
|
||||
type: 'text',
|
||||
label: 'Sekundär CTA länk',
|
||||
defaultValue: '/kontakt',
|
||||
},
|
||||
{
|
||||
name: 'backgroundImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Bakgrundsbild (valfri)',
|
||||
admin: {
|
||||
description: 'Fullbreddsbild bakom texten. Lämna tom för enfärgad bakgrund.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'overlayOpacity',
|
||||
type: 'select',
|
||||
label: 'Overlay-styrka',
|
||||
defaultValue: '50',
|
||||
admin: {
|
||||
condition: (_, siblingData) => Boolean(siblingData?.backgroundImage),
|
||||
description: 'Hur mörk overlay över bilden (för läsbarhet)',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Lätt (30%)', value: '30' },
|
||||
{ label: 'Medium (50%)', value: '50' },
|
||||
{ label: 'Stark (70%)', value: '70' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textColor',
|
||||
type: 'select',
|
||||
label: 'Textfärg',
|
||||
defaultValue: 'auto',
|
||||
options: [
|
||||
{ label: 'Automatisk (baserat på tema)', value: 'auto' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Blå (navy)', value: 'navy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
label: 'Tema',
|
||||
defaultValue: 'light',
|
||||
admin: {
|
||||
description: 'Ignoreras om bakgrundsbild är vald',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Ljust', value: 'light' },
|
||||
{ label: 'Mörkt', value: 'dark' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
82
src/blocks/FDIconBarBlock/Component.tsx
Normal file
82
src/blocks/FDIconBarBlock/Component.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import type { FDIconBarBlock as FDIconBarBlockProps, Media } from '@/payload-types'
|
||||
import { FDImage } from '@/components/FDImage'
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
gray: 'bg-fd-gray-light',
|
||||
navy: 'bg-fd-navy',
|
||||
yellow: 'bg-fd-yellow',
|
||||
}
|
||||
|
||||
const textColorMap: Record<string, string> = {
|
||||
navy: 'text-fd-navy',
|
||||
white: 'text-white',
|
||||
}
|
||||
|
||||
const iconBgMap: Record<string, string> = {
|
||||
navy: 'bg-fd-navy',
|
||||
yellow: 'bg-fd-yellow',
|
||||
gray: 'bg-[#e5e5e5]',
|
||||
none: '',
|
||||
}
|
||||
|
||||
export const FDIconBarBlockComponent: React.FC<FDIconBarBlockProps> = ({
|
||||
heading,
|
||||
icons,
|
||||
iconStyle = 'navy',
|
||||
background = 'gray',
|
||||
textColor = 'navy',
|
||||
}) => {
|
||||
const bg = bgMap[background || 'gray']
|
||||
const txtColor = textColorMap[textColor || 'navy']
|
||||
const iconBg = iconBgMap[iconStyle || 'navy']
|
||||
const hasCircle = iconStyle !== 'none'
|
||||
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${bg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
{heading && (
|
||||
<h2 className={`font-joey-heavy text-fd-h1 mb-10 md:mb-14 ${txtColor}`}>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-center gap-8 md:gap-10 lg:gap-12">
|
||||
{icons?.map((item, index) => {
|
||||
const media = item.icon as Media
|
||||
const content = (
|
||||
<div className="flex flex-col items-center gap-3 md:gap-4">
|
||||
<div
|
||||
className={`w-20 h-20 md:w-24 md:h-24 lg:w-28 lg:h-28 flex items-center justify-center ${
|
||||
hasCircle ? `${iconBg} rounded-full` : ''
|
||||
}`}
|
||||
>
|
||||
{media?.url && (
|
||||
<FDImage
|
||||
media={media}
|
||||
size="thumbnail"
|
||||
className="w-10 h-10 md:w-12 md:h-12 lg:w-14 lg:h-14 object-contain"
|
||||
sizes="56px"
|
||||
fallbackAlt={item.label}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={`font-joey-bold text-fd-body text-center ${txtColor}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
if (item.link) {
|
||||
return (
|
||||
<a key={index} href={item.link} className="hover:opacity-80 transition-opacity">
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return <div key={index}>{content}</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
78
src/blocks/FDIconBarBlock/config.ts
Normal file
78
src/blocks/FDIconBarBlock/config.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDIconBarBlock: Block = {
|
||||
slug: 'fdIconBar',
|
||||
interfaceName: 'FDIconBarBlock',
|
||||
labels: {
|
||||
singular: 'FD Icon Row',
|
||||
plural: 'FD Icon Rows',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Rubrik (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'icons',
|
||||
type: 'array',
|
||||
label: 'Ikoner',
|
||||
minRows: 1,
|
||||
maxRows: 8,
|
||||
fields: [
|
||||
{
|
||||
name: 'icon',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
label: 'Ikon (SVG/PNG)',
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Etikett',
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
type: 'text',
|
||||
label: 'Länk (valfri)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'iconStyle',
|
||||
type: 'select',
|
||||
label: 'Ikonstil',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy cirkel', value: 'navy' },
|
||||
{ label: 'Gul cirkel', value: 'yellow' },
|
||||
{ label: 'Grå cirkel', value: 'gray' },
|
||||
{ label: 'Ingen bakgrund', value: 'none' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'background',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'gray',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textColor',
|
||||
type: 'select',
|
||||
label: 'Textfärg',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
99
src/blocks/FDLocationsGridBlock/Component.tsx
Normal file
99
src/blocks/FDLocationsGridBlock/Component.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import type { FDLocationsGridBlock as Props, Media } from '@/payload-types'
|
||||
|
||||
export const FDLocationsGridBlockComponent: React.FC<Props> = ({
|
||||
title,
|
||||
description,
|
||||
ctaText,
|
||||
ctaLink = '/kontakt',
|
||||
cards = [],
|
||||
hoverColor = 'navy',
|
||||
sectionBackground = 'white',
|
||||
}) => {
|
||||
const bgClass =
|
||||
sectionBackground === 'navy' ? 'bg-fd-navy' :
|
||||
sectionBackground === 'gray' ? 'bg-fd-surface-alt' : 'bg-white'
|
||||
|
||||
const titleClass = sectionBackground === 'navy' ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
const bodyClass = sectionBackground === 'navy' ? 'text-white' : 'text-fd-navy'
|
||||
|
||||
const hoverBgClass =
|
||||
hoverColor === 'yellow' ? 'bg-fd-yellow' :
|
||||
hoverColor === 'mint' ? 'bg-fd-mint' : 'bg-fd-navy'
|
||||
|
||||
const hoverTextClass =
|
||||
hoverColor === 'yellow' ? 'text-fd-navy' :
|
||||
hoverColor === 'mint' ? 'text-fd-navy' : 'text-white'
|
||||
|
||||
return (
|
||||
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
|
||||
{(title || description || ctaText) && (
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6 mb-12 md:mb-16">
|
||||
{title && (
|
||||
<h2 className={`font-joey-medium text-fd-h1 ${titleClass} lg:max-w-[380px]`}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
<div className="flex flex-col gap-4 lg:max-w-[560px]">
|
||||
{description && (
|
||||
<p className={`font-joey text-fd-body-lg ${bodyClass}`}>{description}</p>
|
||||
)}
|
||||
{ctaText && (
|
||||
<a href={ctaLink || '#'} className="self-start fd-btn-primary">
|
||||
{ctaText} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{(cards ?? []).map((card, i) => {
|
||||
const media = card.image as Media | undefined
|
||||
const isLink = Boolean(card.link)
|
||||
const className = `group relative overflow-hidden rounded-[30px] md:rounded-[50px] aspect-[4/3] block ${isLink ? 'cursor-pointer' : ''}`
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{media?.url && (
|
||||
<img
|
||||
src={media.url}
|
||||
alt={(media as any).alt || card.locationName}
|
||||
className="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent transition-opacity duration-300 group-hover:opacity-0" />
|
||||
<div className="absolute bottom-0 left-0 px-7 md:px-8 pb-8 md:pb-10 transition-opacity duration-300 group-hover:opacity-0">
|
||||
<span className="font-joey-bold text-white text-fd-h3">{card.locationName}</span>
|
||||
</div>
|
||||
<div className={`absolute inset-0 ${hoverBgClass} flex flex-col justify-end px-7 md:px-8 pb-10 md:pb-12 pt-6 opacity-0 group-hover:opacity-100 transition-opacity duration-300`}>
|
||||
<span className={`font-joey-bold text-fd-h2 ${hoverTextClass} mb-1`}>
|
||||
{card.locationName}
|
||||
</span>
|
||||
{card.address && (
|
||||
<span className={`font-joey text-fd-body ${hoverTextClass} opacity-80`}>
|
||||
{card.address}
|
||||
</span>
|
||||
)}
|
||||
{card.link && (
|
||||
<span className={`mt-3 font-joey-bold text-fd-small ${hoverTextClass}`}>
|
||||
→ Se mer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return isLink ? (
|
||||
<a key={i} href={card.link!} className={className}>{inner}</a>
|
||||
) : (
|
||||
<div key={i} className={className}>{inner}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
87
src/blocks/FDLocationsGridBlock/config.ts
Normal file
87
src/blocks/FDLocationsGridBlock/config.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDLocationsGridBlock: Block = {
|
||||
slug: 'fdLocationsGrid',
|
||||
interfaceName: 'FDLocationsGridBlock',
|
||||
labels: {
|
||||
singular: 'FD Platser Grid',
|
||||
plural: 'FD Platser Grid',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: 'Rubrik (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Beskrivning (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'ctaText',
|
||||
type: 'text',
|
||||
label: 'CTA-knapp text (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'ctaLink',
|
||||
type: 'text',
|
||||
label: 'CTA-knapp länk',
|
||||
defaultValue: '/kontakt',
|
||||
},
|
||||
{
|
||||
name: 'cards',
|
||||
type: 'array',
|
||||
label: 'Platskort',
|
||||
minRows: 1,
|
||||
maxRows: 12,
|
||||
fields: [
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
label: 'Platsbild',
|
||||
},
|
||||
{
|
||||
name: 'locationName',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Platsnamn',
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
type: 'text',
|
||||
label: 'Adress',
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
type: 'text',
|
||||
label: 'Länk (valfri)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'hoverColor',
|
||||
type: 'select',
|
||||
label: 'Hover-täckfärg',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
{ label: 'Mint', value: 'mint' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
235
src/blocks/FDNewsletterBlock/Component.tsx
Normal file
235
src/blocks/FDNewsletterBlock/Component.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import type { FDNewsletterBlock as FDNewsletterBlockProps } from '@/payload-types'
|
||||
|
||||
const bgClasses: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
navy: 'bg-fd-navy',
|
||||
gray: 'bg-fd-surface-alt',
|
||||
yellow: 'bg-fd-yellow',
|
||||
}
|
||||
|
||||
export const FDNewsletterBlockComponent: React.FC<FDNewsletterBlockProps> = ({
|
||||
heading = 'Håll dig uppdaterad',
|
||||
description,
|
||||
submitEndpoint,
|
||||
buttonText = 'Prenumerera',
|
||||
successMessage = 'Tack! Du är nu prenumerant.',
|
||||
consentText,
|
||||
privacyPolicyLink = '/integritetspolicy',
|
||||
collectName = false,
|
||||
collectCompany = false,
|
||||
layout = 'inline',
|
||||
sectionBackground = 'navy',
|
||||
textColor = 'auto',
|
||||
}) => {
|
||||
const [email, setEmail] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [company, setCompany] = useState('')
|
||||
const [consented, setConsented] = useState(false)
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
const isDark = sectionBackground === 'navy'
|
||||
const headingColor =
|
||||
textColor === 'white' ? 'text-white'
|
||||
: textColor === 'navy' ? 'text-fd-navy'
|
||||
: isDark ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
|
||||
const bodyColor =
|
||||
textColor === 'white' ? 'text-white'
|
||||
: textColor === 'navy' ? 'text-fd-navy'
|
||||
: isDark ? 'text-white' : 'text-fd-navy'
|
||||
|
||||
const bgClass = bgClasses[sectionBackground ?? 'navy'] || 'bg-fd-navy'
|
||||
|
||||
const inputClass =
|
||||
'w-full px-5 py-3 rounded-full text-fd-navy font-joey text-fd-body focus:outline-none focus:ring-2 focus:ring-fd-yellow bg-white border border-gray-200'
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email || !consented) return
|
||||
setStatus('loading')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const payload: Record<string, string> = { email }
|
||||
if (collectName && name) payload.name = name
|
||||
if (collectCompany && company) payload.company = company
|
||||
const response = await fetch(submitEndpoint || '/api/newsletter', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
setStatus('success')
|
||||
setEmail('')
|
||||
setName('')
|
||||
setCompany('')
|
||||
setConsented(false)
|
||||
} catch (err) {
|
||||
setStatus('error')
|
||||
setErrorMsg('Något gick fel. Försök igen senare.')
|
||||
}
|
||||
},
|
||||
[email, name, company, consented, submitEndpoint, collectName, collectCompany],
|
||||
)
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<section className={`relative w-full py-16 md:py-20 lg:py-[99px] ${bgClass} overflow-hidden`}>
|
||||
<div className="relative max-w-[1200px] mx-auto px-6 md:px-8 text-center">
|
||||
<div className="inline-flex items-center gap-3 mb-4">
|
||||
<svg className="w-8 h-8 text-fd-mint" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<p className={`font-joey-bold text-fd-h2 ${headingColor}`}>
|
||||
{successMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const isCard = layout === 'card'
|
||||
const isInline = layout === 'inline'
|
||||
|
||||
return (
|
||||
<section className={`relative w-full py-16 md:py-20 lg:py-[99px] ${bgClass} overflow-hidden`}>
|
||||
<div className="relative max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
{isCard ? (
|
||||
<div className="max-w-[700px] mx-auto bg-white/10 backdrop-blur-sm rounded-[70px] p-8 md:p-12 lg:p-16">
|
||||
<div className="text-center mb-8">
|
||||
{heading && (
|
||||
<h2 className={`font-joey-medium text-fd-h1 mb-4 ${headingColor}`}>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className={`font-joey text-fd-body-lg ${bodyColor}`}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{collectName && (
|
||||
<input type="text" placeholder="Namn" value={name} onChange={(e) => setName(e.target.value)} className={`${inputClass} mb-3`} />
|
||||
)}
|
||||
{collectCompany && (
|
||||
<input type="text" placeholder="Företag" value={company} onChange={(e) => setCompany(e.target.value)} className={`${inputClass} mb-3`} />
|
||||
)}
|
||||
<input type="email" required placeholder="Din e-postadress" value={email} onChange={(e) => setEmail(e.target.value)} className={`${inputClass} mb-4`} />
|
||||
<ConsentCheckbox consented={consented} setConsented={setConsented} consentText={consentText} privacyPolicyLink={privacyPolicyLink} isDark={isDark} bodyColor={bodyColor} />
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!email || !consented || status === 'loading'}
|
||||
className="fd-btn-primary w-full mt-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === 'loading' ? 'Skickar...' : buttonText}
|
||||
</button>
|
||||
{status === 'error' && (
|
||||
<p className="text-red-400 text-fd-small mt-3 text-center font-joey">{errorMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={`${isInline ? 'text-center md:text-left' : 'text-center'} mb-8 md:mb-12`}>
|
||||
{heading && (
|
||||
<h2 className={`font-joey-medium text-fd-h1 mb-4 ${headingColor}`}>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className={`font-joey text-fd-body-lg max-w-[700px] ${isInline ? '' : 'mx-auto'} ${bodyColor}`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`max-w-[700px] ${isInline ? '' : 'mx-auto'}`}>
|
||||
{collectName && (
|
||||
<input type="text" placeholder="Namn" value={name} onChange={(e) => setName(e.target.value)} className={`${inputClass} mb-3`} />
|
||||
)}
|
||||
{collectCompany && (
|
||||
<input type="text" placeholder="Företag" value={company} onChange={(e) => setCompany(e.target.value)} className={`${inputClass} mb-3`} />
|
||||
)}
|
||||
|
||||
{isInline ? (
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<input type="email" required placeholder="Din e-postadress" value={email} onChange={(e) => setEmail(e.target.value)} className={`${inputClass} flex-1`} />
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!email || !consented || status === 'loading'}
|
||||
className="fd-btn-primary whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === 'loading' ? 'Skickar...' : buttonText}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input type="email" required placeholder="Din e-postadress" value={email} onChange={(e) => setEmail(e.target.value)} className={`${inputClass} mb-4`} />
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!email || !consented || status === 'loading'}
|
||||
className="fd-btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === 'loading' ? 'Skickar...' : buttonText}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<ConsentCheckbox consented={consented} setConsented={setConsented} consentText={consentText} privacyPolicyLink={privacyPolicyLink} isDark={isDark} bodyColor={bodyColor} />
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="text-red-400 text-fd-small mt-3 font-joey">{errorMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConsentCheckboxProps {
|
||||
consented: boolean
|
||||
setConsented: (v: boolean) => void
|
||||
consentText?: string | null
|
||||
privacyPolicyLink?: string | null
|
||||
isDark: boolean
|
||||
bodyColor: string
|
||||
}
|
||||
|
||||
const ConsentCheckbox: React.FC<ConsentCheckboxProps> = ({
|
||||
consented,
|
||||
setConsented,
|
||||
consentText,
|
||||
privacyPolicyLink,
|
||||
isDark,
|
||||
bodyColor,
|
||||
}) => (
|
||||
<label className={`flex items-start gap-3 cursor-pointer font-joey text-fd-small ${bodyColor}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consented}
|
||||
onChange={(e) => setConsented(e.target.checked)}
|
||||
className="mt-1 w-5 h-5 rounded border-2 border-current accent-fd-yellow flex-shrink-0"
|
||||
/>
|
||||
<span>
|
||||
{consentText || 'Jag godkänner att mina uppgifter används enligt vår integritetspolicy.'}{' '}
|
||||
{privacyPolicyLink && (
|
||||
<a
|
||||
href={privacyPolicyLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`underline ${isDark ? 'text-fd-yellow hover:text-fd-yellow/80' : 'text-fd-navy hover:text-fd-navy/70'}`}
|
||||
>
|
||||
Läs mer
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
118
src/blocks/FDNewsletterBlock/config.ts
Normal file
118
src/blocks/FDNewsletterBlock/config.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDNewsletterBlock: Block = {
|
||||
slug: 'fdNewsletter',
|
||||
interfaceName: 'FDNewsletterBlock',
|
||||
labels: {
|
||||
singular: 'FD Nyhetsbrev',
|
||||
plural: 'FD Nyhetsbrev',
|
||||
},
|
||||
fields: [
|
||||
// --- Content ---
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Rubrik',
|
||||
defaultValue: 'Håll dig uppdaterad',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Beskrivning',
|
||||
defaultValue: 'Prenumerera på vårt nyhetsbrev för att få de senaste nyheterna om fiber, cloud och IT-infrastruktur.',
|
||||
},
|
||||
// --- Form settings ---
|
||||
{
|
||||
name: 'submitEndpoint',
|
||||
type: 'text',
|
||||
label: 'Formulärets endpoint-URL',
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
'URL dit formulärdata skickas (POST). T.ex. /api/newsletter, en Lime CRM webhook, eller en tredjeparts-URL.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'buttonText',
|
||||
type: 'text',
|
||||
label: 'Knapptext',
|
||||
defaultValue: 'Prenumerera',
|
||||
},
|
||||
{
|
||||
name: 'successMessage',
|
||||
type: 'text',
|
||||
label: 'Bekräftelsemeddelande',
|
||||
defaultValue: 'Tack! Du är nu prenumerant.',
|
||||
},
|
||||
{
|
||||
name: 'consentText',
|
||||
type: 'text',
|
||||
label: 'Samtycketext',
|
||||
defaultValue: 'Jag godkänner att mina uppgifter används enligt vår integritetspolicy.',
|
||||
admin: {
|
||||
description: 'Text bredvid samtyckeskryssrutan (GDPR)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'privacyPolicyLink',
|
||||
type: 'text',
|
||||
label: 'Länk till integritetspolicy',
|
||||
defaultValue: '/integritetspolicy',
|
||||
},
|
||||
// --- Extra fields toggle ---
|
||||
{
|
||||
name: 'collectName',
|
||||
type: 'checkbox',
|
||||
label: 'Samla in namn',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Lägg till ett namnfält i formuläret',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'collectCompany',
|
||||
type: 'checkbox',
|
||||
label: 'Samla in företagsnamn',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Lägg till ett fält för företagsnamn',
|
||||
},
|
||||
},
|
||||
// --- Layout ---
|
||||
{
|
||||
name: 'layout',
|
||||
type: 'select',
|
||||
label: 'Layout',
|
||||
defaultValue: 'inline',
|
||||
options: [
|
||||
{ label: 'Inline (fält + knapp på en rad)', value: 'inline' },
|
||||
{ label: 'Staplad (fält ovanför knapp)', value: 'stacked' },
|
||||
{ label: 'Kort (centrerat med bakgrund)', value: 'card' },
|
||||
],
|
||||
},
|
||||
// --- Style ---
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textColor',
|
||||
type: 'select',
|
||||
label: 'Textfärg',
|
||||
defaultValue: 'auto',
|
||||
options: [
|
||||
{ label: 'Automatisk', value: 'auto' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
57
src/blocks/FDPartnersLogosBlock/Component.tsx
Normal file
57
src/blocks/FDPartnersLogosBlock/Component.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import type { FDPartnersLogosBlock as FDPartnersLogosBlockProps, Media } from '@/payload-types'
|
||||
|
||||
export const FDPartnersLogosBlockComponent: React.FC<FDPartnersLogosBlockProps> = ({
|
||||
title,
|
||||
logos = [],
|
||||
displayMode = 'color',
|
||||
sectionBackground = 'gray',
|
||||
}) => {
|
||||
const bgClass =
|
||||
sectionBackground === 'navy'
|
||||
? 'bg-fd-navy'
|
||||
: sectionBackground === 'gray'
|
||||
? 'bg-fd-surface-alt'
|
||||
: 'bg-white'
|
||||
|
||||
const titleClass = sectionBackground === 'navy' ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
|
||||
const imgFilter =
|
||||
displayMode === 'monochrome'
|
||||
? 'grayscale opacity-40 hover:grayscale-0 hover:opacity-100 transition-all duration-500'
|
||||
: 'hover:opacity-80 transition-opacity duration-300'
|
||||
|
||||
return (
|
||||
<section className={`fd-section ${bgClass}`}>
|
||||
<div className="fd-container">
|
||||
{title && (
|
||||
<h2 className={`font-joey-medium text-fd-h2 text-center mb-10 md:mb-14 ${titleClass}`}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-center gap-10 md:gap-14 lg:gap-16">
|
||||
{(logos ?? []).map((item, i) => {
|
||||
const media = item.image as Media | undefined
|
||||
if (!media?.url) return null
|
||||
|
||||
const logoEl = (
|
||||
<img
|
||||
src={media.url}
|
||||
alt={item.alt || ''}
|
||||
className={`h-12 md:h-16 lg:h-[72px] w-auto max-w-[180px] md:max-w-[220px] object-contain ${imgFilter}`}
|
||||
/>
|
||||
)
|
||||
|
||||
return item.link ? (
|
||||
<a key={i} href={item.link} target="_blank" rel="noopener noreferrer" className="flex items-center justify-center">
|
||||
{logoEl}
|
||||
</a>
|
||||
) : (
|
||||
<div key={i} className="flex items-center justify-center">{logoEl}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
70
src/blocks/FDPartnersLogosBlock/config.ts
Normal file
70
src/blocks/FDPartnersLogosBlock/config.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDPartnersLogosBlock: Block = {
|
||||
slug: 'fdPartnersLogos',
|
||||
interfaceName: 'FDPartnersLogosBlock',
|
||||
labels: {
|
||||
singular: 'FD Partnerlogotyper',
|
||||
plural: 'FD Partnerlogotyper',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: 'Rubrik (valfri)',
|
||||
defaultValue: 'Våra partners',
|
||||
},
|
||||
{
|
||||
name: 'logos',
|
||||
type: 'array',
|
||||
label: 'Logotyper',
|
||||
minRows: 1,
|
||||
fields: [
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
label: 'Logotyp',
|
||||
},
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
label: 'Alt-text',
|
||||
admin: {
|
||||
description: 'Beskrivning av logotypen (tillgänglighet)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
type: 'text',
|
||||
label: 'Länk (valfri)',
|
||||
admin: {
|
||||
description: 'URL till partnerns webbplats',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'displayMode',
|
||||
type: 'select',
|
||||
label: 'Visningsläge',
|
||||
defaultValue: 'color',
|
||||
options: [
|
||||
{ label: 'Färg', value: 'color' },
|
||||
{ label: 'Svartvit (färg vid hover)', value: 'monochrome' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'gray',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Ljusgrå', value: 'gray' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
96
src/blocks/FDPricingCardBlock/Component.tsx
Normal file
96
src/blocks/FDPricingCardBlock/Component.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import type { FDPricingCardBlock as FDPricingCardBlockProps } from '@/payload-types'
|
||||
|
||||
const sectionBgMap: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
navy: 'bg-fd-navy',
|
||||
gray: 'bg-fd-gray-light',
|
||||
yellow: 'bg-fd-yellow',
|
||||
}
|
||||
|
||||
const titleColorMap: Record<string, string> = {
|
||||
navy: 'text-fd-navy',
|
||||
white: 'text-white',
|
||||
yellow: 'text-fd-yellow',
|
||||
}
|
||||
|
||||
const cardStyleMap: Record<string, { bg: string; border: string; title: string; subtitle: string; body: string; bullet: string }> = {
|
||||
outlined: { bg: 'bg-white', border: 'border-[6px] border-[#d1d5db]', title: 'text-fd-navy', subtitle: 'text-fd-navy', body: 'text-fd-navy/80', bullet: 'text-fd-navy' },
|
||||
navy: { bg: 'bg-fd-navy', border: '', title: 'text-fd-yellow', subtitle: 'text-white', body: 'text-white/80', bullet: 'text-white' },
|
||||
gray: { bg: 'bg-[#e5e5e5]', border: '', title: 'text-fd-navy', subtitle: 'text-fd-navy', body: 'text-fd-navy/80', bullet: 'text-fd-navy' },
|
||||
yellow: { bg: 'bg-fd-yellow', border: '', title: 'text-fd-navy', subtitle: 'text-fd-navy', body: 'text-fd-navy/80', bullet: 'text-fd-navy' },
|
||||
white: { bg: 'bg-white shadow-lg', border: '', title: 'text-fd-navy', subtitle: 'text-fd-navy', body: 'text-fd-navy/80', bullet: 'text-fd-navy' },
|
||||
}
|
||||
|
||||
// Map to fd-btn classes; the 'a' tag just uses the class directly
|
||||
const buttonStyleMap: Record<string, string> = {
|
||||
yellow: 'fd-btn-primary',
|
||||
navy: 'fd-btn-navy',
|
||||
outlinedNavy: 'fd-btn-secondary',
|
||||
outlinedWhite: 'fd-btn-secondary-dark',
|
||||
}
|
||||
|
||||
const gridColsMap: Record<number, string> = {
|
||||
1: 'lg:grid-cols-1 max-w-[500px] mx-auto',
|
||||
2: 'lg:grid-cols-2',
|
||||
3: 'lg:grid-cols-3',
|
||||
}
|
||||
|
||||
export const FDPricingCardBlockComponent: React.FC<FDPricingCardBlockProps> = ({
|
||||
sectionTitle,
|
||||
cards,
|
||||
cardStyle = 'outlined',
|
||||
buttonColor = 'yellow',
|
||||
sectionBackground = 'white',
|
||||
titleColor = 'navy',
|
||||
}) => {
|
||||
const sectionBg = sectionBgMap[sectionBackground || 'white']
|
||||
const sectionTitleColor = titleColorMap[titleColor || 'navy']
|
||||
const style = cardStyleMap[cardStyle || 'outlined']
|
||||
const btnClass = buttonStyleMap[buttonColor || 'yellow'] || 'fd-btn-primary'
|
||||
const cardCount = cards?.length || 1
|
||||
const gridCols = gridColsMap[cardCount] || gridColsMap[3]
|
||||
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${sectionBg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
{sectionTitle && (
|
||||
<h2 className={`font-joey-heavy text-fd-h1 text-center mb-10 md:mb-14 ${sectionTitleColor}`}>
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
)}
|
||||
<div className={`grid grid-cols-1 ${gridCols} gap-6 md:gap-8`}>
|
||||
{cards?.map((card, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${style.bg} ${style.border} rounded-[32px] md:rounded-[50px] lg:rounded-[70px] px-8 md:px-10 py-10 md:py-12 flex flex-col gap-5`}
|
||||
>
|
||||
<h3 className={`font-joey-heavy text-fd-h2 ${style.title}`}>{card.title}</h3>
|
||||
{card.subtitle && (
|
||||
<p className={`font-joey-bold text-fd-h3 ${style.subtitle}`}>{card.subtitle}</p>
|
||||
)}
|
||||
{card.description && (
|
||||
<p className={`font-joey text-fd-body ${style.body}`}>{card.description}</p>
|
||||
)}
|
||||
{card.bulletPoints && card.bulletPoints.length > 0 && (
|
||||
<ul className={`flex flex-col gap-1 ${style.bullet}`}>
|
||||
{card.bulletPoints.map((point, i) => (
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<span className="mt-2 w-2 h-2 rounded-full bg-current flex-shrink-0" />
|
||||
<span className="font-joey text-fd-body">{point.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{card.ctaText && (
|
||||
<div className="mt-auto pt-4">
|
||||
<a href={card.ctaLink || '#'} className={btnClass}>{card.ctaText}</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
127
src/blocks/FDPricingCardBlock/config.ts
Normal file
127
src/blocks/FDPricingCardBlock/config.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDPricingCardBlock: Block = {
|
||||
slug: 'fdPricingCard',
|
||||
interfaceName: 'FDPricingCardBlock',
|
||||
labels: {
|
||||
singular: 'FD Price Card',
|
||||
plural: 'FD Price Cards',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'sectionTitle',
|
||||
type: 'text',
|
||||
label: 'Blockrubrik (valfri)',
|
||||
admin: {
|
||||
description: 'Stor rubrik ovanför korten',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cards',
|
||||
type: 'array',
|
||||
label: 'Kort',
|
||||
minRows: 1,
|
||||
maxRows: 3,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Titel',
|
||||
admin: {
|
||||
description: 'T.ex. produktnamn eller tjänstenamn',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'subtitle',
|
||||
type: 'text',
|
||||
label: 'Undertitel',
|
||||
admin: {
|
||||
description: 'T.ex. pris: "från 640 kr/mån"',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Beskrivning',
|
||||
},
|
||||
{
|
||||
name: 'bulletPoints',
|
||||
type: 'array',
|
||||
label: 'Punktlista',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Punkt',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ctaText',
|
||||
type: 'text',
|
||||
label: 'CTA-knapp text',
|
||||
defaultValue: 'Få offert',
|
||||
},
|
||||
{
|
||||
name: 'ctaLink',
|
||||
type: 'text',
|
||||
label: 'CTA-knapp länk',
|
||||
defaultValue: '/kontakt',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'cardStyle',
|
||||
type: 'select',
|
||||
label: 'Kortstil',
|
||||
defaultValue: 'outlined',
|
||||
options: [
|
||||
{ label: 'Kontur (vit med ram)', value: 'outlined' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
{ label: 'Vit (skugga)', value: 'white' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'buttonColor',
|
||||
type: 'select',
|
||||
label: 'Knappfärg',
|
||||
defaultValue: 'yellow',
|
||||
options: [
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Kontur (navy ram)', value: 'outlinedNavy' },
|
||||
{ label: 'Kontur (vit ram)', value: 'outlinedWhite' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'titleColor',
|
||||
type: 'select',
|
||||
label: 'Rubrikfärg',
|
||||
defaultValue: 'navy',
|
||||
admin: {
|
||||
description: 'Färg på blockrubriken',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
116
src/blocks/FDServiceChooserBlock/Component.tsx
Normal file
116
src/blocks/FDServiceChooserBlock/Component.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import type { FDServiceChooserBlock as Props } from '@/payload-types'
|
||||
|
||||
export const FDServiceChooserBlockComponent: React.FC<Props> = ({
|
||||
title,
|
||||
description,
|
||||
categories = [],
|
||||
sectionBackground = 'gray',
|
||||
}) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [animating, setAnimating] = useState(false)
|
||||
const prevIndex = useRef(0)
|
||||
|
||||
const isDark = sectionBackground === 'navy'
|
||||
const bgClass = isDark ? 'bg-fd-navy' : sectionBackground === 'gray' ? 'bg-fd-surface-alt' : 'bg-white'
|
||||
const titleClass = isDark ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
const bodyClass = isDark ? 'text-white' : 'text-fd-navy'
|
||||
const cardBg = isDark ? 'bg-white/10 border border-white/10' : 'bg-white border border-fd-navy/10'
|
||||
|
||||
const handleTabChange = (i: number) => {
|
||||
if (i === activeIndex) return
|
||||
setAnimating(true)
|
||||
prevIndex.current = activeIndex
|
||||
setTimeout(() => {
|
||||
setActiveIndex(i)
|
||||
setAnimating(false)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const activeCategory = (categories ?? [])[activeIndex]
|
||||
|
||||
return (
|
||||
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
|
||||
<div className="text-center mb-10 md:mb-12">
|
||||
{title && (
|
||||
<h2 className={`font-joey-heavy text-fd-h1 mb-4 ${titleClass}`}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className={`font-joey text-fd-body-lg max-w-[600px] mx-auto ${bodyClass}`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-2 md:gap-3 mb-10 md:mb-12">
|
||||
{(categories ?? []).map((cat, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleTabChange(i)}
|
||||
className={`px-5 py-2 rounded-full font-joey-medium text-fd-body transition-all duration-200 border-2 ${
|
||||
activeIndex === i
|
||||
? 'bg-fd-yellow border-fd-yellow text-fd-navy shadow-sm'
|
||||
: isDark
|
||||
? 'border-white/30 text-white hover:border-fd-yellow hover:text-fd-yellow'
|
||||
: 'border-fd-navy/20 text-fd-navy hover:border-fd-yellow hover:bg-fd-yellow/10'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`transition-all duration-200 ${animating ? 'opacity-0 translate-y-2' : 'opacity-100 translate-y-0'}`}
|
||||
style={{ minHeight: '200px' }}
|
||||
>
|
||||
{activeCategory?.intro && (
|
||||
<p className={`text-center font-joey text-fd-body mb-6 opacity-70 ${bodyClass}`}>
|
||||
{activeCategory.intro}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{activeCategory?.services?.map((service, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded-[30px] md:rounded-[40px] p-8 md:p-10 flex flex-col gap-4 ${cardBg}`}
|
||||
style={{
|
||||
transition: 'opacity 0.3s, transform 0.3s',
|
||||
transitionDelay: `${i * 60}ms`,
|
||||
opacity: animating ? 0 : 1,
|
||||
transform: animating ? 'translateY(8px)' : 'translateY(0)',
|
||||
}}
|
||||
>
|
||||
<h3 className={`font-joey-bold text-fd-h3 ${isDark ? 'text-fd-yellow' : 'text-fd-navy'}`}>
|
||||
{service.title}
|
||||
</h3>
|
||||
{service.description && (
|
||||
<p className={`font-joey text-fd-body opacity-80 flex-1 ${bodyClass}`}>
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
{service.ctaText && (
|
||||
<div className="mt-auto pt-2">
|
||||
<a
|
||||
href={service.ctaLink || '/kontakt'}
|
||||
className={isDark ? 'fd-btn-secondary-dark' : 'fd-btn-secondary'}
|
||||
>
|
||||
{service.ctaText}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
88
src/blocks/FDServiceChooserBlock/config.ts
Normal file
88
src/blocks/FDServiceChooserBlock/config.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDServiceChooserBlock: Block = {
|
||||
slug: 'fdServiceChooser',
|
||||
interfaceName: 'FDServiceChooserBlock',
|
||||
labels: {
|
||||
singular: 'FD Tjänsteväljare',
|
||||
plural: 'FD Tjänsteväljare',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: 'Rubrik',
|
||||
defaultValue: 'Välj din bransch',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Beskrivning (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'array',
|
||||
label: 'Kategorier',
|
||||
minRows: 1,
|
||||
maxRows: 8,
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Kategorinamn',
|
||||
admin: {
|
||||
description: 'T.ex. "Butik & Handel", "Lager & Logistik"',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'intro',
|
||||
type: 'textarea',
|
||||
label: 'Kategoriintro (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'services',
|
||||
type: 'array',
|
||||
label: 'Tjänster',
|
||||
minRows: 1,
|
||||
maxRows: 4,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Tjänsttitel',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Beskrivning',
|
||||
},
|
||||
{
|
||||
name: 'ctaText',
|
||||
type: 'text',
|
||||
label: 'CTA-knapp text (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'ctaLink',
|
||||
type: 'text',
|
||||
label: 'CTA-knapp länk',
|
||||
defaultValue: '/kontakt',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'gray',
|
||||
options: [
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
63
src/blocks/FDServicesGridBlock/Component.tsx
Normal file
63
src/blocks/FDServicesGridBlock/Component.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import type { FDServicesGridBlock as FDServicesGridBlockProps, Media } from '@/payload-types'
|
||||
import { FDImage } from '@/components/FDImage'
|
||||
|
||||
const columnClasses: Record<string, string> = {
|
||||
'2': 'grid-cols-1 md:grid-cols-2',
|
||||
'3': 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
'4': 'grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
|
||||
export const FDServicesGridBlockComponent: React.FC<FDServicesGridBlockProps> = ({
|
||||
heading,
|
||||
services,
|
||||
columns = '4',
|
||||
}) => {
|
||||
return (
|
||||
<section className="relative w-full bg-white py-16 md:py-20 lg:py-24">
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
<h2 className="font-joey-heavy text-fd-h1 text-fd-navy mb-8 lg:mb-12">
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
<div className={`grid ${columnClasses[columns || '4']} gap-6 md:gap-10 lg:gap-[69px]`}>
|
||||
{services?.map((service, index) => {
|
||||
const media = service.image as Media
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col gap-4 md:gap-6 lg:gap-[30px]">
|
||||
<h3 className="font-joey-bold text-fd-navy text-fd-h2">
|
||||
{service.title}
|
||||
</h3>
|
||||
|
||||
{media?.url && (
|
||||
<FDImage
|
||||
media={media}
|
||||
size="medium"
|
||||
className="w-full aspect-square object-cover rounded-[70px]"
|
||||
sizes={columns === '2' ? '(max-width: 768px) 100vw, 550px' : '(max-width: 768px) 50vw, 280px'}
|
||||
fallbackAlt={service.title}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="font-joey text-fd-navy text-fd-body">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (service.link) {
|
||||
return (
|
||||
<a key={index} href={service.link} className="group hover:opacity-90 transition-opacity">
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return <div key={index}>{content}</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
62
src/blocks/FDServicesGridBlock/config.ts
Normal file
62
src/blocks/FDServicesGridBlock/config.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDServicesGridBlock: Block = {
|
||||
slug: 'fdServicesGrid',
|
||||
interfaceName: 'FDServicesGridBlock',
|
||||
labels: {
|
||||
singular: 'FD Service Grid',
|
||||
plural: 'FD Service Grids',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Rubrik',
|
||||
defaultValue: 'Företagstjänster',
|
||||
},
|
||||
{
|
||||
name: 'services',
|
||||
type: 'array',
|
||||
label: 'Tjänster',
|
||||
minRows: 1,
|
||||
maxRows: 8,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Titel',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
label: 'Beskrivning',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Bild',
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
type: 'text',
|
||||
label: 'Länk',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'columns',
|
||||
type: 'select',
|
||||
label: 'Antal kolumner (desktop)',
|
||||
defaultValue: '4',
|
||||
options: [
|
||||
{ label: '2 kolumner', value: '2' },
|
||||
{ label: '3 kolumner', value: '3' },
|
||||
{ label: '4 kolumner', value: '4' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
24
src/blocks/FDSpacerBlock/Component.tsx
Normal file
24
src/blocks/FDSpacerBlock/Component.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import type { FDSpacerBlock as FDSpacerBlockProps } from '@/payload-types'
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
sm: 'h-8',
|
||||
md: 'h-16',
|
||||
lg: 'h-24',
|
||||
xl: 'h-32',
|
||||
}
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
navy: 'bg-fd-navy',
|
||||
gray: 'bg-fd-gray-light',
|
||||
yellow: 'bg-fd-yellow',
|
||||
transparent: 'bg-transparent',
|
||||
}
|
||||
|
||||
export const FDSpacerBlockComponent: React.FC<FDSpacerBlockProps> = ({
|
||||
height = 'md',
|
||||
background = 'white',
|
||||
}) => {
|
||||
return <div className={`w-full ${heightMap[height || 'md']} ${bgMap[background || 'white']}`} />
|
||||
}
|
||||
37
src/blocks/FDSpacerBlock/config.ts
Normal file
37
src/blocks/FDSpacerBlock/config.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDSpacerBlock: Block = {
|
||||
slug: 'fdSpacer',
|
||||
interfaceName: 'FDSpacerBlock',
|
||||
labels: {
|
||||
singular: 'FD Space',
|
||||
plural: 'FD Spaces',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'height',
|
||||
type: 'select',
|
||||
label: 'Höjd',
|
||||
defaultValue: 'md',
|
||||
options: [
|
||||
{ label: 'Liten (32px)', value: 'sm' },
|
||||
{ label: 'Medium (64px)', value: 'md' },
|
||||
{ label: 'Stor (96px)', value: 'lg' },
|
||||
{ label: 'Extra stor (128px)', value: 'xl' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'background',
|
||||
type: 'select',
|
||||
label: 'Bakgrundsfärg',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
{ label: 'Transparent', value: 'transparent' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
68
src/blocks/FDStatisticsBlock/Component.tsx
Normal file
68
src/blocks/FDStatisticsBlock/Component.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import type { FDStatisticsBlock as Props } from '@/payload-types'
|
||||
|
||||
export const FDStatisticsBlockComponent: React.FC<Props> = ({
|
||||
title,
|
||||
stats = [],
|
||||
sectionBackground = 'white',
|
||||
numberColor = 'gradient',
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) setVisible(true) },
|
||||
{ threshold: 0.3 },
|
||||
)
|
||||
if (ref.current) observer.observe(ref.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const bgClass =
|
||||
sectionBackground === 'navy' ? 'bg-fd-navy' :
|
||||
sectionBackground === 'gray' ? 'bg-fd-surface-alt' : 'bg-white'
|
||||
|
||||
const titleClass = sectionBackground === 'navy' ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
const labelClass = sectionBackground === 'navy' ? 'text-white' : 'text-fd-navy'
|
||||
|
||||
const getNumberClass = () => {
|
||||
if (numberColor === 'gradient') return 'bg-gradient-to-r from-fd-yellow to-fd-mint bg-clip-text text-transparent'
|
||||
if (numberColor === 'yellow') return 'text-fd-yellow'
|
||||
if (numberColor === 'mint') return 'text-fd-mint'
|
||||
if (numberColor === 'white') return 'text-white'
|
||||
return 'text-fd-navy'
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`} ref={ref}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 text-center">
|
||||
{title && (
|
||||
<h2 className={`font-joey-heavy text-fd-h1 mb-12 md:mb-16 ${titleClass}`}>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-center gap-8 md:gap-12 lg:gap-16">
|
||||
{(stats ?? []).map((stat, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex flex-col items-center gap-2 transition-all duration-700 ${
|
||||
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'
|
||||
}`}
|
||||
style={{ transitionDelay: `${i * 120}ms` }}
|
||||
>
|
||||
{/* Intentionally oversized for visual impact — not mapped to fd-* token */}
|
||||
<span className={`font-joey-heavy text-5xl md:text-7xl lg:text-[96px] leading-none ${getNumberClass()}`}>
|
||||
{stat.number}
|
||||
</span>
|
||||
<span className={`font-joey text-fd-body max-w-[180px] ${labelClass}`}>
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
68
src/blocks/FDStatisticsBlock/config.ts
Normal file
68
src/blocks/FDStatisticsBlock/config.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDStatisticsBlock: Block = {
|
||||
slug: 'fdStatistics',
|
||||
interfaceName: 'FDStatisticsBlock',
|
||||
labels: {
|
||||
singular: 'FD Statistik / Nyckeltal',
|
||||
plural: 'FD Statistik / Nyckeltal',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: 'Blockrubrik (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'stats',
|
||||
type: 'array',
|
||||
label: 'Statistik',
|
||||
minRows: 2,
|
||||
maxRows: 5,
|
||||
fields: [
|
||||
{
|
||||
name: 'number',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Siffra / värde',
|
||||
admin: {
|
||||
description: 'T.ex. "77%", "24/7", "10 Gbit"',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Beskrivning',
|
||||
admin: {
|
||||
description: 'T.ex. "av ärenden lösta av AI"',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'numberColor',
|
||||
type: 'select',
|
||||
label: 'Sifferfärg',
|
||||
defaultValue: 'gradient',
|
||||
options: [
|
||||
{ label: 'Gradient (gul → mint)', value: 'gradient' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
{ label: 'Mint', value: 'mint' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
75
src/blocks/FDTagsBlock/Component.tsx
Normal file
75
src/blocks/FDTagsBlock/Component.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
import type { FDTagsBlock as FDTagsBlockProps } from '@/payload-types'
|
||||
|
||||
const tagStyleMap: Record<string, { bg: string; text: string; border: string }> = {
|
||||
navy: { bg: 'bg-fd-navy', text: 'text-fd-yellow', border: '' },
|
||||
yellow: { bg: 'bg-fd-yellow', text: 'text-fd-navy', border: '' },
|
||||
outlined: { bg: 'bg-transparent', text: 'text-fd-navy', border: 'border-2 border-fd-navy' },
|
||||
gray: { bg: 'bg-fd-surface-alt', text: 'text-fd-navy', border: '' },
|
||||
}
|
||||
|
||||
const tagSizeMap: Record<string, string> = {
|
||||
small: 'px-5 py-2 text-fd-small',
|
||||
medium: 'px-6 py-2.5 text-fd-body',
|
||||
large: 'px-8 py-3 md:px-10 md:py-4 text-fd-body-lg',
|
||||
}
|
||||
|
||||
const sectionBgMap: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
navy: 'bg-fd-navy',
|
||||
gray: 'bg-fd-gray-light',
|
||||
}
|
||||
|
||||
const headingColorMap: Record<string, string> = {
|
||||
white: 'text-fd-navy',
|
||||
navy: 'text-fd-yellow',
|
||||
gray: 'text-fd-navy',
|
||||
}
|
||||
|
||||
export const FDTagsBlockComponent: React.FC<FDTagsBlockProps> = ({
|
||||
heading,
|
||||
tags,
|
||||
tagStyle = 'navy',
|
||||
tagSize = 'large',
|
||||
alignment = 'left',
|
||||
sectionBackground = 'white',
|
||||
}) => {
|
||||
const style = tagStyleMap[tagStyle ?? 'navy'] || tagStyleMap.navy
|
||||
const size = tagSizeMap[tagSize ?? 'large'] || tagSizeMap.large
|
||||
const sectionBg = sectionBgMap[sectionBackground || 'white']
|
||||
const headingColor = headingColorMap[sectionBackground || 'white']
|
||||
const justify = alignment === 'center' ? 'justify-center' : 'justify-start'
|
||||
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${sectionBg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
{heading && (
|
||||
<h2
|
||||
className={`font-joey-heavy text-fd-h2 mb-6 md:mb-8 ${headingColor} ${
|
||||
alignment === 'center' ? 'text-center' : ''
|
||||
}`}
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className={`flex flex-wrap ${justify} gap-3 md:gap-4`}>
|
||||
{tags?.map((tag, index) => {
|
||||
const pillClass = `inline-flex items-center font-joey-bold rounded-full ${style.bg} ${style.text} ${style.border} ${size} whitespace-nowrap transition-opacity`
|
||||
|
||||
if (tag.link) {
|
||||
return (
|
||||
<a key={index} href={tag.link} className={`${pillClass} hover:opacity-85`}>
|
||||
{tag.text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span key={index} className={pillClass}>{tag.text}</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
81
src/blocks/FDTagsBlock/config.ts
Normal file
81
src/blocks/FDTagsBlock/config.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDTagsBlock: Block = {
|
||||
slug: 'fdTags',
|
||||
interfaceName: 'FDTagsBlock',
|
||||
labels: {
|
||||
singular: 'FD Tags Row',
|
||||
plural: 'FD Tags Rows',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Rubrik (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'array',
|
||||
label: 'Taggar',
|
||||
minRows: 1,
|
||||
maxRows: 30,
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Text',
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
type: 'text',
|
||||
label: 'Länk (valfri)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tagStyle',
|
||||
type: 'select',
|
||||
label: 'Taggstil',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy med gul text', value: 'navy' },
|
||||
{ label: 'Gul med navy text', value: 'yellow' },
|
||||
{ label: 'Kontur (navy ram)', value: 'outlined' },
|
||||
{ label: 'Grå med navy text', value: 'gray' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tagSize',
|
||||
type: 'select',
|
||||
label: 'Storlek',
|
||||
defaultValue: 'large',
|
||||
options: [
|
||||
{ label: 'Liten', value: 'small' },
|
||||
{ label: 'Medium', value: 'medium' },
|
||||
{ label: 'Stor', value: 'large' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'alignment',
|
||||
type: 'select',
|
||||
label: 'Justering',
|
||||
defaultValue: 'left',
|
||||
options: [
|
||||
{ label: 'Vänster', value: 'left' },
|
||||
{ label: 'Centrerad', value: 'center' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
49
src/blocks/FDTechPropertiesBlock/Component.tsx
Normal file
49
src/blocks/FDTechPropertiesBlock/Component.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
import type { FDTechPropertiesBlock as FDTechPropertiesBlockProps } from '@/payload-types'
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
navy: 'bg-fd-navy',
|
||||
white: 'bg-white',
|
||||
gray: 'bg-fd-gray-light',
|
||||
yellow: 'bg-fd-yellow',
|
||||
}
|
||||
|
||||
const catColorMap: Record<string, string> = {
|
||||
white: 'text-white',
|
||||
navy: 'text-fd-navy',
|
||||
}
|
||||
|
||||
const valColorMap: Record<string, string> = {
|
||||
yellow: 'text-fd-yellow',
|
||||
white: 'text-white',
|
||||
navy: 'text-fd-navy',
|
||||
}
|
||||
|
||||
export const FDTechPropertiesBlockComponent: React.FC<FDTechPropertiesBlockProps> = ({
|
||||
properties,
|
||||
background = 'navy',
|
||||
categoryColor = 'white',
|
||||
valueColor = 'yellow',
|
||||
}) => {
|
||||
const bg = bgMap[background || 'navy']
|
||||
const catColor = catColorMap[categoryColor || 'white']
|
||||
const valColor = valColorMap[valueColor || 'yellow']
|
||||
const count = properties?.length || 1
|
||||
const gridCols =
|
||||
count <= 2 ? 'grid-cols-2' : count === 3 ? 'grid-cols-3' : 'grid-cols-2 md:grid-cols-4'
|
||||
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${bg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
<div className={`grid ${gridCols} gap-8 md:gap-12`}>
|
||||
{properties?.map((prop, index) => (
|
||||
<div key={index} className="flex flex-col gap-1 md:gap-2">
|
||||
<span className={`font-joey text-fd-body ${catColor}`}>{prop.category}</span>
|
||||
<span className={`font-joey-heavy text-fd-h1 ${valColor}`}>{prop.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
68
src/blocks/FDTechPropertiesBlock/config.ts
Normal file
68
src/blocks/FDTechPropertiesBlock/config.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDTechPropertiesBlock: Block = {
|
||||
slug: 'fdTechProperties',
|
||||
interfaceName: 'FDTechPropertiesBlock',
|
||||
labels: {
|
||||
singular: 'FD Tech Spec',
|
||||
plural: 'FD tech Specs',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'properties',
|
||||
type: 'array',
|
||||
label: 'Egenskaper',
|
||||
minRows: 1,
|
||||
maxRows: 4,
|
||||
fields: [
|
||||
{
|
||||
name: 'category',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Kategori',
|
||||
admin: { description: 'T.ex. "Skyddsklass"' },
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Värde',
|
||||
admin: { description: 'T.ex. "3" eller "1,6 MW"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'background',
|
||||
type: 'select',
|
||||
label: 'Bakgrundsfärg',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'categoryColor',
|
||||
type: 'select',
|
||||
label: 'Kategorifärg',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'valueColor',
|
||||
type: 'select',
|
||||
label: 'Värdefärg',
|
||||
defaultValue: 'yellow',
|
||||
options: [
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
65
src/blocks/FDTextBlock/Component.tsx
Normal file
65
src/blocks/FDTextBlock/Component.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import type { FDTextBlock as FDTextBlockProps } from '@/payload-types'
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
navy: 'bg-fd-navy',
|
||||
gray: 'bg-fd-gray-light',
|
||||
yellow: 'bg-fd-yellow',
|
||||
}
|
||||
|
||||
const alignMap: Record<string, string> = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
}
|
||||
|
||||
const maxWidthMap: Record<string, string> = {
|
||||
narrow: 'max-w-[600px]',
|
||||
medium: 'max-w-[800px]',
|
||||
wide: 'max-w-[1100px]',
|
||||
full: '',
|
||||
}
|
||||
|
||||
const textColorMap: Record<string, { h1: string; h2: string; body: string }> = {
|
||||
navy: { h1: 'text-fd-navy', h2: 'text-fd-navy', body: 'text-fd-navy' },
|
||||
white: { h1: 'text-white', h2: 'text-white', body: 'text-white/90' },
|
||||
yellow: { h1: 'text-fd-yellow', h2: 'text-fd-yellow', body: 'text-fd-yellow/90' },
|
||||
}
|
||||
|
||||
export const FDTextBlockComponent: React.FC<FDTextBlockProps> = ({
|
||||
heading,
|
||||
subheading,
|
||||
body,
|
||||
alignment = 'left',
|
||||
textColor = 'navy',
|
||||
background = 'white',
|
||||
maxWidth = 'wide',
|
||||
}) => {
|
||||
const bg = bgMap[background || 'white']
|
||||
const align = alignMap[alignment || 'left']
|
||||
const width = maxWidthMap[maxWidth || 'wide']
|
||||
const colors = textColorMap[textColor || 'navy']
|
||||
const containerAlign =
|
||||
alignment === 'center' ? 'mx-auto' : alignment === 'right' ? 'ml-auto' : ''
|
||||
|
||||
if (!heading && !subheading && !body) return null
|
||||
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${bg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
<div className={`${width} ${containerAlign} ${align} flex flex-col gap-4 md:gap-6`}>
|
||||
{heading && (
|
||||
<h1 className={`font-joey-heavy text-fd-display ${colors.h1}`}>{heading}</h1>
|
||||
)}
|
||||
{subheading && (
|
||||
<h2 className={`font-joey-medium text-fd-h1 ${colors.h2}`}>{subheading}</h2>
|
||||
)}
|
||||
{body && (
|
||||
<p className={`font-joey text-fd-body-lg ${colors.body}`}>{body}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
73
src/blocks/FDTextBlock/config.ts
Normal file
73
src/blocks/FDTextBlock/config.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDTextBlock: Block = {
|
||||
slug: 'fdText',
|
||||
interfaceName: 'FDTextBlock',
|
||||
labels: {
|
||||
singular: 'FD Text Block',
|
||||
plural: 'FD Text Blocks',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Rubrik',
|
||||
},
|
||||
{
|
||||
name: 'subheading',
|
||||
type: 'text',
|
||||
label: 'Underrubrik',
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'textarea',
|
||||
label: 'Brödtext',
|
||||
},
|
||||
{
|
||||
name: 'alignment',
|
||||
type: 'select',
|
||||
label: 'Textjustering',
|
||||
defaultValue: 'left',
|
||||
options: [
|
||||
{ label: 'Vänster', value: 'left' },
|
||||
{ label: 'Centrerad', value: 'center' },
|
||||
{ label: 'Höger', value: 'right' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textColor',
|
||||
type: 'select',
|
||||
label: 'Textfärg',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'background',
|
||||
type: 'select',
|
||||
label: 'Bakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'maxWidth',
|
||||
type: 'select',
|
||||
label: 'Maxbredd',
|
||||
defaultValue: 'wide',
|
||||
options: [
|
||||
{ label: 'Smal (600px)', value: 'narrow' },
|
||||
{ label: 'Medium (800px)', value: 'medium' },
|
||||
{ label: 'Bred (1100px)', value: 'wide' },
|
||||
{ label: 'Full', value: 'full' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
79
src/blocks/FDUspChecklistBlock/Component.tsx
Normal file
79
src/blocks/FDUspChecklistBlock/Component.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import type { FDUspChecklistBlock as FDUspChecklistBlockProps, Media } from '@/payload-types'
|
||||
import { FDImage } from '@/components/FDImage'
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
gray: 'bg-fd-gray-light',
|
||||
navy: 'bg-fd-navy',
|
||||
}
|
||||
|
||||
const textMap: Record<string, string> = {
|
||||
navy: 'text-fd-navy',
|
||||
white: 'text-white',
|
||||
}
|
||||
|
||||
const checkColors: Record<string, { circle: string; check: string }> = {
|
||||
navy: { circle: '#0E2338', check: 'white' },
|
||||
yellow: { circle: '#FECC02', check: '#0E2338' },
|
||||
gray: { circle: '#F0F0F0', check: '#0E2338' },
|
||||
}
|
||||
|
||||
const CheckIcon: React.FC<{ color: string }> = ({ color }) => {
|
||||
const c = checkColors[color] || checkColors.navy
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg" className="flex-shrink-0">
|
||||
<circle cx="23" cy="23" r="23" fill={c.circle} />
|
||||
<path d="M34.2166 16.2092L19.8151 30.6108C19.3551 31.0354 19.1074 31.3185 18.7182 31.3185C18.3643 31.3185 18.1166 31.1062 17.6212 30.6108L11.9597 24.9492C11.6059 24.5954 11.4289 24.3477 11.4289 24.1C11.4289 23.8523 11.6412 23.5692 11.9951 23.2154L12.5612 22.6492C12.9505 22.26 13.2335 22.0477 13.5166 22.0477C13.7643 22.0477 14.012 22.2246 14.3659 22.5785L18.7182 26.9662L31.8105 13.8385C32.1643 13.4846 32.412 13.3431 32.6597 13.3431C32.9428 13.3431 33.1551 13.4846 33.6151 13.9092L34.1812 14.4754C34.5351 14.8292 34.712 15.1123 34.712 15.36C34.712 15.6077 34.5705 15.8554 34.2166 16.2092Z" fill={c.check} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const FDUspChecklistBlockComponent: React.FC<FDUspChecklistBlockProps> = ({
|
||||
heading,
|
||||
items,
|
||||
image,
|
||||
imagePosition = 'right',
|
||||
checkColor = 'navy',
|
||||
background = 'white',
|
||||
textColor = 'navy',
|
||||
}) => {
|
||||
const bg = bgMap[background || 'white']
|
||||
const txt = textMap[textColor || 'navy']
|
||||
const media = image as Media | undefined
|
||||
const hasImage = Boolean(media?.url)
|
||||
|
||||
const textContent = (
|
||||
<div className="flex-1 flex flex-col gap-6 md:gap-8">
|
||||
<h2 className={`font-joey-heavy text-fd-h1 ${txt}`}>{heading}</h2>
|
||||
<div className="flex flex-col gap-5 md:gap-6">
|
||||
{items?.map((item, index) => (
|
||||
<div key={index} className="flex items-start gap-4">
|
||||
<CheckIcon color={checkColor || 'navy'} />
|
||||
<span className={`font-joey text-fd-body-lg pt-1 ${txt}`}>{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const imageContent = hasImage ? (
|
||||
<div className="flex-1">
|
||||
<FDImage
|
||||
media={media!}
|
||||
size="large"
|
||||
className="w-full h-auto rounded-[40px] object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 550px"
|
||||
fallbackAlt={heading || ''}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${bg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col lg:flex-row items-center gap-10 lg:gap-16">
|
||||
{imagePosition === 'left' ? <>{imageContent}{textContent}</> : <>{textContent}{imageContent}</>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
80
src/blocks/FDUspChecklistBlock/config.ts
Normal file
80
src/blocks/FDUspChecklistBlock/config.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDUspChecklistBlock: Block = {
|
||||
slug: 'fdUspChecklist',
|
||||
interfaceName: 'FDUspChecklistBlock',
|
||||
labels: {
|
||||
singular: 'FD USP Checklist',
|
||||
plural: 'FD USP Checklists',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Rubrik',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
label: 'Punkter',
|
||||
minRows: 1,
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Bild (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'imagePosition',
|
||||
type: 'select',
|
||||
label: 'Bildplacering',
|
||||
defaultValue: 'right',
|
||||
options: [
|
||||
{ label: 'Höger', value: 'right' },
|
||||
{ label: 'Vänster', value: 'left' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'checkColor',
|
||||
type: 'select',
|
||||
label: 'Checkikon-färg',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy (vit bock)', value: 'navy' },
|
||||
{ label: 'Gul (navy bock)', value: 'yellow' },
|
||||
{ label: 'Grå (navy bock)', value: 'gray' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'background',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textColor',
|
||||
type: 'select',
|
||||
label: 'Textfärg',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
74
src/blocks/FDUspTableBlock/Component.tsx
Normal file
74
src/blocks/FDUspTableBlock/Component.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
import type { FDUspTableBlock as FDUspTableBlockProps } from '@/payload-types'
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
gray: 'bg-fd-gray-light',
|
||||
navy: 'bg-fd-navy',
|
||||
}
|
||||
|
||||
const textMap: Record<string, string> = {
|
||||
navy: 'text-fd-navy',
|
||||
white: 'text-white',
|
||||
}
|
||||
|
||||
const borderMap: Record<string, string> = {
|
||||
navy: 'border-fd-navy/10',
|
||||
white: 'border-white/20',
|
||||
}
|
||||
|
||||
const checkColors: Record<string, { circle: string; check: string }> = {
|
||||
navy: { circle: '#0E2338', check: 'white' },
|
||||
yellow: { circle: '#FECC02', check: '#0E2338' },
|
||||
gray: { circle: '#F0F0F0', check: '#0E2338' },
|
||||
}
|
||||
|
||||
const CheckIcon: React.FC<{ color: string }> = ({ color }) => {
|
||||
const c = checkColors[color] || checkColors.navy
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg" className="flex-shrink-0">
|
||||
<circle cx="23" cy="23" r="23" fill={c.circle} />
|
||||
<path d="M34.2166 16.2092L19.8151 30.6108C19.3551 31.0354 19.1074 31.3185 18.7182 31.3185C18.3643 31.3185 18.1166 31.1062 17.6212 30.6108L11.9597 24.9492C11.6059 24.5954 11.4289 24.3477 11.4289 24.1C11.4289 23.8523 11.6412 23.5692 11.9951 23.2154L12.5612 22.6492C12.9505 22.26 13.2335 22.0477 13.5166 22.0477C13.7643 22.0477 14.012 22.2246 14.3659 22.5785L18.7182 26.9662L31.8105 13.8385C32.1643 13.4846 32.412 13.3431 32.6597 13.3431C32.9428 13.3431 33.1551 13.4846 33.6151 13.9092L34.1812 14.4754C34.5351 14.8292 34.712 15.1123 34.712 15.36C34.712 15.6077 34.5705 15.8554 34.2166 16.2092Z" fill={c.check} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const FDUspTableBlockComponent: React.FC<FDUspTableBlockProps> = ({
|
||||
heading,
|
||||
rows,
|
||||
checkColor = 'navy',
|
||||
background = 'white',
|
||||
textColor = 'navy',
|
||||
}) => {
|
||||
const bg = bgMap[background || 'white']
|
||||
const txt = textMap[textColor || 'navy']
|
||||
const border = borderMap[textColor || 'navy']
|
||||
|
||||
return (
|
||||
<section className={`w-full py-12 md:py-16 lg:py-20 ${bg}`}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
|
||||
{heading && (
|
||||
<h2 className={`font-joey-heavy text-fd-h1 mb-10 md:mb-14 ${txt}`}>{heading}</h2>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{rows?.map((row, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex flex-col md:flex-row md:items-start gap-4 md:gap-8 py-7 md:py-8 ${
|
||||
index < (rows.length - 1) ? `border-b ${border}` : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4 md:w-[280px] flex-shrink-0">
|
||||
<CheckIcon color={checkColor || 'navy'} />
|
||||
<span className={`font-joey-bold text-fd-h3 ${txt}`}>{row.title}</span>
|
||||
</div>
|
||||
<p className={`font-joey text-fd-body ${txt} opacity-80 md:pl-0 pl-14`}>
|
||||
{row.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
70
src/blocks/FDUspTableBlock/config.ts
Normal file
70
src/blocks/FDUspTableBlock/config.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDUspTableBlock: Block = {
|
||||
slug: 'fdUspTable',
|
||||
interfaceName: 'FDUspTableBlock',
|
||||
labels: {
|
||||
singular: 'FD USP Table',
|
||||
plural: 'FD USP Tables',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Rubrik (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'rows',
|
||||
type: 'array',
|
||||
label: 'Rader',
|
||||
minRows: 1,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Rubrik',
|
||||
admin: { description: 'T.ex. "Högsta säkerhet"' },
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
label: 'Beskrivning',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'checkColor',
|
||||
type: 'select',
|
||||
label: 'Checkikon-färg',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy (vit bock)', value: 'navy' },
|
||||
{ label: 'Gul (navy bock)', value: 'yellow' },
|
||||
{ label: 'Grå (navy bock)', value: 'gray' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'background',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textColor',
|
||||
type: 'select',
|
||||
label: 'Textfärg',
|
||||
defaultValue: 'navy',
|
||||
options: [
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
194
src/blocks/FDVideoBlock/Component.tsx
Normal file
194
src/blocks/FDVideoBlock/Component.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Image from 'next/image'
|
||||
import type { FDVideoBlock as FDVideoBlockProps, Media } from '@/payload-types'
|
||||
|
||||
const maxWidthClasses: Record<string, string> = {
|
||||
default: 'max-w-[1200px]',
|
||||
narrow: 'max-w-[900px]',
|
||||
wide: 'max-w-[1400px]',
|
||||
}
|
||||
|
||||
const bgClasses: Record<string, string> = {
|
||||
white: 'bg-white',
|
||||
navy: 'bg-fd-navy',
|
||||
gray: 'bg-fd-surface-alt',
|
||||
yellow: 'bg-fd-yellow',
|
||||
transparent: 'bg-transparent',
|
||||
}
|
||||
|
||||
function extractYouTubeId(url: string): string | null {
|
||||
if (!url) return null
|
||||
const patterns = [
|
||||
/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/,
|
||||
/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
||||
/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
|
||||
/(?:youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
|
||||
/(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractVimeoId(url: string): string | null {
|
||||
if (!url) return null
|
||||
const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
export const FDVideoBlockComponent: React.FC<FDVideoBlockProps> = ({
|
||||
heading,
|
||||
description,
|
||||
videoSource = 'upload',
|
||||
videoFile,
|
||||
youtubeUrl,
|
||||
vimeoUrl,
|
||||
thumbnail,
|
||||
aspectRatio = '16/9',
|
||||
autoplay = false,
|
||||
loop = false,
|
||||
maxWidth = 'default',
|
||||
sectionBackground = 'white',
|
||||
textColor = 'auto',
|
||||
}) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
const isDark = sectionBackground === 'navy'
|
||||
const headingColor =
|
||||
textColor === 'white' ? 'text-white'
|
||||
: textColor === 'navy' ? 'text-fd-navy'
|
||||
: isDark ? 'text-fd-yellow' : 'text-fd-navy'
|
||||
|
||||
const bodyColor =
|
||||
textColor === 'white' ? 'text-white'
|
||||
: textColor === 'navy' ? 'text-fd-navy'
|
||||
: isDark ? 'text-white' : 'text-fd-navy'
|
||||
|
||||
const bgClass = bgClasses[sectionBackground ?? 'white'] || 'bg-white'
|
||||
const containerClass = maxWidthClasses[maxWidth ?? 'default'] || 'max-w-[1200px]'
|
||||
|
||||
const videoMedia = videoFile as Media | undefined
|
||||
const thumbnailMedia = thumbnail as Media | undefined
|
||||
const thumbnailUrl = thumbnailMedia?.url || ''
|
||||
const hasThumbnail = Boolean(thumbnailUrl)
|
||||
|
||||
const youtubeId = youtubeUrl ? extractYouTubeId(youtubeUrl) : null
|
||||
const vimeoId = vimeoUrl ? extractVimeoId(vimeoUrl) : null
|
||||
|
||||
const autoThumbnailUrl =
|
||||
!hasThumbnail && youtubeId
|
||||
? `https://img.youtube.com/vi/${youtubeId}/maxresdefault.jpg`
|
||||
: ''
|
||||
|
||||
const showThumbnailOverlay =
|
||||
!isPlaying && (hasThumbnail || autoThumbnailUrl) && videoSource !== 'upload'
|
||||
|
||||
const handlePlay = useCallback(() => { setIsPlaying(true) }, [])
|
||||
|
||||
const PlayButton = () => (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/30 transition-colors">
|
||||
<div className="w-20 h-20 md:w-24 md:h-24 rounded-full bg-fd-yellow flex items-center justify-center shadow-xl group-hover:scale-110 transition-transform">
|
||||
<svg className="w-8 h-8 md:w-10 md:h-10 text-fd-navy ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className={`relative w-full py-16 md:py-20 lg:py-[99px] ${bgClass} overflow-hidden`}>
|
||||
<div className={`relative ${containerClass} mx-auto px-6 md:px-8`}>
|
||||
|
||||
{(heading || description) && (
|
||||
<div className="mb-8 md:mb-12 text-center">
|
||||
{heading && (
|
||||
<h2 className={`font-joey-medium text-fd-h1 mb-4 ${headingColor}`}>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className={`font-joey text-fd-body-lg max-w-[800px] mx-auto ${bodyColor}`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-full rounded-[70px] overflow-hidden bg-black">
|
||||
<div className="relative w-full" style={{ paddingBottom: aspectRatio === '16/10' ? '62.5%' : '56.25%' }}>
|
||||
|
||||
{videoSource === 'upload' && videoMedia?.url && (
|
||||
<>
|
||||
<video
|
||||
src={videoMedia.url}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
controls={!autoplay}
|
||||
autoPlay={autoplay || undefined}
|
||||
muted={autoplay || undefined}
|
||||
loop={loop || undefined}
|
||||
playsInline
|
||||
poster={thumbnailUrl || undefined}
|
||||
>
|
||||
Din webbläsare stöder inte videouppspelning.
|
||||
</video>
|
||||
{!autoplay && hasThumbnail && !isPlaying && (
|
||||
<button onClick={handlePlay} className="absolute inset-0 w-full h-full z-10 cursor-pointer group" aria-label="Spela video">
|
||||
<Image src={thumbnailUrl} alt={(thumbnailMedia as any)?.alt || 'Video miniatyrböld'} fill className="object-cover" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px" />
|
||||
<PlayButton />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{videoSource === 'youtube' && youtubeId && (
|
||||
<>
|
||||
{showThumbnailOverlay ? (
|
||||
<button onClick={handlePlay} className="absolute inset-0 w-full h-full z-10 cursor-pointer group" aria-label="Spela YouTube-video">
|
||||
<Image src={thumbnailUrl || autoThumbnailUrl} alt={(thumbnailMedia as any)?.alt || 'Video miniatyrbild'} fill className="object-cover" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px" />
|
||||
<PlayButton />
|
||||
</button>
|
||||
) : (
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${youtubeId}?autoplay=${isPlaying ? 1 : 0}&rel=0`}
|
||||
title={heading || 'YouTube-video'}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
style={{ border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{videoSource === 'vimeo' && vimeoId && (
|
||||
<>
|
||||
{showThumbnailOverlay ? (
|
||||
<button onClick={handlePlay} className="absolute inset-0 w-full h-full z-10 cursor-pointer group" aria-label="Spela Vimeo-video">
|
||||
<Image src={thumbnailUrl} alt={(thumbnailMedia as any)?.alt || 'Video miniatyrbild'} fill className="object-cover" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px" />
|
||||
<PlayButton />
|
||||
</button>
|
||||
) : (
|
||||
<iframe
|
||||
src={`https://player.vimeo.com/video/${vimeoId}?autoplay=${isPlaying ? 1 : 0}`}
|
||||
title={heading || 'Vimeo-video'}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
style={{ border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
145
src/blocks/FDVideoBlock/config.ts
Normal file
145
src/blocks/FDVideoBlock/config.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const FDVideoBlock: Block = {
|
||||
slug: 'fdVideo',
|
||||
interfaceName: 'FDVideoBlock',
|
||||
labels: {
|
||||
singular: 'FD Video',
|
||||
plural: 'FD Videor',
|
||||
},
|
||||
fields: [
|
||||
// --- Content fields ---
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: 'Rubrik (valfri)',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: 'Beskrivning (valfri)',
|
||||
},
|
||||
// --- Video source ---
|
||||
{
|
||||
name: 'videoSource',
|
||||
type: 'select',
|
||||
label: 'Videokälla',
|
||||
required: true,
|
||||
defaultValue: 'upload',
|
||||
options: [
|
||||
{ label: 'Uppladdad video', value: 'upload' },
|
||||
{ label: 'YouTube', value: 'youtube' },
|
||||
{ label: 'Vimeo', value: 'vimeo' },
|
||||
],
|
||||
},
|
||||
// Upload field
|
||||
{
|
||||
name: 'videoFile',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Videofil',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.videoSource === 'upload',
|
||||
description: 'Ladda upp en MP4, WebM eller annan videofil',
|
||||
},
|
||||
},
|
||||
// YouTube URL
|
||||
{
|
||||
name: 'youtubeUrl',
|
||||
type: 'text',
|
||||
label: 'YouTube URL',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.videoSource === 'youtube',
|
||||
description: 'Full YouTube-länk, t.ex. https://www.youtube.com/watch?v=abc123 eller https://youtu.be/abc123',
|
||||
},
|
||||
},
|
||||
// Vimeo URL
|
||||
{
|
||||
name: 'vimeoUrl',
|
||||
type: 'text',
|
||||
label: 'Vimeo URL',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.videoSource === 'vimeo',
|
||||
description: 'Full Vimeo-länk, t.ex. https://vimeo.com/123456789',
|
||||
},
|
||||
},
|
||||
// --- Thumbnail ---
|
||||
{
|
||||
name: 'thumbnail',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Anpassad miniatyrbild (valfri)',
|
||||
admin: {
|
||||
description:
|
||||
'Ersätter standardminiatyrbilden. Visas innan videon spelas. Rekommenderad storlek: 1920×1080 (16:9) eller 1920×1200 (16:10)',
|
||||
},
|
||||
},
|
||||
// --- Aspect ratio ---
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
type: 'select',
|
||||
label: 'Bildförhållande',
|
||||
defaultValue: '16/9',
|
||||
options: [
|
||||
{ label: '16:9 (standard)', value: '16/9' },
|
||||
{ label: '16:10', value: '16/10' },
|
||||
],
|
||||
},
|
||||
// --- Autoplay / loop for uploaded ---
|
||||
{
|
||||
name: 'autoplay',
|
||||
type: 'checkbox',
|
||||
label: 'Autospela (mutat)',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.videoSource === 'upload',
|
||||
description: 'Spelar videon automatiskt utan ljud vid sidladdning',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'loop',
|
||||
type: 'checkbox',
|
||||
label: 'Loopa',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.videoSource === 'upload',
|
||||
},
|
||||
},
|
||||
// --- Layout/style ---
|
||||
{
|
||||
name: 'maxWidth',
|
||||
type: 'select',
|
||||
label: 'Maxbredd',
|
||||
defaultValue: 'default',
|
||||
options: [
|
||||
{ label: 'Standard (1200px)', value: 'default' },
|
||||
{ label: 'Smal (900px)', value: 'narrow' },
|
||||
{ label: 'Bred (1400px)', value: 'wide' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectionBackground',
|
||||
type: 'select',
|
||||
label: 'Sektionsbakgrund',
|
||||
defaultValue: 'white',
|
||||
options: [
|
||||
{ label: 'Vit', value: 'white' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Grå', value: 'gray' },
|
||||
{ label: 'Gul', value: 'yellow' },
|
||||
{ label: 'Transparent', value: 'transparent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textColor',
|
||||
type: 'select',
|
||||
label: 'Textfärg',
|
||||
defaultValue: 'auto',
|
||||
options: [
|
||||
{ label: 'Automatisk', value: 'auto' },
|
||||
{ label: 'Navy', value: 'navy' },
|
||||
{ label: 'Vit', value: 'white' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
381
src/blocks/FDVpsCalculatorBlock/Component.tsx
Normal file
381
src/blocks/FDVpsCalculatorBlock/Component.tsx
Normal file
@ -0,0 +1,381 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user