forked from cms/tibi-svelte-starter
✨ feat: add new contact form, hero, features, and richtext blocks; implement scroll-reveal action and update styles
- Introduced ContactFormBlock, FeaturesBlock, HeroBlock, and RichtextBlock components. - Implemented a scroll-reveal action for animations on element visibility. - Enhanced CSS styles for better theming and prose formatting. - Added localization support for new components and updated existing translations. - Created e2e tests for demo pages including contact form validation and navigation. - Added a video tour showcasing the demo pages and interactions.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from "../lib/actions/reveal"
|
||||
import { untrack } from "svelte"
|
||||
|
||||
let { block }: { block: ContentBlockEntry } = $props()
|
||||
|
||||
const paddingTop = $derived(
|
||||
block.padding?.top === "lg"
|
||||
? "pt-20"
|
||||
: block.padding?.top === "md"
|
||||
? "pt-12"
|
||||
: block.padding?.top === "sm"
|
||||
? "pt-8"
|
||||
: "pt-4"
|
||||
)
|
||||
const paddingBottom = $derived(
|
||||
block.padding?.bottom === "lg"
|
||||
? "pb-20"
|
||||
: block.padding?.bottom === "md"
|
||||
? "pb-12"
|
||||
: block.padding?.bottom === "sm"
|
||||
? "pb-8"
|
||||
: "pb-4"
|
||||
)
|
||||
|
||||
// Track open/closed state per item — intentionally capture initial prop value only
|
||||
let openItems = $state<boolean[]>(untrack(() => (block.accordionItems || []).map((item) => !!item.open)))
|
||||
|
||||
function toggle(index: number) {
|
||||
openItems = openItems.map((open, i) => (i === index ? !open : open))
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
data-block="accordion"
|
||||
class="accordion-section {paddingTop} {paddingBottom} bg-gray-50"
|
||||
id={block.anchorId || undefined}
|
||||
>
|
||||
<div class="max-w-3xl mx-auto px-6">
|
||||
{#if block.tagline}
|
||||
<div use:reveal>
|
||||
<span class="inline-block text-brand-500 text-sm font-semibold tracking-widest uppercase mb-3">
|
||||
{block.tagline}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if block.headline}
|
||||
<div use:reveal={{ delay: 100 }}>
|
||||
<h2 class="text-3xl sm:text-4xl font-display font-bold text-gray-900 mb-10">
|
||||
{block.headline}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each block.accordionItems || [] as item, i}
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"
|
||||
use:reveal={{ delay: 150 + i * 80 }}
|
||||
>
|
||||
<button
|
||||
class="w-full text-left px-6 py-5 flex items-center justify-between gap-4 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onclick={() => toggle(i)}
|
||||
aria-expanded={openItems[i]}
|
||||
>
|
||||
<span class="font-semibold text-gray-900 text-lg">
|
||||
{item.question}
|
||||
</span>
|
||||
<svg
|
||||
class="w-5 h-5 text-brand-500 shrink-0 transition-transform duration-300"
|
||||
class:rotate-180={openItems[i]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if openItems[i]}
|
||||
<div class="px-6 pb-5 prose max-w-none text-gray-600 border-t border-gray-50">
|
||||
{@html item.answer || ""}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* BlockRenderer — renders ContentBlockEntry[] by type.
|
||||
*
|
||||
* Each block type maps to a dedicated Svelte component.
|
||||
* Unknown types are silently skipped (or shown in dev mode).
|
||||
* Easy to extend: add a new component import + case.
|
||||
*
|
||||
* DEMO: This file is part of the demo showcase.
|
||||
* For real projects, adjust block types and components as needed.
|
||||
*/
|
||||
import HeroBlock from "./HeroBlock.svelte"
|
||||
import FeaturesBlock from "./FeaturesBlock.svelte"
|
||||
import RichtextBlock from "./RichtextBlock.svelte"
|
||||
import AccordionBlock from "./AccordionBlock.svelte"
|
||||
import ContactFormBlock from "./ContactFormBlock.svelte"
|
||||
|
||||
let { blocks = [] }: { blocks: ContentBlockEntry[] } = $props()
|
||||
</script>
|
||||
|
||||
{#each blocks as block, i (i)}
|
||||
{#if !block.hide}
|
||||
{#if block.type === "hero"}
|
||||
<HeroBlock {block} />
|
||||
{:else if block.type === "features"}
|
||||
<FeaturesBlock {block} />
|
||||
{:else if block.type === "richtext"}
|
||||
<RichtextBlock {block} />
|
||||
{:else if block.type === "accordion"}
|
||||
<AccordionBlock {block} />
|
||||
{:else if block.type === "contact-form"}
|
||||
<ContactFormBlock {block} />
|
||||
{:else}
|
||||
<!-- Unknown block type: {block.type} -->
|
||||
{#if typeof window !== "undefined" && window.location?.hostname === "localhost"}
|
||||
<div
|
||||
class="max-w-6xl mx-auto px-6 py-4 bg-yellow-50 border border-yellow-200 rounded-lg my-4 text-sm text-yellow-800"
|
||||
>
|
||||
Unknown block type: <code>{block.type}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "../lib/i18n/index"
|
||||
import { reveal } from "../lib/actions/reveal"
|
||||
import { addToast } from "../lib/toast"
|
||||
import Form from "../widgets/Form.svelte"
|
||||
import Input from "../widgets/Input.svelte"
|
||||
import Select from "../widgets/Select.svelte"
|
||||
import Button from "../widgets/Button.svelte"
|
||||
|
||||
let { block }: { block: ContentBlockEntry } = $props()
|
||||
|
||||
const paddingTop = $derived(
|
||||
block.padding?.top === "lg"
|
||||
? "pt-20"
|
||||
: block.padding?.top === "md"
|
||||
? "pt-12"
|
||||
: block.padding?.top === "sm"
|
||||
? "pt-8"
|
||||
: "pt-4"
|
||||
)
|
||||
const paddingBottom = $derived(
|
||||
block.padding?.bottom === "lg"
|
||||
? "pb-20"
|
||||
: block.padding?.bottom === "md"
|
||||
? "pb-12"
|
||||
: block.padding?.bottom === "sm"
|
||||
? "pb-8"
|
||||
: "pb-4"
|
||||
)
|
||||
|
||||
let name = $state("")
|
||||
let email = $state("")
|
||||
let subject = $state("")
|
||||
let message = $state("")
|
||||
let sending = $state(false)
|
||||
let formRef = $state<{ validate: () => Promise<boolean> } | null>(null)
|
||||
|
||||
const subjectOptions = $derived([
|
||||
{ value: "", label: $_("form.selectSubject") },
|
||||
{ value: "general", label: $_("form.subjects.general") },
|
||||
{ value: "project", label: $_("form.subjects.project") },
|
||||
{ value: "support", label: $_("form.subjects.support") },
|
||||
{ value: "feedback", label: $_("form.subjects.feedback") },
|
||||
])
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
if (!formRef) return
|
||||
|
||||
const isValid = await formRef.validate()
|
||||
if (!isValid) return
|
||||
|
||||
sending = true
|
||||
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
|
||||
addToast($_("form.success"), "success", 5000)
|
||||
|
||||
// Reset form
|
||||
name = ""
|
||||
email = ""
|
||||
subject = ""
|
||||
message = ""
|
||||
sending = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
data-block="contact-form"
|
||||
class="contact-form-section {paddingTop} {paddingBottom}"
|
||||
id={block.anchorId || undefined}
|
||||
>
|
||||
<div class="max-w-2xl mx-auto px-6">
|
||||
{#if block.headline}
|
||||
<div use:reveal>
|
||||
<h2 class="text-3xl font-display font-bold text-gray-900 mb-8">
|
||||
{block.headline}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div use:reveal={{ delay: 150 }}>
|
||||
<Form bind:this={formRef} onsubmit={handleSubmit} class="space-y-6">
|
||||
<div class="grid sm:grid-cols-2 gap-6">
|
||||
<Input
|
||||
label={$_("form.name")}
|
||||
hideLabel={false}
|
||||
bind:value={name}
|
||||
placeholder={$_("form.name")}
|
||||
required
|
||||
name="name"
|
||||
messages={{ valueMissing: $_("form.validation.required") }}
|
||||
/>
|
||||
<Input
|
||||
label={$_("form.email")}
|
||||
hideLabel={false}
|
||||
bind:value={email}
|
||||
placeholder={$_("form.email")}
|
||||
type="email"
|
||||
required
|
||||
name="email"
|
||||
messages={{
|
||||
valueMissing: $_("form.validation.required"),
|
||||
typeMismatch: $_("form.validation.email"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label={$_("form.subject")}
|
||||
hideLabel={false}
|
||||
bind:value={subject}
|
||||
options={subjectOptions}
|
||||
required
|
||||
name="subject"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label for="contact-message" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{$_("form.message")}
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
bind:value={message}
|
||||
rows="5"
|
||||
required
|
||||
name="message"
|
||||
placeholder={$_("form.message")}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none transition-colors resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
text={sending ? $_("loading") : $_("form.send")}
|
||||
disabled={sending}
|
||||
class="bg-brand-600! hover:bg-brand-700! rounded-xl!"
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from "../lib/actions/reveal"
|
||||
|
||||
let { block }: { block: ContentBlockEntry } = $props()
|
||||
|
||||
const paddingTop = $derived(
|
||||
block.padding?.top === "lg"
|
||||
? "pt-20"
|
||||
: block.padding?.top === "md"
|
||||
? "pt-12"
|
||||
: block.padding?.top === "sm"
|
||||
? "pt-8"
|
||||
: "pt-4"
|
||||
)
|
||||
const paddingBottom = $derived(
|
||||
block.padding?.bottom === "lg"
|
||||
? "pb-20"
|
||||
: block.padding?.bottom === "md"
|
||||
? "pb-12"
|
||||
: block.padding?.bottom === "sm"
|
||||
? "pb-8"
|
||||
: "pb-4"
|
||||
)
|
||||
</script>
|
||||
|
||||
<section data-block="features" class="features-section {paddingTop} {paddingBottom}" id={block.anchorId || undefined}>
|
||||
<div class="max-w-6xl mx-auto px-6">
|
||||
{#if block.tagline}
|
||||
<div use:reveal>
|
||||
<span class="inline-block text-brand-500 text-sm font-semibold tracking-widest uppercase mb-3">
|
||||
{block.tagline}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if block.headline}
|
||||
<div use:reveal={{ delay: 100 }}>
|
||||
<h2 class="text-3xl sm:text-4xl font-display font-bold text-gray-900 mb-12">
|
||||
{block.headline}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if block.text}
|
||||
<div class="prose max-w-none" use:reveal={{ delay: 200 }}>
|
||||
{@html block.text}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from "../lib/actions/reveal"
|
||||
|
||||
let { block }: { block: ContentBlockEntry } = $props()
|
||||
|
||||
const hasImage = $derived(block.heroImage?.externalUrl || block.heroImage?.image)
|
||||
</script>
|
||||
|
||||
<section
|
||||
data-block="hero"
|
||||
class="hero-section relative flex items-center justify-center overflow-hidden"
|
||||
class:min-h-[70vh]={block.containerWidth === "full"}
|
||||
class:min-h-[50vh]={block.containerWidth !== "full"}
|
||||
>
|
||||
<!-- Background image -->
|
||||
{#if hasImage}
|
||||
<div class="absolute inset-0 z-0">
|
||||
{#if block.heroImage?.externalUrl}
|
||||
<img src={block.heroImage.externalUrl} alt="" class="w-full h-full object-cover" loading="lazy" />
|
||||
{/if}
|
||||
<div class="absolute inset-0 bg-linear-to-b from-brand-950/80 via-brand-900/70 to-brand-950/90"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="absolute inset-0 bg-linear-to-br from-brand-900 via-brand-800 to-brand-950"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 max-w-4xl mx-auto px-6 py-20 text-center" use:reveal>
|
||||
{#if block.tagline}
|
||||
<span class="inline-block text-brand-300 text-sm font-semibold tracking-widest uppercase mb-4">
|
||||
{block.tagline}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if block.headlineH1}
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-display font-extrabold text-white leading-tight mb-6">
|
||||
{block.headline}
|
||||
</h1>
|
||||
{:else}
|
||||
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-display font-bold text-white leading-tight mb-6">
|
||||
{block.headline}
|
||||
</h2>
|
||||
{/if}
|
||||
|
||||
{#if block.subline}
|
||||
<p class="text-lg sm:text-xl text-brand-200 max-w-2xl mx-auto mb-8 leading-relaxed">
|
||||
{block.subline}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if block.callToAction?.buttonText}
|
||||
<div class="flex justify-center gap-4">
|
||||
<a
|
||||
href={block.callToAction.buttonLink || "#"}
|
||||
target={block.callToAction.buttonTarget || undefined}
|
||||
class="inline-flex items-center gap-2 bg-brand-500 hover:bg-brand-400 text-white font-bold px-8 py-4 rounded-xl text-lg transition-all duration-300 hover:shadow-lg hover:shadow-brand-500/25 hover:-translate-y-0.5"
|
||||
>
|
||||
{block.callToAction.buttonText}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Decorative bottom wave -->
|
||||
<div class="absolute bottom-0 left-0 right-0 z-10">
|
||||
<svg viewBox="0 0 1440 80" fill="none" class="w-full h-auto">
|
||||
<path d="M0 80V40C240 10 480 0 720 20C960 40 1200 50 1440 30V80H0Z" fill="white"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { _ } from "../lib/i18n/index"
|
||||
import { localizedPath } from "../lib/i18n"
|
||||
import { reveal } from "../lib/actions/reveal"
|
||||
</script>
|
||||
|
||||
<section class="min-h-[60vh] flex items-center justify-center bg-gray-50">
|
||||
<div class="text-center px-6 py-20" use:reveal>
|
||||
<div class="text-8xl mb-6 opacity-20 font-display font-black text-brand-900">404</div>
|
||||
<h1 class="text-3xl sm:text-4xl font-display font-bold text-gray-900 mb-4">
|
||||
{$_("page.notFound.title")}
|
||||
</h1>
|
||||
<p class="text-lg text-gray-500 mb-8 max-w-md mx-auto">
|
||||
{$_("page.notFound.text")}
|
||||
</p>
|
||||
<a
|
||||
href={localizedPath("/")}
|
||||
class="inline-flex items-center gap-2 bg-brand-600 hover:bg-brand-700 text-white font-bold px-8 py-4 rounded-xl text-lg transition-all duration-300 hover:shadow-lg hover:shadow-brand-500/25 hover:-translate-y-0.5"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
></path>
|
||||
</svg>
|
||||
{$_("page.notFound.backHome")}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from "../lib/actions/reveal"
|
||||
|
||||
let { block }: { block: ContentBlockEntry } = $props()
|
||||
|
||||
const paddingTop = $derived(
|
||||
block.padding?.top === "lg"
|
||||
? "pt-20"
|
||||
: block.padding?.top === "md"
|
||||
? "pt-12"
|
||||
: block.padding?.top === "sm"
|
||||
? "pt-8"
|
||||
: "pt-4"
|
||||
)
|
||||
const paddingBottom = $derived(
|
||||
block.padding?.bottom === "lg"
|
||||
? "pb-20"
|
||||
: block.padding?.bottom === "md"
|
||||
? "pb-12"
|
||||
: block.padding?.bottom === "sm"
|
||||
? "pb-8"
|
||||
: "pb-4"
|
||||
)
|
||||
|
||||
const hasImage = $derived(block.externalImageUrl || block.image)
|
||||
const imageOnRight = $derived(block.imagePosition === "right")
|
||||
const imageOnLeft = $derived(block.imagePosition === "left")
|
||||
const showImage = $derived(hasImage && (imageOnRight || imageOnLeft))
|
||||
</script>
|
||||
|
||||
<section data-block="richtext" class="richtext-section {paddingTop} {paddingBottom}" id={block.anchorId || undefined}>
|
||||
<div class="max-w-6xl mx-auto px-6">
|
||||
{#if block.tagline}
|
||||
<div use:reveal>
|
||||
<span class="inline-block text-brand-500 text-sm font-semibold tracking-widest uppercase mb-3">
|
||||
{block.tagline}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if block.headline}
|
||||
<div use:reveal={{ delay: 100 }}>
|
||||
<h2 class="text-3xl sm:text-4xl font-display font-bold text-gray-900 mb-8">
|
||||
{block.headline}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showImage}
|
||||
<!-- Layout with image -->
|
||||
<div class="grid md:grid-cols-2 gap-12 items-center" use:reveal={{ delay: 200 }}>
|
||||
<div class:order-2={imageOnLeft} class="prose max-w-none">
|
||||
{@html block.text || ""}
|
||||
</div>
|
||||
<div class:order-1={imageOnLeft} class="relative">
|
||||
<div class="rounded-2xl overflow-hidden shadow-xl shadow-brand-900/10">
|
||||
<img
|
||||
src={block.externalImageUrl || ""}
|
||||
alt={block.headline || ""}
|
||||
class="w-full h-auto object-cover aspect-4/3"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<!-- Decorative gradient behind image -->
|
||||
<div
|
||||
class="absolute -inset-4 -z-10 rounded-3xl bg-linear-to-br from-brand-100 to-brand-50 blur-sm"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Text-only layout -->
|
||||
<div class="prose max-w-3xl" use:reveal={{ delay: 200 }}>
|
||||
{@html block.text || ""}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
Reference in New Issue
Block a user