Compare commits
14 Commits
fix/lighth
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
534a5644bf | ||
|
|
15c3194eb6 | ||
|
|
ea230696f7 | ||
|
|
7bc3c23381 | ||
|
|
05ae8d60d1 | ||
|
|
32251ef681 | ||
|
|
e7df2e3cba | ||
|
|
fb8b75ecaa | ||
|
|
1b68297766 | ||
|
|
4e9e51004f | ||
|
|
d7f3a52838 | ||
|
|
f9327f8d98 | ||
|
|
38ecfce2eb | ||
|
|
805ec291df |
82
.claude/agents/campaign-strategist.md
Normal file
82
.claude/agents/campaign-strategist.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
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 4–5 headlines per primary pillar, 3–4 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 (1–30): Launch + organic testing
|
||||||
|
- Phase 2 (31–60): Optimize + scale paid
|
||||||
|
- Phase 3 (61–90): Expand pillars + retarget
|
||||||
|
|
||||||
|
### 4. LinkedIn Organic Plan
|
||||||
|
- 3x/week minimum: 1 carousel, 1–2 text posts, 1 stat/proof
|
||||||
|
- Hashtag strategy (always include #FiberDirekt)
|
||||||
|
|
||||||
|
### 5. LinkedIn Paid Specs
|
||||||
|
- Targeting: Stockholm area, 20–200 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
|
||||||
44
.claude/agents/carousel-builder.md
Normal file
44
.claude/agents/carousel-builder.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
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:** 100–140px, 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.
|
||||||
94
.claude/agents/content-writer.md
Normal file
94
.claude/agents/content-writer.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
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: 1–2 sentences expanding the promise
|
||||||
|
- CTA: action verb + outcome ("Boka ett möte", "Kom igång", "Få en offert")
|
||||||
|
|
||||||
|
**Statistics (fdStatistics):**
|
||||||
|
- 3–4 stats maximum. Each: a number + 2–4 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: 2–4 words. Card descriptions: 1–2 sentences max.
|
||||||
|
|
||||||
|
**CTA blocks (fdCtaBanner, fdCtaSideImage):**
|
||||||
|
- Heading: question or imperative ("Redo att ta kontrollen?")
|
||||||
|
- Body: 1 sentence. CTA button: 2–4 words with action verb.
|
||||||
|
|
||||||
|
**FAQ blocks (fdFaq):**
|
||||||
|
- 4–6 questions. What real prospects ask. 2–3 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
|
||||||
87
.claude/agents/image-director.md
Normal file
87
.claude/agents/image-director.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
65
.claude/agents/linkedin-writer.md
Normal file
65
.claude/agents/linkedin-writer.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
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 2–4: EXPAND** — Develop with specifics. Use line breaks.
|
||||||
|
**Lines 5–6: 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
|
||||||
|
- 3–5 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 3–5 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
|
||||||
127
.claude/agents/seed-script-builder.md
Normal file
127
.claude/agents/seed-script-builder.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
98
.claude/agents/site-architect.md
Normal file
98
.claude/agents/site-architect.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
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 8–10 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
|
||||||
77
.claude/commands/build-site.md
Normal file
77
.claude/commands/build-site.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
39
.claude/commands/produce-campaign.md
Normal file
39
.claude/commands/produce-campaign.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
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 3–4 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
9
.gitignore
vendored
@ -19,3 +19,12 @@ 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
|
||||||
|
|||||||
108
AGENTS-README.md
Normal file
108
AGENTS-README.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# 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 150K–300K tokens across all agents. You can run several full builds per week without hitting caps. Individual agent calls are much lighter (~20K–40K tokens each).
|
||||||
@ -77,6 +77,8 @@ 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)
|
||||||
@ -85,6 +87,8 @@ 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) => {
|
||||||
|
|||||||
141
references/brand-voice.md
Normal file
141
references/brand-voice.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# 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 10–400 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 — 2–4 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
|
||||||
|
- 2–3 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)
|
||||||
|
- 20–200 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 2–3 alternatives → VD/COO approves budget → procurement 2–6 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
|
||||||
142
references/copy-examples.md
Normal file
142
references/copy-examples.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
# 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:** 1–2 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:** 3–4 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
|
||||||
203
references/post-patterns.md
Normal file
203
references/post-patterns.md
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
# 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 2–4: EXPAND** — Develop with specific facts, stats, or a brief story. Use line breaks.
|
||||||
|
**Lines 5–6: 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 0–1)
|
||||||
|
- 3–5 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]
|
||||||
|
|
||||||
|
[2–3 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]
|
||||||
|
|
||||||
|
[1–2 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 2–3x 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
|
||||||
228
references/products-services.md
Normal file
228
references/products-services.md
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
# 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)
|
||||||
293
seeds/seed-office-komplett.mjs
Normal file
293
seeds/seed-office-komplett.mjs
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
// 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 5–50 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)
|
||||||
|
})
|
||||||
@ -341,6 +341,9 @@ 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;
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
// @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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
// @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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
// @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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@ -2,6 +2,18 @@ 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,
|
||||||
@ -12,9 +24,11 @@ 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'
|
||||||
@ -37,7 +51,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="max-w-[1200px] mx-auto px-6 md:px-8 text-center flex flex-col items-center gap-6 pb-12 md:pb-16">
|
<div className={`${fdContainer} 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>
|
||||||
@ -65,7 +79,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" style={{ maxHeight: '620px', height: '45vw', minHeight: '220px' }}>
|
<div className="relative w-full overflow-hidden" style={{ maxHeight: '620px', height: '45vw', minHeight: '220px' }}>
|
||||||
<FDImage
|
<FDImage
|
||||||
media={media}
|
media={media}
|
||||||
size="hero"
|
size="hero"
|
||||||
@ -75,6 +89,7 @@ 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}`}>
|
||||||
|
|||||||
@ -68,6 +68,25 @@ 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',
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
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',
|
bg: 'bg-fd-navy dark:bg-white/10',
|
||||||
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',
|
||||||
@ -64,7 +65,6 @@ 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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
<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-2 ${style.headingText}`}
|
className={`font-joey-heavy text-fd-h2 mb-6 hyphens-auto break-words ${style.headingText}`}
|
||||||
>
|
>
|
||||||
{card.heading}
|
{card.heading}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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]',
|
||||||
@ -20,7 +21,6 @@ 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,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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]'
|
||||||
@ -12,7 +13,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="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col items-center gap-8 lg:gap-10">
|
<div className={`${fdContainer} 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>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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 */
|
||||||
@ -14,12 +15,11 @@ 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-[#153350] to-fd-navy',
|
navyGradient: 'bg-gradient-to-br from-fd-navy via-fd-navy-700 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="max-w-[1200px] mx-auto px-6 md:px-8 text-center">
|
<div className={`${fdContainer} 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="max-w-[1200px] mx-auto px-6 md:px-8 text-center">
|
<div className={`${fdContainer} 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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
<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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
<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 rounded-[32px] md:rounded-[50px] lg:rounded-[70px]"
|
className={`w-[380px] h-auto max-h-[560px] object-cover ${cardRadius}`}
|
||||||
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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
<div className="max-w-[800px]">{formContent}</div>
|
<div className="max-w-[800px]">{formContent}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
@ -59,7 +60,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
<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'}`}>
|
||||||
|
|||||||
@ -4,8 +4,7 @@ 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'
|
||||||
|
|
||||||
/* Priority #5: Responsive radius */
|
import { fdCardRadius as imageRadius, fdContainer, fdSepiaOverlay} from '@/utilities/fdTheme'
|
||||||
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> = {
|
||||||
@ -14,7 +13,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: 'bg-[#8B7D3C]/30',
|
sepia: fdSepiaOverlay,
|
||||||
blackLight: 'bg-black/20',
|
blackLight: 'bg-black/20',
|
||||||
blackMedium: 'bg-black/40',
|
blackMedium: 'bg-black/40',
|
||||||
}
|
}
|
||||||
@ -56,7 +55,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="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">
|
<div className={`${fdContainer} 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}`}>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'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[]
|
||||||
@ -168,7 +169,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
|
|
||||||
{(heading || description) && (
|
{(heading || description) && (
|
||||||
<div className="mb-10 md:mb-12">
|
<div className="mb-10 md:mb-12">
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
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,
|
||||||
@ -39,7 +40,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="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col items-start gap-6">
|
<div className={`${fdContainer} 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>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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,
|
||||||
@ -33,7 +34,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="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col items-center gap-8">
|
<div className={`${fdContainer} 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}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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',
|
||||||
@ -26,7 +27,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: 'bg-[#8B7D3C]/30',
|
sepia: fdSepiaOverlay,
|
||||||
blackLight: 'bg-black/20',
|
blackLight: 'bg-black/20',
|
||||||
blackMedium: 'bg-black/40',
|
blackMedium: 'bg-black/40',
|
||||||
}
|
}
|
||||||
@ -34,8 +35,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: 'rounded-[20px] md:rounded-[32px] lg:rounded-[40px]',
|
medium: fdCardRadiusSm,
|
||||||
large: 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]',
|
large: fdCardRadius,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FDHeaderTextImageBlockComponent: React.FC<FDHeaderTextImageBlockProps> = ({
|
export const FDHeaderTextImageBlockComponent: React.FC<FDHeaderTextImageBlockProps> = ({
|
||||||
@ -59,7 +60,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="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col gap-8 md:gap-10">
|
<div className={`${fdContainer} 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 && (
|
||||||
|
|||||||
@ -3,6 +3,14 @@ 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,
|
||||||
@ -12,8 +20,10 @@ export const FDHeroBlockComponent: React.FC<FDHeroBlockProps> = ({
|
|||||||
secondaryCtaText,
|
secondaryCtaText,
|
||||||
secondaryCtaLink = '#',
|
secondaryCtaLink = '#',
|
||||||
backgroundImage,
|
backgroundImage,
|
||||||
|
minHeight = 'auto',
|
||||||
overlayOpacity = '50',
|
overlayOpacity = '50',
|
||||||
textColor = 'auto',
|
headingColor: headingColorProp = 'auto',
|
||||||
|
bodyColor: bodyColorProp = 'auto',
|
||||||
theme = 'light',
|
theme = 'light',
|
||||||
anchorId,
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
@ -21,29 +31,36 @@ 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'
|
||||||
|
|
||||||
let headingColor: string
|
const headingColorMap: Record<string, string> = {
|
||||||
let textBodyColor: string
|
auto: isDark ? 'text-fd-yellow' : 'text-fd-navy dark:text-fd-yellow',
|
||||||
if (textColor === 'white') {
|
yellow: 'text-fd-yellow',
|
||||||
headingColor = 'text-white'
|
white: 'text-white',
|
||||||
textBodyColor = 'text-white'
|
navy: 'text-fd-navy',
|
||||||
} 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 = textColor === 'navy' ? false : isDark
|
const secondaryOnDark = 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 && (
|
||||||
@ -55,7 +72,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=""
|
fallbackAlt={heading || ''}
|
||||||
/>
|
/>
|
||||||
<div className={`absolute inset-0 ${overlayClass}`} aria-hidden="true" />
|
<div className={`absolute inset-0 ${overlayClass}`} aria-hidden="true" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -71,6 +71,23 @@ 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',
|
||||||
@ -87,14 +104,27 @@ export const FDHeroBlock: Block = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'textColor',
|
name: 'headingColor',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
label: 'Textfärg',
|
label: 'Rubrikfärg',
|
||||||
defaultValue: 'auto',
|
defaultValue: 'auto',
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Automatisk (baserat på tema)', value: 'auto' },
|
{ label: 'Automatisk (gul på mörkt, navy på ljust)', value: 'auto' },
|
||||||
|
{ label: 'Gul', value: 'yellow' },
|
||||||
{ label: 'Vit', value: 'white' },
|
{ label: 'Vit', value: 'white' },
|
||||||
{ label: 'Blå (navy)', value: 'navy' },
|
{ label: '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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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',
|
||||||
@ -42,7 +43,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
{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}
|
||||||
|
|||||||
134
src/blocks/FDLinkCardsBlock/Component.tsx
Normal file
134
src/blocks/FDLinkCardsBlock/Component.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
src/blocks/FDLinkCardsBlock/config.ts
Normal file
108
src/blocks/FDLinkCardsBlock/config.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
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,
|
||||||
|
],
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
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,
|
||||||
@ -33,7 +35,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
|
|
||||||
{(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">
|
||||||
@ -59,15 +61,18 @@ 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 rounded-[32px] md:rounded-[50px] lg:rounded-[70px] aspect-[16/9] sm:aspect-[4/3] block ${isLink ? 'cursor-pointer' : ''}`
|
const className = `group relative overflow-hidden ${fdCardRadius} aspect-[16/9] sm:aspect-[4/3] block ${isLink ? 'cursor-pointer' : ''}`
|
||||||
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<>
|
<>
|
||||||
{media?.url && (
|
{media?.url && (
|
||||||
<img
|
<FDImage
|
||||||
src={media.url}
|
media={media}
|
||||||
alt={(media as any).alt || card.locationName}
|
size="large"
|
||||||
className="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
fill
|
||||||
|
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" />
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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> = {
|
||||||
@ -12,7 +13,6 @@ 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',
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
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,
|
||||||
@ -24,7 +26,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
{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}
|
||||||
@ -38,10 +40,12 @@ export const FDPartnersLogosBlockComponent: React.FC<FDPartnersLogosBlockProps>
|
|||||||
if (!media?.url) return null
|
if (!media?.url) return null
|
||||||
|
|
||||||
const logoEl = (
|
const logoEl = (
|
||||||
<img
|
<FDImage
|
||||||
src={media.url}
|
media={media}
|
||||||
alt={item.alt || ''}
|
size="medium"
|
||||||
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 || ''}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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',
|
||||||
@ -29,7 +30,7 @@ const cardStyleMap: Record<string, {
|
|||||||
isDark: false,
|
isDark: false,
|
||||||
},
|
},
|
||||||
navy: {
|
navy: {
|
||||||
bg: 'bg-fd-navy', border: '', title: 'text-fd-yellow',
|
bg: 'bg-fd-navy dark:bg-white/10', 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: {
|
||||||
@ -69,7 +70,6 @@ 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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
{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}
|
||||||
|
|||||||
355
src/blocks/FDQuizBlock/Component.tsx
Normal file
355
src/blocks/FDQuizBlock/Component.tsx
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
src/blocks/FDQuizBlock/config.ts
Normal file
199
src/blocks/FDQuizBlock/config.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
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,
|
||||||
|
],
|
||||||
|
}
|
||||||
@ -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-[#e2e8f0] dark:bg-white/20'}`}
|
className={`relative rounded-full transition-all duration-300 ${active ? 'bg-fd-yellow' : 'bg-fd-gray-light 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-[#e2e8f0] ${cardRadius} dark:bg-white/5 dark:border-white/10`
|
: `bg-white border-[5px] border-fd-gray-light ${cardRadius} dark:bg-white/5 dark:border-white/10`
|
||||||
|
|
||||||
const headingColor = isDark ? 'text-white' : 'text-fd-navy dark:text-white'
|
const 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'
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
'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,
|
||||||
@ -53,7 +51,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
|
|
||||||
<div className="text-center mb-10 md:mb-12">
|
<div className="text-center mb-10 md:mb-12">
|
||||||
{heading && (
|
{heading && (
|
||||||
|
|||||||
@ -9,8 +9,7 @@ const columnClasses: Record<string, string> = {
|
|||||||
'4': 'grid-cols-2 min-[820px]:grid-cols-4',
|
'4': 'grid-cols-2 min-[820px]:grid-cols-4',
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Priority #5: Responsive radius for service images */
|
import { fdCardRadius as imageRadius, fdContainer} from '@/utilities/fdTheme'
|
||||||
const imageRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
|
|
||||||
|
|
||||||
export const FDServicesGridBlockComponent: React.FC<FDServicesGridBlockProps> = ({
|
export const FDServicesGridBlockComponent: React.FC<FDServicesGridBlockProps> = ({
|
||||||
heading,
|
heading,
|
||||||
@ -20,7 +19,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
<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>
|
||||||
|
|||||||
190
src/blocks/FDSpecCardsBlock/Component.tsx
Normal file
190
src/blocks/FDSpecCardsBlock/Component.tsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
src/blocks/FDSpecCardsBlock/config.ts
Normal file
125
src/blocks/FDSpecCardsBlock/config.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
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,
|
||||||
|
],
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
'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,
|
||||||
@ -63,7 +64,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="max-w-[1200px] mx-auto px-6 md:px-8 text-center">
|
<div className={`${fdContainer} 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}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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: '' },
|
||||||
@ -42,7 +43,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
{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} ${
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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',
|
||||||
@ -10,7 +11,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',
|
bg: 'bg-fd-navy dark:bg-white/10',
|
||||||
name: 'text-fd-yellow',
|
name: 'text-fd-yellow',
|
||||||
role: 'text-white/70',
|
role: 'text-white/70',
|
||||||
bio: 'text-white/60',
|
bio: 'text-white/60',
|
||||||
@ -39,7 +40,6 @@ 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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
|
|
||||||
{(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' : '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 dark:bg-white/5' : '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" />
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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',
|
||||||
@ -37,7 +38,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
<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">
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@ -32,7 +33,6 @@ 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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
|
|
||||||
{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}`}>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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',
|
||||||
@ -54,7 +55,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
<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>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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',
|
||||||
@ -18,18 +19,29 @@ 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: '#0E2338', check: 'white' },
|
navy: { circle: 'fill-fd-navy dark:fill-white/20', check: 'fill-white dark:fill-fd-yellow' },
|
||||||
yellow: { circle: '#FECC02', check: '#0E2338' },
|
yellow: { circle: 'fill-fd-yellow', check: 'fill-fd-navy' },
|
||||||
gray: { circle: '#F0F0F0', check: '#0E2338' },
|
gray: { circle: 'fill-fd-gray-light dark:fill-white/20', check: 'fill-fd-navy dark:fill-white' },
|
||||||
}
|
}
|
||||||
|
|
||||||
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" fill={c.circle} />
|
<circle cx="23" cy="23" r="23" className={c.circle} />
|
||||||
<path d="M34.2166 16.2092L19.8151 30.6108C19.3551 31.0354 19.1074 31.3185 18.7182 31.3185C18.3643 31.3185 18.1166 31.1062 17.6212 30.6108L11.9597 24.9492C11.6059 24.5954 11.4289 24.3477 11.4289 24.1C11.4289 23.8523 11.6412 23.5692 11.9951 23.2154L12.5612 22.6492C12.9505 22.26 13.2335 22.0477 13.5166 22.0477C13.7643 22.0477 14.012 22.2246 14.3659 22.5785L18.7182 26.9662L31.8105 13.8385C32.1643 13.4846 32.412 13.3431 32.6597 13.3431C32.9428 13.3431 33.1551 13.4846 33.6151 13.9092L34.1812 14.4754C34.5351 14.8292 34.712 15.1123 34.712 15.36C34.712 15.6077 34.5705 15.8554 34.2166 16.2092Z" fill={c.check} />
|
<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} />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -38,6 +50,7 @@ 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',
|
||||||
@ -49,6 +62,7 @@ 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">
|
||||||
@ -65,20 +79,21 @@ export const FDUspChecklistBlockComponent: React.FC<FDUspChecklistBlockProps> =
|
|||||||
)
|
)
|
||||||
|
|
||||||
const imageContent = hasImage ? (
|
const imageContent = hasImage ? (
|
||||||
<div className="flex-1">
|
<div className="flex-1 relative overflow-hidden rounded-[40px]">
|
||||||
<FDImage
|
<FDImage
|
||||||
media={media!}
|
media={media!}
|
||||||
size="large"
|
size="large"
|
||||||
className="w-full h-auto rounded-[40px] object-cover"
|
className="w-full h-auto 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="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">
|
<div className={`${fdContainer} 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>
|
||||||
|
|||||||
@ -39,6 +39,25 @@ 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',
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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',
|
||||||
@ -31,17 +32,17 @@ const borderMap: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkColors: Record<string, { circle: string; check: string }> = {
|
const checkColors: Record<string, { circle: string; check: string }> = {
|
||||||
navy: { circle: '#0E2338', check: 'white' },
|
navy: { circle: 'fill-fd-navy dark:fill-white/20', check: 'fill-white dark:fill-fd-yellow' },
|
||||||
yellow: { circle: '#FECC02', check: '#0E2338' },
|
yellow: { circle: 'fill-fd-yellow', check: 'fill-fd-navy' },
|
||||||
gray: { circle: '#F0F0F0', check: '#0E2338' },
|
gray: { circle: 'fill-fd-gray-light dark:fill-white/20', check: 'fill-fd-navy dark:fill-white' },
|
||||||
}
|
}
|
||||||
|
|
||||||
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" fill={c.circle} />
|
<circle cx="23" cy="23" r="23" className={c.circle} />
|
||||||
<path d="M34.2166 16.2092L19.8151 30.6108C19.3551 31.0354 19.1074 31.3185 18.7182 31.3185C18.3643 31.3185 18.1166 31.1062 17.6212 30.6108L11.9597 24.9492C11.6059 24.5954 11.4289 24.3477 11.4289 24.1C11.4289 23.8523 11.6412 23.5692 11.9951 23.2154L12.5612 22.6492C12.9505 22.26 13.2335 22.0477 13.5166 22.0477C13.7643 22.0477 14.012 22.2246 14.3659 22.5785L18.7182 26.9662L31.8105 13.8385C32.1643 13.4846 32.412 13.3431 32.6597 13.3431C32.9428 13.3431 33.1551 13.4846 33.6151 13.9092L34.1812 14.4754C34.5351 14.8292 34.712 15.1123 34.712 15.36C34.712 15.6077 34.5705 15.8554 34.2166 16.2092Z" fill={c.check} />
|
<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} />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -62,7 +63,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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -41,8 +41,7 @@ function extractVimeoId(url: string): string | null {
|
|||||||
return match ? match[1] : null
|
return match ? match[1] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Priority #5: Responsive video radius */
|
import { fdCardRadius as videoRadius } from '@/utilities/fdTheme'
|
||||||
const videoRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
|
|
||||||
|
|
||||||
export const FDVideoBlockComponent: React.FC<FDVideoBlockProps> = ({
|
export const FDVideoBlockComponent: React.FC<FDVideoBlockProps> = ({
|
||||||
heading,
|
heading,
|
||||||
|
|||||||
159
src/blocks/FDVideoHeroBlock/Component.tsx
Normal file
159
src/blocks/FDVideoHeroBlock/Component.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/blocks/FDVideoHeroBlock/config.ts
Normal file
157
src/blocks/FDVideoHeroBlock/config.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
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,
|
||||||
|
],
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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,
|
||||||
@ -20,7 +21,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-[#e2e8f0] dark:bg-white/20'}`}
|
className={`relative rounded-full transition-all duration-300 ${active ? 'bg-fd-yellow' : 'bg-fd-gray-light dark:bg-white/20'}`}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={active}
|
aria-checked={active}
|
||||||
>
|
>
|
||||||
@ -121,8 +122,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 rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
|
? `bg-white/5 border-[5px] border-white/10 ${fdCardRadius}`
|
||||||
: 'bg-white border-[5px] border-[#e2e8f0] rounded-[32px] md:rounded-[50px] lg:rounded-[70px] dark:bg-white/5 dark:border-white/10'
|
: `bg-white border-[5px] border-fd-gray-light ${fdCardRadius} 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'
|
||||||
|
|||||||
@ -2,9 +2,10 @@ 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', heading: 'text-white', body: 'text-white/80', isDark: true },
|
navy: { bg: 'bg-fd-navy dark:bg-white/10', 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 },
|
||||||
@ -22,7 +23,17 @@ 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,
|
||||||
@ -30,6 +41,7 @@ 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',
|
||||||
@ -40,10 +52,11 @@ 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="max-w-[1200px] mx-auto px-6 md:px-8">
|
<div className={fdContainer}>
|
||||||
<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}`}>
|
||||||
@ -65,13 +78,16 @@ 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">
|
||||||
<FDImage
|
<div className="relative w-full">
|
||||||
media={media}
|
<FDImage
|
||||||
size="large"
|
media={media}
|
||||||
className="w-full h-auto max-h-[320px] object-contain drop-shadow-xl"
|
size="large"
|
||||||
sizes="(max-width: 820px) 80vw, 400px"
|
className="w-full h-auto max-h-[320px] object-contain drop-shadow-xl"
|
||||||
fallbackAlt={heading || ''}
|
sizes="(max-width: 820px) 80vw, 400px"
|
||||||
/>
|
fallbackAlt={heading || ''}
|
||||||
|
/>
|
||||||
|
{overlay && <div className={`absolute inset-0 ${overlay}`} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -42,6 +42,25 @@ 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',
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,982 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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' },
|
|
||||||
]
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@ 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'
|
||||||
@ -35,9 +34,13 @@ 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,
|
||||||
@ -70,6 +73,10 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -38,6 +38,11 @@ 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,
|
||||||
@ -134,6 +139,10 @@ export const Pages: CollectionConfig<'pages'> = {
|
|||||||
FDCtaBannerBlock,
|
FDCtaBannerBlock,
|
||||||
FDTestimonialBlock,
|
FDTestimonialBlock,
|
||||||
FDTeamBlock,
|
FDTeamBlock,
|
||||||
|
FDLinkCardsBlock,
|
||||||
|
FDSpecCardsBlock,
|
||||||
|
FDQuizBlock,
|
||||||
|
FDVideoHeroBlock,
|
||||||
],
|
],
|
||||||
required: true,
|
required: true,
|
||||||
admin: {
|
admin: {
|
||||||
|
|||||||
@ -18,6 +18,8 @@ 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> = ({
|
||||||
@ -28,6 +30,7 @@ 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
|
||||||
@ -47,7 +50,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" />
|
<img src={src} alt={alt} className={className} loading="lazy" aria-hidden={ariaHidden} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,6 +63,7 @@ export const FDImage: React.FC<FDImageProps> = ({
|
|||||||
className={className}
|
className={className}
|
||||||
priority={priority}
|
priority={priority}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
|
aria-hidden={ariaHidden}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -78,6 +82,7 @@ export const FDImage: React.FC<FDImageProps> = ({
|
|||||||
className={className}
|
className={className}
|
||||||
priority={priority}
|
priority={priority}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
|
aria-hidden={ariaHidden}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
37725
src/migrations/20260304_194657.json
Normal file
37725
src/migrations/20260304_194657.json
Normal file
File diff suppressed because it is too large
Load Diff
461
src/migrations/20260304_194657.ts
Normal file
461
src/migrations/20260304_194657.ts
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
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";`)
|
||||||
|
}
|
||||||
38509
src/migrations/20260304_203759.json
Normal file
38509
src/migrations/20260304_203759.json
Normal file
File diff suppressed because it is too large
Load Diff
112
src/migrations/20260304_203759.ts
Normal file
112
src/migrations/20260304_203759.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
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";`)
|
||||||
|
}
|
||||||
38641
src/migrations/20260313_081702.json
Normal file
38641
src/migrations/20260313_081702.json
Normal file
File diff suppressed because it is too large
Load Diff
33
src/migrations/20260313_081702.ts
Normal file
33
src/migrations/20260313_081702.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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";`)
|
||||||
|
}
|
||||||
38717
src/migrations/20260313_085922.json
Normal file
38717
src/migrations/20260313_085922.json
Normal file
File diff suppressed because it is too large
Load Diff
41
src/migrations/20260313_085922.ts
Normal file
41
src/migrations/20260313_085922.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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";`)
|
||||||
|
}
|
||||||
@ -2,6 +2,10 @@ 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 = [
|
||||||
{
|
{
|
||||||
@ -22,6 +26,26 @@ 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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -188,6 +188,10 @@ export interface Page {
|
|||||||
| FDCtaBannerBlock
|
| FDCtaBannerBlock
|
||||||
| FDTestimonialBlock
|
| FDTestimonialBlock
|
||||||
| FDTeamBlock
|
| FDTeamBlock
|
||||||
|
| FDLinkCardsBlock
|
||||||
|
| FDSpecCardsBlock
|
||||||
|
| FDQuizBlock
|
||||||
|
| FDVideoHeroBlock
|
||||||
)[];
|
)[];
|
||||||
meta?: {
|
meta?: {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@ -226,11 +230,16 @@ 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;
|
||||||
textColor?: ('auto' | 'white' | 'navy') | null;
|
headingColor?: ('auto' | 'yellow' | 'white' | 'navy') | null;
|
||||||
|
bodyColor?: ('auto' | 'white' | 'navy' | 'yellow') | null;
|
||||||
/**
|
/**
|
||||||
* Ignoreras om bakgrundsbild är vald
|
* Ignoreras om bakgrundsbild är vald
|
||||||
*/
|
*/
|
||||||
@ -555,6 +564,9 @@ 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;
|
||||||
@ -577,6 +589,9 @@ 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;
|
||||||
@ -938,6 +953,9 @@ 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.
|
||||||
@ -1506,6 +1524,170 @@ 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 på 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 på 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".
|
||||||
@ -1886,6 +2068,10 @@ 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
|
||||||
@ -1914,8 +2100,10 @@ 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;
|
||||||
textColor?: T;
|
headingColor?: T;
|
||||||
|
bodyColor?: T;
|
||||||
theme?: T;
|
theme?: T;
|
||||||
anchorId?: T;
|
anchorId?: T;
|
||||||
id?: T;
|
id?: T;
|
||||||
@ -2110,6 +2298,7 @@ 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;
|
||||||
@ -2128,6 +2317,7 @@ 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;
|
||||||
@ -2253,6 +2443,7 @@ 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;
|
||||||
@ -2612,6 +2803,121 @@ 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".
|
||||||
|
|||||||
@ -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-[#153350] to-fd-navy',
|
navyGradient: 'bg-gradient-to-br from-fd-navy via-fd-navy-700 to-fd-navy',
|
||||||
transparent: 'bg-transparent',
|
transparent: 'bg-transparent',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,3 +74,13 @@ 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'
|
||||||
|
|||||||
@ -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 + ' | Payload Website Template'
|
? doc?.meta?.title + ' | Fiber Direkt'
|
||||||
: 'Payload Website Template'
|
: 'Fiber Direkt'
|
||||||
|
|
||||||
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
Loading…
Reference in New Issue
Block a user