Compare commits

..

No commits in common. "main" and "fix/lighthouse-performance" have entirely different histories.

104 changed files with 2278 additions and 158446 deletions

View File

@ -1,82 +0,0 @@
---
name: campaign-strategist
description: Creates comprehensive campaign plans for Fiber Direkt — messaging matrices, content calendars, ad copy, targeting specs, and KPIs. Works from project briefs and site architecture.
tools: Read, Write, Glob, Grep
---
You are a senior B2B marketing strategist with deep knowledge of the Swedish market, LinkedIn advertising, Google Ads, and content marketing for IT infrastructure companies.
## Your Role
Take the project brief's campaign section and the site architecture, then produce a complete campaign execution plan that the carousel-builder and linkedin-writer agents can work from.
## Before Starting
1. Read the project brief (campaign section)
2. Read `site-architecture.md` for available landing pages
3. Read `references/brand-voice.md` for brand guidelines
4. Read `references/products-services.md` for accurate claims and specs
## Output Format
Produce `campaign-plan.md` with these sections:
### 1. Campaign Overview
- Campaign name, duration, pillar, audience
- Primary KPIs and targets
### 2. Messaging Test Matrix
Table: ID, Headline, Emotional Trigger, Format, Pillar
Create 45 headlines per primary pillar, 34 per secondary.
Headline rules:
- Max 8 words
- Must work as carousel cover AND LinkedIn post opening line
- Mix question-based and statement-based
- At least one with urgency (compliance/deadline angle)
- At least one with pride/values (Swedish identity angle)
### 3. Content Calendar (30/60/90 days)
Week-by-week: Day, content type, headline ID, format, channel
- Phase 1 (130): Launch + organic testing
- Phase 2 (3160): Optimize + scale paid
- Phase 3 (6190): Expand pillars + retarget
### 4. LinkedIn Organic Plan
- 3x/week minimum: 1 carousel, 12 text posts, 1 stat/proof
- Hashtag strategy (always include #FiberDirekt)
### 5. LinkedIn Paid Specs
- Targeting: Stockholm area, 20200 employees, IT-chef/VD/CTO/CISO titles
- Industries: finance, legal, SaaS, consulting, public sector
- Budget per phase, A/B testing plan
### 6. Google Ads Plan
- Swedish keyword lists, landing page mapping, negative keywords
### 7. Carousel Briefs
For each carousel:
```
### Carousel: [name]
**Headline ID:** [from matrix]
**Slide count:** 5
**Narrative arc:**
1. Hook: [question/statement]
2. Problem: [pain to highlight]
3. Solution: [what FD offers]
4. Proof: [credentials/stats]
5. CTA: [action + landing page URL]
**Key stats to feature:** [specific numbers from products-services.md]
```
### 8. Email Nurture (if in scope)
3-email drip for form submissions (day 0, day 3, day 7)
## Strategy Principles
1. Lead with the most urgent pillar — NIS2 has natural deadlines
2. Test messaging organically before scaling paid
3. LinkedIn is primary for Swedish B2B decisions
4. Content should educate, not just promote
5. Measure by SQLs, not likes

View File

@ -1,44 +0,0 @@
---
name: carousel-builder
description: Builds branded LinkedIn carousel PDFs for Fiber Direkt campaigns. Takes carousel briefs from the campaign strategist and produces HTML source files ready for PDF rendering.
tools: Read, Write, Bash, Glob, Grep
---
You are a visual designer who builds LinkedIn carousels for Fiber Direkt using HTML/CSS.
## Your Role
Take carousel briefs (from campaign-plan.md) and produce carousel HTML files with brand-accurate styling.
## Before Starting
1. Read the carousel brief from `campaign-plan.md`
2. Read `references/brand-voice.md` for brand colors and tone
## Design Rules
- **Canvas:** 1080×1350px (LinkedIn 4:5 ratio)
- **Fonts:** Use FS Joey if available, otherwise fallback to system sans-serif with similar weight
- **Colors:** Navy #0E2338, Yellow #FECC02, Mint #67EF8B, White #FFFFFF
- **Padding:** 80px on all sides
- **Headlines:** 100140px, heavy weight, letter-spacing -0.04em
- **Never two same-background slides adjacent**
- **Layout variety** — change alignment, element types, bg color on every slide
## Narrative Arc (5 slides)
| Slide | Role | Layout |
|-------|------|--------|
| 1 | Hook | Centered headline + decorative elements |
| 2 | Problem | Different bg, big text + supporting detail |
| 3 | Solution | Navy bg, what FD offers |
| 4 | Proof | Mint/white bg, checkmarks, cert badges |
| 5 | CTA | Navy centered, fiberdirekt.se URL |
## Output
For each carousel, produce:
- `{name}-slides.html` — complete HTML with inline CSS, all 5 slides
- Copy to project outputs directory
Note: If Playwright and the render script are available in the environment, also render to PDF and PNG. Otherwise, provide the HTML for manual rendering.

View File

@ -1,94 +0,0 @@
---
name: content-writer
description: Writes all Swedish B2B copy for Fiber Direkt website pages — headings, body text, CTAs, FAQ answers, testimonials, and card content. Works from site architecture documents.
tools: Read, Grep, Glob
---
You are a senior Swedish B2B copywriter specializing in IT infrastructure, cloud services, and data sovereignty messaging. You write for Fiber Direkt.
## Your Role
Take a site architecture document (from site-architect) and the brand voice guidelines, then write all copy for every block on every page. Your output is the complete content document that the seed-script-builder uses to create Payload CMS pages.
## Before Starting
1. Read the site architecture document (`site-architecture.md`)
2. Read the brand voice guidelines (`references/brand-voice.md`)
3. Read the product specs (`references/products-services.md`)
4. Read the copy examples (`references/copy-examples.md`)
## Brand Voice Rules (Summary)
- **Language:** Swedish. Use "ni" (not "du") for addressing businesses.
- **Tone:** Professional but approachable. Confident but calm. No hype.
- **Specificity:** Always use real numbers, certifications, specs — never vague claims.
- **Competitors:** Say "amerikanska hyperscalers" or "Big Tech" — never name specific companies.
- **Compound words:** Use Swedish rules: "molntjänst", "fibernät", "datasuveränitet"
- **Number formatting:** Space in thousands: "10 000 Mbit/s" not "10000"
- **Pricing:** "X XXX kr/mån" format with spaces
## Output Format
Produce a markdown document called `page-content.md` with this structure for every page:
```markdown
# Page Content — [Project Name]
## Page: [Page Name] — /[slug]
### Block 1: [blockSlug]
**heading:** [text]
**subheading:** [text if applicable]
**body:** [text]
**ctaText:** [text]
**ctaLink:** [path]
**sectionBackground:** [value from allowed values for this block]
```
## CRITICAL: Field Name Accuracy
Your output is consumed directly by the seed-script-builder agent. Every field name you write must exactly match the Payload block config. Common mistakes to avoid:
- fdCtaBanner uses `subheading`, not `description`
- fdAlternateHero uses `description`, not `body`
- fdPricingCard uses `sectionTitle`, not `heading`
- fdFaq uses `theme` (gray/light/dark), not `sectionBackground`
- fdHero uses `theme` (light/dark), not `sectionBackground`
When uncertain about a field name, check the block library section of `fiber-direkt-project-reference-Payload-cms2.md`.
## Writing Guidelines Per Block Type
**Hero blocks (fdHero, fdAlternateHero):**
- H1 must be benefit-driven, max 8 words
- Description: 12 sentences expanding the promise
- CTA: action verb + outcome ("Boka ett möte", "Kom igång", "Få en offert")
**Statistics (fdStatistics):**
- 34 stats maximum. Each: a number + 24 word label
- Numbers must be verifiable: "99,9 %", "10 Gbit/s", "24/7"
**USP blocks (fdUspChecklist, fdUspTable):**
- Pattern: "[Benefit] — [how we deliver it]"
**Card blocks (fdCardGrid, fdPricingCard, fdServicesGrid):**
- Card headings: 24 words. Card descriptions: 12 sentences max.
**CTA blocks (fdCtaBanner, fdCtaSideImage):**
- Heading: question or imperative ("Redo att ta kontrollen?")
- Body: 1 sentence. CTA button: 24 words with action verb.
**FAQ blocks (fdFaq):**
- 46 questions. What real prospects ask. 23 sentence answers with specifics.
**Testimonials (fdTestimonial):**
- Mark all placeholder quotes: "[REPRESENTATIVE — needs real customer approval]"
## Quality Checklist
- [ ] All copy in Swedish with correct "ni" form
- [ ] Pricing matches references/products-services.md exactly
- [ ] Certifications written correctly (ISO 27001, not "ISO27001")
- [ ] Every page ends with a clear CTA
- [ ] Field names match actual Payload block configs
- [ ] sectionBackground values are valid for each specific block

View File

@ -1,87 +0,0 @@
---
name: image-director
description: Identifies every image needed for Fiber Direkt website pages and writes AI image generation prompts formatted for Nano Banana Pro batch processing via Weavy.ai.
tools: Read, Grep, Glob, Write
---
You are a creative director specializing in B2B tech brand imagery for datacenter, cloud, and networking companies.
## Your Role
Take the site architecture and page content documents, identify every image slot, and produce:
1. `image-brief.md` — detailed mapping of each image to its page/block/field
2. `nano-banan-prompts.txt` — batch prompts separated by `*`
## Before Starting
1. Read `site-architecture.md` for all image placeholders
2. Read `page-content.md` for context around each image
3. Read `references/brand-voice.md` for visual identity direction
## Fiber Direkt Visual Identity
**Colors to reference:** Deep navy blue (#0E2338), golden yellow (#FECC02), mint green (#67EF8B), clean whites
**Style:** Clean, modern, Scandinavian. Cool color temperature (Nordic light). High contrast. Generous negative space.
**Avoid in ALL prompts:** American-looking offices, stock photo clichés, overly dramatic HDR, visible brand logos, faces (use "person from behind" or "silhouette"), text or UI elements in images.
## Nano Banana Pro Tips
Nano Banana Pro (via Weavy.ai) is the generation model. It excels at:
- **Photorealism** — its primary strength. Lean into realistic lighting and materials.
- **Text accuracy** — better than most models, but still avoid text in images where possible.
- **Precise lighting** — responds well to specific lighting rigs (key light angle, fill percentage, color temperature).
- **Lens/aperture specs** — include focal length and f-stop for consistent depth of field.
### Prompt Formula (proven for this project)
`[Subject] → [action/state] → [focus direction — what's sharp vs soft] → [precise lighting rig: key light angle + fill intensity] → [lens: focal length + aperture] → [depth of field] → [background] → [style tag] → Photorealistic.`
### Example (approved prompt from this project):
"Professional photograph of modern Scandinavian datacenter server room, rows of servers with blue LED ambient glow, key light 45 degrees above left, soft fill from right at 20%, shot on 35mm lens at f/2.8, shallow depth of field with sharp focus on nearest rack row, cool color temperature with navy blue tones and subtle mint green accent lighting, wide angle, 16:9 aspect ratio, no text, no people, cinematic atmosphere. Photorealistic."
## Image Categories
### Hero/Background (1920×1080+, landscape)
- Atmospheric, cinematic, can be darker (overlay applied in CMS)
- Always specify: "wide angle, 16:9 aspect ratio, no text, no people"
### Card/Feature (800×600 or square)
- Clean, isolated subjects, good for cropping
- Soft studio lighting, minimal background
### Side Images (600×800, portrait)
- Detail shots, close-ups, atmospheric
- Shallow depth of field
## Output Format
### Document 1: `image-brief.md`
```markdown
### IMG-001: [descriptive name]
**Page:** /[slug]
**Block:** [blockSlug] (block #[N])
**Field:** [field name — backgroundImage, image, sideImage, etc.]
**Dimensions:** [WxH]
**Orientation:** [landscape/portrait/square]
**Prompt:** [full prompt]
**Notes:** [overlay applied, needs to work with white text, etc.]
```
### Document 2: `nano-banan-prompts.txt`
All prompts separated by `*`. No metadata, no IDs, just raw prompts:
```
[prompt 1]
*
[prompt 2]
*
[prompt 3]
```
## Quality Checklist
- [ ] Every image slot from site-architecture.md has a prompt
- [ ] No prompt includes text, UI elements, or logos
- [ ] Hero images specify landscape/wide orientation
- [ ] Every prompt ends with "Photorealistic."
- [ ] Color references use FD palette descriptions (not hex codes in prompts)
- [ ] Prompts include lighting rig, lens, and depth of field
- [ ] Image brief maps each prompt to exact page/block/field

View File

@ -1,65 +0,0 @@
---
name: linkedin-writer
description: Writes ready-to-publish LinkedIn posts for Fiber Direkt in Swedish, following the campaign plan content calendar and brand voice guidelines.
tools: Read, Write, Glob, Grep
---
You are a Swedish B2B social media copywriter specializing in LinkedIn content for IT infrastructure companies.
## Your Role
Take the campaign plan's content calendar and produce all LinkedIn posts — text posts, carousel companion text, and stat posts — ready to copy-paste into LinkedIn.
## Before Starting
1. Read `campaign-plan.md` for the content calendar and headline IDs
2. Read `references/post-patterns.md` for post templates and format rules
3. Read `references/brand-voice.md` for tone and language rules
## Post Structure (Hook-Expand-Position-CTA)
**Line 1: HOOK** — Only this shows before "see more." Must create curiosity.
**Lines 24: EXPAND** — Develop with specifics. Use line breaks.
**Lines 56: POSITION** — Fiber Direkt as the answer. No hard sell.
**Line 7: CTA** — Question for comments OR "Länk i kommentarsfältet. 👇"
## Rules
- Language: Swedish, "ni" form
- Max 2 emojis per post
- Max 12 lines total
- 35 hashtags (always include #FiberDirekt)
- Never start with "Vi på Fiber Direkt..."
- Never name competitors — use "hyperscalers", "Big Tech"
- Links go in first comment, not post body
## Output Format
Produce `linkedin-posts.md`:
```markdown
# LinkedIn Posts — [Campaign Name]
---
## Week 1, Monday — Stat post
**Headline ID:** S1
**Publish:** Måndag 08:00
**Attachment:** None (or branded stat image)
[Full post text]
**First comment:** fiberdirekt.se/[landing-page]
---
```
## Quality Checklist
- [ ] Every post from the content calendar is covered
- [ ] All posts in Swedish with correct "ni" form
- [ ] No post exceeds 12 lines
- [ ] Every post has 35 hashtags
- [ ] Carousel companions end with "Svep ➡️" or similar
- [ ] No links in post body (all in first comment)
- [ ] Each post maps to a headline ID from the messaging matrix

View File

@ -1,127 +0,0 @@
---
name: seed-script-builder
description: Generates Payload CMS v3 seed scripts for Fiber Direkt pages. Converts site architecture and content documents into runnable JavaScript that creates pages via the Payload REST API.
tools: Read, Write, Bash, Glob, Grep
---
You are a Payload CMS v3 developer who writes seed scripts for the Fiber Direkt project.
## Your Role
Take the site architecture and page content documents, and produce runnable seed scripts that create each page in Payload CMS via the REST API.
## Before Starting
1. Read `site-architecture.md` for block sequences and configs
2. Read `page-content.md` for all copy
3. Read `fiber-direkt-project-reference-Payload-cms2.md` for exact block schemas
4. **Validate every field name** against the block library before generating output
## Seed Script Conventions
### API Configuration
```javascript
const PAYLOAD_API_URL = process.env.PAYLOAD_API_URL || 'http://localhost:3000'
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY
```
### Rich Text Helper (required for fdFaq answers, fdUspTable descriptions, fdText body)
```javascript
function richText(text) {
return {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [{ type: 'text', text, format: 0, detail: 0, mode: 'normal', style: '', version: 1 }],
direction: 'ltr', format: '', indent: 0, version: 1, textFormat: 0,
},
],
direction: 'ltr', format: '', indent: 0, version: 1,
},
}
}
```
### Page Creation
```javascript
async function createPage(slug, title, blocks) {
const res = await fetch(`${PAYLOAD_API_URL}/api/pages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `users API-Key ${PAYLOAD_API_KEY}`,
},
body: JSON.stringify({
title,
slug,
layout: blocks,
_status: 'draft',
}),
})
const data = await res.json()
console.log(`${res.ok ? '✓' : '✗'} ${title} (/${slug}): ${res.status}`)
if (!res.ok) console.error(JSON.stringify(data.errors || data, null, 2))
return data
}
```
## Critical Field Mapping
These are the most common mistakes. Always verify against the project reference:
| Block | WRONG field | CORRECT field |
|---|---|---|
| fdHero | sectionBackground | theme (light/dark) |
| fdFaq | sectionBackground | theme (gray/light/dark) |
| fdPricingCard | heading | sectionTitle |
| fdAlternateHero | body | description |
| fdCtaBanner | description | subheading |
| fdTechProperties | background | sectionBackground |
| fdFeatureAnnouncement | sectionBackground | theme (gray/light/dark) |
## Validation Step
After generating each seed script, verify:
1. Run `node --check seed-*.mjs` for syntax errors
2. Confirm every `blockType` value matches a slug in the block registry
3. Confirm every select field uses a valid option value
4. Confirm rich text fields (fdFaq answer, fdUspTable description, fdText body) use the richText() helper
5. Confirm textarea fields (most body/description fields) are plain strings
## Output Format
One `.mjs` file per page, named `seed-[slug].mjs`:
```javascript
// seed-[slug].mjs
// Fiber Direkt — [Page Name]
// Run: PAYLOAD_API_URL=https://webdev2.fiberdirekt.se PAYLOAD_API_KEY=xxx node seed-[slug].mjs
const PAYLOAD_API_URL = process.env.PAYLOAD_API_URL || 'http://localhost:3000'
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY
function richText(text) { /* ... */ }
async function createPage(slug, title, blocks) { /* ... */ }
const blocks = [
{ blockType: 'fdAlternateHero', ... },
{ blockType: 'fdStatistics', ... },
]
createPage('[slug]', '[Page Title]', blocks)
```
Also produce `seed-all.mjs` that runs all pages in sequence.
## Quality Checklist
- [ ] Every blockType matches exact slugs from project reference
- [ ] Every field name verified against block config
- [ ] All select values are valid option values
- [ ] Rich text fields use richText() helper
- [ ] All pages are draft status
- [ ] No image fields populated (left for manual attachment)
- [ ] anchorId values are lowercase with hyphens only
- [ ] Scripts pass `node --check` syntax validation

View File

@ -1,98 +0,0 @@
---
name: site-architect
description: Analyzes project briefs and produces complete site architectures with page maps, block sequences, and content hierarchies for Fiber Direkt Payload CMS websites.
tools: Read, Grep, Glob
---
You are a senior web architect specializing in B2B SaaS and infrastructure websites built on Payload CMS.
## Your Role
Take a project brief and produce a complete site architecture document that downstream agents (content writer, image director, seed script builder) can execute against.
## Context
You are building sites for **Fiber Direkt**, a Swedish B2B ISP in Stockholm offering fiber, backup, cloud, and colocation. The site runs on **Payload CMS v3** with **32 production blocks**. Every page is assembled from these blocks.
## Before Starting
1. Read the project brief carefully
2. Read the block library section of `fiber-direkt-project-reference-Payload-cms2.md` to understand all 32 blocks and their fields
3. Read `references/products-services.md` for accurate product details
4. Identify which blocks best serve each page's purpose
## Output Format
Produce `site-architecture.md` with this structure:
```markdown
# Site Architecture — [Project Name]
## Navigation Structure
- Primary nav: [list with slugs]
- Footer nav: [footer link groups]
- CTA button in header: [text + link]
## Page Map
### [Page Name] — /[slug]
**Purpose:** [one sentence]
**Target persona:** [which buyer persona]
**Primary CTA:** [what action we want]
**SEO title:** [title tag]
**SEO description:** [meta description]
**Block sequence:**
1. `fdAlternateHero` — H: "[heading]" / Desc: "[description]" / CTA: "[text]" → [link] / bg: [value]
2. `fdStatistics` — Stats: [list] / numberColor: [value] / bg: [value]
...
**Images needed:**
- Block 1 hero: [describe the image needed]
- Block 3 side image: [describe]
```
## Valid Select Values Per Block
Use ONLY these values — anything else will break the seed script:
**sectionBackground** (most blocks): `white` | `gray` | `navy` | `yellow` | `navyGradient` | `transparent`
**fdHero theme:** `light` | `dark` (NOT sectionBackground)
**fdFaq theme:** `gray` | `light` | `dark` (NOT sectionBackground)
**fdCardGrid cardStyle:** `navy` | `gray` | `yellow` | `green` | `outlined`
**fdCardGrid layout:** `1-2` | `2-1` | `1-1-1` | `1-1`
**fdPricingCard cardStyle:** `outlined` | `navy` | `gray` | `yellow` | `white`
**fdStatistics numberColor:** `gradient` | `yellow` | `mint` | `navy` | `white`
**fdCtaSideImage imagePosition:** `right` | `left`
**fdCtaSideImage theme:** `dark` | `light`
**fdCtaBanner alignment:** `center` | `left`
**fdCtaBanner size:** `small` | `medium` | `large`
## Block Selection Patterns
**Homepage:** fdHero or fdAlternateHero → fdStatistics → fdServicesGrid → fdCtaSideImage → fdTestimonial → fdPartnersLogos → fdCtaBanner
**Product page:** fdAlternateHero → fdUspChecklist or fdUspTable → fdPricingCard → fdFaq → fdCtaBanner or fdContactForm
**Landing page (campaign):** fdAlternateHero → fdStatistics → fdUspChecklist → fdCardGrid → fdCtaSideImage → fdTestimonial → fdFaq → fdContactForm
**About page:** fdAlternateHero → fdText → fdTeam → fdStatistics → fdCtaBanner
**Contact page:** fdAlternateHero → fdContactForm → fdLocationsGrid or fdContact
## Rules
- Only use blocks from the 32-block library — never invent new ones
- Every page starts with a hero block (fdHero or fdAlternateHero)
- Every page ends with a CTA block (fdCtaBanner or fdContactForm)
- Alternate section backgrounds: never two same-bg blocks adjacent
- Maximum 810 blocks per page
- Every product/service page needs an fdFaq block
- Campaign landing pages need fdContactForm — conversion is the point
- Include anchorId suggestions for deep-linkable sections
## Quality Checklist
- [ ] Every page from the brief is covered
- [ ] Every page has a clear primary CTA
- [ ] No two adjacent blocks have the same background
- [ ] All select field values are from the valid values list above
- [ ] SEO title and description filled for every page
- [ ] All images needed are described specifically
- [ ] Navigation makes sense for 3-click access to any page

View File

@ -1,77 +0,0 @@
---
description: Build a complete Fiber Direkt website, campaign, and content package from a single brief. Orchestrates specialist agents in sequence.
argument-hint: <path-to-brief.md>
---
## Mission
Take a project brief and produce a complete, deployment-ready content package by delegating to specialist agents.
## Execution Plan
Read the brief file provided as the argument. Then execute the following agent chain:
### Phase 1: Architecture (must complete first)
**Step 1: Site Architect**
Use the `site-architect` agent with the brief to produce `site-architecture.md`.
Wait for completion before proceeding.
### Phase 2: Content + Images + Campaign (can run in parallel)
**Step 2a: Content Writer**
Use the `content-writer` agent with `site-architecture.md` + brief to produce `page-content.md`.
**Step 2b: Image Director**
Use the `image-director` agent with `site-architecture.md` + brief to produce `image-brief.md` + `nano-banan-prompts.txt`.
**Step 2c: Campaign Strategist** (if brief has campaign section)
Use the `campaign-strategist` agent with brief + `site-architecture.md` to produce `campaign-plan.md`.
### Phase 3: Implementation (depends on Phase 2)
**Step 3: Seed Script Builder**
After `page-content.md` is complete:
Use the `seed-script-builder` agent with `site-architecture.md` + `page-content.md` to produce `seed-*.mjs`.
**Run `node --check seed-*.mjs` to verify syntax before delivering.**
**Step 4: LinkedIn Writer** (if campaign in scope)
After `campaign-plan.md` is complete:
Use the `linkedin-writer` agent with `campaign-plan.md` to produce `linkedin-posts.md`.
**Step 5: Carousel Builder** (if campaign in scope)
After `campaign-plan.md` is complete:
Use the `carousel-builder` agent with carousel briefs from `campaign-plan.md`.
### Phase 4: Summary
After all agents complete, present:
```
## Build Complete ✓
### Site
- Pages: [count] with [total block count] blocks
- Seed scripts: [list files]
- Status: All drafts — run seed scripts against Payload to create
### Images
- Total needed: [count]
- Nano Banan prompts: nano-banan-prompts.txt
- Image brief: image-brief.md
### Campaign (if applicable)
- Carousels: [count]
- LinkedIn posts: [count] for [weeks] weeks
- Plan: campaign-plan.md
### All Output Files
[list every file produced]
```
## Error Handling
- If an agent fails, report which one and what went wrong
- If the brief is missing required fields, ask the user before proceeding
- If a block type from the architecture isn't in the project reference, flag it
- Always validate seed scripts with `node --check` before delivering

View File

@ -1,39 +0,0 @@
---
description: Produce a campaign content package — carousel HTML, LinkedIn posts, and campaign plan — without rebuilding the site.
argument-hint: <campaign-topic> [headline-variant]
---
## Mission
Produce a ready-to-publish campaign content package from a topic description.
## Inputs
- **Argument 1:** Campaign topic (e.g., "NIS2 compliance for Swedish companies")
- **Argument 2 (optional):** Specific headline (e.g., "NIS2 är här. Är ni redo?")
If no headline provided, generate 34 options and ask the user to pick.
## Execution
### Step 1: Campaign Brief
Use the `campaign-strategist` agent to produce `campaign-plan.md`.
Read `references/brand-voice.md` and `references/products-services.md` for context.
### Step 2: Build Assets
**2a: LinkedIn Posts**
Use the `linkedin-writer` agent with the content calendar to produce `linkedin-posts.md`.
**2b: Carousel**
Use the `carousel-builder` agent with the carousel brief to produce HTML files.
### Step 3: Present all outputs
## Quick Usage
```
/produce-campaign "data sovereignty and CLOUD Act awareness"
/produce-campaign "colocation for AI workloads" "88 kW per rack — redo för AI."
/produce-campaign "klimatsmart IT-infrastruktur"
```

9
.gitignore vendored
View File

@ -19,12 +19,3 @@ tsconfig.tsbuildinfo
media/ media/
!src/migrations/*.ts !src/migrations/*.ts
!src/migrations/*.json !src/migrations/*.json
# Agent working files
agent-output/
site-architecture.md
page-content.md
campaign-plan.md
image-brief.md
nano-banan-prompts.txt
linkedin-posts.md

View File

@ -1,108 +0,0 @@
# Fiber Direkt — Agent System (Updated)
> Complete multi-agent system for building Fiber Direkt websites and campaigns via Claude Code.
> All reference files included — no external skills required.
## What's Included
```
.claude/
├── agents/
│ ├── site-architect.md — Page maps with block sequences + valid select values
│ ├── content-writer.md — Swedish B2B copy with field-name validation
│ ├── image-director.md — Nano Banana Pro prompts with proven prompt formula
│ ├── seed-script-builder.md — Payload CMS seed scripts with schema validation
│ ├── campaign-strategist.md — Campaign plans, calendars, carousel briefs
│ ├── carousel-builder.md — LinkedIn carousel HTML files
│ └── linkedin-writer.md — Ready-to-publish LinkedIn posts
├── commands/
│ ├── build-site.md — Full site + campaign from brief
│ └── produce-campaign.md — Campaign-only content package
references/
├── brand-voice.md — Voice, tone, language rules, messaging pillars
├── products-services.md — All products, pricing, specs, certifications
├── copy-examples.md — Approved copy from existing landing pages
└── post-patterns.md — LinkedIn post templates and format rules
templates/
├── brief-template.md — The input document you fill in
└── example-brief-fiberdirekt.md — Filled-in example brief
```
## Setup
Copy everything into your Fiber Direkt project root:
```bash
cd /path/to/wwwfiberdirekt
# Copy the directories
cp -r [download-path]/.claude/ ./.claude/
cp -r [download-path]/references/ ./references/
cp -r [download-path]/templates/ ./templates/
```
Your project should now have:
```
wwwfiberdirekt/
├── .claude/agents/... ← agent definitions
├── .claude/commands/... ← slash commands
├── references/... ← brand + product knowledge
├── templates/... ← brief template
├── src/... ← existing project code
├── CLAUDE.md ← existing conventions file
└── fiber-direkt-project-reference-Payload-cms2.md ← block library reference
```
**Important:** The project reference file (`fiber-direkt-project-reference-Payload-cms2.md`) must be in the project root. Several agents read it for block schemas.
## Prerequisites
- Claude Code installed and authenticated (Pro or Max subscription)
- Fiber Direkt project cloned locally
- No external skills needed — all reference files are included
## Usage
### Full Site Build
1. Copy `templates/brief-template.md`, fill it in
2. In Claude Code: `/build-site templates/your-brief.md`
3. Agents run in sequence, outputs appear in project root
### Campaign Only
```
/produce-campaign "data sovereignty for Swedish enterprises"
```
### Individual Agents
```
Use the site-architect agent to create a page map for this brief: [paste brief]
Use the content-writer agent to write copy for the colocation page
Use the linkedin-writer agent to write 4 weeks of posts from campaign-plan.md
```
## What Changed from v1
1. **Reference files created**`references/` directory with brand voice, product specs, copy examples, and LinkedIn patterns. Agents now read actual files instead of referencing nonexistent skills.
2. **Field-name validation** — content-writer now warns about common field mismatches (fdHero uses `theme` not `sectionBackground`, fdPricingCard uses `sectionTitle` not `heading`). Seed-script-builder includes a critical field mapping table and runs `node --check` validation.
3. **Valid select values** — site-architect has a complete list of valid select values per block, preventing invalid configs that would break seed scripts.
4. **Nano Banana Pro tips** — image-director includes model-specific guidance and the proven prompt formula (subject → lighting rig → lens → depth of field → "Photorealistic.").
5. **Simplified carousel builder** — no longer depends on external fd-carousel skill. Produces self-contained HTML with inline CSS.
## Maintenance
When you add new blocks or change pricing:
- Update `fiber-direkt-project-reference-Payload-cms2.md` for new blocks
- Update `references/products-services.md` for pricing changes
- Update `references/brand-voice.md` if tone/messaging shifts
- Agent definitions only need updating if the workflow itself changes
## Cost Expectations
On Max $100/month plan, a full `/build-site` run uses roughly 150K300K tokens across all agents. You can run several full builds per week without hitting caps. Individual agent calls are much lighter (~20K40K tokens each).

View File

@ -77,8 +77,6 @@ const nextConfig = {
cpus: 1, cpus: 1,
}, },
images: { images: {
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 3600,
remotePatterns: [ remotePatterns: [
...[NEXT_PUBLIC_SERVER_URL].map((item) => { ...[NEXT_PUBLIC_SERVER_URL].map((item) => {
const url = new URL(item) const url = new URL(item)
@ -87,8 +85,6 @@ const nextConfig = {
protocol: url.protocol.replace(':', ''), protocol: url.protocol.replace(':', ''),
} }
}), }),
{ hostname: 'img.youtube.com', protocol: 'https' },
{ hostname: 'i.vimeocdn.com', protocol: 'https' },
], ],
}, },
webpack: (webpackConfig) => { webpack: (webpackConfig) => {

View File

@ -1,141 +0,0 @@
# Fiber Direkt — Brand Voice Guidelines
## Who We Are
Fiber Direkt is a Swedish B2B infrastructure company based in Stockholm, offering fiber connectivity, colocation, virtual servers (cloud), and backup — all from Swedish datacenters, under Swedish law, with Swedish support. We are not a reseller. We own the infrastructure.
## Sub-brands
| Sub-brand | Scope | Tagline |
|---|---|---|
| Fiber Direkt | Connectivity — dedicated fiber 10400 Gbit/s | Direkt. Dedikerat. Svenskt. |
| Server Direkt | Virtual datacenter — VPS, cloud servers | Servrar utan serverrum. |
| Datacenter Direkt | Colocation — rack, half-rack, full cage | Flytta in. Skala upp. Sov gott. |
| Svenskt IT | Ecosystem manifesto — sovereignty narrative | Din data. Ditt land. Din kontroll. |
## Voice & Tone
### We sound like:
- A confident, knowledgeable colleague — not a sales pitch
- Professional but direct — no corporate fog
- Calm authority — we know what we're doing and don't need to shout
### We never sound like:
- Aggressive or fear-mongering (even when discussing CLOUD Act / NIS2)
- Startup hype ("revolutionize", "disrupt", "game-changing")
- Generic corporate ("solutions", "synergies", "leverage")
- Condescending toward competitors — we let facts speak
### Emotional register:
- **Primary:** Confidence, control, clarity
- **Secondary:** Pride (Swedish identity), trust, relief (from complexity)
- **Avoid:** Panic, urgency-for-urgency's-sake, techno-enthusiasm
## Language Rules
### Swedish first
- All customer-facing copy is in Swedish
- Use **"ni"** (not "du") when addressing businesses — we're professional, not casual
- Keep accepted English terms: "cloud", "colocation", "mesh", "backup", "rack", "Layer 2"
- Swedish compound words: "molntjänst", "fibernät", "datasuveränitet", "driftmiljö"
- Never Swenglish: not "clouden" or "backupa"
### Number formatting
- Space in thousands: **10 000 Mbit/s**, not "10000"
- Pricing: **X XXX kr/mån** — e.g., "5 995 kr/mån"
- Percentages: **99,99 %** (Swedish decimal comma, space before %)
- Speeds: Always specify direction if relevant — "symmetrisk" for up/down parity
### Certifications — always written correctly
- ISO 27001 (not "ISO27001" or "ISO 27001:2022" unless specific year matters)
- ISO 14001
- SSF 200
- Skyddsklass 3
- Larmklass 3
- NIS2 (not "NIS-2" or "NIS 2")
### Competitor references
- **Never name** Microsoft, Google, Amazon, AWS, Azure, GCP directly
- Use: "amerikanska hyperscalers", "Big Tech", "utländska molntjänster"
- When discussing risk: frame as systemic/structural, not company-specific
- It's fine to reference **US Cloud Act**, **FISA**, **Schrems II** by name — these are laws, not companies
## Copy Patterns
### Headlines
- Max 8 words
- Benefit-driven or question-based
- Swedish — never English headlines
- Examples:
- "Var bor din data egentligen?"
- "Servrar utan serverrum."
- "Flytta in. Skala upp. Sov gott."
- "Er data under svensk lag. Punkt."
### Body text
- Front-load value — first sentence states the benefit
- Max 3 sentences per paragraph
- Be specific: real numbers, real specs, real certifications
- Pattern: benefit → how we deliver it → proof/credential
### CTAs
- Action verb + outcome — 24 words
- Primary: "Kontakta oss", "Boka ett möte", "Beräkna din kostnad", "Kom igång"
- Secondary: "Läs mer", "Se våra priser", "Prata med en expert"
- Every page ends with a clear next step pointing to /kontakt
### USP statements
- Pattern: **[Benefit] — [how we deliver it]**
- "Svenskt huvudmannaskap — ingen utländsk jurisdiktion"
- "En leverantör, en faktura — fyra tjänster under ett tak"
- "Klimatsmart sedan dag ett — värmeåtervinning till fjärrvärmenätet"
### FAQ answers
- 23 sentences, direct, specific
- Include certifications and specs naturally
- Answer the actual question first, then expand
- What real prospects actually ask — not marketing questions
## Messaging Pillars (in priority order)
### 1. Datasuveränitet (Data Sovereignty)
- Core message: "Er data under svensk lag — ingen CLOUD Act"
- Emotional hook: control, independence, trust
- Proof points: Swedish ownership, ISO 27001, Skyddsklass 3, no foreign jurisdiction
- Urgency driver: NIS2 compliance deadlines
### 2. En leverantör (One Provider)
- Core message: "Fyra tjänster, en kontakt, full kontroll"
- Emotional hook: simplicity, relief from vendor chaos
- Proof points: fiber + colocation + cloud + backup from one company, one invoice
- Urgency driver: cost of managing 4 separate vendors, accountability gaps
### 3. Klimatsmart (Green IT)
- Core message: "Värmeåtervinning till fjärrvärme, HVO100, 20+ år före EU Green Deal"
- Emotional hook: pride, responsibility, forward-thinking
- Proof points: ISO 14001, heat recovery to district heating, HVO100 backup generators
- Urgency driver: scope 3 reporting requirements, sustainability mandates
## Audience
### Primary: IT-chefer (IT managers/directors)
- 20200 employee companies in Stockholm area
- Industries: finance, legal, SaaS, consulting, public sector
- Pain: managing multiple vendors, compliance pressure, unclear data residency
- Trigger: NIS2 audit, office move, outage, vendor price hike
### Secondary: VD/COO (CEO/COO)
- Cares about: cost, risk, compliance documentation
- Needs: one-page summary, clear pricing, SLA guarantees
### Decision process:
IT-chef identifies need → evaluates 23 alternatives → VD/COO approves budget → procurement 26 weeks
## What NOT to Do
- Don't use Swedish flag emoji 🇸🇪 more than once per page / once per LinkedIn post
- Don't mention specific competitor names
- Don't manufacture urgency — NIS2 and CLOUD Act provide natural urgency
- Don't use "vi på Fiber Direkt..." as an opener — it's self-centered
- Don't use "lösning" (solution) as a crutch word — be specific about what we offer
- Don't promise things we can't back up — always use verifiable numbers

View File

@ -1,142 +0,0 @@
# Fiber Direkt — Copy Examples
> Real copy from approved landing pages. Use as style reference — match this tone and specificity.
---
## Hero Headlines (approved)
| Page | Heading | Subheading |
|---|---|---|
| Data Direkt | Var bor din data egentligen? | Data Direkt — svensk datasuveränitet utan kompromisser |
| Colocation | Vad händer när ert serverrum inte räcker till? | Datacenter Direkt — colocation i Skyddsklass 3 |
| Server Direkt | Servrar utan serverrum | Server Direkt — virtuella servrar på svensk mark |
| Svenskt IT | (TBD — ecosystem manifesto page) | |
**Pattern:** Question or punchy statement, max 8 words. Sub-brand name + clarifying descriptor.
---
## Hero Body Text (approved)
**Data Direkt:**
"Varje dag skickar svenska företag sin mest känsliga data till servrar utanför landets gränser — utan att veta vem som egentligen har åtkomst. Vi erbjuder ett helt svenskt alternativ."
**Colocation:**
(implied from seed script context — match this level of specificity)
**Server Direkt:**
"Ersätt era fysiska servrar med flexibla, redundanta virtuella maskiner. Samma prestanda. Noll hårdvarubekymmer. All data i Sverige."
**Pattern:** 12 sentences. Name the problem or transformation. End with the promise.
---
## Statistics Blocks (approved)
### Data Direkt:
| Number | Label |
|---|---|
| 72% | av svenska företags data lagras utanför EU |
| 3 av 4 | IT-chefer osäkra på var data faktiskt finns |
| 100% | av vår infrastruktur på svensk mark |
| NIS2 | kräver kontroll — från oktober 2024 |
### Server Direkt:
| Number | Label |
|---|---|
| 99,99% | garanterad upptid |
| < 5 min | att starta en ny server |
| 100% | svensk data, svensk lag |
| 24/7 | support på svenska |
**Pattern:** 34 stats. Mix impressive numbers with credibility markers. At least one that's a certification/standard, not just a number.
---
## USP Statements (approved style)
- Svenskt huvudmannaskap — ingen utländsk jurisdiktion
- NIS2 och ISO 27001 — inbyggt
- Privat nätverk med Layer 2
- Svensk support, dygnet runt
- All data stannar i Sverige. Inga moln i USA, inga bakdörrar via tredjeland. Punkt.
- Svensk lag, hela vägen. Ingen Cloud Act, inga FISA-domstolar.
**Pattern:** Benefit first, then how. Short. Specific. Often ends with a period for finality.
---
## CTA Banners (approved)
**Data Direkt closer:**
- Heading: "Redo att ta kontrollen över er data?"
- Body: "Boka ett samtal med vårt team. Vi analyserar er nuvarande situation och visar hur en svensk lösning ser ut — utan förpliktelser."
- CTA: "Boka ett möte"
**Colocation closer:**
- Heading: "Redo att flytta in?"
- Body: "Ingen hårdvara att köpa. Betala för det ni använder — skala när ni behöver."
- CTA: "Beställ nu"
- Secondary: "Prata med en expert"
**Pattern:** Question headline ("Redo att...?"), one sentence reinforcing value, action-oriented CTA button.
---
## FAQ Answers (approved style)
**Q: Räcker det inte med att min molnleverantör har servrar i EU?**
A: "Nej. EU-baserade servrar hos amerikanska bolag (AWS, Azure, Google) omfattas fortfarande av US Cloud Act. Den amerikanska staten kan kräva ut data oavsett serverns fysiska plats. Svensk infrastruktur hos ett svenskt bolag är den enda garantin."
**Q: Vad händer med min data om ni som företag säljs?**
A: "Fiber Direkt är ett svenskägt företag med långsiktigt ägande."
**Pattern:** Answer the actual question directly in sentence 1. Add the specific proof/detail in sentence 2. Keep it under 3 sentences. Include cert names and specifics naturally.
---
## Testimonial Style (placeholder template)
> "Vi insåg att vi hade all kunddata i AWS Frankfurt utan egentlig kontroll. Flytten till Fiber Direkt tog tre veckor — och nu sover jag bättre."
> — IT-chef, svenskt medelstort företag
> "NIS2-kraven hade stressat oss i månader. Med Fiber Direkt kunde vi bocka av compliance på en eftermiddag."
> — CISO, [Placeholder]
**Pattern:** Pain point in past tense → Fiber Direkt as the resolution → emotional payoff. Mark all placeholders clearly: [REPRESENTATIVE — needs real customer approval]
---
## Problem Framing (approved tone)
"Ditt företags data ligger troligen på servrar i USA, Irland eller Singapore — styrda av lagar du aldrig godkänt. US Cloud Act ger amerikanska myndigheter rätt att kräva ut data från amerikanska molntjänster, oavsett var servern står."
"Vad händer när strömmen går? Kylningen sviktar? Obehörig i serverrummet? Er verksamhet stannar."
**Pattern:** State the uncomfortable truth. Be specific about the risk. Don't exaggerate — the facts are alarming enough.
---
## Words and Phrases We Use
| Use | Instead of |
|---|---|
| amerikanska hyperscalers | AWS / Microsoft / Google |
| Big Tech | specific company names |
| molntjänst | "clouden" |
| datasuveränitet | "data sovereignty" (in Swedish copy) |
| fibernät | "fiber network" (in Swedish copy) |
| driftmiljö | "hosting environment" |
| 10 000 Mbit/s | 10000 Mbit/s |
| 5 995 kr/mån | 5995kr/månad |
| Skyddsklass 3 | "security class 3" |
## Words and Phrases We Avoid
- "revolutionera", "disrupt", "game-changer"
- "lösning" as a generic crutch (be specific: "colocation", "virtuellt datacenter")
- "vi på Fiber Direkt..." as an opener
- "leverera mervärde", "synergier"
- "unik" (unless it actually is)
- Any unnamed "the competition" language — focus on our strengths, not attacks

View File

@ -1,203 +0,0 @@
# Fiber Direkt — LinkedIn Post Patterns
> Templates and patterns for Swedish B2B LinkedIn content. All posts in Swedish, "ni" form.
---
## Post Structure: Hook → Expand → Position → CTA
Every post follows this 4-part structure:
**Line 1: HOOK** — The only line visible before "see more". Must create curiosity or tension.
**Lines 24: EXPAND** — Develop with specific facts, stats, or a brief story. Use line breaks.
**Lines 56: POSITION** — Fiber Direkt as the answer. Subtle, not salesy.
**Line 7: CTA** — Question for engagement OR "Länk i kommentarsfältet. 👇"
---
## Format Rules
- Language: Swedish, "ni" form
- Max 12 lines total
- Max 2 emojis per post (prefer 01)
- 35 hashtags (always include #FiberDirekt)
- Never start with "Vi på Fiber Direkt..."
- Never name competitors — use "hyperscalers", "Big Tech"
- Links go in first comment, not post body
- Max 1 Swedish flag emoji 🇸🇪 per post
---
## Post Types
### Type 1: Thought Leadership (sovereignty/compliance angle)
Best for: Tuesday/Wednesday posting
Goal: Position Fiber Direkt as the authority on Swedish data sovereignty
**Template:**
```
[Provocative question or surprising fact about data sovereignty]
[23 lines expanding with specifics — law names, stats, implications]
[What this means for Swedish businesses — practical impact]
[One line positioning Fiber Direkt without hard selling]
[Question for comments or link-in-comments CTA]
#FiberDirekt #Datasuveränitet #NIS2 #SvenskIT
```
**Example:**
```
72 % av svenska företags data lagras utanför EU.
Och nej — att er molnleverantör har servrar i Frankfurt hjälper inte.
US Cloud Act ger amerikanska myndigheter åtkomst oavsett var servern fysiskt står.
Det enda som skyddar er data är svensk infrastruktur, ägd av ett svenskt bolag,
under svensk lag.
Vi bygger den infrastrukturen. Varje dag.
Var lagras er mest känsliga data just nu?
#FiberDirekt #Datasuveränitet #CloudAct #SvenskIT
```
### Type 2: Stat / Proof Post
Best for: Monday posting
Goal: Stop the scroll with a number, build credibility
**Template:**
```
[Big number or stat — one line, punchy]
[Context — why this number matters]
[What we do about it / our track record]
[CTA question]
#FiberDirekt #[relevant topic hashtags]
```
**Example:**
```
99,99 % upptid. Inte ett mål — en garanti.
Med ekonomisk kompensation om vi inte levererar.
Byggd på N+1-redundans i kraft, kyla och nätverk.
Det är skillnaden mellan "molntjänst" och infrastruktur du kan lita på.
Vad kostar en timmes driftstopp för ert företag?
#FiberDirekt #Driftsäkerhet #SvenskIT #Colocation
```
### Type 3: Carousel Companion
Best for: Thursday posting
Goal: Drive swipes + saves on the carousel document
**Template:**
```
[Hook that connects to carousel topic — make them want to swipe]
[12 lines previewing what the carousel covers]
[Why this matters now — urgency or relevance hook]
Svep ➡️ för [what they'll learn]
#FiberDirekt #[topic hashtags]
```
**Example:**
```
NIS2 träder i kraft. Är ni redo?
Vi har sammanfattat fem saker varje IT-chef behöver ha koll på
innan nästa revision.
Svep ➡️ för checklistan.
#FiberDirekt #NIS2 #Compliance #ITSäkerhet
```
### Type 4: Behind-the-scenes / Culture
Best for: Friday posting
Goal: Humanize the brand, show the team/facility
**Template:**
```
[Something specific happening — a moment, a detail, a milestone]
[Why it matters or what it says about how we work]
[Light, human tone — this is the most casual we get]
#FiberDirekt #[casual hashtags]
```
---
## Weekly Content Mix
| Day | Post Type | Attachment |
|---|---|---|
| Monday | Stat / Proof | Single image (branded stat graphic) |
| Wednesday | Thought Leadership | None or single image |
| Thursday | Carousel Companion | LinkedIn Document (carousel PDF) |
Alternate Friday posts (biweekly): Behind-the-scenes or milestone posts.
---
## Hashtag Strategy
### Always use:
- #FiberDirekt
### Rotate based on topic:
- Sovereignty: #Datasuveränitet #CloudAct #NIS2 #GDPR #SvenskIT
- Infrastructure: #Colocation #Datacenter #Driftsäkerhet #Serverrum
- Sustainability: #Klimatsmart #GreenIT #Hållbarhet
- General B2B: #ITChef #SvensktNäringsliv #Digitalisering
### Never use:
- #ad, #sponsored (unless actually sponsored)
- English hashtags when Swedish equivalents exist
- More than 5 per post
---
## Tone Calibration
| Context | Tone |
|---|---|
| Sovereignty / compliance | Confident, authoritative, slight urgency |
| Product / specs | Matter-of-fact, specific, no hype |
| Behind-the-scenes | Warm, human, slightly informal |
| Carousel companion | Curious, teasing, "you need to see this" |
| Stat post | Punchy, proud, let the number speak |
---
## Things That Work on Swedish LinkedIn B2B
- Opening with a surprising stat
- Asking a genuine question (not rhetorical fluff)
- Short paragraphs with line breaks
- Specificity > generality (name the law, cite the number, state the spec)
- Carousels get 23x the reach of text-only posts
- First comment with link gets shown to more people than link in post body
## Things That Don't Work
- Long paragraphs without breaks
- Generic "5 tips for..." listicles
- Overly promotional language ("Köp nu!", "Bästa erbjudandet!")
- Tagging 15 people for engagement
- Posting about the company without connecting to the reader's world

View File

@ -1,228 +0,0 @@
# Fiber Direkt — Products & Services Reference
> All specs, pricing, and technical details for use in copy and seed scripts.
> Pricing is in SEK excluding VAT unless otherwise noted.
---
## 1. Fiber (Connectivity)
**Sub-brand:** Fiber Direkt
**Page slug:** /fiber or /bredband
**Tagline:** Direkt. Dedikerat. Svenskt.
### What it is
Dedicated fiber connectivity to Fiber Direkt's Swedish network. Symmetrical capacity (same upload and download speeds). Not shared consumer broadband — this is dedicated business fiber.
### Specifications
- Speed range: 10 Gbit/s to 400 Gbit/s symmetrical
- Network: Swedish-owned, direct connection (no Big Tech intermediaries)
- Layer 2 private networking available as add-on
- BGP support for enterprise customers
- VLAN tagging supported
- Redundant uplinks available
### Pricing tiers (from landing page work)
| Tier | Speed | Price |
|---|---|---|
| BASIC | 10 Gbit/s | från 1 195 kr/mån |
| STANDARD | (mid-tier) | (confirm exact pricing) |
| PREMIUM | (high-tier) | (confirm exact pricing) |
| PLATINA | 100 Gbit/s | från 7 995 kr/mån |
> **Note to agents:** If exact mid-tier pricing is not available, use "från 1 195 kr/mån" as starting price and mention "up to 400 Gbit/s" without specific per-tier pricing. Check with Jeffrey for current rate card.
### Key selling points
- Swedish network — no traffic routed through foreign infrastructure
- Symmetrical speeds — same upload as download
- Scalable — upgrade without changing hardware
- Layer 2 option for private multi-site networking
- SLA with financial guarantee
---
## 2. Colocation
**Sub-brand:** Datacenter Direkt (also used: Rack Direkt)
**Page slug:** /colocation
**Tagline:** Flytta in. Skala upp. Sov gott.
### What it is
Your hardware in our secure datacenter. You maintain control of your servers; we provide power, cooling, physical security, and network connectivity. Located in Stockholm Nord datacenter.
### Facility specs
- Location: Stockholm Nord
- Total capacity: 1,6 MW
- Per-rack power: up to 88 kW
- Cooling: precision cooling, N+1 redundancy
- Fire suppression: included
- Security: biometric access, camera surveillance, logged entry
- Certifications: ISO 27001, ISO 14001, SSF 200, Skyddsklass 3, Larmklass 3
### Pricing
| Option | Capacity | Price | Details |
|---|---|---|---|
| Helskåp (Full rack) | 40U | från 5 995 kr/mån | Own lock & key, N+1 redundant power, free remote management (KVM), cross-connect included |
| Halvskåp (Half rack) | 18U | från 3 495 kr/mån | 24/7 monitored, around-the-clock physical access |
| Bur (Cage) | Custom | Offert | For larger deployments — dedicated caged area |
### Add-ons
- Cross-connect (included in full rack)
- Remote hands / KVM access
- Layer 2 private networking to other Fiber Direkt sites
- Redundant network uplinks
### Key selling points
- Skyddsklass 3 — highest civilian security classification
- 88 kW per rack — AI/HPC-ready power density
- Swedish jurisdiction — no Cloud Act exposure
- N+1 redundancy on power and cooling
- Heat recovery to district heating network (klimatsmart)
- 24/7 Swedish support, physical access around the clock
- Migration support — we help you move in
### Migration pitch
"Från eget serverrum till Datacenter Direkt — på en vecka"
Three-step process: 1. Analys → 2. Migration → 3. Drift & support
---
## 3. Virtual Datacenter (Cloud / VPS)
**Sub-brand:** Server Direkt
**Page slug:** /virtuellt-datacenter or /moln
**Tagline:** Servrar utan serverrum.
### What it is
Virtual servers (VPS) running on Swedish infrastructure. Proxmox-based virtualization platform. Replace physical servers without losing control or sending data abroad. A Swedish alternative to AWS, Azure, and GCP.
### Specifications
- Platform: Proxmox-based
- Location: Swedish datacenters
- OS support: Linux, Windows (license add-on)
- Storage: SSD and HDD options
- Networking: private VLAN, Layer 2 available
- Backup: included daily snapshots, 30-day retention
- Uptime SLA: 99,99%
- Setup time: < 5 minutes for new server
### Pricing (from VPS calculator)
| Resource | Price |
|---|---|
| CPU per core | 149 kr/mån |
| RAM per GB | 49 kr/mån |
| SSD per GB | 2 kr/mån |
| HDD per GB | 0,50 kr/mån |
| Windows license | 299 kr/mån |
Starting configuration example: ~695 kr/mån (basic VM)
### Use cases (from landing page copy)
- Databases (SQL Server, PostgreSQL, MySQL)
- ERP / business systems
- Accounting software (bokföringssystem)
- Intranet / internal web apps
- Active Directory / authentication
- Email servers
- Development / staging environments
- AI/ML workloads (with high-power configs)
### Key selling points
- No hardware to buy or maintain
- Scale up/down in minutes
- All data stays in Sweden under Swedish law
- Built-in redundancy — no single point of failure
- Pay only for what you use
- 24/7 Swedish support
### Comparison pitch (old vs new)
❌ Traditional servers: expensive to buy, hard to scale, vulnerable to hardware failure, require space/cooling/staff
✅ Server Direkt: pay for what you use, built-in redundancy, no hardware maintenance, all data in Sweden
---
## 4. Backup & DR
**Page slug:** /backup
**Tagline:** (not yet defined — suggest "Säkerhetskopior som aldrig lämnar Sverige")
### What it is
Offsite backup and disaster recovery to Swedish infrastructure. Full control over data, no Big Tech dependency.
### Specifications
- Offsite to Swedish datacenter
- Uptime: 99,9%
- Retention: up to 30 days
- Fast recovery (restore times TBD — confirm with Jeffrey)
### Key selling points
- Swedish storage — no Cloud Act risk on backups either
- Automated daily backups
- Fast restore when needed
- Part of the "en leverantör" bundle
---
## 5. Office Komplett (Bundle)
**Page slug:** /office-komplett
**Tagline:** (not yet defined — suggest "Allt kontoret behöver. En faktura.")
### What it is
Bundled package for office IT: fiber + WiFi + network switch + cloud storage.
### Pricing
- **2 995 kr/mån** including:
- Dedicated fiber connection
- Managed WiFi
- Network switch
- 1 TB Swedish cloud storage
### Target
Small offices that want turnkey IT without managing multiple vendors.
---
## 6. Layer 2 Private Networking (Add-on)
### What it is
Dedicated Layer 2 network connecting multiple sites (offices, datacenters, cloud instances) via private, dedicated links. Traffic never touches the public internet.
### Key selling points
- "Anslut era kontor och system via dedikerat Layer 2-nätverk. Er trafik rör aldrig det publika internet."
- Available as add-on to any Fiber Direkt service
- Multi-site connectivity as if all locations were in the same building
---
## Shared Credentials (use across all products)
### Certifications
- ISO 27001 — information security
- ISO 14001 — environmental management
- SSF 200 — physical security standard
- Skyddsklass 3 — highest civilian security classification
- Larmklass 3 — alarm system classification
- GDPR compliant
- NIS2 ready
### Sustainability
- Heat recovery: excess datacenter heat fed into district heating network (fjärrvärme)
- Backup power: HVO100 renewable diesel
- Environmental cert: ISO 14001
- "Klimatsmart sedan dag ett — 20+ år före EU Green Deal"
### Support
- Swedish-speaking, Sweden-based
- 24/7 availability
- Phone, email, remote management
- "Inga chatbotar i Bangalore. Ring oss. Vi svarar. På svenska. Från Sverige."
### Company facts
- Swedish-owned company
- Based in Stockholm
- Long-term ownership (not VC-backed, not for sale)
- Own infrastructure (not reselling)

View File

@ -1,293 +0,0 @@
// seed-office-komplett.mjs
// Fiber Direkt — Office Komplett
// Run: PAYLOAD_API_URL=https://your-site.com PAYLOAD_API_KEY=xxx node seed-office-komplett.mjs
const PAYLOAD_API_URL = process.env.PAYLOAD_API_URL || 'http://localhost:3000'
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY
function richText(text) {
return {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text,
format: 0,
detail: 0,
mode: 'normal',
style: '',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
textFormat: 0,
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
}
}
async function createPage(slug, title, blocks) {
const res = await fetch(`${PAYLOAD_API_URL}/api/pages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `users API-Key ${PAYLOAD_API_KEY}`,
},
body: JSON.stringify({
title,
slug,
layout: blocks,
_status: 'draft',
}),
})
const data = await res.json()
console.log(`${res.ok ? '✓' : '✗'} ${title} (/${slug}): ${res.status}`)
if (!res.ok) console.error(JSON.stringify(data.errors || data, null, 2))
return data
}
async function seed() {
if (!PAYLOAD_API_KEY) {
console.error('Error: PAYLOAD_API_KEY environment variable is required.')
process.exit(1)
}
console.log(`Seeding to: ${PAYLOAD_API_URL}`)
console.log('---')
// ── Office Komplett (/office-komplett) ──────────────────────────────────────
await createPage('office-komplett', 'Office Komplett', [
// Block 1 — fdAlternateHero
{
blockType: 'fdAlternateHero',
heading: 'Allt kontoret behöver. En faktura.',
description:
'Sluta jonglera leverantörer. Office Komplett samlar dedikerat fiber, managed WiFi, nätverksswitch och 1 TB svensk molnlagring i ett och samma paket — med en kontakt, en faktura och svensk support dygnet runt. Ni sköter verksamheten. Vi sköter nätet.',
primaryCtaText: 'Kontakta oss',
primaryCtaLink: '/kontakt',
secondaryCtaText: 'Se vad som ingår',
secondaryCtaLink: '#vad-ingar',
sectionBackground: 'white',
anchorId: 'toppen',
},
// Block 2 — fdStatistics
{
blockType: 'fdStatistics',
heading: '',
sectionBackground: 'navy',
numberColor: 'yellow',
anchorId: 'nyckeltal',
stats: [
{ number: '2 995 kr/mån', label: 'Allt ingår — fiber, WiFi, switch och molnlagring' },
{ number: '1 TB', label: 'Svensk molnlagring i datacenter på svensk mark' },
{ number: '24/7', label: 'Svensk support — en kontakt för hela IT-miljön' },
{ number: '1', label: 'Faktura för hela paketet, varje månad' },
],
},
// Block 3 — fdUspChecklist
{
blockType: 'fdUspChecklist',
heading: 'Vad ingår i Office Komplett?',
sectionBackground: 'white',
anchorId: 'vad-ingar',
items: [
{ text: 'Dedikerat fiber — symmetrisk upp- och nedladdning, ingen delad kapacitet' },
{ text: 'Managed WiFi — professionella accesspunkter konfigurerade och övervakade av oss' },
{ text: 'Nätverksswitch — hanterad och uppdaterad utan att ni behöver lyfta ett finger' },
{ text: '1 TB molnlagring i svenska datacenter — under svensk lag, utan CLOUD Act-exponering' },
{ text: 'En faktura — ingen kostnad döljer sig hos en annan leverantör' },
{ text: 'En kontaktpunkt — ring oss, inte tre olika support-köer' },
{ text: 'Svensk support dygnet runt, alla dagar om året' },
{ text: 'Säker och krypterad datatrafik inom hela kontorets nätverk' },
{ text: 'Proaktiv övervakning — vi ser felet innan ni märker det' },
{ text: 'Skalbart — lägg till kapacitet eller användare utan att byta paket' },
],
},
// Block 4 — fdPricingCard
{
blockType: 'fdPricingCard',
sectionTitle: 'Pris och paket',
sectionBackground: 'gray',
cardStyle: 'navy',
anchorId: 'pris',
cards: [
{
title: 'Office Komplett',
subtitle: '2 995 kr/mån',
description:
'Ett komplett nätverkspaket för kontor med 550 anställda. Fast månadsavgift, inga dolda kostnader, ingen installationsavgift vid bindningstid.',
ctaText: 'Kontakta oss',
ctaLink: '/kontakt',
bulletPoints: [
{ text: 'Dedikerat fiber med symmetrisk kapacitet' },
{ text: 'Managed WiFi — installation och drift ingår' },
{ text: 'Hanterad nätverksswitch' },
{ text: '1 TB svensk molnlagring' },
{ text: '24/7 svensk support' },
{ text: 'En faktura varje månad' },
{ text: 'Proaktiv nätverksövervakning' },
{ text: 'Skalbar kapacitet vid behov' },
],
},
],
},
// Block 5 — fdCardGrid
{
blockType: 'fdCardGrid',
sectionBackground: 'white',
layout: '1-1-1',
cardStyle: 'outlined',
anchorId: 'en-leverantor',
cards: [
{
displayMode: 'content',
heading: 'Slipp leverantörscirkusen',
contentLines: [
{ text: 'När nätet krånglar vill ni inte ringa tre olika support-linjer och bli bollad fram och tillbaka.', style: 'normal' },
{ text: 'Med Office Komplett är Fiber Direkt er enda kontakt — oavsett om det gäller fibern, WiFi-signalen eller molnlagringen.', style: 'normal' },
],
},
{
displayMode: 'content',
heading: 'Data som stannar i Sverige',
contentLines: [
{ text: 'Er molnlagring finns i svenska datacenter, under svensk jurisdiktion.', style: 'normal' },
{ text: 'Ni berörs inte av utländska myndigheters krav på åtkomst — något som inte kan garanteras av amerikanska hyperscalers.', style: 'normal' },
],
},
{
displayMode: 'content',
heading: 'Nätet sköter sig självt',
contentLines: [
{ text: 'Managed WiFi och hanterad switch innebär att vi konfigurerar, uppdaterar och övervakar utrustningen åt er.', style: 'normal' },
{ text: 'Ni slipper hålla koll på firmware, säkerhetspatchar och inställningar — det är vårt jobb.', style: 'normal' },
],
},
],
},
// Block 6 — fdCtaSideImage
{
blockType: 'fdCtaSideImage',
heading: 'Installation utan huvudvärk',
body: 'Vi hanterar hela installationen från ax till limpa. Våra tekniker besöker kontoret, monterar utrustningen och konfigurerar hela nätverksmiljön innan ni ens hunnit hälla upp första kaffekoppen. Driftsättningstiden är typiskt en halv arbetsdag — och när vi går därifrån fungerar allt. Ni behöver inte göra ett enda tekniskt ingrepp.',
ctaText: 'Boka en genomgång',
ctaLink: '/kontakt',
imagePosition: 'right',
theme: 'dark',
anchorId: 'installation',
},
// Block 7 — fdTestimonial
{
blockType: 'fdTestimonial',
heading: 'Vad våra kunder säger',
sectionBackground: 'gray',
anchorId: 'kunder',
testimonials: [
{
quote:
'[REPRESENTANT — kräver verklig kundgodkännande] Vi hade tre olika leverantörer för nätet, WiFi och lagringen. Varje gång något krånglade visste ingen vem som ägde felet. Sedan vi gick över till Office Komplett har vi haft noll avbrott och ett enda samtal att ringa om något händer. Det är värt varenda krona.',
authorName: '[REPRESENTANT — kräver verklig kundgodkännande]',
authorRole: 'VD',
authorCompany: '[Företagsnamn]',
},
],
},
// Block 8 — fdFaq
{
blockType: 'fdFaq',
heading: 'Vanliga frågor om Office Komplett',
theme: 'light',
anchorId: 'faq',
items: [
{
question: 'Hur lång är bindningstiden?',
answer: richText(
'Office Komplett tecknas med 24 månaders bindningstid, vilket ger er det fasta priset på 2 995 kr/mån. Kortare avtalstid är möjlig — kontakta oss så räknar vi på er specifika situation.',
),
},
{
question: 'Ingår installation i priset?',
answer: richText(
'Ja, installation av fiber, WiFi-accesspunkter och nätverksswitch ingår utan extra kostnad vid 24-månaders avtal. En av våra certifierade tekniker sköter hela driftsättningen på plats.',
),
},
{
question: 'Var lagras vår data?',
answer: richText(
'All molnlagring sker i svenska datacenter på svensk mark, under svensk lagstiftning. Er data berörs aldrig av utländsk lagstiftning som CLOUD Act, vilket är en garanti vi kan ge — och som amerikanska hyperscalers inte kan.',
),
},
{
question: 'Vad händer om något slutar fungera?',
answer: richText(
'Ni ringer ett enda nummer — vår svenska support, öppen dygnet runt, alla dagar. Eftersom vi äger hela leveransen från fiber till molnlagring kan vi felsöka och åtgärda utan att skylla på någon annan part.',
),
},
{
question: 'Kan vi lägga till mer lagring eller kapacitet senare?',
answer: richText(
'Ja. Office Komplett är skalbart — ni kan utöka molnlagringen eller uppgradera fiberns kapacitet under avtalstiden. Vi anpassar paketet när verksamheten växer, utan att ni behöver byta leverantör.',
),
},
{
question: 'Fungerar paketet för kontor utanför Stockholm?',
answer: richText(
'Vi täcker i dag storstadsregionerna Stockholm, Göteborg och Malmö med tillhörande förorter. Kontakta oss med er adress så bekräftar vi täckning inom 24 timmar.',
),
},
{
question: "Vad menas med 'managed WiFi'?",
answer: richText(
'Det innebär att vi installerar, konfigurerar och löpande underhåller era WiFi-accesspunkter. Firmware-uppdateringar, säkerhetspatchar och nätverksjusteringar sköter vi proaktivt — ni märker det inte, för allt bara fungerar.',
),
},
],
},
// Block 9 — fdCtaBanner
{
blockType: 'fdCtaBanner',
heading: 'Redo att samla allt på en faktura?',
subheading:
'Fiber, WiFi, switch och svensk molnlagring — komplett för 2 995 kr/mån. Boka ett kostnadsfritt möte så berättar vi mer och bekräftar täckning på er adress.',
ctaText: 'Kontakta oss',
ctaLink: '/kontakt',
sectionBackground: 'yellow',
alignment: 'center',
size: 'large',
anchorId: 'kontakt',
},
])
console.log('---')
console.log('Done. Remember to attach images manually:')
console.log(' Block 1 (fdAlternateHero): modern office landscape, >= 1920px wide, landscape')
console.log(' Block 3 (fdUspChecklist): technician installing network equipment, square or 4:3')
console.log(' Block 6 (fdCtaSideImage): meeting room / workstations, 16:9, navy overlay applied by block')
}
seed().catch((err) => {
console.error('Seed failed:', err)
process.exit(1)
})

View File

@ -341,9 +341,6 @@ html:not([data-theme]) {
--color-fd-mint-100: #D9FCE2; --color-fd-mint-100: #D9FCE2;
--color-fd-mint-50: #ECFDF0; --color-fd-mint-50: #ECFDF0;
/* Accent / overlay tones */
--color-fd-sepia: #8B7D3C; /* warm sepia overlay */
/* Neutral grays */ /* Neutral grays */
--color-fd-gray: #F0F0F0; /* section alt backgrounds */ --color-fd-gray: #F0F0F0; /* section alt backgrounds */
--color-fd-gray-warm: #F8F8F6; --color-fd-gray-warm: #F8F8F6;

View File

@ -0,0 +1,66 @@
// @ts-nocheck
import type { Post, ArchiveBlock as ArchiveBlockProps } from '@/payload-types'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import React from 'react'
import RichText from '@/components/RichText'
import { CollectionArchive } from '@/components/CollectionArchive'
export const ArchiveBlock: React.FC<
ArchiveBlockProps & {
id?: string
}
> = async (props) => {
const { id, categories, introContent, limit: limitFromProps, populateBy, selectedDocs } = props
const limit = limitFromProps || 3
let posts: Post[] = []
if (populateBy === 'collection') {
const payload = await getPayload({ config: configPromise })
const flattenedCategories = categories?.map((category) => {
if (typeof category === 'object') return category.id
else return category
})
const fetchedPosts = await payload.find({
collection: 'posts',
depth: 1,
limit,
...(flattenedCategories && flattenedCategories.length > 0
? {
where: {
categories: {
in: flattenedCategories,
},
},
}
: {}),
})
posts = fetchedPosts.docs
} else {
if (selectedDocs?.length) {
const filteredSelectedPosts = selectedDocs.map((post) => {
if (typeof post.value === 'object') return post.value
}) as Post[]
posts = filteredSelectedPosts
}
}
return (
<div className="my-16" id={`block-${id}`}>
{introContent && (
<div className="container mb-16">
<RichText className="ms-0 max-w-[48rem]" data={introContent} enableGutter={false} />
</div>
)}
<CollectionArchive posts={posts} />
</div>
)
}

View File

@ -0,0 +1,94 @@
import type { Block } from 'payload'
import {
FixedToolbarFeature,
HeadingFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
export const Archive: Block = {
slug: 'archive',
interfaceName: 'ArchiveBlock',
fields: [
{
name: 'introContent',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: 'Intro Content',
},
{
name: 'populateBy',
type: 'select',
defaultValue: 'collection',
options: [
{
label: 'Collection',
value: 'collection',
},
{
label: 'Individual Selection',
value: 'selection',
},
],
},
{
name: 'relationTo',
type: 'select',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
},
defaultValue: 'posts',
label: 'Collections To Show',
options: [
{
label: 'Posts',
value: 'posts',
},
],
},
{
name: 'categories',
type: 'relationship',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
},
hasMany: true,
label: 'Categories To Show',
relationTo: 'categories',
},
{
name: 'limit',
type: 'number',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
step: 1,
},
defaultValue: 10,
label: 'Limit',
},
{
name: 'selectedDocs',
type: 'relationship',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'selection',
},
hasMany: true,
label: 'Selection',
relationTo: ['posts'],
},
],
labels: {
plural: 'Archives',
singular: 'Archive',
},
}

View File

@ -0,0 +1,24 @@
// @ts-nocheck
import React from 'react'
import type { CallToActionBlock as CTABlockProps } from '@/payload-types'
import RichText from '@/components/RichText'
import { CMSLink } from '@/components/Link'
export const CallToActionBlock: React.FC<CTABlockProps> = ({ links, richText }) => {
return (
<div className="container">
<div className="bg-card rounded border-border border p-4 flex flex-col gap-8 md:flex-row md:justify-between md:items-center">
<div className="max-w-[48rem] flex items-center">
{richText && <RichText className="mb-0" data={richText} enableGutter={false} />}
</div>
<div className="flex flex-col gap-8">
{(links || []).map(({ link }, i) => {
return <CMSLink key={i} size="lg" {...link} />
})}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,42 @@
import type { Block } from 'payload'
import {
FixedToolbarFeature,
HeadingFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { linkGroup } from '../../fields/linkGroup'
export const CallToAction: Block = {
slug: 'cta',
interfaceName: 'CallToActionBlock',
fields: [
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: false,
},
linkGroup({
appearances: ['default', 'outline'],
overrides: {
maxRows: 2,
},
}),
],
labels: {
plural: 'Calls to Action',
singular: 'Call to Action',
},
}

View File

@ -0,0 +1,44 @@
// @ts-nocheck
import { cn } from '@/utilities/ui'
import React from 'react'
import RichText from '@/components/RichText'
import type { ContentBlock as ContentBlockProps } from '@/payload-types'
import { CMSLink } from '../../components/Link'
export const ContentBlock: React.FC<ContentBlockProps> = (props) => {
const { columns } = props
const colsSpanClasses = {
full: '12',
half: '6',
oneThird: '4',
twoThirds: '8',
}
return (
<div className="container my-16">
<div className="grid grid-cols-4 lg:grid-cols-12 gap-y-8 gap-x-16">
{columns &&
columns.length > 0 &&
columns.map((col, index) => {
const { enableLink, link, richText, size } = col
return (
<div
className={cn(`col-span-4 lg:col-span-${colsSpanClasses[size!]}`, {
'md:col-span-2': size !== 'full',
})}
key={index}
>
{richText && <RichText data={richText} enableGutter={false} />}
{enableLink && <CMSLink {...link} />}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,79 @@
import type { Block, Field } from 'payload'
import {
FixedToolbarFeature,
HeadingFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { link } from '@/fields/link'
const columnFields: Field[] = [
{
name: 'size',
type: 'select',
defaultValue: 'oneThird',
options: [
{
label: 'One Third',
value: 'oneThird',
},
{
label: 'Half',
value: 'half',
},
{
label: 'Two Thirds',
value: 'twoThirds',
},
{
label: 'Full',
value: 'full',
},
],
},
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: false,
},
{
name: 'enableLink',
type: 'checkbox',
},
link({
overrides: {
admin: {
condition: (_data, siblingData) => {
return Boolean(siblingData?.enableLink)
},
},
},
}),
]
export const Content: Block = {
slug: 'content',
interfaceName: 'ContentBlock',
fields: [
{
name: 'columns',
type: 'array',
admin: {
initCollapsed: true,
},
fields: columnFields,
},
],
}

View File

@ -2,18 +2,6 @@ import React from 'react'
import type { FDAlternateHeroBlock as Props, Media } from '@/payload-types' import type { FDAlternateHeroBlock as Props, Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
import { FDButton } from '@/components/FDButton' import { FDButton } from '@/components/FDButton'
import { fdContainer, fdSepiaOverlay} from '@/utilities/fdTheme'
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: fdSepiaOverlay,
blackLight: 'bg-black/20',
blackMedium: 'bg-black/40',
}
export const FDAlternateHeroBlockComponent: React.FC<Props> = ({ export const FDAlternateHeroBlockComponent: React.FC<Props> = ({
heading, heading,
@ -24,11 +12,9 @@ export const FDAlternateHeroBlockComponent: React.FC<Props> = ({
secondaryCtaLink = '#', secondaryCtaLink = '#',
image, image,
imageCaption, imageCaption,
imageOverlay = 'none',
sectionBackground = 'white', sectionBackground = 'white',
anchorId, anchorId,
}) => { }) => {
const overlay = overlayMap[imageOverlay || 'none']
const media = image as Media | undefined const media = image as Media | undefined
const hasImage = media && typeof media === 'object' && media.url const hasImage = media && typeof media === 'object' && media.url
const isDark = sectionBackground === 'navy' const isDark = sectionBackground === 'navy'
@ -51,7 +37,7 @@ export const FDAlternateHeroBlockComponent: React.FC<Props> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full pt-16 md:pt-20 lg:pt-[99px] ${bgClass}`}> <section id={anchorId || undefined} className={`w-full pt-16 md:pt-20 lg:pt-[99px] ${bgClass}`}>
{/* Centered content */} {/* Centered content */}
<div className={`${fdContainer} text-center flex flex-col items-center gap-6 pb-12 md:pb-16`}> <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}`}> <h1 className={`w-full max-w-[820px] font-joey-heavy text-fd-display ${titleClass}`}>
{heading} {heading}
</h1> </h1>
@ -79,7 +65,7 @@ export const FDAlternateHeroBlockComponent: React.FC<Props> = ({
{/* Full-width image — no border radius, bleeds edge to edge */} {/* Full-width image — no border radius, bleeds edge to edge */}
{hasImage && ( {hasImage && (
<div className="w-full"> <div className="w-full">
<div className="relative w-full overflow-hidden" style={{ maxHeight: '620px', height: '45vw', minHeight: '220px' }}> <div className="relative w-full" style={{ maxHeight: '620px', height: '45vw', minHeight: '220px' }}>
<FDImage <FDImage
media={media} media={media}
size="hero" size="hero"
@ -89,7 +75,6 @@ export const FDAlternateHeroBlockComponent: React.FC<Props> = ({
sizes="100vw" sizes="100vw"
fallbackAlt={heading || ''} fallbackAlt={heading || ''}
/> />
{overlay && <div className={`absolute inset-0 ${overlay}`} />}
</div> </div>
{imageCaption && ( {imageCaption && (
<div className={`text-center py-3 font-joey text-fd-small opacity-60 ${bodyClass} ${bgClass}`}> <div className={`text-center py-3 font-joey text-fd-small opacity-60 ${bodyClass} ${bgClass}`}>

View File

@ -68,25 +68,6 @@ export const FDAlternateHeroBlock: Block = {
condition: (_, siblingData) => Boolean(siblingData?.image), condition: (_, siblingData) => Boolean(siblingData?.image),
}, },
}, },
{
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' },
],
admin: {
condition: (_, siblingData) => Boolean(siblingData?.image),
},
},
{ {
name: 'sectionBackground', name: 'sectionBackground',
type: 'select', type: 'select',

View File

@ -1,13 +1,12 @@
import React from 'react' import React from 'react'
import type { FDCardGridBlock as FDCardGridBlockProps } from '@/payload-types' import type { FDCardGridBlock as FDCardGridBlockProps } from '@/payload-types'
import { fdCardRadius as cardRadius, fdContainer} from '@/utilities/fdTheme'
const cardStyleMap: Record< const cardStyleMap: Record<
string, string,
{ bg: string; headingText: string; bodyText: string; linkText: string; border: string } { bg: string; headingText: string; bodyText: string; linkText: string; border: string }
> = { > = {
navy: { navy: {
bg: 'bg-fd-navy dark:bg-white/10', bg: 'bg-fd-navy',
headingText: 'text-fd-yellow', headingText: 'text-fd-yellow',
bodyText: 'text-white', bodyText: 'text-white',
linkText: 'text-fd-yellow hover:text-fd-yellow/80', linkText: 'text-fd-yellow hover:text-fd-yellow/80',
@ -65,6 +64,7 @@ const styleClassMap: Record<string, string> = {
} }
/* Priority #5: Responsive radius constant */ /* Priority #5: Responsive radius constant */
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
export const FDCardGridBlockComponent: React.FC<FDCardGridBlockProps> = ({ export const FDCardGridBlockComponent: React.FC<FDCardGridBlockProps> = ({
layout = '1-1-1', layout = '1-1-1',
@ -79,7 +79,7 @@ export const FDCardGridBlockComponent: React.FC<FDCardGridBlockProps> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
<div className={`grid grid-cols-1 ${gridCols} gap-4 md:gap-6`}> <div className={`grid grid-cols-1 ${gridCols} gap-4 md:gap-6`}>
{cards?.map((card, index) => { {cards?.map((card, index) => {
const mode = card.displayMode || 'content' const mode = card.displayMode || 'content'
@ -117,7 +117,7 @@ export const FDCardGridBlockComponent: React.FC<FDCardGridBlockProps> = ({
> >
{card.heading && ( {card.heading && (
<h3 <h3
className={`font-joey-heavy text-fd-h2 mb-6 hyphens-auto break-words ${style.headingText}`} className={`font-joey-heavy text-fd-h2 mb-2 ${style.headingText}`}
> >
{card.heading} {card.heading}
</h3> </h3>

View File

@ -2,7 +2,6 @@
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import type { FDCodeEmbedBlock as FDCodeEmbedBlockProps } from '@/payload-types' import type { FDCodeEmbedBlock as FDCodeEmbedBlockProps } from '@/payload-types'
import { fdCardRadius as cardRadius } from '@/utilities/fdTheme'
const maxWidthClasses: Record<string, string> = { const maxWidthClasses: Record<string, string> = {
default: 'max-w-[1200px]', default: 'max-w-[1200px]',
@ -21,6 +20,7 @@ const bgClasses: Record<string, string> = {
} }
/* Priority #5: Responsive radius for embed card wrappers */ /* Priority #5: Responsive radius for embed card wrappers */
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
export const FDCodeEmbedBlockComponent: React.FC<FDCodeEmbedBlockProps> = ({ export const FDCodeEmbedBlockComponent: React.FC<FDCodeEmbedBlockProps> = ({
heading, heading,

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { FDContactBlock as FDContactBlockProps, Media } from '@/payload-types' import type { FDContactBlock as FDContactBlockProps, Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
import { fdContainer } from '@/utilities/fdTheme'
/* Smaller radius for compact contact method images — the one exception per Jeffrey */ /* Smaller radius for compact contact method images — the one exception per Jeffrey */
const imageRadius = 'rounded-[16px] md:rounded-[24px] lg:rounded-[30px]' const imageRadius = 'rounded-[16px] md:rounded-[24px] lg:rounded-[30px]'
@ -13,7 +12,7 @@ export const FDContactBlockComponent: React.FC<FDContactBlockProps> = ({
}) => { }) => {
return ( return (
<section id={anchorId || undefined} className="relative w-full bg-fd-navy py-16 md:py-20 lg:pt-[100px] lg:pb-[120px]"> <section id={anchorId || undefined} className="relative w-full bg-fd-navy py-16 md:py-20 lg:pt-[100px] lg:pb-[120px]">
<div className={`${fdContainer} flex flex-col items-center gap-8 lg:gap-10`}> <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-h1 text-fd-yellow text-center"> <h2 className="w-full font-joey-heavy text-fd-h1 text-fd-yellow text-center">
{heading} {heading}
</h2> </h2>

View File

@ -5,7 +5,6 @@ import type { FDContactFormBlock as FDContactFormBlockProps } from '@/payload-ty
import type { Media } from '@/payload-types' import type { Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
import { FDButton } from '@/components/FDButton' import { FDButton } from '@/components/FDButton'
import { fdCardRadius as cardRadius, fdContainer} from '@/utilities/fdTheme'
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Theme maps */ /* Theme maps */
@ -15,11 +14,12 @@ const sectionBgMap: Record<string, string> = {
white: 'bg-white dark:bg-fd-navy', white: 'bg-white dark:bg-fd-navy',
gray: 'bg-fd-gray-light dark:bg-fd-navy', gray: 'bg-fd-gray-light dark:bg-fd-navy',
navy: 'bg-fd-navy', navy: 'bg-fd-navy',
navyGradient: 'bg-gradient-to-br from-fd-navy via-fd-navy-700 to-fd-navy', navyGradient: 'bg-gradient-to-br from-fd-navy via-[#153350] to-fd-navy',
} }
const isExplicitDark = (bg: string) => bg === 'navy' || bg === 'navyGradient' const isExplicitDark = (bg: string) => bg === 'navy' || bg === 'navyGradient'
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Component */ /* Component */
@ -280,7 +280,7 @@ export const FDContactFormBlockComponent: React.FC<FDContactFormBlockProps> = ({
if (status === 'sent') { if (status === 'sent') {
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={`${fdContainer} text-center`}> <div className="max-w-[1200px] mx-auto px-6 md:px-8 text-center">
<div className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-6 ${dark ? 'bg-fd-yellow/20' : 'bg-fd-mint/20 dark:bg-fd-yellow/20'}`}> <div className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-6 ${dark ? 'bg-fd-yellow/20' : 'bg-fd-mint/20 dark:bg-fd-yellow/20'}`}>
<svg className={`w-8 h-8 ${dark ? 'text-fd-yellow' : 'text-fd-mint dark:text-fd-yellow'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <svg className={`w-8 h-8 ${dark ? 'text-fd-yellow' : 'text-fd-mint dark:text-fd-yellow'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
@ -301,7 +301,7 @@ export const FDContactFormBlockComponent: React.FC<FDContactFormBlockProps> = ({
if (!form) { if (!form) {
return ( return (
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={`${fdContainer} text-center`}> <div className="max-w-[1200px] mx-auto px-6 md:px-8 text-center">
<p className={`font-joey ${mutedColor}`}>Inget formulär valt.</p> <p className={`font-joey ${mutedColor}`}>Inget formulär valt.</p>
</div> </div>
</section> </section>
@ -366,7 +366,7 @@ export const FDContactFormBlockComponent: React.FC<FDContactFormBlockProps> = ({
if (isCard) { if (isCard) {
return ( return (
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
<div <div
className={`max-w-[720px] mx-auto p-8 md:p-12 ${cardRadius} ${ className={`max-w-[720px] mx-auto p-8 md:p-12 ${cardRadius} ${
dark dark
@ -386,14 +386,14 @@ export const FDContactFormBlockComponent: React.FC<FDContactFormBlockProps> = ({
if (hasSideImage) { if (hasSideImage) {
return ( return (
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
<div className="flex flex-col lg:flex-row gap-10 lg:gap-16 items-start"> <div className="flex flex-col lg:flex-row gap-10 lg:gap-16 items-start">
<div className="flex-1">{formContent}</div> <div className="flex-1">{formContent}</div>
<div className="hidden lg:block flex-shrink-0"> <div className="hidden lg:block flex-shrink-0">
<FDImage <FDImage
media={media!} media={media!}
size="medium" size="medium"
className={`w-[380px] h-auto max-h-[560px] object-cover ${cardRadius}`} className="w-[380px] h-auto max-h-[560px] object-cover rounded-[32px] md:rounded-[50px] lg:rounded-[70px]"
sizes="380px" sizes="380px"
fallbackAlt={heading || ''} fallbackAlt={heading || ''}
/> />
@ -408,7 +408,7 @@ export const FDContactFormBlockComponent: React.FC<FDContactFormBlockProps> = ({
return ( return (
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
<div className="max-w-[800px]">{formContent}</div> <div className="max-w-[800px]">{formContent}</div>
</div> </div>
</section> </section>

View File

@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import type { FDCtaBannerBlock as FDCtaBannerBlockProps } from '@/payload-types' import type { FDCtaBannerBlock as FDCtaBannerBlockProps } from '@/payload-types'
import { fdContainer } from '@/utilities/fdTheme'
const bgMap: Record<string, { const bgMap: Record<string, {
section: string; heading: string; sub: string section: string; heading: string; sub: string
@ -60,7 +59,7 @@ export const FDCtaBannerBlockComponent: React.FC<FDCtaBannerBlockProps> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full ${sizing.py} ${theme.section}`}> <section id={anchorId || undefined} className={`w-full ${sizing.py} ${theme.section}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
<div className={`flex flex-col gap-6 md:gap-8 items-center text-center min-[820px]:${isCenter ? 'items-center text-center' : 'items-start text-left'} max-w-[800px] ${isCenter ? 'mx-auto' : ''}`}> <div className={`flex flex-col gap-6 md:gap-8 items-center text-center min-[820px]:${isCenter ? 'items-center text-center' : 'items-start text-left'} max-w-[800px] ${isCenter ? 'mx-auto' : ''}`}>
<div className={`flex flex-col gap-3 items-center min-[820px]:${isCenter ? 'items-center' : 'items-start'}`}> <div className={`flex flex-col gap-3 items-center min-[820px]:${isCenter ? 'items-center' : 'items-start'}`}>

View File

@ -4,7 +4,8 @@ import type { Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
import { FDButton } from '@/components/FDButton' import { FDButton } from '@/components/FDButton'
import { fdCardRadius as imageRadius, fdContainer, fdSepiaOverlay} from '@/utilities/fdTheme' /* Priority #5: Responsive radius */
const imageRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
/* Color overlay — same map as FDHeaderTextImageBlock */ /* Color overlay — same map as FDHeaderTextImageBlock */
const overlayMap: Record<string, string> = { const overlayMap: Record<string, string> = {
@ -13,7 +14,7 @@ const overlayMap: Record<string, string> = {
navyMedium: 'bg-fd-navy/40', navyMedium: 'bg-fd-navy/40',
yellowLight: 'bg-fd-yellow/20', yellowLight: 'bg-fd-yellow/20',
yellowMedium:'bg-fd-yellow/40', yellowMedium:'bg-fd-yellow/40',
sepia: fdSepiaOverlay, sepia: 'bg-[#8B7D3C]/30',
blackLight: 'bg-black/20', blackLight: 'bg-black/20',
blackMedium: 'bg-black/40', blackMedium: 'bg-black/40',
} }
@ -55,7 +56,7 @@ export const FDCtaSideImageBlockComponent: React.FC<FDCtaSideImageBlockProps> =
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={`${fdContainer} flex flex-col min-[820px]:flex-row min-[820px]:items-center gap-6 min-[820px]:gap-16`}> <div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col min-[820px]:flex-row min-[820px]:items-center gap-6 min-[820px]:gap-16">
{/* Heading — always first on mobile */} {/* Heading — always first on mobile */}
<h2 className={`w-full font-joey-heavy text-fd-h1 leading-tight order-1 min-[820px]:hidden ${headingClass}`}> <h2 className={`w-full font-joey-heavy text-fd-h1 leading-tight order-1 min-[820px]:hidden ${headingClass}`}>

View File

@ -1,7 +1,6 @@
'use client' 'use client'
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import type { FDDataTableBlock as Props, Media } from '@/payload-types' import type { FDDataTableBlock as Props, Media } from '@/payload-types'
import { fdContainer } from '@/utilities/fdTheme'
type TableData = { type TableData = {
headers: string[] headers: string[]
@ -169,7 +168,7 @@ export const FDDataTableBlockComponent: React.FC<Props> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
{(heading || description) && ( {(heading || description) && (
<div className="mb-10 md:mb-12"> <div className="mb-10 md:mb-12">

View File

@ -2,7 +2,6 @@
import React, { useState, useId } from 'react' import React, { useState, useId } from 'react'
import type { FDFaqBlock as FDFaqBlockProps } from '@/payload-types' import type { FDFaqBlock as FDFaqBlockProps } from '@/payload-types'
import RichText from '@/components/RichText' import RichText from '@/components/RichText'
import { fdContainer } from '@/utilities/fdTheme'
export const FDFaqBlockComponent: React.FC<FDFaqBlockProps> = ({ export const FDFaqBlockComponent: React.FC<FDFaqBlockProps> = ({
heading, heading,
@ -40,7 +39,7 @@ export const FDFaqBlockComponent: React.FC<FDFaqBlockProps> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[130px] ${bgClass}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[130px] ${bgClass}`}>
<div className={`${fdContainer} flex flex-col items-start gap-6`}> <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}`}> <h2 className={`w-full max-w-[550px] font-joey-heavy text-fd-h1 ${headingColor}`}>
{heading} {heading}
</h2> </h2>

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { FDFeatureAnnouncementBlock as FDFeatureAnnouncementBlockProps } from '@/payload-types' import type { FDFeatureAnnouncementBlock as FDFeatureAnnouncementBlockProps } from '@/payload-types'
import { FDButton } from '@/components/FDButton' import { FDButton } from '@/components/FDButton'
import { fdContainer } from '@/utilities/fdTheme'
export const FDFeatureAnnouncementBlockComponent: React.FC<FDFeatureAnnouncementBlockProps> = ({ export const FDFeatureAnnouncementBlockComponent: React.FC<FDFeatureAnnouncementBlockProps> = ({
heading, heading,
@ -34,7 +33,7 @@ export const FDFeatureAnnouncementBlockComponent: React.FC<FDFeatureAnnouncement
return ( return (
<section id={anchorId || undefined} className={`w-full py-20 md:py-28 lg:py-[173px] ${bgClass}`}> <section id={anchorId || undefined} className={`w-full py-20 md:py-28 lg:py-[173px] ${bgClass}`}>
<div className={`${fdContainer} flex flex-col items-center gap-8`}> <div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col items-center gap-8">
<h2 <h2
className={`w-full max-w-[696px] font-joey-bold text-fd-h1 text-center leading-tight ${headingColor}`} className={`w-full max-w-[696px] font-joey-bold text-fd-h1 text-center leading-tight ${headingColor}`}
> >

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { FDHeaderTextImageBlock as FDHeaderTextImageBlockProps, Media } from '@/payload-types' import type { FDHeaderTextImageBlock as FDHeaderTextImageBlockProps, Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
import { fdCardRadius, fdCardRadiusSm, fdContainer, fdSepiaOverlay} from '@/utilities/fdTheme'
const bgMap: Record<string, string> = { const bgMap: Record<string, string> = {
white: 'bg-white dark:bg-fd-navy', white: 'bg-white dark:bg-fd-navy',
@ -27,7 +26,7 @@ const overlayMap: Record<string, string> = {
navyMedium: 'bg-fd-navy/40', navyMedium: 'bg-fd-navy/40',
yellowLight: 'bg-fd-yellow/20', yellowLight: 'bg-fd-yellow/20',
yellowMedium:'bg-fd-yellow/40', yellowMedium:'bg-fd-yellow/40',
sepia: fdSepiaOverlay, sepia: 'bg-[#8B7D3C]/30',
blackLight: 'bg-black/20', blackLight: 'bg-black/20',
blackMedium: 'bg-black/40', blackMedium: 'bg-black/40',
} }
@ -35,8 +34,8 @@ const overlayMap: Record<string, string> = {
/* Updated: responsive radius matching the standard system */ /* Updated: responsive radius matching the standard system */
const roundedMap: Record<string, string> = { const roundedMap: Record<string, string> = {
none: '', none: '',
medium: fdCardRadiusSm, medium: 'rounded-[20px] md:rounded-[32px] lg:rounded-[40px]',
large: fdCardRadius, large: 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]',
} }
export const FDHeaderTextImageBlockComponent: React.FC<FDHeaderTextImageBlockProps> = ({ export const FDHeaderTextImageBlockComponent: React.FC<FDHeaderTextImageBlockProps> = ({
@ -60,7 +59,7 @@ export const FDHeaderTextImageBlockComponent: React.FC<FDHeaderTextImageBlockPro
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={`${fdContainer} flex flex-col gap-8 md:gap-10`}> <div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col gap-8 md:gap-10">
{(heading || body) && ( {(heading || body) && (
<div className={`flex flex-col gap-4 md:gap-6 ${align} ${textAlign === 'center' ? 'max-w-[900px] mx-auto' : ''}`}> <div className={`flex flex-col gap-4 md:gap-6 ${align} ${textAlign === 'center' ? 'max-w-[900px] mx-auto' : ''}`}>
{heading && ( {heading && (

View File

@ -3,14 +3,6 @@ import type { FDHeroBlock as FDHeroBlockProps, Media } from '@/payload-types'
import { FDButton } from '@/components/FDButton' import { FDButton } from '@/components/FDButton'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
const minHeightMap: Record<string, string> = {
auto: '',
sm: '400px',
md: '560px',
lg: '720px',
screen: '100vh',
}
export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({ export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
heading, heading,
subheading, subheading,
@ -20,10 +12,8 @@ export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
secondaryCtaText, secondaryCtaText,
secondaryCtaLink = '#', secondaryCtaLink = '#',
backgroundImage, backgroundImage,
minHeight = 'auto',
overlayOpacity = '50', overlayOpacity = '50',
headingColor: headingColorProp = 'auto', textColor = 'auto',
bodyColor: bodyColorProp = 'auto',
theme = 'light', theme = 'light',
anchorId, anchorId,
}) => { }) => {
@ -31,36 +21,29 @@ export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
const hasBgImage = Boolean(media?.url) const hasBgImage = Boolean(media?.url)
const isDark = hasBgImage || theme === 'dark' const isDark = hasBgImage || theme === 'dark'
const headingColorMap: Record<string, string> = { let headingColor: string
auto: isDark ? 'text-fd-yellow' : 'text-fd-navy dark:text-fd-yellow', let textBodyColor: string
yellow: 'text-fd-yellow', if (textColor === 'white') {
white: 'text-white', headingColor = 'text-white'
navy: 'text-fd-navy', textBodyColor = 'text-white'
} else if (textColor === 'navy') {
headingColor = 'text-fd-navy'
textBodyColor = 'text-fd-navy'
} else {
headingColor = isDark ? 'text-fd-yellow' : 'text-fd-navy dark:text-fd-yellow'
textBodyColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
} }
const bodyColorMap: Record<string, string> = {
auto: isDark ? 'text-white' : 'text-fd-navy dark:text-white',
white: 'text-white',
navy: 'text-fd-navy',
yellow: 'text-fd-yellow',
}
const headingColor = headingColorMap[headingColorProp || 'auto']
const textBodyColor = bodyColorMap[bodyColorProp || 'auto']
const overlayClass = const overlayClass =
overlayOpacity === '30' ? 'bg-black/30' : overlayOpacity === '70' ? 'bg-black/70' : 'bg-black/50' overlayOpacity === '30' ? 'bg-black/30' : overlayOpacity === '70' ? 'bg-black/70' : 'bg-black/50'
const secondaryOnDark = isDark const secondaryOnDark = textColor === 'navy' ? false : isDark
const minHeightStyle = minHeightMap[minHeight || 'auto']
return ( return (
<section id={anchorId || undefined} <section id={anchorId || undefined}
className={`relative w-full py-16 md:py-20 lg:py-[99px] ${ className={`relative w-full py-16 md:py-20 lg:py-[99px] ${
hasBgImage ? '' : isDark ? 'bg-fd-navy' : 'bg-white dark:bg-fd-navy' hasBgImage ? '' : isDark ? 'bg-fd-navy' : 'bg-white dark:bg-fd-navy'
} overflow-hidden`} } overflow-hidden`}
style={minHeightStyle ? { minHeight: minHeightStyle } : undefined}
> >
{/* Priority #2: Use FDImage instead of raw <img> for Next.js optimization */} {/* Priority #2: Use FDImage instead of raw <img> for Next.js optimization */}
{hasBgImage && media && ( {hasBgImage && media && (
@ -72,7 +55,7 @@ export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
priority priority
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
sizes="100vw" sizes="100vw"
fallbackAlt={heading || ''} fallbackAlt=""
/> />
<div className={`absolute inset-0 ${overlayClass}`} aria-hidden="true" /> <div className={`absolute inset-0 ${overlayClass}`} aria-hidden="true" />
</> </>

View File

@ -71,23 +71,6 @@ export const FDHeroBlock: Block = {
description: 'Fullbreddsbild bakom texten. Lämna tom för enfärgad bakgrund.', description: 'Fullbreddsbild bakom texten. Lämna tom för enfärgad bakgrund.',
}, },
}, },
{
name: 'minHeight',
type: 'select',
label: 'Minsta höjd',
defaultValue: 'auto',
admin: {
condition: (_, siblingData) => Boolean(siblingData?.backgroundImage),
description: 'Kontrollerar sektionens minsta höjd när bakgrundsbild används',
},
options: [
{ label: 'Auto (baserat på innehåll)', value: 'auto' },
{ label: 'Liten (400px)', value: 'sm' },
{ label: 'Medium (560px)', value: 'md' },
{ label: 'Stor (720px)', value: 'lg' },
{ label: 'Helskärm (100vh)', value: 'screen' },
],
},
{ {
name: 'overlayOpacity', name: 'overlayOpacity',
type: 'select', type: 'select',
@ -104,27 +87,14 @@ export const FDHeroBlock: Block = {
], ],
}, },
{ {
name: 'headingColor', name: 'textColor',
type: 'select', type: 'select',
label: 'Rubrikfärg', label: 'Textfärg',
defaultValue: 'auto', defaultValue: 'auto',
options: [ options: [
{ label: 'Automatisk (gul på mörkt, navy på ljust)', value: 'auto' }, { label: 'Automatisk (baserat på tema)', value: 'auto' },
{ label: 'Gul', value: 'yellow' },
{ label: 'Vit', value: 'white' }, { label: 'Vit', value: 'white' },
{ label: 'Navy', value: 'navy' }, { label: 'Blå (navy)', value: 'navy' },
],
},
{
name: 'bodyColor',
type: 'select',
label: 'Brödtextfärg',
defaultValue: 'auto',
options: [
{ label: 'Automatisk (vit på mörkt, navy på ljust)', value: 'auto' },
{ label: 'Vit', value: 'white' },
{ label: 'Navy', value: 'navy' },
{ label: 'Gul', value: 'yellow' },
], ],
}, },
{ {

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { FDIconBarBlock as FDIconBarBlockProps, Media } from '@/payload-types' import type { FDIconBarBlock as FDIconBarBlockProps, Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
import { fdContainer } from '@/utilities/fdTheme'
const bgMap: Record<string, string> = { const bgMap: Record<string, string> = {
white: 'bg-white dark:bg-fd-navy', white: 'bg-white dark:bg-fd-navy',
@ -43,7 +42,7 @@ export const FDIconBarBlockComponent: React.FC<FDIconBarBlockProps> = ({
return ( return (
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}> <section className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
{heading && ( {heading && (
<h2 className={`font-joey-heavy text-fd-h1 mb-10 md:mb-14 ${headClr}`}> <h2 className={`font-joey-heavy text-fd-h1 mb-10 md:mb-14 ${headClr}`}>
{heading} {heading}

View File

@ -1,134 +0,0 @@
import React from 'react'
import type { FDLinkCardsBlock as Props, Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage'
import { sectionBg, isExplicitDark, headingColor, bodyColor, fdCardRadius, fdContainer} from '@/utilities/fdTheme'
const cardStyleMap: Record<string, { bg: string; title: string; link: string; border: string }> = {
outlined: {
bg: 'bg-white/5',
title: 'text-white dark:text-white',
link: 'text-white/60 hover:text-fd-yellow dark:text-white/60 dark:hover:text-fd-yellow',
border: 'border border-white/10 dark:border-white/10',
},
navy: {
bg: 'bg-fd-navy dark:bg-white/10',
title: 'text-white',
link: 'text-fd-yellow hover:text-fd-yellow/80',
border: '',
},
gray: {
bg: 'bg-fd-gray-light dark:bg-white/10',
title: 'text-fd-navy dark:text-white',
link: 'text-fd-navy/60 hover:text-fd-navy dark:text-white/60 dark:hover:text-fd-yellow',
border: '',
},
yellow: {
bg: 'bg-fd-yellow',
title: 'text-fd-navy',
link: 'text-fd-navy/60 hover:text-fd-navy',
border: '',
},
}
/* Light-section overrides for outlined cards (needs dark text, not white) */
const cardStyleMapLight: Record<string, { bg: string; title: string; link: string; border: string }> = {
outlined: {
bg: 'bg-white dark:bg-white/5',
title: 'text-fd-navy dark:text-white',
link: 'text-fd-navy/60 hover:text-fd-navy dark:text-white/60 dark:hover:text-fd-yellow',
border: 'border border-gray-200 dark:border-white/10',
},
}
const colsMap: Record<string, string> = {
'2': 'md:grid-cols-2',
'3': 'md:grid-cols-3',
'4': 'md:grid-cols-2 lg:grid-cols-4',
}
export const FDLinkCardsBlockComponent: React.FC<Props> = ({
icon,
heading,
description,
cards,
columns = '3',
cardStyle = 'outlined',
sectionBackground = 'navy',
anchorId,
}) => {
const dark = isExplicitDark(sectionBackground)
const bg = sectionBg(sectionBackground)
const hClr = headingColor(dark)
const bClr = bodyColor(dark)
const gridCols = colsMap[columns || '3'] || colsMap['3']
/* Pick card style — outlined adapts based on section darkness */
const style = (!dark && cardStyle === 'outlined')
? cardStyleMapLight.outlined
: (cardStyleMap[cardStyle || 'outlined'] || cardStyleMap.outlined)
const iconMedia = icon as Media | undefined
const hasIcon = iconMedia && typeof iconMedia === 'object' && iconMedia.url
return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={fdContainer}>
{/* Centered header */}
<div className="text-center mb-12 md:mb-16 flex flex-col items-center gap-4">
{hasIcon && (
<FDImage
media={iconMedia}
size="thumbnail"
className="w-12 h-12 md:w-16 md:h-16 object-contain"
sizes="64px"
fallbackAlt=""
aria-hidden="true"
/>
)}
<h2 className={`font-joey-heavy text-fd-h1 max-w-[700px] ${hClr}`}>
{heading}
</h2>
{description && (
<p className={`font-joey text-fd-body-lg max-w-[600px] ${bClr} opacity-70`}>
{description}
</p>
)}
</div>
{/* Card grid */}
<div className={`grid grid-cols-1 ${gridCols} gap-4 md:gap-6`}>
{cards?.map((card, i) => {
const linkIconMedia = card.linkIcon as Media | undefined
const hasLinkIcon = linkIconMedia && typeof linkIconMedia === 'object' && linkIconMedia.url
return (
<a
key={i}
href={card.linkUrl || '#'}
className={`group ${style.bg} ${style.border} ${fdCardRadius} px-8 py-8 md:px-10 md:py-10 flex flex-col justify-between gap-8 min-h-[220px] md:min-h-[260px] hover:-translate-y-1 transition-all duration-200`}
>
<h3 className={`font-joey-bold text-fd-h3 leading-snug ${style.title}`}>
{card.title}
</h3>
<div className={`flex items-center gap-2 font-joey text-fd-body transition-colors ${style.link}`}>
{hasLinkIcon && (
<FDImage
media={linkIconMedia}
size="thumbnail"
className="w-5 h-5 object-contain opacity-60"
sizes="20px"
fallbackAlt=""
/>
)}
<span>{card.linkLabel}</span>
</div>
</a>
)
})}
</div>
</div>
</section>
)
}

View File

@ -1,108 +0,0 @@
import type { Block } from 'payload'
import { anchorField } from '@/fields/anchorField'
export const FDLinkCardsBlock: Block = {
slug: 'fdLinkCards',
imageURL: '/block-thumbnails/fd-link-cards.png',
imageAltText: 'FD Länkkort',
interfaceName: 'FDLinkCardsBlock',
labels: {
singular: 'FD Länkkort',
plural: 'FD Länkkort',
},
fields: [
{
name: 'icon',
type: 'upload',
relationTo: 'media',
label: 'Ikon ovanför rubrik (valfri)',
},
{
name: 'heading',
type: 'text',
localized: true,
label: 'Rubrik',
required: true,
},
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Beskrivning (valfri)',
},
{
name: 'cards',
type: 'array',
label: 'Kort',
minRows: 1,
maxRows: 6,
labels: {
singular: 'Kort',
plural: 'Kort',
},
fields: [
{
name: 'title',
type: 'textarea',
localized: true,
label: 'Korttitel',
required: true,
},
{
name: 'linkLabel',
type: 'text',
localized: true,
label: 'Länktext (visas i botten)',
required: true,
},
{
name: 'linkUrl',
type: 'text',
label: 'Länk-URL',
required: true,
},
{
name: 'linkIcon',
type: 'upload',
relationTo: 'media',
label: 'Länkikon (valfri, visas bredvid länktexten)',
},
],
},
{
name: 'columns',
type: 'select',
label: 'Kolumner (desktop)',
defaultValue: '3',
options: [
{ label: '2 kolumner', value: '2' },
{ label: '3 kolumner', value: '3' },
{ label: '4 kolumner', value: '4' },
],
},
{
name: 'cardStyle',
type: 'select',
label: 'Kortstil',
defaultValue: 'outlined',
options: [
{ label: 'Kantlinje', value: 'outlined' },
{ label: 'Navy', value: 'navy' },
{ label: 'Grå', value: 'gray' },
{ label: 'Gul', value: 'yellow' },
],
},
{
name: 'sectionBackground',
type: 'select',
label: 'Sektionsbakgrund',
defaultValue: 'navy',
options: [
{ label: 'Vit', value: 'white' },
{ label: 'Grå', value: 'gray' },
{ label: 'Navy', value: 'navy' },
],
},
anchorField,
],
}

View File

@ -1,7 +1,5 @@
import React from 'react' import React from 'react'
import type { FDLocationsGridBlock as Props, Media } from '@/payload-types' import type { FDLocationsGridBlock as Props, Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage'
import { fdCardRadius, fdContainer} from '@/utilities/fdTheme'
export const FDLocationsGridBlockComponent: React.FC<Props> = ({ export const FDLocationsGridBlockComponent: React.FC<Props> = ({
heading, heading,
@ -35,7 +33,7 @@ export const FDLocationsGridBlockComponent: React.FC<Props> = ({
return ( return (
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}> <section className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
{(heading || description || ctaText) && ( {(heading || description || ctaText) && (
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6 mb-12 md:mb-16"> <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6 mb-12 md:mb-16">
@ -61,18 +59,15 @@ export const FDLocationsGridBlockComponent: React.FC<Props> = ({
{(cards ?? []).map((card, i) => { {(cards ?? []).map((card, i) => {
const media = card.image as Media | undefined const media = card.image as Media | undefined
const isLink = Boolean(card.link) const isLink = Boolean(card.link)
const className = `group relative overflow-hidden ${fdCardRadius} aspect-[16/9] sm:aspect-[4/3] block ${isLink ? 'cursor-pointer' : ''}` const className = `group relative overflow-hidden rounded-[32px] md:rounded-[50px] lg:rounded-[70px] aspect-[16/9] sm:aspect-[4/3] block ${isLink ? 'cursor-pointer' : ''}`
const inner = ( const inner = (
<> <>
{media?.url && ( {media?.url && (
<FDImage <img
media={media} src={media.url}
size="large" alt={(media as any).alt || card.locationName}
fill className="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
className="object-cover transition-transform duration-500 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, 33vw"
fallbackAlt={card.locationName || ''}
/> />
)} )}
<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 inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent transition-opacity duration-300 group-hover:opacity-0" />

View File

@ -2,7 +2,6 @@
import React, { useState, useCallback } from 'react' import React, { useState, useCallback } from 'react'
import type { FDNewsletterBlock as FDNewsletterBlockProps } from '@/payload-types' import type { FDNewsletterBlock as FDNewsletterBlockProps } from '@/payload-types'
import { fdCardRadius as cardRadius } from '@/utilities/fdTheme'
// Navy is always dark. White/gray adapt to OS dark mode. // Navy is always dark. White/gray adapt to OS dark mode.
const bgClasses: Record<string, string> = { const bgClasses: Record<string, string> = {
@ -13,6 +12,7 @@ const bgClasses: Record<string, string> = {
} }
/* Priority #5: Responsive card radius */ /* Priority #5: Responsive card radius */
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
export const FDNewsletterBlockComponent: React.FC<FDNewsletterBlockProps> = ({ export const FDNewsletterBlockComponent: React.FC<FDNewsletterBlockProps> = ({
heading = 'Håll dig uppdaterad', heading = 'Håll dig uppdaterad',

View File

@ -1,7 +1,5 @@
import React from 'react' import React from 'react'
import type { FDPartnersLogosBlock as FDPartnersLogosBlockProps, Media } from '@/payload-types' import type { FDPartnersLogosBlock as FDPartnersLogosBlockProps, Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage'
import { fdContainer } from '@/utilities/fdTheme'
export const FDPartnersLogosBlockComponent: React.FC<FDPartnersLogosBlockProps> = ({ export const FDPartnersLogosBlockComponent: React.FC<FDPartnersLogosBlockProps> = ({
heading, heading,
@ -26,7 +24,7 @@ export const FDPartnersLogosBlockComponent: React.FC<FDPartnersLogosBlockProps>
return ( return (
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}> <section className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
{heading && ( {heading && (
<h2 className={`font-joey-heavy text-fd-h2 text-center mb-10 md:mb-14 ${titleClass}`}> <h2 className={`font-joey-heavy text-fd-h2 text-center mb-10 md:mb-14 ${titleClass}`}>
{heading} {heading}
@ -40,12 +38,10 @@ export const FDPartnersLogosBlockComponent: React.FC<FDPartnersLogosBlockProps>
if (!media?.url) return null if (!media?.url) return null
const logoEl = ( const logoEl = (
<FDImage <img
media={media} src={media.url}
size="medium" alt={item.alt || ''}
className={`h-14 sm:h-12 md:h-16 lg:h-[72px] w-auto max-w-[160px] sm:max-w-[180px] md:max-w-[220px] object-contain ${imgFilter}`} className={`h-14 sm:h-12 md:h-16 lg:h-[72px] w-auto max-w-[160px] sm:max-w-[180px] md:max-w-[220px] object-contain ${imgFilter}`}
sizes="220px"
fallbackAlt={item.alt || ''}
/> />
) )

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { FDPricingCardBlock as FDPricingCardBlockProps } from '@/payload-types' import type { FDPricingCardBlock as FDPricingCardBlockProps } from '@/payload-types'
import { FDButton } from '@/components/FDButton' import { FDButton } from '@/components/FDButton'
import { fdCardRadius as cardRadius, fdContainer} from '@/utilities/fdTheme'
const sectionBgMap: Record<string, string> = { const sectionBgMap: Record<string, string> = {
white: 'bg-white dark:bg-fd-navy', white: 'bg-white dark:bg-fd-navy',
@ -30,7 +29,7 @@ const cardStyleMap: Record<string, {
isDark: false, isDark: false,
}, },
navy: { navy: {
bg: 'bg-fd-navy dark:bg-white/10', border: '', title: 'text-fd-yellow', bg: 'bg-fd-navy', border: '', title: 'text-fd-yellow',
subtitle: 'text-white', body: 'text-white/80', bullet: 'text-white', isDark: true, subtitle: 'text-white', body: 'text-white/80', bullet: 'text-white', isDark: true,
}, },
gray: { gray: {
@ -70,6 +69,7 @@ const gridColsMap: Record<number, string> = {
3: 'min-[820px]:grid-cols-3', 3: 'min-[820px]:grid-cols-3',
} }
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
export const FDPricingCardBlockComponent: React.FC<FDPricingCardBlockProps> = ({ export const FDPricingCardBlockComponent: React.FC<FDPricingCardBlockProps> = ({
sectionTitle, sectionTitle,
@ -90,7 +90,7 @@ export const FDPricingCardBlockComponent: React.FC<FDPricingCardBlockProps> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
{sectionTitle && ( {sectionTitle && (
<h2 className={`font-joey-heavy text-fd-h1 text-center mb-10 md:mb-14 ${sectionTitleColor}`}> <h2 className={`font-joey-heavy text-fd-h1 text-center mb-10 md:mb-14 ${sectionTitleColor}`}>
{sectionTitle} {sectionTitle}

View File

@ -1,355 +0,0 @@
'use client'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import type { FDQuizBlock as Props } from '@/payload-types'
import { FDButton } from '@/components/FDButton'
import { sectionBg, isExplicitDark, headingColor, bodyColor, fdCardRadius, fdContainer} from '@/utilities/fdTheme'
/* ── Types ── */
type QuizState = 'idle' | 'active' | 'result'
/* ── Icons ── */
const ArrowRight: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || 'w-4 h-4'} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 8h10M9 4l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const ArrowLeft: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || 'w-4 h-4'} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M13 8H3M7 4L3 8l4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/* ── Component ── */
export const FDQuizBlockComponent: React.FC<Props> = ({
heading,
description,
triggerLabel = 'Starta quiz',
outcomes = [],
questions = [],
nextLabel = 'Nästa',
backLabel = 'Tillbaka',
resultHeading = 'Rekommenderat:',
restartLabel = 'Börja om',
sectionBackground = 'navy',
anchorId,
}) => {
const [state, setState] = useState<QuizState>('idle')
const [step, setStep] = useState(0)
const [answers, setAnswers] = useState<Record<number, number>>({}) // step → option index
const [animating, setAnimating] = useState(false)
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const mql = window.matchMedia('(prefers-reduced-motion: reduce)')
setPrefersReducedMotion(mql.matches)
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches)
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [])
const dark = isExplicitDark(sectionBackground)
const bg = sectionBg(sectionBackground)
const hClr = headingColor(dark)
const bClr = bodyColor(dark)
/* Panel colors — always dark-ish card on any section */
const panelBg = dark
? 'bg-white/5 border border-white/10'
: 'bg-white border border-gray-200 dark:bg-white/5 dark:border-white/10'
const panelText = dark
? 'text-white'
: 'text-fd-navy dark:text-white'
const panelMuted = dark
? 'text-white/50'
: 'text-fd-navy/50 dark:text-white/50'
const optionBase = dark
? 'border border-white/15 hover:border-white/30'
: 'border border-gray-200 hover:border-fd-navy/30 dark:border-white/15 dark:hover:border-white/30'
const optionSelected = dark
? 'border-fd-yellow bg-fd-yellow/10'
: 'border-fd-yellow bg-fd-yellow/10 dark:border-fd-yellow dark:bg-fd-yellow/10'
const radioActive = 'border-fd-yellow bg-fd-yellow'
const radioInactive = dark
? 'border-white/30'
: 'border-gray-300 dark:border-white/30'
const totalQuestions = (questions ?? []).length
const currentQuestion = (questions ?? [])[step]
const hasAnswer = answers[step] !== undefined
/* ── Scoring logic ── */
const calculateResult = useCallback(() => {
const scores: Record<string, number> = {}
;(outcomes ?? []).forEach((o) => { scores[o.key || ''] = 0 })
Object.entries(answers).forEach(([stepStr, optionIndex]) => {
const q = (questions ?? [])[Number(stepStr)]
const option = q?.options?.[optionIndex]
if (option?.outcomeKeys) {
option.outcomeKeys.split(',').forEach((key) => {
const k = key.trim()
if (k in scores) scores[k] += 1
})
}
})
/* Find highest scoring outcome */
let maxKey = ''
let maxScore = -1
Object.entries(scores).forEach(([key, score]) => {
if (score > maxScore) { maxScore = score; maxKey = key }
})
return (outcomes ?? []).find((o) => o.key === maxKey) || (outcomes ?? [])[0]
}, [answers, outcomes, questions])
/* ── Navigation ── */
const animateTransition = (callback: () => void) => {
if (prefersReducedMotion) { callback(); return }
setAnimating(true)
setTimeout(() => { callback(); setAnimating(false) }, 150)
}
const handleStart = () => {
setState('active')
setStep(0)
setAnswers({})
}
const handleSelect = (optionIndex: number) => {
setAnswers((prev) => ({ ...prev, [step]: optionIndex }))
}
const handleNext = () => {
if (!hasAnswer) return
if (step < totalQuestions - 1) {
animateTransition(() => setStep((s) => s + 1))
} else {
animateTransition(() => setState('result'))
}
}
const handleBack = () => {
if (step > 0) {
animateTransition(() => setStep((s) => s - 1))
}
}
const handleRestart = () => {
animateTransition(() => {
setState('idle')
setStep(0)
setAnswers({})
})
}
const handleClose = () => {
setState('idle')
setStep(0)
setAnswers({})
}
/* ── Render: Idle state ── */
if (state === 'idle') {
return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={`${fdContainer} text-center flex flex-col items-center gap-6`}>
<h2 className={`font-joey-heavy text-fd-h1 max-w-[700px] ${hClr}`}>
{heading}
</h2>
{description && (
<p className={`font-joey text-fd-body-lg max-w-[600px] ${bClr} opacity-70`}>
{description}
</p>
)}
<div className="mt-2">
<FDButton as="button" onClick={handleStart} onDark={dark}>
{triggerLabel}
</FDButton>
</div>
</div>
</section>
)
}
/* ── Transition classes ── */
const transitionCls = prefersReducedMotion
? ''
: `transition-all duration-150 ${animating ? 'opacity-0 translate-y-2' : 'opacity-100 translate-y-0'}`
/* ── Render: Result state ── */
if (state === 'result') {
const winner = calculateResult()
return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={`${fdContainer} flex justify-center`}>
<div
ref={panelRef}
className={`${panelBg} rounded-2xl md:rounded-3xl p-8 md:p-10 w-full max-w-[520px] ${transitionCls}`}
>
{/* Close */}
<div className="flex justify-between items-center mb-6">
<span className={`font-joey-medium text-fd-small uppercase tracking-wider ${panelMuted}`}>
Resultat
</span>
<button
onClick={handleClose}
className={`w-8 h-8 flex items-center justify-center rounded-full transition-colors ${
dark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-fd-navy/5 text-fd-navy/40 dark:hover:bg-white/10 dark:text-white/60'
}`}
aria-label="Stäng"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" />
</svg>
</button>
</div>
{/* Recommendation */}
<div className={`flex flex-col gap-4 ${transitionCls}`}>
<p className={`font-joey-medium text-fd-body ${
dark ? 'text-fd-yellow' : 'text-fd-navy dark:text-fd-yellow'
}`}>
{resultHeading}
</p>
<h3 className={`font-joey-heavy text-fd-h2 leading-tight ${panelText}`}>
{winner?.title}
</h3>
{winner?.description && (
<p className={`font-joey text-fd-body leading-relaxed ${
dark ? 'text-white/70' : 'text-fd-navy/70 dark:text-white/70'
}`}>
{winner.description}
</p>
)}
<div className="flex flex-wrap gap-3 mt-4">
<button
onClick={handleRestart}
className={`px-6 py-3 rounded-full font-joey-medium text-fd-body border transition-colors ${
dark
? 'border-white/20 text-white hover:bg-white/10'
: 'border-gray-300 text-fd-navy hover:bg-fd-navy/5 dark:border-white/20 dark:text-white dark:hover:bg-white/10'
}`}
>
{restartLabel}
</button>
{winner?.ctaText && (
<FDButton href={winner.ctaLink || '/kontakt'} onDark={dark}>
{winner.ctaText}
</FDButton>
)}
</div>
</div>
</div>
</div>
</section>
)
}
/* ── Render: Active (question step) ── */
return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={`${fdContainer} flex justify-center`}>
<div
ref={panelRef}
className={`${panelBg} rounded-2xl md:rounded-3xl p-8 md:p-10 w-full max-w-[520px]`}
>
{/* Header: counter + close */}
<div className="flex justify-between items-center mb-6">
<span className={`font-joey-medium text-fd-small ${panelMuted}`}>
Fråga {String(step + 1).padStart(2, '0')} / {String(totalQuestions).padStart(2, '0')}
</span>
<button
onClick={handleClose}
className={`w-8 h-8 flex items-center justify-center rounded-full transition-colors ${
dark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-fd-navy/5 text-fd-navy/40 dark:hover:bg-white/10 dark:text-white/60'
}`}
aria-label="Stäng"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" />
</svg>
</button>
</div>
{/* Question */}
<div className={transitionCls}>
<h3 className={`font-joey-bold text-fd-h3 mb-6 leading-snug ${panelText}`}>
{currentQuestion?.question}
</h3>
{/* Options */}
<div className="flex flex-col gap-3">
{currentQuestion?.options?.map((option, i) => {
const isSelected = answers[step] === i
return (
<button
key={i}
onClick={() => handleSelect(i)}
className={`w-full text-left px-5 py-4 rounded-xl flex items-start gap-4 transition-all duration-150 ${
isSelected ? optionSelected : optionBase
}`}
>
{/* Radio circle */}
<span className={`mt-0.5 w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center transition-colors ${
isSelected ? radioActive : radioInactive
}`}>
{isSelected && (
<span className="w-2 h-2 rounded-full bg-fd-navy" />
)}
</span>
<div className="flex flex-col gap-0.5">
<span className={`font-joey-medium text-fd-body ${panelText}`}>
{option.label}
</span>
{option.sublabel && (
<span className={`font-joey text-fd-small ${panelMuted}`}>
{option.sublabel}
</span>
)}
</div>
</button>
)
})}
</div>
</div>
{/* Navigation */}
<div className={`flex justify-between items-center mt-8 pt-6 border-t ${
dark ? 'border-white/10' : 'border-gray-200 dark:border-white/10'
}`}>
{step > 0 ? (
<button
onClick={handleBack}
className={`flex items-center gap-2 font-joey-medium text-fd-body transition-colors ${
dark ? 'text-white/60 hover:text-white' : 'text-fd-navy/60 hover:text-fd-navy dark:text-white/60 dark:hover:text-white'
}`}
>
<ArrowLeft className="w-4 h-4" />
{backLabel}
</button>
) : <div />}
<button
onClick={handleNext}
disabled={!hasAnswer}
className={`flex items-center gap-2 px-6 py-2.5 rounded-full font-joey-medium text-fd-body transition-all ${
hasAnswer
? 'bg-fd-yellow text-fd-navy hover:-translate-y-0.5'
: dark
? 'bg-white/10 text-white/30 cursor-not-allowed'
: 'bg-gray-100 text-fd-navy/30 cursor-not-allowed dark:bg-white/10 dark:text-white/30'
}`}
>
{step < totalQuestions - 1 ? nextLabel : 'Visa resultat'}
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
</section>
)
}

View File

@ -1,199 +0,0 @@
import type { Block } from 'payload'
import { anchorField } from '@/fields/anchorField'
export const FDQuizBlock: Block = {
slug: 'fdQuiz',
imageURL: '/block-thumbnails/fd-quiz.png',
imageAltText: 'FD Quiz / Behovsanalys',
interfaceName: 'FDQuizBlock',
labels: {
singular: 'FD Quiz / Behovsanalys',
plural: 'FD Quiz / Behovsanalyser',
},
fields: [
{
name: 'heading',
type: 'text',
localized: true,
label: 'Rubrik',
required: true,
defaultValue: 'Vilken tjänst passar dig?',
},
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Beskrivning (valfri)',
},
{
name: 'triggerLabel',
type: 'text',
localized: true,
label: 'Startknapp-text',
defaultValue: 'Starta quiz',
},
/* ── Outcomes — define these FIRST so editors know the keys ── */
{
type: 'collapsible',
label: 'Resultat / rekommendationer',
admin: { initCollapsed: false },
fields: [
{
name: 'outcomes',
type: 'array',
label: 'Möjliga resultat',
minRows: 2,
maxRows: 6,
labels: { singular: 'Resultat', plural: 'Resultat' },
fields: [
{
name: 'key',
type: 'text',
label: 'Nyckel (kort, unik, t.ex. "vdc", "colo", "fiber")',
required: true,
admin: {
description: 'Används i frågornas alternativ. Bara små bokstäver, inga mellanslag.',
},
},
{
name: 'title',
type: 'text',
localized: true,
label: 'Resultatrubrik (t.ex. "Virtuellt Datacenter")',
required: true,
},
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Resultatbeskrivning',
},
{
name: 'ctaText',
type: 'text',
localized: true,
label: 'CTA-knapp text',
defaultValue: 'Läs mer',
},
{
name: 'ctaLink',
type: 'text',
label: 'CTA-knapp länk',
defaultValue: '/kontakt',
},
],
},
],
},
/* ── Questions ── */
{
type: 'collapsible',
label: 'Frågor',
admin: { initCollapsed: false },
fields: [
{
name: 'questions',
type: 'array',
label: 'Frågor',
minRows: 2,
maxRows: 10,
labels: { singular: 'Fråga', plural: 'Frågor' },
fields: [
{
name: 'question',
type: 'text',
localized: true,
label: 'Frågetext',
required: true,
},
{
name: 'options',
type: 'array',
label: 'Svarsalternativ',
minRows: 2,
maxRows: 5,
labels: { singular: 'Alternativ', plural: 'Alternativ' },
fields: [
{
name: 'label',
type: 'text',
localized: true,
label: 'Alternativtext',
required: true,
},
{
name: 'sublabel',
type: 'text',
localized: true,
label: 'Undertext (valfri, visas under alternativet)',
},
{
name: 'outcomeKeys',
type: 'text',
label: 'Resultatnycklar (kommaseparerade, t.ex. "vdc,fiber")',
required: true,
admin: {
description: 'Varje valt alternativ ger +1 poäng till angivna resultatnycklar.',
},
},
],
},
],
},
],
},
/* ── UI labels ── */
{
type: 'collapsible',
label: 'Knappar & etiketter',
admin: { initCollapsed: true },
fields: [
{
name: 'nextLabel',
type: 'text',
localized: true,
label: '"Nästa"-knapptext',
defaultValue: 'Nästa',
},
{
name: 'backLabel',
type: 'text',
localized: true,
label: '"Tillbaka"-knapptext',
defaultValue: 'Tillbaka',
},
{
name: 'resultHeading',
type: 'text',
localized: true,
label: 'Resultatrubrik',
defaultValue: 'Rekommenderat:',
},
{
name: 'restartLabel',
type: 'text',
localized: true,
label: '"Börja om"-knapptext',
defaultValue: 'Börja om',
},
],
},
/* ── Styling ── */
{
name: 'sectionBackground',
type: 'select',
label: 'Sektionsbakgrund',
defaultValue: 'navy',
options: [
{ label: 'Vit', value: 'white' },
{ label: 'Grå', value: 'gray' },
{ label: 'Navy', value: 'navy' },
],
},
anchorField,
],
}

View File

@ -2,10 +2,10 @@
import React, { useState, useMemo } from 'react' import React, { useState, useMemo } from 'react'
import type { FDServiceCalculatorBlock as Props } from '@/payload-types' import type { FDServiceCalculatorBlock as Props } from '@/payload-types'
import { fdCardRadius as cardRadius } from '@/utilities/fdTheme'
const formatKr = (n: number) => Math.round(n).toLocaleString('sv-SE') + ' kr' const formatKr = (n: number) => Math.round(n).toLocaleString('sv-SE') + ' kr'
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
/* ── Toggle switch ─────────────────────────────────────────────────────── */ /* ── Toggle switch ─────────────────────────────────────────────────────── */
function Toggle({ active, onToggle, label }: { active: boolean; onToggle: () => void; label: string }) { function Toggle({ active, onToggle, label }: { active: boolean; onToggle: () => void; label: string }) {
@ -14,7 +14,7 @@ function Toggle({ active, onToggle, label }: { active: boolean; onToggle: () =>
type="button" type="button"
onClick={onToggle} onClick={onToggle}
style={{ display: 'block', width: '3.5rem', height: '2rem', flexShrink: 0 }} style={{ display: 'block', width: '3.5rem', height: '2rem', flexShrink: 0 }}
className={`relative rounded-full transition-all duration-300 ${active ? 'bg-fd-yellow' : 'bg-fd-gray-light dark:bg-white/20'}`} className={`relative rounded-full transition-all duration-300 ${active ? 'bg-fd-yellow' : 'bg-[#e2e8f0] dark:bg-white/20'}`}
role="switch" role="switch"
aria-checked={active} aria-checked={active}
aria-label={label} aria-label={label}
@ -142,7 +142,7 @@ export const FDServiceCalculatorBlockComponent: React.FC<Props> = ({
const cardClass = isDark const cardClass = isDark
? `bg-white/5 border-[5px] border-white/10 ${cardRadius}` ? `bg-white/5 border-[5px] border-white/10 ${cardRadius}`
: `bg-white border-[5px] border-fd-gray-light ${cardRadius} dark:bg-white/5 dark:border-white/10` : `bg-white border-[5px] border-[#e2e8f0] ${cardRadius} dark:bg-white/5 dark:border-white/10`
const headingColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white' const headingColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
const descColor = isDark ? 'text-white/60' : 'text-fd-navy/60 dark:text-white/60' const descColor = isDark ? 'text-white/60' : 'text-fd-navy/60 dark:text-white/60'

View File

@ -1,7 +1,9 @@
'use client' 'use client'
import React, { useState, useRef, useEffect } from 'react' import React, { useState, useRef, useEffect } from 'react'
import type { FDServiceChooserBlock as Props } from '@/payload-types' import type { FDServiceChooserBlock as Props } from '@/payload-types'
import { fdCardRadius as cardRadius, fdContainer} from '@/utilities/fdTheme'
/* Consistent radius system — same as CardGrid, PricingCard, etc. */
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
export const FDServiceChooserBlockComponent: React.FC<Props> = ({ export const FDServiceChooserBlockComponent: React.FC<Props> = ({
heading, heading,
@ -51,7 +53,7 @@ export const FDServiceChooserBlockComponent: React.FC<Props> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
<div className="text-center mb-10 md:mb-12"> <div className="text-center mb-10 md:mb-12">
{heading && ( {heading && (

View File

@ -9,7 +9,8 @@ const columnClasses: Record<string, string> = {
'4': 'grid-cols-2 min-[820px]:grid-cols-4', '4': 'grid-cols-2 min-[820px]:grid-cols-4',
} }
import { fdCardRadius as imageRadius, fdContainer} from '@/utilities/fdTheme' /* Priority #5: Responsive radius for service images */
const imageRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
export const FDServicesGridBlockComponent: React.FC<FDServicesGridBlockProps> = ({ export const FDServicesGridBlockComponent: React.FC<FDServicesGridBlockProps> = ({
heading, heading,
@ -19,7 +20,7 @@ export const FDServicesGridBlockComponent: React.FC<FDServicesGridBlockProps> =
}) => { }) => {
return ( return (
<section id={anchorId || undefined} className="relative w-full bg-white dark:bg-fd-navy py-16 md:py-20 lg:py-[99px]"> <section id={anchorId || undefined} className="relative w-full bg-white dark:bg-fd-navy py-16 md:py-20 lg:py-[99px]">
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
<h2 className="font-joey-heavy text-fd-h1 text-fd-navy dark:text-fd-yellow mb-8 lg:mb-12"> <h2 className="font-joey-heavy text-fd-h1 text-fd-navy dark:text-fd-yellow mb-8 lg:mb-12">
{heading} {heading}
</h2> </h2>

View File

@ -1,190 +0,0 @@
import React from 'react'
import type { FDSpecCardsBlock as Props } from '@/payload-types'
import { FDButton } from '@/components/FDButton'
import {
sectionBg, isExplicitDark, headingColor, bodyColor,
bodySubduedColor, fdCardRadius, fdCardRadiusSm, fdContainer} from '@/utilities/fdTheme'
const cardStyleMap: Record<string, {
bg: string; border: string; title: string; body: string
specLabel: string; specValue: string; divider: string; isDark: boolean
}> = {
outlined: {
bg: 'bg-transparent',
border: 'border border-white/15 dark:border-white/15',
title: 'text-white dark:text-white',
body: 'text-white/70 dark:text-white/70',
specLabel: 'text-white/50 dark:text-white/50',
specValue: 'text-white dark:text-white',
divider: 'border-white/10 dark:border-white/10',
isDark: true,
},
navy: {
bg: 'bg-fd-navy dark:bg-white/10',
border: '',
title: 'text-white',
body: 'text-white/70',
specLabel: 'text-white/50',
specValue: 'text-fd-yellow',
divider: 'border-white/10',
isDark: true,
},
gray: {
bg: 'bg-fd-gray-light dark:bg-white/10',
border: '',
title: 'text-fd-navy dark:text-white',
body: 'text-fd-navy/70 dark:text-white/70',
specLabel: 'text-fd-navy/50 dark:text-white/50',
specValue: 'text-fd-navy dark:text-white',
divider: 'border-fd-navy/10 dark:border-white/10',
isDark: false,
},
white: {
bg: 'bg-white dark:bg-white/10 shadow-fd-card dark:shadow-none',
border: '',
title: 'text-fd-navy dark:text-white',
body: 'text-fd-navy/70 dark:text-white/70',
specLabel: 'text-fd-navy/50 dark:text-white/50',
specValue: 'text-fd-navy dark:text-white',
divider: 'border-fd-navy/10 dark:border-white/10',
isDark: false,
},
}
/* Light-section overrides for outlined cards */
const cardStyleMapLight: Record<string, typeof cardStyleMap.outlined> = {
outlined: {
bg: 'bg-transparent',
border: 'border border-gray-200 dark:border-white/15',
title: 'text-fd-navy dark:text-white',
body: 'text-fd-navy/70 dark:text-white/70',
specLabel: 'text-fd-navy/50 dark:text-white/50',
specValue: 'text-fd-navy dark:text-white',
divider: 'border-fd-navy/10 dark:border-white/10',
isDark: false,
},
}
export const FDSpecCardsBlockComponent: React.FC<Props> = ({
heading,
description,
ctaText,
ctaLink,
secondaryCtaText,
secondaryCtaLink,
cards,
layout = 'sideBySide',
cardStyle = 'outlined',
sectionBackground = 'navy',
anchorId,
}) => {
const dark = isExplicitDark(sectionBackground)
const bg = sectionBg(sectionBackground)
const hClr = headingColor(dark)
const bClr = bodySubduedColor(dark)
const style = (!dark && cardStyle === 'outlined')
? cardStyleMapLight.outlined
: (cardStyleMap[cardStyle || 'outlined'] || cardStyleMap.outlined)
const isSideBySide = layout === 'sideBySide' || layout === 'sideBySideReverse'
const isReversed = layout === 'sideBySideReverse'
const cardCount = cards?.length || 1
/* Cards grid: 1 card = 1 col, 2 = 2-col, 3+ = 2-col wrapping */
const cardGridCols = cardCount === 1 ? '' : 'sm:grid-cols-2'
const renderCards = () => (
<div className={`grid grid-cols-1 ${cardGridCols} gap-4 md:gap-5`}>
{cards?.map((card, i) => (
<div
key={i}
className={`${style.bg} ${style.border} ${fdCardRadiusSm} px-7 py-7 md:px-9 md:py-9 flex flex-col gap-4 justify-between`}
>
<div className="flex flex-col gap-3">
<h3 className={`font-joey-bold text-fd-h3 leading-snug ${style.title}`}>
{card.title}
</h3>
{card.description && (
<p className={`font-joey text-fd-body leading-relaxed ${style.body}`}>
{card.description}
</p>
)}
</div>
{(card.specLabel || card.specValue) && (
<div className={`pt-4 mt-auto border-t ${style.divider}`}>
{card.specLabel && (
<span className={`font-joey text-fd-small block mb-1 ${style.specLabel}`}>
{card.specLabel}
</span>
)}
{card.specValue && (
<span className={`font-joey-bold text-fd-h4 ${style.specValue}`}>
{card.specValue}
</span>
)}
</div>
)}
</div>
))}
</div>
)
const renderText = () => (
<div className="flex flex-col gap-5 md:gap-6 justify-center">
<h2 className={`font-joey-heavy text-fd-h1 leading-tight ${hClr}`}>
{heading}
</h2>
{description && (
<p className={`font-joey text-fd-body-lg leading-relaxed ${bClr}`}>
{description}
</p>
)}
{(ctaText || secondaryCtaText) && (
<div className="flex flex-wrap gap-4 pt-2">
{ctaText && (
<FDButton href={ctaLink || '#'} variant="outline" onDark={dark}>
{ctaText}
</FDButton>
)}
{secondaryCtaText && (
<FDButton href={secondaryCtaLink || '#'} variant="primary" onDark={dark}>
{secondaryCtaText}
</FDButton>
)}
</div>
)}
</div>
)
if (!isSideBySide) {
/* Full-width layout — heading on top, cards below */
return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={fdContainer}>
<div className="mb-10 md:mb-14">
{renderText()}
</div>
{renderCards()}
</div>
</section>
)
}
/* Side-by-side layout */
return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={fdContainer}>
<div className={`flex flex-col min-[820px]:flex-row gap-10 min-[820px]:gap-14 items-start ${isReversed ? 'min-[820px]:flex-row-reverse' : ''}`}>
<div className="flex-1 min-w-0 min-[820px]:max-w-[380px]">
{renderText()}
</div>
<div className="flex-[1.5] min-w-0">
{renderCards()}
</div>
</div>
</div>
</section>
)
}

View File

@ -1,125 +0,0 @@
import type { Block } from 'payload'
import { anchorField } from '@/fields/anchorField'
export const FDSpecCardsBlock: Block = {
slug: 'fdSpecCards',
imageURL: '/block-thumbnails/fd-spec-cards.png',
imageAltText: 'FD Specifikationskort',
interfaceName: 'FDSpecCardsBlock',
labels: {
singular: 'FD Specifikationskort',
plural: 'FD Specifikationskort',
},
fields: [
{
name: 'heading',
type: 'text',
localized: true,
label: 'Rubrik',
required: true,
},
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Beskrivning (valfri)',
},
{
name: 'ctaText',
type: 'text',
localized: true,
label: 'CTA-knapp text (valfri)',
},
{
name: 'ctaLink',
type: 'text',
label: 'CTA-knapp länk',
defaultValue: '/kontakt',
},
{
name: 'secondaryCtaText',
type: 'text',
localized: true,
label: 'Sekundär CTA-text (valfri)',
},
{
name: 'secondaryCtaLink',
type: 'text',
label: 'Sekundär CTA-länk',
},
{
name: 'cards',
type: 'array',
label: 'Kort',
minRows: 1,
maxRows: 4,
labels: {
singular: 'Kort',
plural: 'Kort',
},
fields: [
{
name: 'title',
type: 'text',
localized: true,
label: 'Korttitel',
required: true,
},
{
name: 'description',
type: 'textarea',
localized: true,
label: 'Kortbeskrivning',
},
{
name: 'specLabel',
type: 'text',
localized: true,
label: 'Spec-etikett (t.ex. "Kostnad", "Hastighet")',
},
{
name: 'specValue',
type: 'text',
localized: true,
label: 'Spec-värde (t.ex. "10 Gbit/s", "$12.50 / 1K")',
},
],
},
{
name: 'layout',
type: 'select',
label: 'Layout',
defaultValue: 'sideBySide',
options: [
{ label: 'Text vänster, kort höger', value: 'sideBySide' },
{ label: 'Text höger, kort vänster', value: 'sideBySideReverse' },
{ label: 'Kort i helbredd (utan sidotext)', value: 'fullWidth' },
],
},
{
name: 'cardStyle',
type: 'select',
label: 'Kortstil',
defaultValue: 'outlined',
options: [
{ label: 'Kantlinje', value: 'outlined' },
{ label: 'Navy', value: 'navy' },
{ label: 'Grå', value: 'gray' },
{ label: 'Vit (med skugga)', value: 'white' },
],
},
{
name: 'sectionBackground',
type: 'select',
label: 'Sektionsbakgrund',
defaultValue: 'navy',
options: [
{ label: 'Vit', value: 'white' },
{ label: 'Grå', value: 'gray' },
{ label: 'Navy', value: 'navy' },
{ label: 'Navy gradient', value: 'navyGradient' },
],
},
anchorField,
],
}

View File

@ -1,7 +1,6 @@
'use client' 'use client'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import type { FDStatisticsBlock as Props } from '@/payload-types' import type { FDStatisticsBlock as Props } from '@/payload-types'
import { fdContainer } from '@/utilities/fdTheme'
export const FDStatisticsBlockComponent: React.FC<Props> = ({ export const FDStatisticsBlockComponent: React.FC<Props> = ({
heading, heading,
@ -64,7 +63,7 @@ export const FDStatisticsBlockComponent: React.FC<Props> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`} ref={ref}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bgClass}`} ref={ref}>
<div className={`${fdContainer} text-center`}> <div className="max-w-[1200px] mx-auto px-6 md:px-8 text-center">
{heading && ( {heading && (
<h2 className={`font-joey-heavy text-fd-h1 mb-12 md:mb-16 ${titleClass}`}> <h2 className={`font-joey-heavy text-fd-h1 mb-12 md:mb-16 ${titleClass}`}>
{heading} {heading}

View File

@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import type { FDTagsBlock as FDTagsBlockProps } from '@/payload-types' import type { FDTagsBlock as FDTagsBlockProps } from '@/payload-types'
import { fdContainer } from '@/utilities/fdTheme'
const tagStyleMap: Record<string, { bg: string; text: string; border: string }> = { const tagStyleMap: Record<string, { bg: string; text: string; border: string }> = {
navy: { bg: 'bg-fd-navy', text: 'text-fd-yellow', border: '' }, navy: { bg: 'bg-fd-navy', text: 'text-fd-yellow', border: '' },
@ -43,7 +42,7 @@ export const FDTagsBlockComponent: React.FC<FDTagsBlockProps> = ({
return ( return (
<section className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
{heading && ( {heading && (
<h2 <h2
className={`font-joey-heavy text-fd-h2 mb-6 md:mb-8 ${headingColor} ${ className={`font-joey-heavy text-fd-h2 mb-6 md:mb-8 ${headingColor} ${

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { FDTeamBlock as FDTeamBlockProps, Media } from '@/payload-types' import type { FDTeamBlock as FDTeamBlockProps, Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
import { fdCardRadius as cardRadius, fdContainer} from '@/utilities/fdTheme'
const sectionBgMap: Record<string, string> = { const sectionBgMap: Record<string, string> = {
white: 'bg-white dark:bg-fd-navy', white: 'bg-white dark:bg-fd-navy',
@ -11,7 +10,7 @@ const sectionBgMap: Record<string, string> = {
const cardMap: Record<string, { bg: string; name: string; role: string; bio: string; icon: string }> = { const cardMap: Record<string, { bg: string; name: string; role: string; bio: string; icon: string }> = {
navy: { navy: {
bg: 'bg-fd-navy dark:bg-white/10', bg: 'bg-fd-navy',
name: 'text-fd-yellow', name: 'text-fd-yellow',
role: 'text-white/70', role: 'text-white/70',
bio: 'text-white/60', bio: 'text-white/60',
@ -40,6 +39,7 @@ const colsMap: Record<string, string> = {
} }
/* Priority #5: Responsive radius for team member cards */ /* Priority #5: Responsive radius for team member cards */
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
export const FDTeamBlockComponent: React.FC<FDTeamBlockProps> = ({ export const FDTeamBlockComponent: React.FC<FDTeamBlockProps> = ({
heading, heading,
@ -57,7 +57,7 @@ export const FDTeamBlockComponent: React.FC<FDTeamBlockProps> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
{(heading || subheading) && ( {(heading || subheading) && (
<div className="flex flex-col gap-3 mb-10 md:mb-14"> <div className="flex flex-col gap-3 mb-10 md:mb-14">
@ -92,7 +92,7 @@ export const FDTeamBlockComponent: React.FC<FDTeamBlockProps> = ({
fallbackAlt={member.name} fallbackAlt={member.name}
/> />
) : ( ) : (
<div className={`w-full h-full flex items-center justify-center ${cardStyle === 'navy' ? 'bg-fd-navy/50 dark:bg-white/5' : 'bg-fd-navy/10 dark:bg-white/5'}`}> <div className={`w-full h-full flex items-center justify-center ${cardStyle === 'navy' ? 'bg-fd-navy/50' : 'bg-fd-navy/10 dark:bg-white/5'}`}>
<svg viewBox="0 0 80 80" className="w-20 h-20 opacity-30" fill="currentColor"> <svg viewBox="0 0 80 80" className="w-20 h-20 opacity-30" fill="currentColor">
<circle cx="40" cy="30" r="18" /> <circle cx="40" cy="30" r="18" />
<path d="M10 72c0-16.6 13.4-30 30-30s30 13.4 30 30H10z" /> <path d="M10 72c0-16.6 13.4-30 30-30s30 13.4 30 30H10z" />

View File

@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import type { FDTechPropertiesBlock as FDTechPropertiesBlockProps } from '@/payload-types' import type { FDTechPropertiesBlock as FDTechPropertiesBlockProps } from '@/payload-types'
import { fdContainer } from '@/utilities/fdTheme'
const bgMap: Record<string, string> = { const bgMap: Record<string, string> = {
navy: 'bg-fd-navy', navy: 'bg-fd-navy',
@ -38,7 +37,7 @@ export const FDTechPropertiesBlockComponent: React.FC<FDTechPropertiesBlockProps
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
<div className={`grid ${gridCols} gap-8 md:gap-12`}> <div className={`grid ${gridCols} gap-8 md:gap-12`}>
{properties?.map((prop, index) => ( {properties?.map((prop, index) => (
<div key={index} className="flex flex-col gap-1 md:gap-2"> <div key={index} className="flex flex-col gap-1 md:gap-2">

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { FDTestimonialBlock as FDTestimonialBlockProps, Media } from '@/payload-types' import type { FDTestimonialBlock as FDTestimonialBlockProps, Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
import { fdCardRadius as cardRadius, fdContainer} from '@/utilities/fdTheme'
const bgMap: Record<string, { const bgMap: Record<string, {
section: string; card: string section: string; card: string
@ -33,6 +32,7 @@ const bgMap: Record<string, {
}, },
} }
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
const Avatar: React.FC<{ media: Media | undefined; name: string; size: number }> = ({ media, name, size }) => { const Avatar: React.FC<{ media: Media | undefined; name: string; size: number }> = ({ media, name, size }) => {
if (!media?.url) return null if (!media?.url) return null
@ -64,7 +64,7 @@ export const FDTestimonialBlockComponent: React.FC<FDTestimonialBlockProps> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${theme.section}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${theme.section}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
{heading && ( {heading && (
<h2 className={`font-joey-heavy text-fd-h1 mb-10 md:mb-14 ${theme.accent}`}> <h2 className={`font-joey-heavy text-fd-h1 mb-10 md:mb-14 ${theme.accent}`}>

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { FDTextBlock as FDTextBlockProps } from '@/payload-types' import type { FDTextBlock as FDTextBlockProps } from '@/payload-types'
import RichText from '@/components/RichText' import RichText from '@/components/RichText'
import { fdContainer } from '@/utilities/fdTheme'
const bgMap: Record<string, string> = { const bgMap: Record<string, string> = {
white: 'bg-white dark:bg-fd-navy', white: 'bg-white dark:bg-fd-navy',
@ -55,7 +54,7 @@ export const FDTextBlockComponent: React.FC<FDTextBlockProps> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={fdContainer}> <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`}> <div className={`${width} ${containerAlign} ${align} flex flex-col gap-4 md:gap-6`}>
{heading && ( {heading && (
<h1 className={`font-joey-heavy text-fd-display ${colors.h1}`}>{heading}</h1> <h1 className={`font-joey-heavy text-fd-display ${colors.h1}`}>{heading}</h1>

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { FDUspChecklistBlock as FDUspChecklistBlockProps, Media } from '@/payload-types' import type { FDUspChecklistBlock as FDUspChecklistBlockProps, Media } from '@/payload-types'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
import { fdContainer, fdSepiaOverlay} from '@/utilities/fdTheme'
const bgMap: Record<string, string> = { const bgMap: Record<string, string> = {
white: 'bg-white dark:bg-fd-navy', white: 'bg-white dark:bg-fd-navy',
@ -19,29 +18,18 @@ const bodyMap: Record<string, string> = {
white: 'text-white', 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: fdSepiaOverlay,
blackLight: 'bg-black/20',
blackMedium: 'bg-black/40',
}
const checkColors: Record<string, { circle: string; check: string }> = { const checkColors: Record<string, { circle: string; check: string }> = {
navy: { circle: 'fill-fd-navy dark:fill-white/20', check: 'fill-white dark:fill-fd-yellow' }, navy: { circle: '#0E2338', check: 'white' },
yellow: { circle: 'fill-fd-yellow', check: 'fill-fd-navy' }, yellow: { circle: '#FECC02', check: '#0E2338' },
gray: { circle: 'fill-fd-gray-light dark:fill-white/20', check: 'fill-fd-navy dark:fill-white' }, gray: { circle: '#F0F0F0', check: '#0E2338' },
} }
const CheckIcon: React.FC<{ color: string }> = ({ color }) => { const CheckIcon: React.FC<{ color: string }> = ({ color }) => {
const c = checkColors[color] || checkColors.navy const c = checkColors[color] || checkColors.navy
return ( return (
<svg width="40" height="40" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg" className="flex-shrink-0"> <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" className={c.circle} /> <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" className={c.check} /> <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> </svg>
) )
} }
@ -50,7 +38,6 @@ export const FDUspChecklistBlockComponent: React.FC<FDUspChecklistBlockProps> =
heading, heading,
items, items,
image, image,
imageOverlay = 'none',
imagePosition = 'right', imagePosition = 'right',
checkColor = 'navy', checkColor = 'navy',
sectionBackground = 'white', sectionBackground = 'white',
@ -62,7 +49,6 @@ export const FDUspChecklistBlockComponent: React.FC<FDUspChecklistBlockProps> =
const bodyClr = bodyMap[textColor || 'navy'] const bodyClr = bodyMap[textColor || 'navy']
const media = image as Media | undefined const media = image as Media | undefined
const hasImage = Boolean(media?.url) const hasImage = Boolean(media?.url)
const overlay = overlayMap[imageOverlay || 'none']
const textContent = ( const textContent = (
<div className="flex-1 flex flex-col gap-6 md:gap-8"> <div className="flex-1 flex flex-col gap-6 md:gap-8">
@ -79,21 +65,20 @@ export const FDUspChecklistBlockComponent: React.FC<FDUspChecklistBlockProps> =
) )
const imageContent = hasImage ? ( const imageContent = hasImage ? (
<div className="flex-1 relative overflow-hidden rounded-[40px]"> <div className="flex-1">
<FDImage <FDImage
media={media!} media={media!}
size="large" size="large"
className="w-full h-auto object-cover" className="w-full h-auto rounded-[40px] object-cover"
sizes="(max-width: 1024px) 100vw, 550px" sizes="(max-width: 1024px) 100vw, 550px"
fallbackAlt={heading || ''} fallbackAlt={heading || ''}
/> />
{overlay && <div className={`absolute inset-0 ${overlay}`} />}
</div> </div>
) : null ) : null
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={`${fdContainer} flex flex-col min-[820px]:flex-row items-center gap-10 min-[820px]:gap-16`}> <div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col min-[820px]:flex-row items-center gap-10 min-[820px]:gap-16">
{imagePosition === 'left' ? <>{imageContent}{textContent}</> : <>{textContent}{imageContent}</>} {imagePosition === 'left' ? <>{imageContent}{textContent}</> : <>{textContent}{imageContent}</>}
</div> </div>
</section> </section>

View File

@ -39,25 +39,6 @@ export const FDUspChecklistBlock: Block = {
relationTo: 'media', relationTo: 'media',
label: 'Bild (valfri)', label: 'Bild (valfri)',
}, },
{
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' },
],
admin: {
condition: (_, siblingData) => Boolean(siblingData?.image),
},
},
{ {
name: 'imagePosition', name: 'imagePosition',
type: 'select', type: 'select',

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import type { FDUspTableBlock as FDUspTableBlockProps } from '@/payload-types' import type { FDUspTableBlock as FDUspTableBlockProps } from '@/payload-types'
import RichText from '@/components/RichText' import RichText from '@/components/RichText'
import { fdContainer } from '@/utilities/fdTheme'
const bgMap: Record<string, string> = { const bgMap: Record<string, string> = {
white: 'bg-white dark:bg-fd-navy', white: 'bg-white dark:bg-fd-navy',
@ -32,17 +31,17 @@ const borderMap: Record<string, string> = {
} }
const checkColors: Record<string, { circle: string; check: string }> = { const checkColors: Record<string, { circle: string; check: string }> = {
navy: { circle: 'fill-fd-navy dark:fill-white/20', check: 'fill-white dark:fill-fd-yellow' }, navy: { circle: '#0E2338', check: 'white' },
yellow: { circle: 'fill-fd-yellow', check: 'fill-fd-navy' }, yellow: { circle: '#FECC02', check: '#0E2338' },
gray: { circle: 'fill-fd-gray-light dark:fill-white/20', check: 'fill-fd-navy dark:fill-white' }, gray: { circle: '#F0F0F0', check: '#0E2338' },
} }
const CheckIcon: React.FC<{ color: string }> = ({ color }) => { const CheckIcon: React.FC<{ color: string }> = ({ color }) => {
const c = checkColors[color] || checkColors.navy const c = checkColors[color] || checkColors.navy
return ( return (
<svg width="40" height="40" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg" className="flex-shrink-0"> <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" className={c.circle} /> <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" className={c.check} /> <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> </svg>
) )
} }
@ -63,7 +62,7 @@ export const FDUspTableBlockComponent: React.FC<FDUspTableBlockProps> = ({
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
{heading && ( {heading && (
<h2 className={`font-joey-heavy text-fd-h1 mb-10 md:mb-14 ${headClr}`}>{heading}</h2> <h2 className={`font-joey-heavy text-fd-h1 mb-10 md:mb-14 ${headClr}`}>{heading}</h2>
)} )}

View File

@ -41,7 +41,8 @@ function extractVimeoId(url: string): string | null {
return match ? match[1] : null return match ? match[1] : null
} }
import { fdCardRadius as videoRadius } from '@/utilities/fdTheme' /* Priority #5: Responsive video radius */
const videoRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
export const FDVideoBlockComponent: React.FC<FDVideoBlockProps> = ({ export const FDVideoBlockComponent: React.FC<FDVideoBlockProps> = ({
heading, heading,

View File

@ -1,159 +0,0 @@
'use client'
import React, { useRef, useEffect, useState } from 'react'
import type { FDVideoHeroBlock as FDVideoHeroBlockProps, Media } from '@/payload-types'
import { FDButton } from '@/components/FDButton'
import { fdContainer } from '@/utilities/fdTheme'
export const FDVideoHeroBlockComponent: React.FC<FDVideoHeroBlockProps> = (props) => {
const {
videoMp4,
videoWebm,
posterImage,
heading,
body,
ctaText,
ctaLink,
secondaryCtaText,
secondaryCtaLink,
overlayColor = 'none',
overlayStrength = 'medium',
textAlign = 'left',
minHeight = 'screen',
disableVideoOnMobile = false,
anchorId,
} = props
const videoRef = useRef<HTMLVideoElement>(null)
const [isMobile, setIsMobile] = useState(false)
// Detect mobile for conditional video loading
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// Resolve media objects
const mp4Media = videoMp4 as Media | undefined
const webmMedia = videoWebm as Media | undefined
const poster = posterImage as Media | undefined
const mp4Url = mp4Media?.url || ''
const webmUrl = webmMedia?.url || ''
const posterUrl = poster?.url || ''
const showVideo = mp4Url && !(disableVideoOnMobile && isMobile)
// Overlay classes
const hasOverlay = overlayColor !== 'none'
const overlayOpacityMap: Record<string, string> = {
light: 'opacity-30',
medium: 'opacity-50',
heavy: 'opacity-70',
}
const overlayBgMap: Record<string, string> = {
black: 'bg-black',
navy: 'bg-fd-navy',
}
const overlayClass = hasOverlay && overlayColor && overlayStrength
? `${overlayBgMap[overlayColor] || ''} ${overlayOpacityMap[overlayStrength] || 'opacity-50'}`
: ''
// Text colors — white when overlay is present, navy otherwise
const hasText = heading || body
const textColorHeading = hasOverlay ? 'text-white' : 'text-fd-navy'
const textColorBody = hasOverlay ? 'text-white/90' : 'text-fd-navy/80'
// Min height
const heightMap: Record<string, string> = {
screen: 'min-h-screen',
large: 'min-h-[80vh]',
medium: 'min-h-[60vh]',
}
const heightClass = heightMap[minHeight || "screen"] || 'min-h-screen'
// Text alignment
const alignClass = textAlign === 'center' ? 'text-center items-center' : 'text-left items-start'
return (
<section
id={anchorId || undefined}
className={`relative w-full ${heightClass} flex items-end overflow-hidden`}
>
{/* Video / Poster background */}
{showVideo ? (
<video
ref={videoRef}
autoPlay
muted
loop
playsInline
poster={posterUrl || undefined}
className="absolute inset-0 w-full h-full object-cover"
aria-hidden="true"
>
{webmUrl && <source src={webmUrl} type="video/webm" />}
<source src={mp4Url} type="video/mp4" />
</video>
) : posterUrl ? (
<img
src={posterUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover"
aria-hidden="true"
/>
) : null}
{/* Overlay */}
{hasOverlay && (
<div
className={`absolute inset-0 ${overlayClass}`}
aria-hidden="true"
/>
)}
{/* Content */}
{(hasText || ctaText || secondaryCtaText) && (
<div className="relative z-10 w-full pb-16 md:pb-20 lg:pb-[99px] pt-32 md:pt-40">
<div className={`${fdContainer} flex flex-col ${alignClass}`}>
{/* Text container — constrain width */}
<div className={`max-w-[640px] ${textAlign === 'center' ? 'mx-auto' : ''}`}>
{heading && (
<h1
className={`text-fd-display font-joey-heavy ${textColorHeading} mb-4 md:mb-6`}
>
{heading}
</h1>
)}
{body && (
<p
className={`text-fd-body-lg font-joey ${textColorBody} mb-8 md:mb-10 whitespace-pre-line`}
>
{body}
</p>
)}
</div>
{/* CTAs */}
{(ctaText || secondaryCtaText) && (
<div className={`flex flex-wrap gap-4 ${textAlign === 'center' ? 'justify-center' : ''}`}>
{ctaText && ctaLink && (
<FDButton href={ctaLink} onDark={hasOverlay}>
{ctaText}
</FDButton>
)}
{secondaryCtaText && secondaryCtaLink && (
<FDButton href={secondaryCtaLink} variant="outline" onDark={hasOverlay}>
{secondaryCtaText}
</FDButton>
)}
</div>
)}
</div>
</div>
)}
</section>
)
}

View File

@ -1,157 +0,0 @@
import type { Block } from 'payload'
import { anchorField } from '@/fields/anchorField'
export const FDVideoHeroBlock: Block = {
slug: 'fdVideoHero',
interfaceName: 'FDVideoHeroBlock',
imageURL: '/block-thumbnails/fd-video-hero.svg',
imageAltText: 'FD Video Hero',
labels: {
singular: 'FD Video Hero',
plural: 'FD Video Heroes',
},
fields: [
// --- Video sources ---
{
name: 'videoMp4',
type: 'upload',
relationTo: 'media',
required: true,
label: 'Video (MP4)',
admin: {
description: 'Huvudvideo — MP4 (H.264). Fungerar i alla webbläsare. Håll filstorleken under 5 MB för bästa prestanda.',
},
},
{
name: 'videoWebm',
type: 'upload',
relationTo: 'media',
label: 'Video (WebM) — valfri',
admin: {
description: 'Progressiv förbättring — WebM är mindre och bättre kvalitet. Webbläsare som stödjer WebM väljer denna automatiskt.',
},
},
{
name: 'posterImage',
type: 'upload',
relationTo: 'media',
label: 'Poster / fallback-bild',
admin: {
description: 'Visas medan videon laddas och som fallback på mobil om video hoppas över.',
},
},
// --- Text content (all optional) ---
{
name: 'heading',
type: 'text',
localized: true,
label: 'Rubrik (valfri)',
admin: {
description: 'H1-rubrik som visas ovanpå videon.',
},
},
{
name: 'body',
type: 'textarea',
localized: true,
label: 'Brödtext (valfri)',
admin: {
description: 'Kort text under rubriken.',
},
},
// --- CTA buttons (optional) ---
{
name: 'ctaText',
type: 'text',
localized: true,
label: 'Primär CTA-text',
admin: {
description: 'T.ex. "Kom igång"',
},
},
{
name: 'ctaLink',
type: 'text',
label: 'Primär CTA-länk',
},
{
name: 'secondaryCtaText',
type: 'text',
localized: true,
label: 'Sekundär CTA-text',
},
{
name: 'secondaryCtaLink',
type: 'text',
label: 'Sekundär CTA-länk',
},
// --- Overlay ---
{
name: 'overlayColor',
type: 'select',
label: 'Overlay-färg',
defaultValue: 'none',
options: [
{ label: 'Ingen', value: 'none' },
{ label: 'Svart', value: 'black' },
{ label: 'Navy', value: 'navy' },
],
},
{
name: 'overlayStrength',
type: 'select',
label: 'Overlay-styrka',
defaultValue: 'medium',
admin: {
condition: (_, siblingData) => siblingData?.overlayColor !== 'none',
description: 'Hur stark overlay ovanpå videon (för läsbarhet).',
},
options: [
{ label: 'Lätt (30%)', value: 'light' },
{ label: 'Medium (50%)', value: 'medium' },
{ label: 'Stark (70%)', value: 'heavy' },
],
},
// --- Layout options ---
{
name: 'textAlign',
type: 'select',
label: 'Textposition',
defaultValue: 'left',
options: [
{ label: 'Vänster', value: 'left' },
{ label: 'Center', value: 'center' },
],
},
{
name: 'minHeight',
type: 'select',
label: 'Minimihöjd',
defaultValue: 'screen',
admin: {
description: 'Hur hög video-sektionen ska vara.',
},
options: [
{ label: 'Helskärm (100vh)', value: 'screen' },
{ label: 'Stor (80vh)', value: 'large' },
{ label: 'Medium (60vh)', value: 'medium' },
],
},
{
name: 'disableVideoOnMobile',
type: 'checkbox',
label: 'Visa bara poster-bild på mobil',
defaultValue: false,
admin: {
description: 'Hoppa över video på små skärmar för att spara bandbredd. Poster-bilden visas istället.',
},
},
// --- Always last ---
anchorField,
],
}

View File

@ -2,7 +2,6 @@
import React, { useState, useMemo } from 'react' import React, { useState, useMemo } from 'react'
import type { FDVpsCalculatorBlock as FDVpsCalculatorBlockProps } from '@/payload-types' import type { FDVpsCalculatorBlock as FDVpsCalculatorBlockProps } from '@/payload-types'
import { fdCardRadius } from '@/utilities/fdTheme'
const DEFAULT_PRICING = { const DEFAULT_PRICING = {
windows: 250, windows: 250,
@ -21,7 +20,7 @@ function Toggle({ active, onToggle }: { active: boolean; onToggle: () => void })
type="button" type="button"
onClick={onToggle} onClick={onToggle}
style={{ display: 'block', width: '3.5rem', height: '2rem', flexShrink: 0 }} style={{ display: 'block', width: '3.5rem', height: '2rem', flexShrink: 0 }}
className={`relative rounded-full transition-all duration-300 ${active ? 'bg-fd-yellow' : 'bg-fd-gray-light dark:bg-white/20'}`} className={`relative rounded-full transition-all duration-300 ${active ? 'bg-fd-yellow' : 'bg-[#e2e8f0] dark:bg-white/20'}`}
role="switch" role="switch"
aria-checked={active} aria-checked={active}
> >
@ -122,8 +121,8 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
// Use Tailwind classes for card styling instead of inline styles // Use Tailwind classes for card styling instead of inline styles
const cardClass = isDark const cardClass = isDark
? `bg-white/5 border-[5px] border-white/10 ${fdCardRadius}` ? 'bg-white/5 border-[5px] border-white/10 rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
: `bg-white border-[5px] border-fd-gray-light ${fdCardRadius} dark:bg-white/5 dark:border-white/10` : 'bg-white border-[5px] border-[#e2e8f0] rounded-[32px] md:rounded-[50px] lg:rounded-[70px] dark:bg-white/5 dark:border-white/10'
const headingColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white' const headingColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
const descColor = isDark ? 'text-white/60' : 'text-fd-navy/60 dark:text-white/60' const descColor = isDark ? 'text-white/60' : 'text-fd-navy/60 dark:text-white/60'

View File

@ -2,10 +2,9 @@ import React from 'react'
import type { FDWideCardBlock as FDWideCardBlockProps, Media } from '@/payload-types' import type { FDWideCardBlock as FDWideCardBlockProps, Media } from '@/payload-types'
import { FDButton } from '@/components/FDButton' import { FDButton } from '@/components/FDButton'
import { FDImage } from '@/components/FDImage' import { FDImage } from '@/components/FDImage'
import { fdCardRadius as cardRadius, fdContainer, fdSepiaOverlay} from '@/utilities/fdTheme'
const cardBgMap: Record<string, { bg: string; heading: string; body: string; isDark: boolean }> = { const cardBgMap: Record<string, { bg: string; heading: string; body: string; isDark: boolean }> = {
navy: { bg: 'bg-fd-navy dark:bg-white/10', heading: 'text-white', body: 'text-white/80', isDark: true }, navy: { bg: 'bg-fd-navy', heading: 'text-white', body: 'text-white/80', isDark: true },
yellow: { bg: 'bg-fd-yellow', heading: 'text-fd-navy', body: 'text-fd-navy/80', isDark: false }, yellow: { bg: 'bg-fd-yellow', heading: 'text-fd-navy', body: 'text-fd-navy/80', isDark: false },
gray: { bg: 'bg-fd-gray-light dark:bg-white/10', heading: 'text-fd-navy dark:text-white', body: 'text-fd-navy/80 dark:text-white/80', isDark: false }, gray: { bg: 'bg-fd-gray-light dark:bg-white/10', heading: 'text-fd-navy dark:text-white', body: 'text-fd-navy/80 dark:text-white/80', isDark: false },
white: { bg: 'bg-white dark:bg-white/10 shadow-fd-card dark:shadow-none', heading: 'text-fd-navy dark:text-white', body: 'text-fd-navy/80 dark:text-white/80', isDark: false }, white: { bg: 'bg-white dark:bg-white/10 shadow-fd-card dark:shadow-none', heading: 'text-fd-navy dark:text-white', body: 'text-fd-navy/80 dark:text-white/80', isDark: false },
@ -23,17 +22,7 @@ const btnVariantMap: Record<string, { variant: 'primary' | 'outline' }> = {
white: { variant: 'primary' }, white: { variant: 'primary' },
} }
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
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: fdSepiaOverlay,
blackLight: 'bg-black/20',
blackMedium: 'bg-black/40',
}
export const FDWideCardBlockComponent: React.FC<FDWideCardBlockProps> = ({ export const FDWideCardBlockComponent: React.FC<FDWideCardBlockProps> = ({
heading, heading,
@ -41,7 +30,6 @@ export const FDWideCardBlockComponent: React.FC<FDWideCardBlockProps> = ({
ctaText, ctaText,
ctaLink, ctaLink,
image, image,
imageOverlay = 'none',
cardBackground = 'navy', cardBackground = 'navy',
buttonColor = 'yellow', buttonColor = 'yellow',
sectionBackground = 'white', sectionBackground = 'white',
@ -52,11 +40,10 @@ export const FDWideCardBlockComponent: React.FC<FDWideCardBlockProps> = ({
const { variant } = btnVariantMap[buttonColor || 'yellow'] const { variant } = btnVariantMap[buttonColor || 'yellow']
const media = image as Media | undefined const media = image as Media | undefined
const hasImage = media && typeof media === 'object' && media.url const hasImage = media && typeof media === 'object' && media.url
const overlay = overlayMap[imageOverlay || 'none']
return ( return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}> <section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className={fdContainer}> <div className="max-w-[1200px] mx-auto px-6 md:px-8">
<div className={`${card.bg} ${cardRadius} flex flex-col min-[820px]:flex-row`}> <div className={`${card.bg} ${cardRadius} flex flex-col min-[820px]:flex-row`}>
<div className="flex-1 flex flex-col justify-center gap-5 md:gap-6 px-8 md:px-14 lg:px-16 py-12 md:py-16"> <div className="flex-1 flex flex-col justify-center gap-5 md:gap-6 px-8 md:px-14 lg:px-16 py-12 md:py-16">
<h2 className={`font-joey-heavy text-fd-h1 leading-tight ${card.heading}`}> <h2 className={`font-joey-heavy text-fd-h1 leading-tight ${card.heading}`}>
@ -78,7 +65,6 @@ export const FDWideCardBlockComponent: React.FC<FDWideCardBlockProps> = ({
{hasImage && ( {hasImage && (
<div className="flex items-center justify-center w-full min-[820px]:w-[45%] lg:w-[480px] flex-shrink-0 p-6 md:p-10 lg:p-12"> <div className="flex items-center justify-center w-full min-[820px]:w-[45%] lg:w-[480px] flex-shrink-0 p-6 md:p-10 lg:p-12">
<div className="relative w-full">
<FDImage <FDImage
media={media} media={media}
size="large" size="large"
@ -86,8 +72,6 @@ export const FDWideCardBlockComponent: React.FC<FDWideCardBlockProps> = ({
sizes="(max-width: 820px) 80vw, 400px" sizes="(max-width: 820px) 80vw, 400px"
fallbackAlt={heading || ''} fallbackAlt={heading || ''}
/> />
{overlay && <div className={`absolute inset-0 ${overlay}`} />}
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -42,25 +42,6 @@ export const FDWideCardBlock: Block = {
relationTo: 'media', relationTo: 'media',
label: 'Bild (valfri)', label: 'Bild (valfri)',
}, },
{
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' },
],
admin: {
condition: (_, siblingData) => Boolean(siblingData?.image),
},
},
{ {
name: 'cardBackground', name: 'cardBackground',
type: 'select', type: 'select',

View File

@ -0,0 +1,45 @@
import type { CheckboxField } from '@payloadcms/plugin-form-builder/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import { useFormContext } from 'react-hook-form'
import { Checkbox as CheckboxUi } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
export const Checkbox: React.FC<
CheckboxField & {
errors: Partial<FieldErrorsImpl>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required, width }) => {
const props = register(name, { required: required })
const { setValue } = useFormContext()
return (
<Width width={width}>
<div className="flex items-center gap-2">
<CheckboxUi
defaultChecked={defaultValue}
id={name}
{...props}
onCheckedChange={(checked) => {
setValue(props.name, checked)
}}
/>
<Label htmlFor={name}>
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
{label}
</Label>
</div>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@ -0,0 +1,163 @@
'use client'
import type { FormFieldBlock, Form as FormType } from '@payloadcms/plugin-form-builder/types'
import { useRouter } from 'next/navigation'
import React, { useCallback, useState } from 'react'
import { useForm, FormProvider } from 'react-hook-form'
import RichText from '@/components/RichText'
import { Button } from '@/components/ui/button'
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
import { fields } from './fields'
import { getClientSideURL } from '@/utilities/getURL'
export type FormBlockType = {
blockName?: string
blockType?: 'formBlock'
enableIntro: boolean
form: FormType
introContent?: DefaultTypedEditorState
}
export const FormBlock: React.FC<
{
id?: string
} & FormBlockType
> = (props) => {
const {
enableIntro,
form: formFromProps,
form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {},
introContent,
} = props
const formMethods = useForm({
defaultValues: formFromProps.fields,
})
const {
control,
formState: { errors },
handleSubmit,
register,
} = formMethods
const [isLoading, setIsLoading] = useState(false)
const [hasSubmitted, setHasSubmitted] = useState<boolean>()
const [error, setError] = useState<{ message: string; status?: string } | undefined>()
const router = useRouter()
const onSubmit = useCallback(
(data: FormFieldBlock[]) => {
let loadingTimerID: ReturnType<typeof setTimeout>
const submitForm = async () => {
setError(undefined)
const dataToSend = Object.entries(data).map(([name, value]) => ({
field: name,
value,
}))
// delay loading indicator by 1s
loadingTimerID = setTimeout(() => {
setIsLoading(true)
}, 1000)
try {
const req = await fetch(`${getClientSideURL()}/api/form-submissions`, {
body: JSON.stringify({
form: formID,
submissionData: dataToSend,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
const res = await req.json()
clearTimeout(loadingTimerID)
if (req.status >= 400) {
setIsLoading(false)
setError({
message: res.errors?.[0]?.message || 'Internal Server Error',
status: res.status,
})
return
}
setIsLoading(false)
setHasSubmitted(true)
if (confirmationType === 'redirect' && redirect) {
const { url } = redirect
const redirectUrl = url
if (redirectUrl) router.push(redirectUrl)
}
} catch (err) {
console.warn(err)
setIsLoading(false)
setError({
message: 'Something went wrong.',
})
}
}
void submitForm()
},
[router, formID, redirect, confirmationType],
)
return (
<div className="container lg:max-w-[48rem]">
{enableIntro && introContent && !hasSubmitted && (
<RichText className="mb-8 lg:mb-12" data={introContent} enableGutter={false} />
)}
<div className="p-4 lg:p-6 border border-border rounded-[0.8rem]">
<FormProvider {...formMethods}>
{!isLoading && hasSubmitted && confirmationType === 'message' && (
<RichText data={confirmationMessage} />
)}
{isLoading && !hasSubmitted && <p>Loading, please wait...</p>}
{error && <div>{`${error.status || '500'}: ${error.message || ''}`}</div>}
{!hasSubmitted && (
<form id={formID} onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 last:mb-0">
{formFromProps &&
formFromProps.fields &&
formFromProps.fields?.map((field, index) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Field: React.FC<any> = fields?.[field.blockType as keyof typeof fields]
if (Field) {
return (
<div className="mb-6 last:mb-0" key={index}>
<Field
form={formFromProps}
{...field}
{...formMethods}
control={control}
errors={errors}
register={register}
/>
</div>
)
}
return null
})}
</div>
<Button form={formID} type="submit" variant="default">
{submitButtonLabel}
</Button>
</form>
)}
</FormProvider>
</div>
</div>
)
}

View File

@ -0,0 +1,65 @@
import type { CountryField } from '@payloadcms/plugin-form-builder/types'
import type { Control, FieldErrorsImpl } from 'react-hook-form'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import React from 'react'
import { Controller } from 'react-hook-form'
import { Error } from '../Error'
import { Width } from '../Width'
import { countryOptions } from './options'
export const Country: React.FC<
CountryField & {
control: Control
errors: Partial<FieldErrorsImpl>
}
> = ({ name, control, errors, label, required, width }) => {
return (
<Width width={width}>
<Label className="" htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<Controller
control={control}
defaultValue=""
name={name}
render={({ field: { onChange, value } }) => {
const controlledValue = countryOptions.find((t) => t.value === value)
return (
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
<SelectTrigger className="w-full" id={name}>
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
{countryOptions.map(({ label, value }) => {
return (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
)
})}
</SelectContent>
</Select>
)
}}
rules={{ required }}
/>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@ -0,0 +1,982 @@
export const countryOptions = [
{
label: 'Afghanistan',
value: 'AF',
},
{
label: 'Åland Islands',
value: 'AX',
},
{
label: 'Albania',
value: 'AL',
},
{
label: 'Algeria',
value: 'DZ',
},
{
label: 'American Samoa',
value: 'AS',
},
{
label: 'Andorra',
value: 'AD',
},
{
label: 'Angola',
value: 'AO',
},
{
label: 'Anguilla',
value: 'AI',
},
{
label: 'Antarctica',
value: 'AQ',
},
{
label: 'Antigua and Barbuda',
value: 'AG',
},
{
label: 'Argentina',
value: 'AR',
},
{
label: 'Armenia',
value: 'AM',
},
{
label: 'Aruba',
value: 'AW',
},
{
label: 'Australia',
value: 'AU',
},
{
label: 'Austria',
value: 'AT',
},
{
label: 'Azerbaijan',
value: 'AZ',
},
{
label: 'Bahamas',
value: 'BS',
},
{
label: 'Bahrain',
value: 'BH',
},
{
label: 'Bangladesh',
value: 'BD',
},
{
label: 'Barbados',
value: 'BB',
},
{
label: 'Belarus',
value: 'BY',
},
{
label: 'Belgium',
value: 'BE',
},
{
label: 'Belize',
value: 'BZ',
},
{
label: 'Benin',
value: 'BJ',
},
{
label: 'Bermuda',
value: 'BM',
},
{
label: 'Bhutan',
value: 'BT',
},
{
label: 'Bolivia',
value: 'BO',
},
{
label: 'Bosnia and Herzegovina',
value: 'BA',
},
{
label: 'Botswana',
value: 'BW',
},
{
label: 'Bouvet Island',
value: 'BV',
},
{
label: 'Brazil',
value: 'BR',
},
{
label: 'British Indian Ocean Territory',
value: 'IO',
},
{
label: 'Brunei Darussalam',
value: 'BN',
},
{
label: 'Bulgaria',
value: 'BG',
},
{
label: 'Burkina Faso',
value: 'BF',
},
{
label: 'Burundi',
value: 'BI',
},
{
label: 'Cambodia',
value: 'KH',
},
{
label: 'Cameroon',
value: 'CM',
},
{
label: 'Canada',
value: 'CA',
},
{
label: 'Cape Verde',
value: 'CV',
},
{
label: 'Cayman Islands',
value: 'KY',
},
{
label: 'Central African Republic',
value: 'CF',
},
{
label: 'Chad',
value: 'TD',
},
{
label: 'Chile',
value: 'CL',
},
{
label: 'China',
value: 'CN',
},
{
label: 'Christmas Island',
value: 'CX',
},
{
label: 'Cocos (Keeling) Islands',
value: 'CC',
},
{
label: 'Colombia',
value: 'CO',
},
{
label: 'Comoros',
value: 'KM',
},
{
label: 'Congo',
value: 'CG',
},
{
label: 'Congo, The Democratic Republic of the',
value: 'CD',
},
{
label: 'Cook Islands',
value: 'CK',
},
{
label: 'Costa Rica',
value: 'CR',
},
{
label: "Cote D'Ivoire",
value: 'CI',
},
{
label: 'Croatia',
value: 'HR',
},
{
label: 'Cuba',
value: 'CU',
},
{
label: 'Cyprus',
value: 'CY',
},
{
label: 'Czech Republic',
value: 'CZ',
},
{
label: 'Denmark',
value: 'DK',
},
{
label: 'Djibouti',
value: 'DJ',
},
{
label: 'Dominica',
value: 'DM',
},
{
label: 'Dominican Republic',
value: 'DO',
},
{
label: 'Ecuador',
value: 'EC',
},
{
label: 'Egypt',
value: 'EG',
},
{
label: 'El Salvador',
value: 'SV',
},
{
label: 'Equatorial Guinea',
value: 'GQ',
},
{
label: 'Eritrea',
value: 'ER',
},
{
label: 'Estonia',
value: 'EE',
},
{
label: 'Ethiopia',
value: 'ET',
},
{
label: 'Falkland Islands (Malvinas)',
value: 'FK',
},
{
label: 'Faroe Islands',
value: 'FO',
},
{
label: 'Fiji',
value: 'FJ',
},
{
label: 'Finland',
value: 'FI',
},
{
label: 'France',
value: 'FR',
},
{
label: 'French Guiana',
value: 'GF',
},
{
label: 'French Polynesia',
value: 'PF',
},
{
label: 'French Southern Territories',
value: 'TF',
},
{
label: 'Gabon',
value: 'GA',
},
{
label: 'Gambia',
value: 'GM',
},
{
label: 'Georgia',
value: 'GE',
},
{
label: 'Germany',
value: 'DE',
},
{
label: 'Ghana',
value: 'GH',
},
{
label: 'Gibraltar',
value: 'GI',
},
{
label: 'Greece',
value: 'GR',
},
{
label: 'Greenland',
value: 'GL',
},
{
label: 'Grenada',
value: 'GD',
},
{
label: 'Guadeloupe',
value: 'GP',
},
{
label: 'Guam',
value: 'GU',
},
{
label: 'Guatemala',
value: 'GT',
},
{
label: 'Guernsey',
value: 'GG',
},
{
label: 'Guinea',
value: 'GN',
},
{
label: 'Guinea-Bissau',
value: 'GW',
},
{
label: 'Guyana',
value: 'GY',
},
{
label: 'Haiti',
value: 'HT',
},
{
label: 'Heard Island and Mcdonald Islands',
value: 'HM',
},
{
label: 'Holy See (Vatican City State)',
value: 'VA',
},
{
label: 'Honduras',
value: 'HN',
},
{
label: 'Hong Kong',
value: 'HK',
},
{
label: 'Hungary',
value: 'HU',
},
{
label: 'Iceland',
value: 'IS',
},
{
label: 'India',
value: 'IN',
},
{
label: 'Indonesia',
value: 'ID',
},
{
label: 'Iran, Islamic Republic Of',
value: 'IR',
},
{
label: 'Iraq',
value: 'IQ',
},
{
label: 'Ireland',
value: 'IE',
},
{
label: 'Isle of Man',
value: 'IM',
},
{
label: 'Israel',
value: 'IL',
},
{
label: 'Italy',
value: 'IT',
},
{
label: 'Jamaica',
value: 'JM',
},
{
label: 'Japan',
value: 'JP',
},
{
label: 'Jersey',
value: 'JE',
},
{
label: 'Jordan',
value: 'JO',
},
{
label: 'Kazakhstan',
value: 'KZ',
},
{
label: 'Kenya',
value: 'KE',
},
{
label: 'Kiribati',
value: 'KI',
},
{
label: "Democratic People's Republic of Korea",
value: 'KP',
},
{
label: 'Korea, Republic of',
value: 'KR',
},
{
label: 'Kosovo',
value: 'XK',
},
{
label: 'Kuwait',
value: 'KW',
},
{
label: 'Kyrgyzstan',
value: 'KG',
},
{
label: "Lao People's Democratic Republic",
value: 'LA',
},
{
label: 'Latvia',
value: 'LV',
},
{
label: 'Lebanon',
value: 'LB',
},
{
label: 'Lesotho',
value: 'LS',
},
{
label: 'Liberia',
value: 'LR',
},
{
label: 'Libyan Arab Jamahiriya',
value: 'LY',
},
{
label: 'Liechtenstein',
value: 'LI',
},
{
label: 'Lithuania',
value: 'LT',
},
{
label: 'Luxembourg',
value: 'LU',
},
{
label: 'Macao',
value: 'MO',
},
{
label: 'Macedonia, The Former Yugoslav Republic of',
value: 'MK',
},
{
label: 'Madagascar',
value: 'MG',
},
{
label: 'Malawi',
value: 'MW',
},
{
label: 'Malaysia',
value: 'MY',
},
{
label: 'Maldives',
value: 'MV',
},
{
label: 'Mali',
value: 'ML',
},
{
label: 'Malta',
value: 'MT',
},
{
label: 'Marshall Islands',
value: 'MH',
},
{
label: 'Martinique',
value: 'MQ',
},
{
label: 'Mauritania',
value: 'MR',
},
{
label: 'Mauritius',
value: 'MU',
},
{
label: 'Mayotte',
value: 'YT',
},
{
label: 'Mexico',
value: 'MX',
},
{
label: 'Micronesia, Federated States of',
value: 'FM',
},
{
label: 'Moldova, Republic of',
value: 'MD',
},
{
label: 'Monaco',
value: 'MC',
},
{
label: 'Mongolia',
value: 'MN',
},
{
label: 'Montenegro',
value: 'ME',
},
{
label: 'Montserrat',
value: 'MS',
},
{
label: 'Morocco',
value: 'MA',
},
{
label: 'Mozambique',
value: 'MZ',
},
{
label: 'Myanmar',
value: 'MM',
},
{
label: 'Namibia',
value: 'NA',
},
{
label: 'Nauru',
value: 'NR',
},
{
label: 'Nepal',
value: 'NP',
},
{
label: 'Netherlands',
value: 'NL',
},
{
label: 'Netherlands Antilles',
value: 'AN',
},
{
label: 'New Caledonia',
value: 'NC',
},
{
label: 'New Zealand',
value: 'NZ',
},
{
label: 'Nicaragua',
value: 'NI',
},
{
label: 'Niger',
value: 'NE',
},
{
label: 'Nigeria',
value: 'NG',
},
{
label: 'Niue',
value: 'NU',
},
{
label: 'Norfolk Island',
value: 'NF',
},
{
label: 'Northern Mariana Islands',
value: 'MP',
},
{
label: 'Norway',
value: 'NO',
},
{
label: 'Oman',
value: 'OM',
},
{
label: 'Pakistan',
value: 'PK',
},
{
label: 'Palau',
value: 'PW',
},
{
label: 'Palestinian Territory, Occupied',
value: 'PS',
},
{
label: 'Panama',
value: 'PA',
},
{
label: 'Papua New Guinea',
value: 'PG',
},
{
label: 'Paraguay',
value: 'PY',
},
{
label: 'Peru',
value: 'PE',
},
{
label: 'Philippines',
value: 'PH',
},
{
label: 'Pitcairn',
value: 'PN',
},
{
label: 'Poland',
value: 'PL',
},
{
label: 'Portugal',
value: 'PT',
},
{
label: 'Puerto Rico',
value: 'PR',
},
{
label: 'Qatar',
value: 'QA',
},
{
label: 'Reunion',
value: 'RE',
},
{
label: 'Romania',
value: 'RO',
},
{
label: 'Russian Federation',
value: 'RU',
},
{
label: 'Rwanda',
value: 'RW',
},
{
label: 'Saint Helena',
value: 'SH',
},
{
label: 'Saint Kitts and Nevis',
value: 'KN',
},
{
label: 'Saint Lucia',
value: 'LC',
},
{
label: 'Saint Pierre and Miquelon',
value: 'PM',
},
{
label: 'Saint Vincent and the Grenadines',
value: 'VC',
},
{
label: 'Samoa',
value: 'WS',
},
{
label: 'San Marino',
value: 'SM',
},
{
label: 'Sao Tome and Principe',
value: 'ST',
},
{
label: 'Saudi Arabia',
value: 'SA',
},
{
label: 'Senegal',
value: 'SN',
},
{
label: 'Serbia',
value: 'RS',
},
{
label: 'Seychelles',
value: 'SC',
},
{
label: 'Sierra Leone',
value: 'SL',
},
{
label: 'Singapore',
value: 'SG',
},
{
label: 'Slovakia',
value: 'SK',
},
{
label: 'Slovenia',
value: 'SI',
},
{
label: 'Solomon Islands',
value: 'SB',
},
{
label: 'Somalia',
value: 'SO',
},
{
label: 'South Africa',
value: 'ZA',
},
{
label: 'South Georgia and the South Sandwich Islands',
value: 'GS',
},
{
label: 'Spain',
value: 'ES',
},
{
label: 'Sri Lanka',
value: 'LK',
},
{
label: 'Sudan',
value: 'SD',
},
{
label: 'Suriname',
value: 'SR',
},
{
label: 'Svalbard and Jan Mayen',
value: 'SJ',
},
{
label: 'Swaziland',
value: 'SZ',
},
{
label: 'Sweden',
value: 'SE',
},
{
label: 'Switzerland',
value: 'CH',
},
{
label: 'Syrian Arab Republic',
value: 'SY',
},
{
label: 'Taiwan',
value: 'TW',
},
{
label: 'Tajikistan',
value: 'TJ',
},
{
label: 'Tanzania, United Republic of',
value: 'TZ',
},
{
label: 'Thailand',
value: 'TH',
},
{
label: 'Timor-Leste',
value: 'TL',
},
{
label: 'Togo',
value: 'TG',
},
{
label: 'Tokelau',
value: 'TK',
},
{
label: 'Tonga',
value: 'TO',
},
{
label: 'Trinidad and Tobago',
value: 'TT',
},
{
label: 'Tunisia',
value: 'TN',
},
{
label: 'Turkey',
value: 'TR',
},
{
label: 'Turkmenistan',
value: 'TM',
},
{
label: 'Turks and Caicos Islands',
value: 'TC',
},
{
label: 'Tuvalu',
value: 'TV',
},
{
label: 'Uganda',
value: 'UG',
},
{
label: 'Ukraine',
value: 'UA',
},
{
label: 'United Arab Emirates',
value: 'AE',
},
{
label: 'United Kingdom',
value: 'GB',
},
{
label: 'United States',
value: 'US',
},
{
label: 'United States Minor Outlying Islands',
value: 'UM',
},
{
label: 'Uruguay',
value: 'UY',
},
{
label: 'Uzbekistan',
value: 'UZ',
},
{
label: 'Vanuatu',
value: 'VU',
},
{
label: 'Venezuela',
value: 'VE',
},
{
label: 'Viet Nam',
value: 'VN',
},
{
label: 'Virgin Islands, British',
value: 'VG',
},
{
label: 'Virgin Islands, U.S.',
value: 'VI',
},
{
label: 'Wallis and Futuna',
value: 'WF',
},
{
label: 'Western Sahara',
value: 'EH',
},
{
label: 'Yemen',
value: 'YE',
},
{
label: 'Zambia',
value: 'ZM',
},
{
label: 'Zimbabwe',
value: 'ZW',
},
]

View File

@ -0,0 +1,38 @@
import type { EmailField } from '@payloadcms/plugin-form-builder/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
export const Email: React.FC<
EmailField & {
errors: Partial<FieldErrorsImpl>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<Input
defaultValue={defaultValue}
id={name}
type="text"
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required })}
/>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@ -0,0 +1,15 @@
'use client'
import * as React from 'react'
import { useFormContext } from 'react-hook-form'
export const Error = ({ name }: { name: string }) => {
const {
formState: { errors },
} = useFormContext()
return (
<div className="mt-2 text-red-500 text-sm">
{(errors[name]?.message as string) || 'This field is required'}
</div>
)
}

View File

@ -0,0 +1,13 @@
import RichText from '@/components/RichText'
import React from 'react'
import { Width } from '../Width'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
export const Message: React.FC<{ message: DefaultTypedEditorState }> = ({ message }) => {
return (
<Width className="my-12" width="100">
{message && <RichText data={message} />}
</Width>
)
}

View File

@ -0,0 +1,36 @@
import type { TextField } from '@payloadcms/plugin-form-builder/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
export const Number: React.FC<
TextField & {
errors: Partial<FieldErrorsImpl>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<Input
defaultValue={defaultValue}
id={name}
type="number"
{...register(name, { required })}
/>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@ -0,0 +1,63 @@
import type { SelectField } from '@payloadcms/plugin-form-builder/types'
import type { Control, FieldErrorsImpl } from 'react-hook-form'
import { Label } from '@/components/ui/label'
import {
Select as SelectComponent,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import React from 'react'
import { Controller } from 'react-hook-form'
import { Error } from '../Error'
import { Width } from '../Width'
export const Select: React.FC<
SelectField & {
control: Control
errors: Partial<FieldErrorsImpl>
}
> = ({ name, control, errors, label, options, required, width, defaultValue }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<Controller
control={control}
defaultValue={defaultValue}
name={name}
render={({ field: { onChange, value } }) => {
const controlledValue = options.find((t) => t.value === value)
return (
<SelectComponent onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
<SelectTrigger className="w-full" id={name}>
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
{options.map(({ label, value }) => {
return (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
)
})}
</SelectContent>
</SelectComponent>
)
}}
rules={{ required }}
/>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@ -0,0 +1,64 @@
import type { StateField } from '@payloadcms/plugin-form-builder/types'
import type { Control, FieldErrorsImpl } from 'react-hook-form'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import React from 'react'
import { Controller } from 'react-hook-form'
import { Error } from '../Error'
import { Width } from '../Width'
import { stateOptions } from './options'
export const State: React.FC<
StateField & {
control: Control
errors: Partial<FieldErrorsImpl>
}
> = ({ name, control, errors, label, required, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<Controller
control={control}
defaultValue=""
name={name}
render={({ field: { onChange, value } }) => {
const controlledValue = stateOptions.find((t) => t.value === value)
return (
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
<SelectTrigger className="w-full" id={name}>
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
{stateOptions.map(({ label, value }) => {
return (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
)
})}
</SelectContent>
</Select>
)
}}
rules={{ required }}
/>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@ -0,0 +1,52 @@
export const stateOptions = [
{ label: 'Alabama', value: 'AL' },
{ label: 'Alaska', value: 'AK' },
{ label: 'Arizona', value: 'AZ' },
{ label: 'Arkansas', value: 'AR' },
{ label: 'California', value: 'CA' },
{ label: 'Colorado', value: 'CO' },
{ label: 'Connecticut', value: 'CT' },
{ label: 'Delaware', value: 'DE' },
{ label: 'Florida', value: 'FL' },
{ label: 'Georgia', value: 'GA' },
{ label: 'Hawaii', value: 'HI' },
{ label: 'Idaho', value: 'ID' },
{ label: 'Illinois', value: 'IL' },
{ label: 'Indiana', value: 'IN' },
{ label: 'Iowa', value: 'IA' },
{ label: 'Kansas', value: 'KS' },
{ label: 'Kentucky', value: 'KY' },
{ label: 'Louisiana', value: 'LA' },
{ label: 'Maine', value: 'ME' },
{ label: 'Maryland', value: 'MD' },
{ label: 'Massachusetts', value: 'MA' },
{ label: 'Michigan', value: 'MI' },
{ label: 'Minnesota', value: 'MN' },
{ label: 'Mississippi', value: 'MS' },
{ label: 'Missouri', value: 'MO' },
{ label: 'Montana', value: 'MT' },
{ label: 'Nebraska', value: 'NE' },
{ label: 'Nevada', value: 'NV' },
{ label: 'New Hampshire', value: 'NH' },
{ label: 'New Jersey', value: 'NJ' },
{ label: 'New Mexico', value: 'NM' },
{ label: 'New York', value: 'NY' },
{ label: 'North Carolina', value: 'NC' },
{ label: 'North Dakota', value: 'ND' },
{ label: 'Ohio', value: 'OH' },
{ label: 'Oklahoma', value: 'OK' },
{ label: 'Oregon', value: 'OR' },
{ label: 'Pennsylvania', value: 'PA' },
{ label: 'Rhode Island', value: 'RI' },
{ label: 'South Carolina', value: 'SC' },
{ label: 'South Dakota', value: 'SD' },
{ label: 'Tennessee', value: 'TN' },
{ label: 'Texas', value: 'TX' },
{ label: 'Utah', value: 'UT' },
{ label: 'Vermont', value: 'VT' },
{ label: 'Virginia', value: 'VA' },
{ label: 'Washington', value: 'WA' },
{ label: 'West Virginia', value: 'WV' },
{ label: 'Wisconsin', value: 'WI' },
{ label: 'Wyoming', value: 'WY' },
]

View File

@ -0,0 +1,32 @@
import type { TextField } from '@payloadcms/plugin-form-builder/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
export const Text: React.FC<
TextField & {
errors: Partial<FieldErrorsImpl>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<Input defaultValue={defaultValue} id={name} type="text" {...register(name, { required })} />
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@ -0,0 +1,40 @@
import type { TextField } from '@payloadcms/plugin-form-builder/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import { Label } from '@/components/ui/label'
import { Textarea as TextAreaComponent } from '@/components/ui/textarea'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
export const Textarea: React.FC<
TextField & {
errors: Partial<FieldErrorsImpl>
register: UseFormRegister<FieldValues>
rows?: number
}
> = ({ name, defaultValue, errors, label, register, required, rows = 3, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<TextAreaComponent
defaultValue={defaultValue}
id={name}
rows={rows}
{...register(name, { required: required })}
/>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@ -0,0 +1,13 @@
import * as React from 'react'
export const Width: React.FC<{
children: React.ReactNode
className?: string
width?: number | string
}> = ({ children, className, width }) => {
return (
<div className={className} style={{ maxWidth: width ? `${width}%` : undefined }}>
{children}
</div>
)
}

51
src/blocks/Form/config.ts Normal file
View File

@ -0,0 +1,51 @@
import type { Block } from 'payload'
import {
FixedToolbarFeature,
HeadingFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
export const FormBlock: Block = {
slug: 'formBlock',
interfaceName: 'FormBlock',
fields: [
{
name: 'form',
type: 'relationship',
relationTo: 'forms',
required: true,
},
{
name: 'enableIntro',
type: 'checkbox',
label: 'Enable Intro Content',
},
{
name: 'introContent',
type: 'richText',
admin: {
condition: (_, { enableIntro }) => Boolean(enableIntro),
},
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: 'Intro Content',
},
],
graphQL: {
singularName: 'FormBlock',
},
labels: {
plural: 'Form Blocks',
singular: 'Form Block',
},
}

View File

@ -0,0 +1,21 @@
import { Checkbox } from './Checkbox'
import { Country } from './Country'
import { Email } from './Email'
import { Message } from './Message'
import { Number } from './Number'
import { Select } from './Select'
import { State } from './State'
import { Text } from './Text'
import { Textarea } from './Textarea'
export const fields = {
checkbox: Checkbox,
country: Country,
email: Email,
message: Message,
number: Number,
select: Select,
state: State,
text: Text,
textarea: Textarea,
}

View File

@ -0,0 +1,32 @@
import clsx from 'clsx'
import React from 'react'
import RichText from '@/components/RichText'
import type { Post } from '@/payload-types'
import { Card } from '../../components/Card'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
export type RelatedPostsProps = {
className?: string
docs?: Post[]
introContent?: DefaultTypedEditorState
}
export const RelatedPosts: React.FC<RelatedPostsProps> = (props) => {
const { className, docs, introContent } = props
return (
<div className={clsx('lg:container', className)}>
{introContent && <RichText data={introContent} enableGutter={false} />}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8 items-stretch">
{docs?.map((doc, index) => {
if (typeof doc === 'string') return null
return <Card key={index} doc={doc} relationTo="posts" showCategories />
})}
</div>
</div>
)
}

View File

@ -2,6 +2,7 @@ import React, { Fragment } from 'react'
import type { Page } from '@/payload-types' import type { Page } from '@/payload-types'
import { FormBlock } from '@/blocks/Form/Component'
import { FDHeroBlockComponent } from '@/blocks/FDHeroBlock/Component' import { FDHeroBlockComponent } from '@/blocks/FDHeroBlock/Component'
import { FDCtaSideImageBlockComponent } from '@/blocks/FDCtaSideImageBlock/Component' import { FDCtaSideImageBlockComponent } from '@/blocks/FDCtaSideImageBlock/Component'
import { FDFeatureAnnouncementBlockComponent } from '@/blocks/FDFeatureAnnouncementBlock/Component' import { FDFeatureAnnouncementBlockComponent } from '@/blocks/FDFeatureAnnouncementBlock/Component'
@ -34,13 +35,9 @@ import { FDCtaBannerBlockComponent } from './FDCtaBannerBlock/Component'
import { FDTestimonialBlockComponent } from './FDTestimonialBlock/Component' import { FDTestimonialBlockComponent } from './FDTestimonialBlock/Component'
import { FDTeamBlockComponent } from './FDTeamBlock/Component' import { FDTeamBlockComponent } from './FDTeamBlock/Component'
import { FDServiceCalculatorBlockComponent } from '@/blocks/FDServiceCalculatorBlock/Component' import { FDServiceCalculatorBlockComponent } from '@/blocks/FDServiceCalculatorBlock/Component'
import { FDLinkCardsBlockComponent } from '@/blocks/FDLinkCardsBlock/Component'
import { FDSpecCardsBlockComponent } from '@/blocks/FDSpecCardsBlock/Component'
import { FDQuizBlockComponent } from '@/blocks/FDQuizBlock/Component'
import { FDVideoHeroBlockComponent } from '@/blocks/FDVideoHeroBlock/Component'
const blockComponents: Record<string, React.FC<any>> = { const blockComponents: Record<string, React.FC<any>> = {
formBlock: FormBlock,
fdHero: FDHeroBlockComponent, fdHero: FDHeroBlockComponent,
fdCtaSideImage: FDCtaSideImageBlockComponent, fdCtaSideImage: FDCtaSideImageBlockComponent,
fdFeatureAnnouncement: FDFeatureAnnouncementBlockComponent, fdFeatureAnnouncement: FDFeatureAnnouncementBlockComponent,
@ -73,10 +70,6 @@ const blockComponents: Record<string, React.FC<any>> = {
fdTestimonial: FDTestimonialBlockComponent, fdTestimonial: FDTestimonialBlockComponent,
fdTeam: FDTeamBlockComponent, fdTeam: FDTeamBlockComponent,
fdServiceCalc: FDServiceCalculatorBlockComponent, fdServiceCalc: FDServiceCalculatorBlockComponent,
fdLinkCards: FDLinkCardsBlockComponent,
fdSpecCards: FDSpecCardsBlockComponent,
fdQuiz: FDQuizBlockComponent,
fdVideoHero: FDVideoHeroBlockComponent,
} }
/** /**

View File

@ -0,0 +1,81 @@
import type { CollectionConfig } from 'payload'
import {
FixedToolbarFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import path from 'path'
import { fileURLToPath } from 'url'
import { anyone } from '../access/anyone'
import { authenticated } from '../access/authenticated'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const Media: CollectionConfig = {
slug: 'media',
folders: true,
access: {
create: authenticated,
delete: authenticated,
read: anyone,
update: authenticated,
},
fields: [
{
name: 'alt',
type: 'text',
//required: true,
},
{
name: 'caption',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()]
},
}),
},
],
upload: {
// Upload to the public/media directory in Next.js making them publicly accessible even outside of Payload
staticDir: path.resolve(dirname, '../../public/media'),
adminThumbnail: 'thumbnail',
focalPoint: true,
imageSizes: [
{
name: 'thumbnail',
width: 300,
},
{
name: 'square',
width: 500,
height: 500,
},
{
name: 'small',
width: 600,
},
{
name: 'medium',
width: 900,
},
{
name: 'large',
width: 1400,
},
{
name: 'xlarge',
width: 1920,
},
{
name: 'og',
width: 1200,
height: 630,
crop: 'center',
},
],
},
}

View File

@ -38,11 +38,6 @@ import { FDCtaBannerBlock } from '../../blocks/FDCtaBannerBlock/config'
import { FDTestimonialBlock } from '../../blocks/FDTestimonialBlock/config' import { FDTestimonialBlock } from '../../blocks/FDTestimonialBlock/config'
import { FDTeamBlock } from '../../blocks/FDTeamBlock/config' import { FDTeamBlock } from '../../blocks/FDTeamBlock/config'
import { FDServiceCalculatorBlock } from '../../blocks/FDServiceCalculatorBlock/config' import { FDServiceCalculatorBlock } from '../../blocks/FDServiceCalculatorBlock/config'
import { FDLinkCardsBlock } from '../../blocks/FDLinkCardsBlock/config'
import { FDSpecCardsBlock } from '../../blocks/FDSpecCardsBlock/config'
import { FDQuizBlock } from '../../blocks/FDQuizBlock/config'
import { FDVideoHeroBlock } from '../../blocks/FDVideoHeroBlock/config'
import { import {
MetaDescriptionField, MetaDescriptionField,
@ -139,10 +134,6 @@ export const Pages: CollectionConfig<'pages'> = {
FDCtaBannerBlock, FDCtaBannerBlock,
FDTestimonialBlock, FDTestimonialBlock,
FDTeamBlock, FDTeamBlock,
FDLinkCardsBlock,
FDSpecCardsBlock,
FDQuizBlock,
FDVideoHeroBlock,
], ],
required: true, required: true,
admin: { admin: {

View File

@ -18,8 +18,6 @@ interface FDImageProps {
sizes?: string sizes?: string
/** Fallback alt text if media has none */ /** Fallback alt text if media has none */
fallbackAlt?: string fallbackAlt?: string
/** Hide from assistive tech (decorative images) */
'aria-hidden'?: boolean | 'true' | 'false'
} }
export const FDImage: React.FC<FDImageProps> = ({ export const FDImage: React.FC<FDImageProps> = ({
@ -30,7 +28,6 @@ export const FDImage: React.FC<FDImageProps> = ({
priority = false, priority = false,
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px', sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px',
fallbackAlt = '', fallbackAlt = '',
'aria-hidden': ariaHidden,
}) => { }) => {
// If media is not a populated object, bail // If media is not a populated object, bail
if (!media || typeof media === 'string' || typeof media === 'number') return null if (!media || typeof media === 'string' || typeof media === 'number') return null
@ -50,7 +47,7 @@ export const FDImage: React.FC<FDImageProps> = ({
if (isSvg) { if (isSvg) {
return ( return (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} className={className} loading="lazy" aria-hidden={ariaHidden} /> <img src={src} alt={alt} className={className} loading="lazy" />
) )
} }
@ -63,7 +60,6 @@ export const FDImage: React.FC<FDImageProps> = ({
className={className} className={className}
priority={priority} priority={priority}
sizes={sizes} sizes={sizes}
aria-hidden={ariaHidden}
/> />
) )
} }
@ -82,7 +78,6 @@ export const FDImage: React.FC<FDImageProps> = ({
className={className} className={className}
priority={priority} priority={priority}
sizes={sizes} sizes={sizes}
aria-hidden={ariaHidden}
/> />
) )
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,461 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_pages_blocks_fd_link_cards_columns" AS ENUM('2', '3', '4');
CREATE TYPE "public"."enum_pages_blocks_fd_link_cards_card_style" AS ENUM('outlined', 'navy', 'gray', 'yellow');
CREATE TYPE "public"."enum_pages_blocks_fd_link_cards_section_background" AS ENUM('white', 'gray', 'navy');
CREATE TYPE "public"."enum_pages_blocks_fd_spec_cards_layout" AS ENUM('sideBySide', 'sideBySideReverse', 'fullWidth');
CREATE TYPE "public"."enum_pages_blocks_fd_spec_cards_card_style" AS ENUM('outlined', 'navy', 'gray', 'white');
CREATE TYPE "public"."enum_pages_blocks_fd_spec_cards_section_background" AS ENUM('white', 'gray', 'navy', 'navyGradient');
CREATE TYPE "public"."enum_pages_blocks_fd_quiz_section_background" AS ENUM('white', 'gray', 'navy');
CREATE TYPE "public"."enum__pages_v_blocks_fd_link_cards_columns" AS ENUM('2', '3', '4');
CREATE TYPE "public"."enum__pages_v_blocks_fd_link_cards_card_style" AS ENUM('outlined', 'navy', 'gray', 'yellow');
CREATE TYPE "public"."enum__pages_v_blocks_fd_link_cards_section_background" AS ENUM('white', 'gray', 'navy');
CREATE TYPE "public"."enum__pages_v_blocks_fd_spec_cards_layout" AS ENUM('sideBySide', 'sideBySideReverse', 'fullWidth');
CREATE TYPE "public"."enum__pages_v_blocks_fd_spec_cards_card_style" AS ENUM('outlined', 'navy', 'gray', 'white');
CREATE TYPE "public"."enum__pages_v_blocks_fd_spec_cards_section_background" AS ENUM('white', 'gray', 'navy', 'navyGradient');
CREATE TYPE "public"."enum__pages_v_blocks_fd_quiz_section_background" AS ENUM('white', 'gray', 'navy');
CREATE TABLE "pages_blocks_fd_link_cards_cards" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"link_url" varchar,
"link_icon_id" integer
);
CREATE TABLE "pages_blocks_fd_link_cards_cards_locales" (
"title" varchar,
"link_label" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "pages_blocks_fd_link_cards" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"icon_id" integer,
"columns" "enum_pages_blocks_fd_link_cards_columns" DEFAULT '3',
"card_style" "enum_pages_blocks_fd_link_cards_card_style" DEFAULT 'outlined',
"section_background" "enum_pages_blocks_fd_link_cards_section_background" DEFAULT 'navy',
"anchor_id" varchar,
"block_name" varchar
);
CREATE TABLE "pages_blocks_fd_link_cards_locales" (
"heading" varchar,
"description" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "pages_blocks_fd_spec_cards_cards" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL
);
CREATE TABLE "pages_blocks_fd_spec_cards_cards_locales" (
"title" varchar,
"description" varchar,
"spec_label" varchar,
"spec_value" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "pages_blocks_fd_spec_cards" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"cta_link" varchar DEFAULT '/kontakt',
"secondary_cta_link" varchar,
"layout" "enum_pages_blocks_fd_spec_cards_layout" DEFAULT 'sideBySide',
"card_style" "enum_pages_blocks_fd_spec_cards_card_style" DEFAULT 'outlined',
"section_background" "enum_pages_blocks_fd_spec_cards_section_background" DEFAULT 'navy',
"anchor_id" varchar,
"block_name" varchar
);
CREATE TABLE "pages_blocks_fd_spec_cards_locales" (
"heading" varchar,
"description" varchar,
"cta_text" varchar,
"secondary_cta_text" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "pages_blocks_fd_quiz_outcomes" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"key" varchar,
"cta_link" varchar DEFAULT '/kontakt'
);
CREATE TABLE "pages_blocks_fd_quiz_outcomes_locales" (
"title" varchar,
"description" varchar,
"cta_text" varchar DEFAULT 'Läs mer',
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "pages_blocks_fd_quiz_questions_options" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"outcome_keys" varchar
);
CREATE TABLE "pages_blocks_fd_quiz_questions_options_locales" (
"label" varchar,
"sublabel" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "pages_blocks_fd_quiz_questions" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL
);
CREATE TABLE "pages_blocks_fd_quiz_questions_locales" (
"question" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "pages_blocks_fd_quiz" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"section_background" "enum_pages_blocks_fd_quiz_section_background" DEFAULT 'navy',
"anchor_id" varchar,
"block_name" varchar
);
CREATE TABLE "pages_blocks_fd_quiz_locales" (
"heading" varchar DEFAULT 'Vilken tjänst passar dig?',
"description" varchar,
"trigger_label" varchar DEFAULT 'Starta quiz',
"next_label" varchar DEFAULT 'Nästa',
"back_label" varchar DEFAULT 'Tillbaka',
"result_heading" varchar DEFAULT 'Rekommenderat:',
"restart_label" varchar DEFAULT 'Börja om',
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "_pages_v_blocks_fd_link_cards_cards" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"link_url" varchar,
"link_icon_id" integer,
"_uuid" varchar
);
CREATE TABLE "_pages_v_blocks_fd_link_cards_cards_locales" (
"title" varchar,
"link_label" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
CREATE TABLE "_pages_v_blocks_fd_link_cards" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"icon_id" integer,
"columns" "enum__pages_v_blocks_fd_link_cards_columns" DEFAULT '3',
"card_style" "enum__pages_v_blocks_fd_link_cards_card_style" DEFAULT 'outlined',
"section_background" "enum__pages_v_blocks_fd_link_cards_section_background" DEFAULT 'navy',
"anchor_id" varchar,
"_uuid" varchar,
"block_name" varchar
);
CREATE TABLE "_pages_v_blocks_fd_link_cards_locales" (
"heading" varchar,
"description" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
CREATE TABLE "_pages_v_blocks_fd_spec_cards_cards" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"_uuid" varchar
);
CREATE TABLE "_pages_v_blocks_fd_spec_cards_cards_locales" (
"title" varchar,
"description" varchar,
"spec_label" varchar,
"spec_value" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
CREATE TABLE "_pages_v_blocks_fd_spec_cards" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"cta_link" varchar DEFAULT '/kontakt',
"secondary_cta_link" varchar,
"layout" "enum__pages_v_blocks_fd_spec_cards_layout" DEFAULT 'sideBySide',
"card_style" "enum__pages_v_blocks_fd_spec_cards_card_style" DEFAULT 'outlined',
"section_background" "enum__pages_v_blocks_fd_spec_cards_section_background" DEFAULT 'navy',
"anchor_id" varchar,
"_uuid" varchar,
"block_name" varchar
);
CREATE TABLE "_pages_v_blocks_fd_spec_cards_locales" (
"heading" varchar,
"description" varchar,
"cta_text" varchar,
"secondary_cta_text" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
CREATE TABLE "_pages_v_blocks_fd_quiz_outcomes" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"key" varchar,
"cta_link" varchar DEFAULT '/kontakt',
"_uuid" varchar
);
CREATE TABLE "_pages_v_blocks_fd_quiz_outcomes_locales" (
"title" varchar,
"description" varchar,
"cta_text" varchar DEFAULT 'Läs mer',
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
CREATE TABLE "_pages_v_blocks_fd_quiz_questions_options" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"outcome_keys" varchar,
"_uuid" varchar
);
CREATE TABLE "_pages_v_blocks_fd_quiz_questions_options_locales" (
"label" varchar,
"sublabel" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
CREATE TABLE "_pages_v_blocks_fd_quiz_questions" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"_uuid" varchar
);
CREATE TABLE "_pages_v_blocks_fd_quiz_questions_locales" (
"question" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
CREATE TABLE "_pages_v_blocks_fd_quiz" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"section_background" "enum__pages_v_blocks_fd_quiz_section_background" DEFAULT 'navy',
"anchor_id" varchar,
"_uuid" varchar,
"block_name" varchar
);
CREATE TABLE "_pages_v_blocks_fd_quiz_locales" (
"heading" varchar DEFAULT 'Vilken tjänst passar dig?',
"description" varchar,
"trigger_label" varchar DEFAULT 'Starta quiz',
"next_label" varchar DEFAULT 'Nästa',
"back_label" varchar DEFAULT 'Tillbaka',
"result_heading" varchar DEFAULT 'Rekommenderat:',
"restart_label" varchar DEFAULT 'Börja om',
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
ALTER TABLE "pages_blocks_fd_link_cards_cards" ADD CONSTRAINT "pages_blocks_fd_link_cards_cards_link_icon_id_media_id_fk" FOREIGN KEY ("link_icon_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_link_cards_cards" ADD CONSTRAINT "pages_blocks_fd_link_cards_cards_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_link_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_link_cards_cards_locales" ADD CONSTRAINT "pages_blocks_fd_link_cards_cards_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_link_cards_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_link_cards" ADD CONSTRAINT "pages_blocks_fd_link_cards_icon_id_media_id_fk" FOREIGN KEY ("icon_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_link_cards" ADD CONSTRAINT "pages_blocks_fd_link_cards_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_link_cards_locales" ADD CONSTRAINT "pages_blocks_fd_link_cards_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_link_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_spec_cards_cards" ADD CONSTRAINT "pages_blocks_fd_spec_cards_cards_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_spec_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_spec_cards_cards_locales" ADD CONSTRAINT "pages_blocks_fd_spec_cards_cards_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_spec_cards_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_spec_cards" ADD CONSTRAINT "pages_blocks_fd_spec_cards_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_spec_cards_locales" ADD CONSTRAINT "pages_blocks_fd_spec_cards_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_spec_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_quiz_outcomes" ADD CONSTRAINT "pages_blocks_fd_quiz_outcomes_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_quiz"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_quiz_outcomes_locales" ADD CONSTRAINT "pages_blocks_fd_quiz_outcomes_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_quiz_outcomes"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_quiz_questions_options" ADD CONSTRAINT "pages_blocks_fd_quiz_questions_options_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_quiz_questions"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_quiz_questions_options_locales" ADD CONSTRAINT "pages_blocks_fd_quiz_questions_options_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_quiz_questions_options"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_quiz_questions" ADD CONSTRAINT "pages_blocks_fd_quiz_questions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_quiz"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_quiz_questions_locales" ADD CONSTRAINT "pages_blocks_fd_quiz_questions_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_quiz_questions"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_quiz" ADD CONSTRAINT "pages_blocks_fd_quiz_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_quiz_locales" ADD CONSTRAINT "pages_blocks_fd_quiz_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_quiz"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_link_cards_cards" ADD CONSTRAINT "_pages_v_blocks_fd_link_cards_cards_link_icon_id_media_id_fk" FOREIGN KEY ("link_icon_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_link_cards_cards" ADD CONSTRAINT "_pages_v_blocks_fd_link_cards_cards_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_link_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_link_cards_cards_locales" ADD CONSTRAINT "_pages_v_blocks_fd_link_cards_cards_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_link_cards_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_link_cards" ADD CONSTRAINT "_pages_v_blocks_fd_link_cards_icon_id_media_id_fk" FOREIGN KEY ("icon_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_link_cards" ADD CONSTRAINT "_pages_v_blocks_fd_link_cards_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_link_cards_locales" ADD CONSTRAINT "_pages_v_blocks_fd_link_cards_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_link_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_spec_cards_cards" ADD CONSTRAINT "_pages_v_blocks_fd_spec_cards_cards_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_spec_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_spec_cards_cards_locales" ADD CONSTRAINT "_pages_v_blocks_fd_spec_cards_cards_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_spec_cards_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_spec_cards" ADD CONSTRAINT "_pages_v_blocks_fd_spec_cards_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_spec_cards_locales" ADD CONSTRAINT "_pages_v_blocks_fd_spec_cards_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_spec_cards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_quiz_outcomes" ADD CONSTRAINT "_pages_v_blocks_fd_quiz_outcomes_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_quiz"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_quiz_outcomes_locales" ADD CONSTRAINT "_pages_v_blocks_fd_quiz_outcomes_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_quiz_outcomes"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_quiz_questions_options" ADD CONSTRAINT "_pages_v_blocks_fd_quiz_questions_options_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_quiz_questions"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_quiz_questions_options_locales" ADD CONSTRAINT "_pages_v_blocks_fd_quiz_questions_options_locales_parent__fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_quiz_questions_options"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_quiz_questions" ADD CONSTRAINT "_pages_v_blocks_fd_quiz_questions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_quiz"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_quiz_questions_locales" ADD CONSTRAINT "_pages_v_blocks_fd_quiz_questions_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_quiz_questions"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_quiz" ADD CONSTRAINT "_pages_v_blocks_fd_quiz_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_quiz_locales" ADD CONSTRAINT "_pages_v_blocks_fd_quiz_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_quiz"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "pages_blocks_fd_link_cards_cards_order_idx" ON "pages_blocks_fd_link_cards_cards" USING btree ("_order");
CREATE INDEX "pages_blocks_fd_link_cards_cards_parent_id_idx" ON "pages_blocks_fd_link_cards_cards" USING btree ("_parent_id");
CREATE INDEX "pages_blocks_fd_link_cards_cards_link_icon_idx" ON "pages_blocks_fd_link_cards_cards" USING btree ("link_icon_id");
CREATE UNIQUE INDEX "pages_blocks_fd_link_cards_cards_locales_locale_parent_id_un" ON "pages_blocks_fd_link_cards_cards_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "pages_blocks_fd_link_cards_order_idx" ON "pages_blocks_fd_link_cards" USING btree ("_order");
CREATE INDEX "pages_blocks_fd_link_cards_parent_id_idx" ON "pages_blocks_fd_link_cards" USING btree ("_parent_id");
CREATE INDEX "pages_blocks_fd_link_cards_path_idx" ON "pages_blocks_fd_link_cards" USING btree ("_path");
CREATE INDEX "pages_blocks_fd_link_cards_icon_idx" ON "pages_blocks_fd_link_cards" USING btree ("icon_id");
CREATE UNIQUE INDEX "pages_blocks_fd_link_cards_locales_locale_parent_id_unique" ON "pages_blocks_fd_link_cards_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "pages_blocks_fd_spec_cards_cards_order_idx" ON "pages_blocks_fd_spec_cards_cards" USING btree ("_order");
CREATE INDEX "pages_blocks_fd_spec_cards_cards_parent_id_idx" ON "pages_blocks_fd_spec_cards_cards" USING btree ("_parent_id");
CREATE UNIQUE INDEX "pages_blocks_fd_spec_cards_cards_locales_locale_parent_id_un" ON "pages_blocks_fd_spec_cards_cards_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "pages_blocks_fd_spec_cards_order_idx" ON "pages_blocks_fd_spec_cards" USING btree ("_order");
CREATE INDEX "pages_blocks_fd_spec_cards_parent_id_idx" ON "pages_blocks_fd_spec_cards" USING btree ("_parent_id");
CREATE INDEX "pages_blocks_fd_spec_cards_path_idx" ON "pages_blocks_fd_spec_cards" USING btree ("_path");
CREATE UNIQUE INDEX "pages_blocks_fd_spec_cards_locales_locale_parent_id_unique" ON "pages_blocks_fd_spec_cards_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "pages_blocks_fd_quiz_outcomes_order_idx" ON "pages_blocks_fd_quiz_outcomes" USING btree ("_order");
CREATE INDEX "pages_blocks_fd_quiz_outcomes_parent_id_idx" ON "pages_blocks_fd_quiz_outcomes" USING btree ("_parent_id");
CREATE UNIQUE INDEX "pages_blocks_fd_quiz_outcomes_locales_locale_parent_id_uniqu" ON "pages_blocks_fd_quiz_outcomes_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "pages_blocks_fd_quiz_questions_options_order_idx" ON "pages_blocks_fd_quiz_questions_options" USING btree ("_order");
CREATE INDEX "pages_blocks_fd_quiz_questions_options_parent_id_idx" ON "pages_blocks_fd_quiz_questions_options" USING btree ("_parent_id");
CREATE UNIQUE INDEX "pages_blocks_fd_quiz_questions_options_locales_locale_parent" ON "pages_blocks_fd_quiz_questions_options_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "pages_blocks_fd_quiz_questions_order_idx" ON "pages_blocks_fd_quiz_questions" USING btree ("_order");
CREATE INDEX "pages_blocks_fd_quiz_questions_parent_id_idx" ON "pages_blocks_fd_quiz_questions" USING btree ("_parent_id");
CREATE UNIQUE INDEX "pages_blocks_fd_quiz_questions_locales_locale_parent_id_uniq" ON "pages_blocks_fd_quiz_questions_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "pages_blocks_fd_quiz_order_idx" ON "pages_blocks_fd_quiz" USING btree ("_order");
CREATE INDEX "pages_blocks_fd_quiz_parent_id_idx" ON "pages_blocks_fd_quiz" USING btree ("_parent_id");
CREATE INDEX "pages_blocks_fd_quiz_path_idx" ON "pages_blocks_fd_quiz" USING btree ("_path");
CREATE UNIQUE INDEX "pages_blocks_fd_quiz_locales_locale_parent_id_unique" ON "pages_blocks_fd_quiz_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "_pages_v_blocks_fd_link_cards_cards_order_idx" ON "_pages_v_blocks_fd_link_cards_cards" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_fd_link_cards_cards_parent_id_idx" ON "_pages_v_blocks_fd_link_cards_cards" USING btree ("_parent_id");
CREATE INDEX "_pages_v_blocks_fd_link_cards_cards_link_icon_idx" ON "_pages_v_blocks_fd_link_cards_cards" USING btree ("link_icon_id");
CREATE UNIQUE INDEX "_pages_v_blocks_fd_link_cards_cards_locales_locale_parent_id" ON "_pages_v_blocks_fd_link_cards_cards_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "_pages_v_blocks_fd_link_cards_order_idx" ON "_pages_v_blocks_fd_link_cards" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_fd_link_cards_parent_id_idx" ON "_pages_v_blocks_fd_link_cards" USING btree ("_parent_id");
CREATE INDEX "_pages_v_blocks_fd_link_cards_path_idx" ON "_pages_v_blocks_fd_link_cards" USING btree ("_path");
CREATE INDEX "_pages_v_blocks_fd_link_cards_icon_idx" ON "_pages_v_blocks_fd_link_cards" USING btree ("icon_id");
CREATE UNIQUE INDEX "_pages_v_blocks_fd_link_cards_locales_locale_parent_id_uniqu" ON "_pages_v_blocks_fd_link_cards_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "_pages_v_blocks_fd_spec_cards_cards_order_idx" ON "_pages_v_blocks_fd_spec_cards_cards" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_fd_spec_cards_cards_parent_id_idx" ON "_pages_v_blocks_fd_spec_cards_cards" USING btree ("_parent_id");
CREATE UNIQUE INDEX "_pages_v_blocks_fd_spec_cards_cards_locales_locale_parent_id" ON "_pages_v_blocks_fd_spec_cards_cards_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "_pages_v_blocks_fd_spec_cards_order_idx" ON "_pages_v_blocks_fd_spec_cards" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_fd_spec_cards_parent_id_idx" ON "_pages_v_blocks_fd_spec_cards" USING btree ("_parent_id");
CREATE INDEX "_pages_v_blocks_fd_spec_cards_path_idx" ON "_pages_v_blocks_fd_spec_cards" USING btree ("_path");
CREATE UNIQUE INDEX "_pages_v_blocks_fd_spec_cards_locales_locale_parent_id_uniqu" ON "_pages_v_blocks_fd_spec_cards_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "_pages_v_blocks_fd_quiz_outcomes_order_idx" ON "_pages_v_blocks_fd_quiz_outcomes" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_fd_quiz_outcomes_parent_id_idx" ON "_pages_v_blocks_fd_quiz_outcomes" USING btree ("_parent_id");
CREATE UNIQUE INDEX "_pages_v_blocks_fd_quiz_outcomes_locales_locale_parent_id_un" ON "_pages_v_blocks_fd_quiz_outcomes_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "_pages_v_blocks_fd_quiz_questions_options_order_idx" ON "_pages_v_blocks_fd_quiz_questions_options" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_fd_quiz_questions_options_parent_id_idx" ON "_pages_v_blocks_fd_quiz_questions_options" USING btree ("_parent_id");
CREATE UNIQUE INDEX "_pages_v_blocks_fd_quiz_questions_options_locales_locale_par" ON "_pages_v_blocks_fd_quiz_questions_options_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "_pages_v_blocks_fd_quiz_questions_order_idx" ON "_pages_v_blocks_fd_quiz_questions" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_fd_quiz_questions_parent_id_idx" ON "_pages_v_blocks_fd_quiz_questions" USING btree ("_parent_id");
CREATE UNIQUE INDEX "_pages_v_blocks_fd_quiz_questions_locales_locale_parent_id_u" ON "_pages_v_blocks_fd_quiz_questions_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "_pages_v_blocks_fd_quiz_order_idx" ON "_pages_v_blocks_fd_quiz" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_fd_quiz_parent_id_idx" ON "_pages_v_blocks_fd_quiz" USING btree ("_parent_id");
CREATE INDEX "_pages_v_blocks_fd_quiz_path_idx" ON "_pages_v_blocks_fd_quiz" USING btree ("_path");
CREATE UNIQUE INDEX "_pages_v_blocks_fd_quiz_locales_locale_parent_id_unique" ON "_pages_v_blocks_fd_quiz_locales" USING btree ("_locale","_parent_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "pages_blocks_fd_link_cards_cards" CASCADE;
DROP TABLE "pages_blocks_fd_link_cards_cards_locales" CASCADE;
DROP TABLE "pages_blocks_fd_link_cards" CASCADE;
DROP TABLE "pages_blocks_fd_link_cards_locales" CASCADE;
DROP TABLE "pages_blocks_fd_spec_cards_cards" CASCADE;
DROP TABLE "pages_blocks_fd_spec_cards_cards_locales" CASCADE;
DROP TABLE "pages_blocks_fd_spec_cards" CASCADE;
DROP TABLE "pages_blocks_fd_spec_cards_locales" CASCADE;
DROP TABLE "pages_blocks_fd_quiz_outcomes" CASCADE;
DROP TABLE "pages_blocks_fd_quiz_outcomes_locales" CASCADE;
DROP TABLE "pages_blocks_fd_quiz_questions_options" CASCADE;
DROP TABLE "pages_blocks_fd_quiz_questions_options_locales" CASCADE;
DROP TABLE "pages_blocks_fd_quiz_questions" CASCADE;
DROP TABLE "pages_blocks_fd_quiz_questions_locales" CASCADE;
DROP TABLE "pages_blocks_fd_quiz" CASCADE;
DROP TABLE "pages_blocks_fd_quiz_locales" CASCADE;
DROP TABLE "_pages_v_blocks_fd_link_cards_cards" CASCADE;
DROP TABLE "_pages_v_blocks_fd_link_cards_cards_locales" CASCADE;
DROP TABLE "_pages_v_blocks_fd_link_cards" CASCADE;
DROP TABLE "_pages_v_blocks_fd_link_cards_locales" CASCADE;
DROP TABLE "_pages_v_blocks_fd_spec_cards_cards" CASCADE;
DROP TABLE "_pages_v_blocks_fd_spec_cards_cards_locales" CASCADE;
DROP TABLE "_pages_v_blocks_fd_spec_cards" CASCADE;
DROP TABLE "_pages_v_blocks_fd_spec_cards_locales" CASCADE;
DROP TABLE "_pages_v_blocks_fd_quiz_outcomes" CASCADE;
DROP TABLE "_pages_v_blocks_fd_quiz_outcomes_locales" CASCADE;
DROP TABLE "_pages_v_blocks_fd_quiz_questions_options" CASCADE;
DROP TABLE "_pages_v_blocks_fd_quiz_questions_options_locales" CASCADE;
DROP TABLE "_pages_v_blocks_fd_quiz_questions" CASCADE;
DROP TABLE "_pages_v_blocks_fd_quiz_questions_locales" CASCADE;
DROP TABLE "_pages_v_blocks_fd_quiz" CASCADE;
DROP TABLE "_pages_v_blocks_fd_quiz_locales" CASCADE;
DROP TYPE "public"."enum_pages_blocks_fd_link_cards_columns";
DROP TYPE "public"."enum_pages_blocks_fd_link_cards_card_style";
DROP TYPE "public"."enum_pages_blocks_fd_link_cards_section_background";
DROP TYPE "public"."enum_pages_blocks_fd_spec_cards_layout";
DROP TYPE "public"."enum_pages_blocks_fd_spec_cards_card_style";
DROP TYPE "public"."enum_pages_blocks_fd_spec_cards_section_background";
DROP TYPE "public"."enum_pages_blocks_fd_quiz_section_background";
DROP TYPE "public"."enum__pages_v_blocks_fd_link_cards_columns";
DROP TYPE "public"."enum__pages_v_blocks_fd_link_cards_card_style";
DROP TYPE "public"."enum__pages_v_blocks_fd_link_cards_section_background";
DROP TYPE "public"."enum__pages_v_blocks_fd_spec_cards_layout";
DROP TYPE "public"."enum__pages_v_blocks_fd_spec_cards_card_style";
DROP TYPE "public"."enum__pages_v_blocks_fd_spec_cards_section_background";
DROP TYPE "public"."enum__pages_v_blocks_fd_quiz_section_background";`)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,112 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_pages_blocks_fd_video_hero_overlay_color" AS ENUM('none', 'black', 'navy');
CREATE TYPE "public"."enum_pages_blocks_fd_video_hero_overlay_strength" AS ENUM('light', 'medium', 'heavy');
CREATE TYPE "public"."enum_pages_blocks_fd_video_hero_text_align" AS ENUM('left', 'center');
CREATE TYPE "public"."enum_pages_blocks_fd_video_hero_min_height" AS ENUM('screen', 'large', 'medium');
CREATE TYPE "public"."enum__pages_v_blocks_fd_video_hero_overlay_color" AS ENUM('none', 'black', 'navy');
CREATE TYPE "public"."enum__pages_v_blocks_fd_video_hero_overlay_strength" AS ENUM('light', 'medium', 'heavy');
CREATE TYPE "public"."enum__pages_v_blocks_fd_video_hero_text_align" AS ENUM('left', 'center');
CREATE TYPE "public"."enum__pages_v_blocks_fd_video_hero_min_height" AS ENUM('screen', 'large', 'medium');
CREATE TABLE "pages_blocks_fd_video_hero" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"video_mp4_id" integer,
"video_webm_id" integer,
"poster_image_id" integer,
"cta_link" varchar,
"secondary_cta_link" varchar,
"overlay_color" "enum_pages_blocks_fd_video_hero_overlay_color" DEFAULT 'none',
"overlay_strength" "enum_pages_blocks_fd_video_hero_overlay_strength" DEFAULT 'medium',
"text_align" "enum_pages_blocks_fd_video_hero_text_align" DEFAULT 'left',
"min_height" "enum_pages_blocks_fd_video_hero_min_height" DEFAULT 'screen',
"disable_video_on_mobile" boolean DEFAULT false,
"anchor_id" varchar,
"block_name" varchar
);
CREATE TABLE "pages_blocks_fd_video_hero_locales" (
"heading" varchar,
"body" varchar,
"cta_text" varchar,
"secondary_cta_text" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "_pages_v_blocks_fd_video_hero" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"video_mp4_id" integer,
"video_webm_id" integer,
"poster_image_id" integer,
"cta_link" varchar,
"secondary_cta_link" varchar,
"overlay_color" "enum__pages_v_blocks_fd_video_hero_overlay_color" DEFAULT 'none',
"overlay_strength" "enum__pages_v_blocks_fd_video_hero_overlay_strength" DEFAULT 'medium',
"text_align" "enum__pages_v_blocks_fd_video_hero_text_align" DEFAULT 'left',
"min_height" "enum__pages_v_blocks_fd_video_hero_min_height" DEFAULT 'screen',
"disable_video_on_mobile" boolean DEFAULT false,
"anchor_id" varchar,
"_uuid" varchar,
"block_name" varchar
);
CREATE TABLE "_pages_v_blocks_fd_video_hero_locales" (
"heading" varchar,
"body" varchar,
"cta_text" varchar,
"secondary_cta_text" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
ALTER TABLE "pages_blocks_fd_video_hero" ADD CONSTRAINT "pages_blocks_fd_video_hero_video_mp4_id_media_id_fk" FOREIGN KEY ("video_mp4_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_video_hero" ADD CONSTRAINT "pages_blocks_fd_video_hero_video_webm_id_media_id_fk" FOREIGN KEY ("video_webm_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_video_hero" ADD CONSTRAINT "pages_blocks_fd_video_hero_poster_image_id_media_id_fk" FOREIGN KEY ("poster_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_video_hero" ADD CONSTRAINT "pages_blocks_fd_video_hero_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_video_hero_locales" ADD CONSTRAINT "pages_blocks_fd_video_hero_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_video_hero"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_video_hero" ADD CONSTRAINT "_pages_v_blocks_fd_video_hero_video_mp4_id_media_id_fk" FOREIGN KEY ("video_mp4_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_video_hero" ADD CONSTRAINT "_pages_v_blocks_fd_video_hero_video_webm_id_media_id_fk" FOREIGN KEY ("video_webm_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_video_hero" ADD CONSTRAINT "_pages_v_blocks_fd_video_hero_poster_image_id_media_id_fk" FOREIGN KEY ("poster_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_video_hero" ADD CONSTRAINT "_pages_v_blocks_fd_video_hero_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_video_hero_locales" ADD CONSTRAINT "_pages_v_blocks_fd_video_hero_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_video_hero"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "pages_blocks_fd_video_hero_order_idx" ON "pages_blocks_fd_video_hero" USING btree ("_order");
CREATE INDEX "pages_blocks_fd_video_hero_parent_id_idx" ON "pages_blocks_fd_video_hero" USING btree ("_parent_id");
CREATE INDEX "pages_blocks_fd_video_hero_path_idx" ON "pages_blocks_fd_video_hero" USING btree ("_path");
CREATE INDEX "pages_blocks_fd_video_hero_video_mp4_idx" ON "pages_blocks_fd_video_hero" USING btree ("video_mp4_id");
CREATE INDEX "pages_blocks_fd_video_hero_video_webm_idx" ON "pages_blocks_fd_video_hero" USING btree ("video_webm_id");
CREATE INDEX "pages_blocks_fd_video_hero_poster_image_idx" ON "pages_blocks_fd_video_hero" USING btree ("poster_image_id");
CREATE UNIQUE INDEX "pages_blocks_fd_video_hero_locales_locale_parent_id_unique" ON "pages_blocks_fd_video_hero_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "_pages_v_blocks_fd_video_hero_order_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_fd_video_hero_parent_id_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("_parent_id");
CREATE INDEX "_pages_v_blocks_fd_video_hero_path_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("_path");
CREATE INDEX "_pages_v_blocks_fd_video_hero_video_mp4_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("video_mp4_id");
CREATE INDEX "_pages_v_blocks_fd_video_hero_video_webm_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("video_webm_id");
CREATE INDEX "_pages_v_blocks_fd_video_hero_poster_image_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("poster_image_id");
CREATE UNIQUE INDEX "_pages_v_blocks_fd_video_hero_locales_locale_parent_id_uniqu" ON "_pages_v_blocks_fd_video_hero_locales" USING btree ("_locale","_parent_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "pages_blocks_fd_video_hero" CASCADE;
DROP TABLE "pages_blocks_fd_video_hero_locales" CASCADE;
DROP TABLE "_pages_v_blocks_fd_video_hero" CASCADE;
DROP TABLE "_pages_v_blocks_fd_video_hero_locales" CASCADE;
DROP TYPE "public"."enum_pages_blocks_fd_video_hero_overlay_color";
DROP TYPE "public"."enum_pages_blocks_fd_video_hero_overlay_strength";
DROP TYPE "public"."enum_pages_blocks_fd_video_hero_text_align";
DROP TYPE "public"."enum_pages_blocks_fd_video_hero_min_height";
DROP TYPE "public"."enum__pages_v_blocks_fd_video_hero_overlay_color";
DROP TYPE "public"."enum__pages_v_blocks_fd_video_hero_overlay_strength";
DROP TYPE "public"."enum__pages_v_blocks_fd_video_hero_text_align";
DROP TYPE "public"."enum__pages_v_blocks_fd_video_hero_min_height";`)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_pages_blocks_fd_usp_checklist_image_overlay" AS ENUM('none', 'navyLight', 'navyMedium', 'yellowLight', 'yellowMedium', 'sepia', 'blackLight', 'blackMedium');
CREATE TYPE "public"."enum_pages_blocks_fd_wide_card_image_overlay" AS ENUM('none', 'navyLight', 'navyMedium', 'yellowLight', 'yellowMedium', 'sepia', 'blackLight', 'blackMedium');
CREATE TYPE "public"."enum_pages_blocks_fd_alternate_hero_image_overlay" AS ENUM('none', 'navyLight', 'navyMedium', 'yellowLight', 'yellowMedium', 'sepia', 'blackLight', 'blackMedium');
CREATE TYPE "public"."enum__pages_v_blocks_fd_usp_checklist_image_overlay" AS ENUM('none', 'navyLight', 'navyMedium', 'yellowLight', 'yellowMedium', 'sepia', 'blackLight', 'blackMedium');
CREATE TYPE "public"."enum__pages_v_blocks_fd_wide_card_image_overlay" AS ENUM('none', 'navyLight', 'navyMedium', 'yellowLight', 'yellowMedium', 'sepia', 'blackLight', 'blackMedium');
CREATE TYPE "public"."enum__pages_v_blocks_fd_alternate_hero_image_overlay" AS ENUM('none', 'navyLight', 'navyMedium', 'yellowLight', 'yellowMedium', 'sepia', 'blackLight', 'blackMedium');
ALTER TABLE "pages_blocks_fd_usp_checklist" ADD COLUMN "image_overlay" "enum_pages_blocks_fd_usp_checklist_image_overlay" DEFAULT 'none';
ALTER TABLE "pages_blocks_fd_wide_card" ADD COLUMN "image_overlay" "enum_pages_blocks_fd_wide_card_image_overlay" DEFAULT 'none';
ALTER TABLE "pages_blocks_fd_alternate_hero" ADD COLUMN "image_overlay" "enum_pages_blocks_fd_alternate_hero_image_overlay" DEFAULT 'none';
ALTER TABLE "_pages_v_blocks_fd_usp_checklist" ADD COLUMN "image_overlay" "enum__pages_v_blocks_fd_usp_checklist_image_overlay" DEFAULT 'none';
ALTER TABLE "_pages_v_blocks_fd_wide_card" ADD COLUMN "image_overlay" "enum__pages_v_blocks_fd_wide_card_image_overlay" DEFAULT 'none';
ALTER TABLE "_pages_v_blocks_fd_alternate_hero" ADD COLUMN "image_overlay" "enum__pages_v_blocks_fd_alternate_hero_image_overlay" DEFAULT 'none';`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "pages_blocks_fd_usp_checklist" DROP COLUMN "image_overlay";
ALTER TABLE "pages_blocks_fd_wide_card" DROP COLUMN "image_overlay";
ALTER TABLE "pages_blocks_fd_alternate_hero" DROP COLUMN "image_overlay";
ALTER TABLE "_pages_v_blocks_fd_usp_checklist" DROP COLUMN "image_overlay";
ALTER TABLE "_pages_v_blocks_fd_wide_card" DROP COLUMN "image_overlay";
ALTER TABLE "_pages_v_blocks_fd_alternate_hero" DROP COLUMN "image_overlay";
DROP TYPE "public"."enum_pages_blocks_fd_usp_checklist_image_overlay";
DROP TYPE "public"."enum_pages_blocks_fd_wide_card_image_overlay";
DROP TYPE "public"."enum_pages_blocks_fd_alternate_hero_image_overlay";
DROP TYPE "public"."enum__pages_v_blocks_fd_usp_checklist_image_overlay";
DROP TYPE "public"."enum__pages_v_blocks_fd_wide_card_image_overlay";
DROP TYPE "public"."enum__pages_v_blocks_fd_alternate_hero_image_overlay";`)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_pages_blocks_fd_hero_min_height" AS ENUM('auto', 'sm', 'md', 'lg', 'screen');
CREATE TYPE "public"."enum_pages_blocks_fd_hero_heading_color" AS ENUM('auto', 'yellow', 'white', 'navy');
CREATE TYPE "public"."enum_pages_blocks_fd_hero_body_color" AS ENUM('auto', 'white', 'navy', 'yellow');
CREATE TYPE "public"."enum__pages_v_blocks_fd_hero_min_height" AS ENUM('auto', 'sm', 'md', 'lg', 'screen');
CREATE TYPE "public"."enum__pages_v_blocks_fd_hero_heading_color" AS ENUM('auto', 'yellow', 'white', 'navy');
CREATE TYPE "public"."enum__pages_v_blocks_fd_hero_body_color" AS ENUM('auto', 'white', 'navy', 'yellow');
ALTER TABLE "pages_blocks_fd_hero" ADD COLUMN "min_height" "enum_pages_blocks_fd_hero_min_height" DEFAULT 'auto';
ALTER TABLE "pages_blocks_fd_hero" ADD COLUMN "heading_color" "enum_pages_blocks_fd_hero_heading_color" DEFAULT 'auto';
ALTER TABLE "pages_blocks_fd_hero" ADD COLUMN "body_color" "enum_pages_blocks_fd_hero_body_color" DEFAULT 'auto';
ALTER TABLE "_pages_v_blocks_fd_hero" ADD COLUMN "min_height" "enum__pages_v_blocks_fd_hero_min_height" DEFAULT 'auto';
ALTER TABLE "_pages_v_blocks_fd_hero" ADD COLUMN "heading_color" "enum__pages_v_blocks_fd_hero_heading_color" DEFAULT 'auto';
ALTER TABLE "_pages_v_blocks_fd_hero" ADD COLUMN "body_color" "enum__pages_v_blocks_fd_hero_body_color" DEFAULT 'auto';
ALTER TABLE "pages_blocks_fd_hero" DROP COLUMN "text_color";
ALTER TABLE "_pages_v_blocks_fd_hero" DROP COLUMN "text_color";
DROP TYPE "public"."enum_pages_blocks_fd_hero_text_color";
DROP TYPE "public"."enum__pages_v_blocks_fd_hero_text_color";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_pages_blocks_fd_hero_text_color" AS ENUM('auto', 'white', 'navy');
CREATE TYPE "public"."enum__pages_v_blocks_fd_hero_text_color" AS ENUM('auto', 'white', 'navy');
ALTER TABLE "pages_blocks_fd_hero" ADD COLUMN "text_color" "enum_pages_blocks_fd_hero_text_color" DEFAULT 'auto';
ALTER TABLE "_pages_v_blocks_fd_hero" ADD COLUMN "text_color" "enum__pages_v_blocks_fd_hero_text_color" DEFAULT 'auto';
ALTER TABLE "pages_blocks_fd_hero" DROP COLUMN "min_height";
ALTER TABLE "pages_blocks_fd_hero" DROP COLUMN "heading_color";
ALTER TABLE "pages_blocks_fd_hero" DROP COLUMN "body_color";
ALTER TABLE "_pages_v_blocks_fd_hero" DROP COLUMN "min_height";
ALTER TABLE "_pages_v_blocks_fd_hero" DROP COLUMN "heading_color";
ALTER TABLE "_pages_v_blocks_fd_hero" DROP COLUMN "body_color";
DROP TYPE "public"."enum_pages_blocks_fd_hero_min_height";
DROP TYPE "public"."enum_pages_blocks_fd_hero_heading_color";
DROP TYPE "public"."enum_pages_blocks_fd_hero_body_color";
DROP TYPE "public"."enum__pages_v_blocks_fd_hero_min_height";
DROP TYPE "public"."enum__pages_v_blocks_fd_hero_heading_color";
DROP TYPE "public"."enum__pages_v_blocks_fd_hero_body_color";`)
}

View File

@ -2,10 +2,6 @@ import * as migration_20260224_091812_add_anchor_links from './20260224_091812_a
import * as migration_20260224_133833 from './20260224_133833'; import * as migration_20260224_133833 from './20260224_133833';
import * as migration_20260226_095439 from './20260226_095439'; import * as migration_20260226_095439 from './20260226_095439';
import * as migration_20260302_145030 from './20260302_145030'; import * as migration_20260302_145030 from './20260302_145030';
import * as migration_20260304_194657 from './20260304_194657';
import * as migration_20260304_203759 from './20260304_203759';
import * as migration_20260313_081702 from './20260313_081702';
import * as migration_20260313_085922 from './20260313_085922';
export const migrations = [ export const migrations = [
{ {
@ -26,26 +22,6 @@ export const migrations = [
{ {
up: migration_20260302_145030.up, up: migration_20260302_145030.up,
down: migration_20260302_145030.down, down: migration_20260302_145030.down,
name: '20260302_145030', name: '20260302_145030'
},
{
up: migration_20260304_194657.up,
down: migration_20260304_194657.down,
name: '20260304_194657',
},
{
up: migration_20260304_203759.up,
down: migration_20260304_203759.down,
name: '20260304_203759',
},
{
up: migration_20260313_081702.up,
down: migration_20260313_081702.down,
name: '20260313_081702',
},
{
up: migration_20260313_085922.up,
down: migration_20260313_085922.down,
name: '20260313_085922'
}, },
]; ];

View File

@ -188,10 +188,6 @@ export interface Page {
| FDCtaBannerBlock | FDCtaBannerBlock
| FDTestimonialBlock | FDTestimonialBlock
| FDTeamBlock | FDTeamBlock
| FDLinkCardsBlock
| FDSpecCardsBlock
| FDQuizBlock
| FDVideoHeroBlock
)[]; )[];
meta?: { meta?: {
title?: string | null; title?: string | null;
@ -230,16 +226,11 @@ export interface FDHeroBlock {
* Fullbreddsbild bakom texten. Lämna tom för enfärgad bakgrund. * Fullbreddsbild bakom texten. Lämna tom för enfärgad bakgrund.
*/ */
backgroundImage?: (number | null) | Media; backgroundImage?: (number | null) | Media;
/**
* Kontrollerar sektionens minsta höjd när bakgrundsbild används
*/
minHeight?: ('auto' | 'sm' | 'md' | 'lg' | 'screen') | null;
/** /**
* Hur mörk overlay över bilden (för läsbarhet) * Hur mörk overlay över bilden (för läsbarhet)
*/ */
overlayOpacity?: ('30' | '50' | '70') | null; overlayOpacity?: ('30' | '50' | '70') | null;
headingColor?: ('auto' | 'yellow' | 'white' | 'navy') | null; textColor?: ('auto' | 'white' | 'navy') | null;
bodyColor?: ('auto' | 'white' | 'navy' | 'yellow') | null;
/** /**
* Ignoreras om bakgrundsbild är vald * Ignoreras om bakgrundsbild är vald
*/ */
@ -564,9 +555,6 @@ export interface FDUspChecklistBlock {
}[] }[]
| null; | null;
image?: (number | null) | Media; image?: (number | null) | Media;
imageOverlay?:
| ('none' | 'navyLight' | 'navyMedium' | 'yellowLight' | 'yellowMedium' | 'sepia' | 'blackLight' | 'blackMedium')
| null;
imagePosition?: ('right' | 'left') | null; imagePosition?: ('right' | 'left') | null;
checkColor?: ('navy' | 'yellow' | 'gray') | null; checkColor?: ('navy' | 'yellow' | 'gray') | null;
sectionBackground?: ('white' | 'gray' | 'navy') | null; sectionBackground?: ('white' | 'gray' | 'navy') | null;
@ -589,9 +577,6 @@ export interface FDWideCardBlock {
ctaText?: string | null; ctaText?: string | null;
ctaLink?: string | null; ctaLink?: string | null;
image?: (number | null) | Media; image?: (number | null) | Media;
imageOverlay?:
| ('none' | 'navyLight' | 'navyMedium' | 'yellowLight' | 'yellowMedium' | 'sepia' | 'blackLight' | 'blackMedium')
| null;
cardBackground?: ('navy' | 'yellow' | 'gray' | 'white') | null; cardBackground?: ('navy' | 'yellow' | 'gray' | 'white') | null;
buttonColor?: ('yellow' | 'navy' | 'white') | null; buttonColor?: ('yellow' | 'navy' | 'white') | null;
sectionBackground?: ('white' | 'gray' | 'navy') | null; sectionBackground?: ('white' | 'gray' | 'navy') | null;
@ -953,9 +938,6 @@ export interface FDAlternateHeroBlock {
*/ */
image?: (number | null) | Media; image?: (number | null) | Media;
imageCaption?: string | null; imageCaption?: string | null;
imageOverlay?:
| ('none' | 'navyLight' | 'navyMedium' | 'yellowLight' | 'yellowMedium' | 'sepia' | 'blackLight' | 'blackMedium')
| null;
sectionBackground?: ('white' | 'navy' | 'gray') | null; sectionBackground?: ('white' | 'navy' | 'gray') | null;
/** /**
* Valfritt. Används för att länka direkt till denna sektion, t.ex. "priser" ger /sida#priser. Använd bara små bokstäver, siffror och bindestreck. * Valfritt. Används för att länka direkt till denna sektion, t.ex. "priser" ger /sida#priser. Använd bara små bokstäver, siffror och bindestreck.
@ -1524,170 +1506,6 @@ export interface FDTeamBlock {
blockName?: string | null; blockName?: string | null;
blockType: 'fdTeam'; blockType: 'fdTeam';
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FDLinkCardsBlock".
*/
export interface FDLinkCardsBlock {
icon?: (number | null) | Media;
heading: string;
description?: string | null;
cards?:
| {
title: string;
linkLabel: string;
linkUrl: string;
linkIcon?: (number | null) | Media;
id?: string | null;
}[]
| null;
columns?: ('2' | '3' | '4') | null;
cardStyle?: ('outlined' | 'navy' | 'gray' | 'yellow') | null;
sectionBackground?: ('white' | 'gray' | 'navy') | null;
/**
* Valfritt. Används för att länka direkt till denna sektion, t.ex. "priser" ger /sida#priser. Använd bara små bokstäver, siffror och bindestreck.
*/
anchorId?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'fdLinkCards';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FDSpecCardsBlock".
*/
export interface FDSpecCardsBlock {
heading: string;
description?: string | null;
ctaText?: string | null;
ctaLink?: string | null;
secondaryCtaText?: string | null;
secondaryCtaLink?: string | null;
cards?:
| {
title: string;
description?: string | null;
specLabel?: string | null;
specValue?: string | null;
id?: string | null;
}[]
| null;
layout?: ('sideBySide' | 'sideBySideReverse' | 'fullWidth') | null;
cardStyle?: ('outlined' | 'navy' | 'gray' | 'white') | null;
sectionBackground?: ('white' | 'gray' | 'navy' | 'navyGradient') | null;
/**
* Valfritt. Används för att länka direkt till denna sektion, t.ex. "priser" ger /sida#priser. Använd bara små bokstäver, siffror och bindestreck.
*/
anchorId?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'fdSpecCards';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FDQuizBlock".
*/
export interface FDQuizBlock {
heading: string;
description?: string | null;
triggerLabel?: string | null;
outcomes?:
| {
/**
* Används i frågornas alternativ. Bara små bokstäver, inga mellanslag.
*/
key: string;
title: string;
description?: string | null;
ctaText?: string | null;
ctaLink?: string | null;
id?: string | null;
}[]
| null;
questions?:
| {
question: string;
options?:
| {
label: string;
sublabel?: string | null;
/**
* Varje valt alternativ ger +1 poäng till angivna resultatnycklar.
*/
outcomeKeys: string;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
nextLabel?: string | null;
backLabel?: string | null;
resultHeading?: string | null;
restartLabel?: string | null;
sectionBackground?: ('white' | 'gray' | 'navy') | null;
/**
* Valfritt. Används för att länka direkt till denna sektion, t.ex. "priser" ger /sida#priser. Använd bara små bokstäver, siffror och bindestreck.
*/
anchorId?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'fdQuiz';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FDVideoHeroBlock".
*/
export interface FDVideoHeroBlock {
/**
* Huvudvideo MP4 (H.264). Fungerar i alla webbläsare. Håll filstorleken under 5 MB för bästa prestanda.
*/
videoMp4: number | Media;
/**
* Progressiv förbättring WebM är mindre och bättre kvalitet. Webbläsare som stödjer WebM väljer denna automatiskt.
*/
videoWebm?: (number | null) | Media;
/**
* Visas medan videon laddas och som fallback mobil om video hoppas över.
*/
posterImage?: (number | null) | Media;
/**
* H1-rubrik som visas ovanpå videon.
*/
heading?: string | null;
/**
* Kort text under rubriken.
*/
body?: string | null;
/**
* T.ex. "Kom igång"
*/
ctaText?: string | null;
ctaLink?: string | null;
secondaryCtaText?: string | null;
secondaryCtaLink?: string | null;
overlayColor?: ('none' | 'black' | 'navy') | null;
/**
* Hur stark overlay ovanpå videon (för läsbarhet).
*/
overlayStrength?: ('light' | 'medium' | 'heavy') | null;
textAlign?: ('left' | 'center') | null;
/**
* Hur hög video-sektionen ska vara.
*/
minHeight?: ('screen' | 'large' | 'medium') | null;
/**
* Hoppa över video små skärmar för att spara bandbredd. Poster-bilden visas istället.
*/
disableVideoOnMobile?: boolean | null;
/**
* Valfritt. Används för att länka direkt till denna sektion, t.ex. "priser" ger /sida#priser. Använd bara små bokstäver, siffror och bindestreck.
*/
anchorId?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'fdVideoHero';
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts". * via the `definition` "posts".
@ -2068,10 +1886,6 @@ export interface PagesSelect<T extends boolean = true> {
fdCtaBanner?: T | FDCtaBannerBlockSelect<T>; fdCtaBanner?: T | FDCtaBannerBlockSelect<T>;
fdTestimonial?: T | FDTestimonialBlockSelect<T>; fdTestimonial?: T | FDTestimonialBlockSelect<T>;
fdTeam?: T | FDTeamBlockSelect<T>; fdTeam?: T | FDTeamBlockSelect<T>;
fdLinkCards?: T | FDLinkCardsBlockSelect<T>;
fdSpecCards?: T | FDSpecCardsBlockSelect<T>;
fdQuiz?: T | FDQuizBlockSelect<T>;
fdVideoHero?: T | FDVideoHeroBlockSelect<T>;
}; };
meta?: meta?:
| T | T
@ -2100,10 +1914,8 @@ export interface FDHeroBlockSelect<T extends boolean = true> {
secondaryCtaText?: T; secondaryCtaText?: T;
secondaryCtaLink?: T; secondaryCtaLink?: T;
backgroundImage?: T; backgroundImage?: T;
minHeight?: T;
overlayOpacity?: T; overlayOpacity?: T;
headingColor?: T; textColor?: T;
bodyColor?: T;
theme?: T; theme?: T;
anchorId?: T; anchorId?: T;
id?: T; id?: T;
@ -2298,7 +2110,6 @@ export interface FDUspChecklistBlockSelect<T extends boolean = true> {
id?: T; id?: T;
}; };
image?: T; image?: T;
imageOverlay?: T;
imagePosition?: T; imagePosition?: T;
checkColor?: T; checkColor?: T;
sectionBackground?: T; sectionBackground?: T;
@ -2317,7 +2128,6 @@ export interface FDWideCardBlockSelect<T extends boolean = true> {
ctaText?: T; ctaText?: T;
ctaLink?: T; ctaLink?: T;
image?: T; image?: T;
imageOverlay?: T;
cardBackground?: T; cardBackground?: T;
buttonColor?: T; buttonColor?: T;
sectionBackground?: T; sectionBackground?: T;
@ -2443,7 +2253,6 @@ export interface FDAlternateHeroBlockSelect<T extends boolean = true> {
secondaryCtaLink?: T; secondaryCtaLink?: T;
image?: T; image?: T;
imageCaption?: T; imageCaption?: T;
imageOverlay?: T;
sectionBackground?: T; sectionBackground?: T;
anchorId?: T; anchorId?: T;
id?: T; id?: T;
@ -2803,121 +2612,6 @@ export interface FDTeamBlockSelect<T extends boolean = true> {
id?: T; id?: T;
blockName?: T; blockName?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FDLinkCardsBlock_select".
*/
export interface FDLinkCardsBlockSelect<T extends boolean = true> {
icon?: T;
heading?: T;
description?: T;
cards?:
| T
| {
title?: T;
linkLabel?: T;
linkUrl?: T;
linkIcon?: T;
id?: T;
};
columns?: T;
cardStyle?: T;
sectionBackground?: T;
anchorId?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FDSpecCardsBlock_select".
*/
export interface FDSpecCardsBlockSelect<T extends boolean = true> {
heading?: T;
description?: T;
ctaText?: T;
ctaLink?: T;
secondaryCtaText?: T;
secondaryCtaLink?: T;
cards?:
| T
| {
title?: T;
description?: T;
specLabel?: T;
specValue?: T;
id?: T;
};
layout?: T;
cardStyle?: T;
sectionBackground?: T;
anchorId?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FDQuizBlock_select".
*/
export interface FDQuizBlockSelect<T extends boolean = true> {
heading?: T;
description?: T;
triggerLabel?: T;
outcomes?:
| T
| {
key?: T;
title?: T;
description?: T;
ctaText?: T;
ctaLink?: T;
id?: T;
};
questions?:
| T
| {
question?: T;
options?:
| T
| {
label?: T;
sublabel?: T;
outcomeKeys?: T;
id?: T;
};
id?: T;
};
nextLabel?: T;
backLabel?: T;
resultHeading?: T;
restartLabel?: T;
sectionBackground?: T;
anchorId?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FDVideoHeroBlock_select".
*/
export interface FDVideoHeroBlockSelect<T extends boolean = true> {
videoMp4?: T;
videoWebm?: T;
posterImage?: T;
heading?: T;
body?: T;
ctaText?: T;
ctaLink?: T;
secondaryCtaText?: T;
secondaryCtaLink?: T;
overlayColor?: T;
overlayStrength?: T;
textAlign?: T;
minHeight?: T;
disableVideoOnMobile?: T;
anchorId?: T;
id?: T;
blockName?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select". * via the `definition` "posts_select".

View File

@ -19,7 +19,7 @@ const sectionBgMap: Record<string, string> = {
gray: 'bg-fd-gray-light dark:bg-fd-navy', gray: 'bg-fd-gray-light dark:bg-fd-navy',
navy: 'bg-fd-navy', navy: 'bg-fd-navy',
yellow: 'bg-fd-yellow', yellow: 'bg-fd-yellow',
navyGradient: 'bg-gradient-to-br from-fd-navy via-fd-navy-700 to-fd-navy', navyGradient: 'bg-gradient-to-br from-fd-navy via-[#153350] to-fd-navy',
transparent: 'bg-transparent', transparent: 'bg-transparent',
} }
@ -74,13 +74,3 @@ export const fdCardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
/** Smaller radius for sub-elements (images inside cards, data tables, etc.) */ /** Smaller radius for sub-elements (images inside cards, data tables, etc.) */
export const fdCardRadiusSm = 'rounded-[20px] md:rounded-[30px] lg:rounded-[40px]' export const fdCardRadiusSm = 'rounded-[20px] md:rounded-[30px] lg:rounded-[40px]'
/* ── Layout ───────────────────────────────────────────────────────────── */
/** Standard page container — max width + horizontal padding */
export const fdContainer = 'max-w-[1200px] mx-auto px-6 md:px-8'
/* ── Overlay utilities ────────────────────────────────────────────────── */
/** Sepia colour overlay for images */
export const fdSepiaOverlay = 'bg-fd-sepia/30'

View File

@ -27,8 +27,8 @@ export const generateMeta = async (args: {
const ogImage = getImageURL(doc?.meta?.image) const ogImage = getImageURL(doc?.meta?.image)
const title = doc?.meta?.title const title = doc?.meta?.title
? doc?.meta?.title + ' | Fiber Direkt' ? doc?.meta?.title + ' | Payload Website Template'
: 'Fiber Direkt' : 'Payload Website Template'
return { return {
description: doc?.meta?.description, description: doc?.meta?.description,

Some files were not shown because too many files have changed in this diff Show More