Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { STANDARD_BENEFITS, type Benefit } from "./data";
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
id = "benefits",
|
||||
benefits = STANDARD_BENEFITS
|
||||
}: {
|
||||
id?: string;
|
||||
sectionTitle?: string;
|
||||
sectionDescription?: string;
|
||||
benefits?: Benefit[];
|
||||
} = $props();
|
||||
|
||||
const benefitKeys: Record<Benefit["icon"], { title: string; desc: string }> = {
|
||||
shield: { title: "home.benefits.shield.title", desc: "home.benefits.shield.desc" },
|
||||
chart: { title: "home.benefits.chart.title", desc: "home.benefits.chart.desc" },
|
||||
database: { title: "home.benefits.database.title", desc: "home.benefits.database.desc" },
|
||||
search: { title: "home.benefits.search.title", desc: "home.benefits.search.desc" },
|
||||
cost: { title: "home.benefits.cost.title", desc: "home.benefits.cost.desc" },
|
||||
scale: { title: "home.benefits.scale.title", desc: "home.benefits.scale.desc" }
|
||||
};
|
||||
|
||||
const iconPaths: Record<Benefit["icon"], string> = {
|
||||
shield:
|
||||
"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
|
||||
chart:
|
||||
"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
|
||||
database:
|
||||
"M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4",
|
||||
search: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
|
||||
cost: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
scale:
|
||||
"M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3"
|
||||
};
|
||||
</script>
|
||||
|
||||
<section {id} class="w-full bg-muted py-16 md:py-24">
|
||||
<div class="container mx-auto max-w-[1216px] px-4 md:px-6">
|
||||
<div class="mb-16 flex flex-col items-center text-center">
|
||||
<h2 class="h2-responsive max-w-3xl font-bold tracking-tight text-text">
|
||||
{i18n.t("home.benefits.title")}
|
||||
</h2>
|
||||
<p class="body-responsive mt-4 max-w-2xl text-text-muted">
|
||||
{i18n.t("home.benefits.lead")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto grid max-w-5xl grid-cols-1 gap-x-20 gap-y-16 md:grid-cols-2">
|
||||
{#each benefits as benefit}
|
||||
{@const keys = benefitKeys[benefit.icon]}
|
||||
<div class="flex items-start">
|
||||
<div class="mr-6 flex-shrink-0">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-surface text-primary shadow-sm ring-1 ring-border"
|
||||
>
|
||||
<svg
|
||||
class="h-7 w-7"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={iconPaths[benefit.icon]}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="h3-responsive mb-3 font-semibold text-text">
|
||||
{keys ? i18n.t(keys.title) : benefit.title}
|
||||
</h3>
|
||||
<p class="body-responsive text-text-muted">
|
||||
{keys ? i18n.t(keys.desc) : benefit.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { CTA_DATA } from "./data";
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
id = "cta",
|
||||
class: className = ""
|
||||
}: {
|
||||
id?: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const featureKeys = ["home.cta.f1", "home.cta.f2", "home.cta.f3"] as const;
|
||||
</script>
|
||||
|
||||
<section {id} class={`w-full bg-muted py-16 md:py-24 ${className}`}>
|
||||
<div class="container mx-auto max-w-[1216px] px-4 md:px-6">
|
||||
<div class="overflow-hidden rounded-2xl bg-secondary text-secondary-foreground shadow-xl">
|
||||
<div class="grid grid-cols-1 items-center overflow-hidden lg:grid-cols-2">
|
||||
<div class="p-8 md:p-12 lg:p-16">
|
||||
<h2 class="h2-responsive mb-6 font-bold text-secondary-foreground">
|
||||
{i18n.t("home.cta.titleLead")}<span
|
||||
class="underline decoration-primary decoration-2 underline-offset-4"
|
||||
>{i18n.t("home.cta.titleHighlight")}</span
|
||||
>
|
||||
</h2>
|
||||
<p class="body-responsive mb-8 text-secondary-foreground/75">
|
||||
{i18n.t("home.cta.description")}
|
||||
</p>
|
||||
|
||||
<div class="mb-10 space-y-4">
|
||||
{#each CTA_DATA.features as feature, index}
|
||||
<div class="flex items-start">
|
||||
<div
|
||||
class="mt-1 mr-3 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-primary/20"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="body-responsive font-medium text-secondary-foreground">
|
||||
{featureKeys[index] ? i18n.t(featureKeys[index]) : feature.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={CTA_DATA.ctaButton.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded-md bg-surface px-5 py-3 text-sm font-semibold text-text shadow-sm transition hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
{i18n.t("home.cta.apply")} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="hidden h-full items-stretch justify-end pl-8 md:pl-12 lg:flex lg:pl-16">
|
||||
<img
|
||||
src="/descrybe_product_pages.png"
|
||||
alt={i18n.t("home.cta.imageAlt")}
|
||||
width="600"
|
||||
height="400"
|
||||
class="h-full max-w-none rounded-none object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { PricingFaqItem } from "./data";
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
id = "faq",
|
||||
sectionTitle,
|
||||
sectionDescription,
|
||||
items = [] as PricingFaqItem[]
|
||||
}: {
|
||||
id?: string;
|
||||
sectionTitle?: string;
|
||||
sectionDescription?: string;
|
||||
items?: PricingFaqItem[];
|
||||
} = $props();
|
||||
|
||||
const resolvedTitle = $derived(sectionTitle ?? i18n.t("pricing.section.faqTitle"));
|
||||
const resolvedDescription = $derived(
|
||||
sectionDescription ?? i18n.t("pricing.section.faqDescription")
|
||||
);
|
||||
|
||||
let openFaq = $state<number | null>(null);
|
||||
|
||||
function toggleFaq(index: number) {
|
||||
openFaq = openFaq === index ? null : index;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section {id} class="w-full bg-background py-16 md:py-24" aria-labelledby="home-faq-heading">
|
||||
<div class="container mx-auto max-w-[1216px] px-4 md:px-6">
|
||||
<div class="mb-12 flex flex-col items-center text-center">
|
||||
<h2
|
||||
id="home-faq-heading"
|
||||
class="h2-responsive max-w-3xl font-bold tracking-tight text-text"
|
||||
>
|
||||
{resolvedTitle}
|
||||
</h2>
|
||||
{#if resolvedDescription}
|
||||
<p class="body-responsive mt-4 max-w-2xl text-text-muted">
|
||||
{resolvedDescription}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mx-auto w-full max-w-3xl divide-y divide-border rounded-lg border border-border bg-surface"
|
||||
role="list"
|
||||
>
|
||||
{#each items as item, index (index)}
|
||||
{@const panelId = `home-faq-panel-${index}`}
|
||||
{@const buttonId = `home-faq-button-${index}`}
|
||||
<div role="listitem">
|
||||
<button
|
||||
id={buttonId}
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between px-4 py-4 text-left text-lg font-semibold text-text transition-colors hover:bg-accent/50"
|
||||
aria-expanded={openFaq === index}
|
||||
aria-controls={panelId}
|
||||
onclick={() => toggleFaq(index)}
|
||||
>
|
||||
{item.questionKey ? i18n.t(item.questionKey) : item.question}
|
||||
<span class="ml-4 text-text-muted" aria-hidden="true">
|
||||
{openFaq === index ? "−" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
{#if openFaq === index}
|
||||
<div
|
||||
id={panelId}
|
||||
role="region"
|
||||
aria-labelledby={buttonId}
|
||||
class="px-4 pb-4 text-base text-text-muted"
|
||||
>
|
||||
{item.answerKey ? i18n.t(item.answerKey) : item.answer}
|
||||
</div>
|
||||
{:else}
|
||||
<div id={panelId} hidden></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { FOOTER_DATA } from "./data";
|
||||
import { cookieConsent } from "$lib/cookie-consent.svelte";
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
let { class: className = "" }: { class?: string } = $props();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const columnTitleKeys: Record<string, string> = {
|
||||
Navigation: "site.footer.navTitle"
|
||||
};
|
||||
const linkTextKeys: Record<string, string> = {
|
||||
Platform: "site.footer.platform",
|
||||
Solutions: "site.footer.solutions",
|
||||
"Contact Us": "site.footer.contact",
|
||||
Pricing: "site.nav.pricing",
|
||||
"API Docs": "site.nav.apiDocs",
|
||||
"Privacy Policy": "site.privacyPolicy",
|
||||
"Cookie Policy": "site.cookiePolicy",
|
||||
"Terms of Service": "site.termsOfService",
|
||||
"Cookie settings": "site.cookieSettings"
|
||||
};
|
||||
|
||||
function linkLabel(text: string): string {
|
||||
const key = linkTextKeys[text];
|
||||
return key ? i18n.t(key) : text;
|
||||
}
|
||||
</script>
|
||||
|
||||
<footer class={`w-full border-t border-border bg-background ${className}`} aria-label={i18n.t("site.footer.aria")}>
|
||||
<div class="container mx-auto max-w-[1216px] px-4 py-16 md:px-6">
|
||||
<div class="grid grid-cols-1 gap-12 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="lg:col-span-2">
|
||||
<a href="/" class="mb-6 flex items-center space-x-3" aria-label={i18n.t("app.home")}>
|
||||
<div class="relative flex h-10 w-10 items-center justify-center">
|
||||
<img
|
||||
src="/descrybe_logo.png"
|
||||
alt=""
|
||||
width="40"
|
||||
height="40"
|
||||
class="h-auto w-auto"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-text">{i18n.t("app.name")}</span>
|
||||
</a>
|
||||
|
||||
<p class="mb-6 max-w-md text-text-muted">
|
||||
{i18n.t("site.footer.description")}
|
||||
</p>
|
||||
|
||||
{#if FOOTER_DATA.contactInfo}
|
||||
<div class="flex flex-col space-y-3">
|
||||
{#if FOOTER_DATA.contactInfo.email}
|
||||
<div class="flex items-start gap-3">
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 text-text-muted"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<a
|
||||
href={`mailto:${FOOTER_DATA.contactInfo.email}`}
|
||||
class="text-text-muted transition-colors hover:text-link"
|
||||
>
|
||||
{FOOTER_DATA.contactInfo.email}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each FOOTER_DATA.columns as column}
|
||||
<div>
|
||||
<h2 class="mb-5 text-base font-semibold text-text">
|
||||
{columnTitleKeys[column.title]
|
||||
? i18n.t(columnTitleKeys[column.title])
|
||||
: column.title}
|
||||
</h2>
|
||||
<ul class="space-y-3">
|
||||
{#each column.links as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-muted transition-colors hover:text-link"
|
||||
>
|
||||
{linkLabel(link.text)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full border-t border-border">
|
||||
<div class="container mx-auto max-w-[1216px] px-4 py-6 md:px-6">
|
||||
<div class="flex flex-col items-center justify-between md:flex-row">
|
||||
<div class="mb-4 text-sm text-text-muted md:mb-0">
|
||||
{i18n.t("site.footer.rightsLine", {
|
||||
year: currentYear,
|
||||
name: i18n.t("app.name")
|
||||
})}
|
||||
</div>
|
||||
|
||||
{#if FOOTER_DATA.legalLinks.length > 0}
|
||||
<div class="flex flex-wrap gap-6 text-sm">
|
||||
{#each FOOTER_DATA.legalLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-muted transition-colors hover:text-link"
|
||||
>
|
||||
{linkLabel(link.text)}
|
||||
</a>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted transition-colors hover:text-link"
|
||||
onclick={() => cookieConsent.openPreferences()}
|
||||
>
|
||||
{i18n.t("site.cookieSettings")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { DEMO_BOOKING_URL } from "$lib/site";
|
||||
import { PROCESS_STEPS } from "./data";
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
id = "how-it-works"
|
||||
}: {
|
||||
id?: string;
|
||||
sectionTitle?: string;
|
||||
sectionDescription?: string;
|
||||
} = $props();
|
||||
|
||||
const stepKeys = [
|
||||
{
|
||||
title: "home.how.step1.title",
|
||||
desc: "home.how.step1.desc",
|
||||
features: ["home.how.step1.f1", "home.how.step1.f2", "home.how.step1.f3"]
|
||||
},
|
||||
{
|
||||
title: "home.how.step2.title",
|
||||
desc: "home.how.step2.desc",
|
||||
features: ["home.how.step2.f1", "home.how.step2.f2", "home.how.step2.f3"]
|
||||
},
|
||||
{
|
||||
title: "home.how.step3.title",
|
||||
desc: "home.how.step3.desc",
|
||||
features: ["home.how.step3.f1", "home.how.step3.f2", "home.how.step3.f3"]
|
||||
},
|
||||
{
|
||||
title: "home.how.step4.title",
|
||||
desc: "home.how.step4.desc",
|
||||
features: ["home.how.step4.f1", "home.how.step4.f2", "home.how.step4.f3"]
|
||||
}
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<section {id} class="w-full bg-muted py-8 md:py-12 lg:pt-8 lg:pb-32">
|
||||
<div class="container mx-auto max-w-[1216px] px-4 md:px-6">
|
||||
<div class="mb-16 flex flex-col items-center text-center">
|
||||
<h2 class="h2-responsive max-w-3xl font-bold tracking-tight text-text">
|
||||
{i18n.t("home.how.title")}
|
||||
</h2>
|
||||
<p class="body-responsive mt-4 max-w-2xl text-text-muted">
|
||||
{i18n.t("home.how.lead")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-24">
|
||||
{#each PROCESS_STEPS as step, stepIndex}
|
||||
{@const keys = stepKeys[stepIndex]}
|
||||
<div class="grid grid-cols-1 items-center gap-8 lg:grid-cols-2 lg:gap-16">
|
||||
<div class={step.number % 2 === 1 ? "order-2 lg:order-1" : "order-2 lg:order-2"}>
|
||||
<div
|
||||
class="relative flex h-[320px] w-full items-center justify-center overflow-hidden md:h-[420px] lg:h-[480px]"
|
||||
>
|
||||
<img
|
||||
src={step.image}
|
||||
alt={keys ? i18n.t(keys.title) : step.title}
|
||||
width="800"
|
||||
height="600"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={step.number % 2 === 1 ? "order-1 lg:order-2" : "order-1 lg:order-1"}>
|
||||
<div class={`space-y-8 ${step.number % 2 === 0 ? "text-left lg:text-right" : ""}`}>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class={`mb-2 inline-flex h-10 w-10 items-center justify-center rounded-full bg-accent text-primary ${step.number % 2 === 0 ? "lg:ml-auto" : ""}`}
|
||||
>
|
||||
<span class="font-bold">{step.number}</span>
|
||||
</div>
|
||||
<h3 class="h3-responsive font-bold tracking-tight text-text">
|
||||
{keys ? i18n.t(keys.title) : step.title}
|
||||
</h3>
|
||||
<p class="body-responsive text-text-muted">
|
||||
{keys ? i18n.t(keys.desc) : step.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-4">
|
||||
{#each step.features as feature, fi}
|
||||
<li
|
||||
class={`flex items-start ${step.number % 2 === 0 ? "lg:flex-row-reverse" : ""}`}
|
||||
>
|
||||
<svg
|
||||
class={`h-6 w-6 flex-shrink-0 text-primary ${step.number % 2 === 0 ? "lg:ml-2" : "mr-2"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="body-responsive text-text/80">
|
||||
{keys?.features[fi] ? i18n.t(keys.features[fi]) : feature.text}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-24 text-center">
|
||||
<a
|
||||
href={DEMO_BOOKING_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center rounded-md bg-primary px-5 py-3 text-sm font-semibold text-primary-foreground shadow-sm transition hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
{i18n.t("home.how.apply")}
|
||||
<svg
|
||||
class="ml-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n";
|
||||
</script>
|
||||
|
||||
<section class="bg-muted pt-5 pb-4 sm:pb-8">
|
||||
<div class="mx-auto max-w-[1440px] px-2 sm:px-3">
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src="/descrybe_preview.png"
|
||||
alt={i18n.t("home.image.previewAlt")}
|
||||
width="1400"
|
||||
height="875"
|
||||
class="h-auto max-w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { api } from "$lib/api";
|
||||
import { buttonClasses } from "$lib/components/ui/button-variants";
|
||||
import type { MeResponse } from "$lib/types";
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
type Props = {
|
||||
/** Optional third link (e.g. Contact sales) rendered after the primary pair. */
|
||||
extra?: import("svelte").Snippet;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { extra, class: className = "" }: Props = $props();
|
||||
|
||||
let me = $state<MeResponse | null>(null);
|
||||
let ready = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
void api<MeResponse>("/api/auth/me")
|
||||
.then((res) => {
|
||||
me = res;
|
||||
})
|
||||
.catch(() => {
|
||||
me = null;
|
||||
})
|
||||
.finally(() => {
|
||||
ready = true;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Equal CTAs: identical box model.
|
||||
* Primary uses transparent border so height matches outline’s 1px border.
|
||||
*/
|
||||
const size =
|
||||
"box-border h-10 min-h-10 max-h-10 px-5 py-0 text-sm font-medium leading-none";
|
||||
const primaryExtra = `${size} border border-transparent`;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-3 {className}" data-ready={ready}>
|
||||
{#if me}
|
||||
<a href="/dashboard" class={buttonClasses("default", "default", primaryExtra)}
|
||||
>{i18n.t("site.goToApp")}</a
|
||||
>
|
||||
<a href="/plans" class={buttonClasses("outline", "default", size)}
|
||||
>{i18n.t("billing.comparePlans")}</a
|
||||
>
|
||||
{:else}
|
||||
<a href="/register" class={buttonClasses("default", "default", primaryExtra)}
|
||||
>{i18n.t("site.getStarted")}</a
|
||||
>
|
||||
<a href="/login" class={buttonClasses("outline", "default", size)}>{i18n.t("site.logIn")}</a>
|
||||
{/if}
|
||||
{#if extra}
|
||||
{@render extra()}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Footer from "./Footer.svelte";
|
||||
</script>
|
||||
|
||||
<!-- Alias for legal/pricing pages that expect MarketingFooter -->
|
||||
<Footer />
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import SiteHeader from "./SiteHeader.svelte";
|
||||
</script>
|
||||
|
||||
<!-- Alias for legal/pricing pages that expect MarketingHeader -->
|
||||
<SiteHeader />
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { DEMO_BOOKING_URL } from "$lib/site";
|
||||
import { i18n } from "$lib/i18n";
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="relative flex min-h-[70vh] flex-col justify-center overflow-hidden bg-background pb-8"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-accent/50 via-background to-muted"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div
|
||||
class="bg-tiled-pattern dark:bg-tiled-pattern-dark pointer-events-none absolute inset-x-0 bottom-0 bg-repeat opacity-90"
|
||||
style="top: 4rem; mask-image: linear-gradient(to bottom, black 0%, transparent 55%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 55%, transparent 100%);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="relative container mx-auto px-4 pt-28 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<p
|
||||
class="mb-5 inline-flex items-center gap-2 text-2xl font-bold tracking-tight text-text sm:text-3xl"
|
||||
>
|
||||
<img
|
||||
src="/descrybe_logo.png"
|
||||
alt=""
|
||||
width="40"
|
||||
height="40"
|
||||
class="h-9 w-9 sm:h-10 sm:w-10"
|
||||
/>
|
||||
<span>{i18n.t("app.name")}</span>
|
||||
</p>
|
||||
<p class="body-responsive mb-4 font-medium text-text-muted">
|
||||
{i18n.t("home.hero.tagline")}
|
||||
</p>
|
||||
<h1 class="h1-responsive font-extrabold tracking-tight text-text">
|
||||
{i18n.t("home.hero.title")}
|
||||
</h1>
|
||||
<p class="h3-responsive mt-6 text-text-muted">
|
||||
{i18n.t("home.hero.lead")}
|
||||
</p>
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-3 sm:gap-x-4">
|
||||
<a
|
||||
href="#how-it-works"
|
||||
class="rounded-md bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-sm transition hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
{i18n.t("home.hero.learnMore")}
|
||||
</a>
|
||||
<a
|
||||
href={DEMO_BOOKING_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded-md border border-border bg-surface/70 px-4 py-2.5 text-sm leading-6 font-semibold text-text backdrop-blur-sm transition hover:bg-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
{i18n.t("home.hero.apply")} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-10 flex flex-col items-center justify-center">
|
||||
<p class="body-responsive mb-3 font-medium text-text-muted">{i18n.t("home.hero.supportedBy")}</p>
|
||||
<img
|
||||
src="/MS_Startups_Badge_Dark.png"
|
||||
alt={i18n.t("home.hero.msAlt")}
|
||||
width="180"
|
||||
height="90"
|
||||
class="block opacity-90 dark:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "$lib/components/ui";
|
||||
import { Check, ChevronDown, ChevronUp, Sparkles, Zap } from "@lucide/svelte";
|
||||
import { ANNUAL_DISCOUNT, type PricingPlan } from "./data";
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
plan,
|
||||
isYearly = false,
|
||||
class: className = ""
|
||||
}: {
|
||||
plan: PricingPlan;
|
||||
isYearly?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let showAllFeatures = $state(false);
|
||||
|
||||
const yearlyPrice = $derived(Math.round(plan.pricePerMonth * 12 * (1 - ANNUAL_DISCOUNT)));
|
||||
const displayPrice = $derived(isYearly ? yearlyPrice : plan.pricePerMonth);
|
||||
const displayPeriod = $derived(
|
||||
isYearly ? i18n.t("pricing.card.perYear") : i18n.t("pricing.card.perMonth")
|
||||
);
|
||||
const monthlySavings = $derived(
|
||||
isYearly && plan.pricePerMonth > 0
|
||||
? Math.round(plan.pricePerMonth - yearlyPrice / 12)
|
||||
: 0
|
||||
);
|
||||
const initialFeaturesCount = 5;
|
||||
const displayedFeatures = $derived(
|
||||
showAllFeatures ? plan.features : plan.features.slice(0, initialFeaturesCount)
|
||||
);
|
||||
const hasMoreFeatures = $derived(plan.features.length > initialFeaturesCount);
|
||||
const isFree = $derived(!plan.customPrice && plan.pricePerMonth === 0);
|
||||
const maxProductsLabel = $derived(
|
||||
plan.customPrice
|
||||
? i18n.t("pricing.card.unlimitedSkus")
|
||||
: plan.maxProducts >= 1_000_000
|
||||
? i18n.t("pricing.card.oneMSkus")
|
||||
: i18n.t("pricing.card.upToSkus", { count: plan.maxProducts.toLocaleString() })
|
||||
);
|
||||
const creditsLabel = $derived(
|
||||
plan.customPrice
|
||||
? i18n.t("pricing.card.unlimitedAi")
|
||||
: plan.monthlyCredits === 0
|
||||
? i18n.t("pricing.card.zeroCredits")
|
||||
: i18n.t("pricing.card.creditsPerMonth", {
|
||||
count: plan.monthlyCredits.toLocaleString()
|
||||
})
|
||||
);
|
||||
const planDescription = $derived(
|
||||
plan.descriptionKey ? i18n.t(plan.descriptionKey) : plan.description
|
||||
);
|
||||
/** Distinct link text per plan (avoid three identical “Upgrade” labels). */
|
||||
const ctaLabel = $derived(
|
||||
plan.ctaText === "Upgrade"
|
||||
? i18n.t("pricing.card.upgradeTo", { name: plan.name })
|
||||
: plan.ctaText === "Get started"
|
||||
? i18n.t("site.getStarted")
|
||||
: plan.ctaText === "Contact sales"
|
||||
? i18n.t("pricing.section.contactSales")
|
||||
: plan.ctaText
|
||||
);
|
||||
|
||||
function featureLabel(feature: PricingPlan["features"][number]): string {
|
||||
return feature.nameKey ? i18n.t(feature.nameKey) : feature.name;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card
|
||||
class={`relative flex flex-col ${plan.popular ? "scale-105 border-primary shadow-xl ring-2 ring-primary/20" : "shadow-lg"} ${className}`}
|
||||
>
|
||||
{#if plan.popular}
|
||||
<Badge
|
||||
class="absolute -top-3 left-1/2 -translate-x-1/2 transform bg-primary px-4 py-1 text-primary-foreground"
|
||||
>
|
||||
<Sparkles class="mr-1 h-3 w-3" />
|
||||
{i18n.t("pricing.card.mostPopular")}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
<CardHeader class="pb-6 text-center">
|
||||
<CardTitle class="text-2xl font-bold">{plan.name}</CardTitle>
|
||||
<CardDescription class="flex min-h-[40px] items-center justify-center text-sm">
|
||||
{planDescription}
|
||||
</CardDescription>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex items-baseline justify-center">
|
||||
{#if plan.customPrice}
|
||||
<span class="text-4xl font-bold">{i18n.t("pricing.card.custom")}</span>
|
||||
{:else if isFree}
|
||||
<span class="text-4xl font-bold">$0</span>
|
||||
<span class="ml-2 text-lg text-text-muted">{i18n.t("pricing.card.forever")}</span>
|
||||
{:else}
|
||||
<span class="text-4xl font-bold">${displayPrice}</span>
|
||||
<span class="ml-2 text-lg text-text-muted">/{displayPeriod}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isYearly && monthlySavings > 0}
|
||||
<div class="flex items-center justify-center space-x-2 text-sm">
|
||||
<span class="text-text-muted">{i18n.t("pricing.card.savePerMonth", { amount: String(monthlySavings) })}</span>
|
||||
<Badge variant="success">{i18n.t("pricing.card.discountBadge")}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-sm text-text-muted">{maxProductsLabel}</p>
|
||||
|
||||
<p class="text-xs text-text-muted">{creditsLabel}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="flex-grow space-y-3">
|
||||
{#each displayedFeatures as feature}
|
||||
<div class="flex items-start space-x-3">
|
||||
<Check
|
||||
class={`mt-0.5 h-4 w-4 flex-shrink-0 ${feature.included ? "text-success" : "text-text-muted opacity-30"}`}
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span
|
||||
class={`text-sm ${feature.included ? "text-text" : "text-text-muted line-through opacity-70"}`}
|
||||
>
|
||||
{featureLabel(feature)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if hasMoreFeatures}
|
||||
<div class="pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => (showAllFeatures = !showAllFeatures)}
|
||||
class="w-full text-xs text-text-muted hover:text-text"
|
||||
>
|
||||
{#if showAllFeatures}
|
||||
<ChevronUp class="mr-1 h-3 w-3" />
|
||||
{i18n.t("pricing.card.showLess")}
|
||||
{:else}
|
||||
<ChevronDown class="mr-1 h-3 w-3" />
|
||||
{@const moreCount = plan.features.length - initialFeaturesCount}
|
||||
{moreCount === 1
|
||||
? i18n.t("pricing.card.showMoreFeature", { count: moreCount })
|
||||
: i18n.t("pricing.card.showMoreFeatures", { count: moreCount })}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter class="pt-6">
|
||||
<a
|
||||
href={plan.ctaHref}
|
||||
class={`inline-flex h-11 w-full items-center justify-center gap-2 rounded-md text-base font-semibold ${
|
||||
plan.popular
|
||||
? "bg-gradient-to-r from-primary to-primary/90 text-primary-foreground shadow-lg hover:from-primary/90 hover:to-primary"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
}`}
|
||||
target={plan.ctaHref.startsWith("http") ? "_blank" : undefined}
|
||||
rel={plan.ctaHref.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{#if plan.popular}
|
||||
<Zap class="h-4 w-4" aria-hidden="true" />
|
||||
{/if}
|
||||
{ctaLabel}
|
||||
</a>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
/** Re-export: single public pricing section lives in `$lib/components/pricing`. */
|
||||
import PricingSection from "../pricing/PricingSection.svelte";
|
||||
|
||||
let {
|
||||
id = "pricing",
|
||||
hideIntro = false,
|
||||
headingLevel = 2,
|
||||
class: className = ""
|
||||
}: {
|
||||
id?: string;
|
||||
hideIntro?: boolean;
|
||||
headingLevel?: 1 | 2;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PricingSection {id} {hideIntro} {headingLevel} class={className} />
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { PRODUCT_EXPLANATION } from "./data";
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
id = PRODUCT_EXPLANATION.id
|
||||
}: {
|
||||
id?: string;
|
||||
} = $props();
|
||||
|
||||
const pipelineKeys = [
|
||||
{ label: "home.product.p1.label", detail: "home.product.p1.detail" },
|
||||
{ label: "home.product.p2.label", detail: "home.product.p2.detail" },
|
||||
{ label: "home.product.p3.label", detail: "home.product.p3.detail" },
|
||||
{ label: "home.product.p4.label", detail: "home.product.p4.detail" }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<section {id} class="w-full bg-background py-16 md:py-20">
|
||||
<div class="container mx-auto max-w-[1216px] px-4 md:px-6">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<p class="body-responsive mb-3 font-medium text-primary">
|
||||
{i18n.t("home.product.eyebrow")}
|
||||
</p>
|
||||
<h2 class="h2-responsive font-bold tracking-tight text-text">
|
||||
{i18n.t("home.product.title")}
|
||||
</h2>
|
||||
<p class="body-responsive mt-4 text-text-muted">
|
||||
{i18n.t("home.product.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol
|
||||
class="mx-auto mt-12 grid max-w-5xl grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4"
|
||||
aria-label={i18n.t("home.product.pipelineAria")}
|
||||
>
|
||||
{#each PRODUCT_EXPLANATION.pipeline as step, index}
|
||||
{@const keys = pipelineKeys[index]}
|
||||
<li class="text-left">
|
||||
<span
|
||||
class="mb-3 inline-flex h-8 w-8 items-center justify-center rounded-full bg-accent text-sm font-bold text-accent-foreground"
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<p class="text-base font-semibold text-text">
|
||||
{keys ? i18n.t(keys.label) : step.label}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-text-muted">
|
||||
{keys ? i18n.t(keys.detail) : step.detail}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { SITE_NAME, type PageSeo } from "$lib/seo";
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
image = "/descrybe_preview.png",
|
||||
imageAlt = "Descrybe product catalog preview",
|
||||
type = "website"
|
||||
}: PageSeo & { type?: string } = $props();
|
||||
|
||||
const origin = $derived(page.url.origin);
|
||||
const canonical = $derived(`${origin}${path}`);
|
||||
const imageUrl = $derived(image.startsWith("http") ? image : `${origin}${image}`);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
<meta property="og:image:alt" content={imageAlt} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
<meta name="twitter:image:alt" content={imageAlt} />
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { Button, buttonClasses } from "$lib/components/ui";
|
||||
import { api } from "$lib/api";
|
||||
import type { MeResponse } from "$lib/types";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import { i18n } from "$lib/i18n";
|
||||
import { NAV_ITEMS } from "./data";
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
let me = $state<MeResponse | null>(null);
|
||||
let authReady = $state(false);
|
||||
|
||||
const loggedIn = $derived(me != null);
|
||||
const accountLabel = $derived.by(() => {
|
||||
const company = me?.company?.name?.trim();
|
||||
if (company) return company;
|
||||
const name = me?.user?.name?.trim();
|
||||
if (name) return name;
|
||||
const email = me?.user?.email?.trim();
|
||||
if (email) return email;
|
||||
return i18n.t("site.account");
|
||||
});
|
||||
|
||||
function navTitle(href: string): string {
|
||||
if (href === "/") return i18n.t("site.nav.home");
|
||||
if (href === "/pricing") return i18n.t("site.nav.pricing");
|
||||
if (href === "/docs") return i18n.t("site.nav.apiDocs");
|
||||
return href;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const ac = new AbortController();
|
||||
const timer = window.setTimeout(() => ac.abort(), 8_000);
|
||||
void api<MeResponse>("/api/auth/me", { signal: ac.signal })
|
||||
.then((res) => {
|
||||
me = res;
|
||||
})
|
||||
.catch(() => {
|
||||
me = null;
|
||||
})
|
||||
.finally(() => {
|
||||
window.clearTimeout(timer);
|
||||
authReady = true;
|
||||
});
|
||||
return () => {
|
||||
ac.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
});
|
||||
|
||||
function scrollToSection(href: string) {
|
||||
if (!href.startsWith("#")) return;
|
||||
const targetId = href.slice(1);
|
||||
const element = document.getElementById(targetId);
|
||||
if (!element) return;
|
||||
const headerOffset = 100;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: "smooth" });
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function onNavClick(event: MouseEvent, href: string) {
|
||||
if (href.startsWith("#")) {
|
||||
event.preventDefault();
|
||||
scrollToSection(href);
|
||||
return;
|
||||
}
|
||||
const hashIdx = href.indexOf("#");
|
||||
if (hashIdx >= 0) {
|
||||
const path = href.slice(0, hashIdx) || "/";
|
||||
const hash = href.slice(hashIdx);
|
||||
const here = typeof window !== "undefined" ? window.location.pathname : "";
|
||||
if (path === "/" || path === here) {
|
||||
event.preventDefault();
|
||||
if (here !== "/" && path === "/") {
|
||||
window.location.assign(href);
|
||||
return;
|
||||
}
|
||||
scrollToSection(hash);
|
||||
return;
|
||||
}
|
||||
}
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[60] focus:rounded-md focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-text focus:shadow-md focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-ring"
|
||||
>
|
||||
{i18n.t("a11y.skipToContent")}
|
||||
</a>
|
||||
|
||||
<header
|
||||
class="fixed top-0 right-0 left-0 z-[110] border-b border-border/80 bg-background/90 backdrop-blur-md"
|
||||
>
|
||||
<div class="container mx-auto max-w-7xl">
|
||||
<div class="flex h-16 items-center justify-between px-4 sm:px-0">
|
||||
<a href="/" class="group flex items-center space-x-3" aria-label={i18n.t("app.home")}>
|
||||
<div
|
||||
class="relative flex h-9 w-9 items-center justify-center transition-transform duration-200 group-hover:scale-105"
|
||||
>
|
||||
<img
|
||||
src="/descrybe_logo.png"
|
||||
alt=""
|
||||
width="36"
|
||||
height="36"
|
||||
class="h-auto w-auto"
|
||||
/>
|
||||
</div>
|
||||
<span class="hidden text-xl font-bold text-text sm:inline-block">{i18n.t("app.name")}</span>
|
||||
</a>
|
||||
|
||||
<div class="absolute left-1/2 hidden -translate-x-1/2 transform lg:block">
|
||||
<nav class="flex items-center space-x-10" aria-label={i18n.t("site.nav.primary")}>
|
||||
{#each NAV_ITEMS as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="text-sm font-medium text-text-muted transition-colors hover:text-link"
|
||||
onclick={(e) => onNavClick(e, item.href)}
|
||||
>
|
||||
{navTitle(item.href)}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 sm:gap-2" data-auth-ready={authReady}>
|
||||
<ThemeToggle />
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
{#if loggedIn}
|
||||
<span
|
||||
class="max-w-[10rem] truncate text-sm font-medium text-text-muted"
|
||||
title={accountLabel}>{accountLabel}</span
|
||||
>
|
||||
<a
|
||||
href="/dashboard"
|
||||
class={buttonClasses(
|
||||
"default",
|
||||
"sm",
|
||||
"box-border h-9 min-h-9 border border-transparent px-4 text-sm font-medium leading-none"
|
||||
)}>{i18n.t("site.goToApp")}</a
|
||||
>
|
||||
{:else}
|
||||
<a
|
||||
href="/login"
|
||||
class={buttonClasses(
|
||||
"ghost",
|
||||
"sm",
|
||||
"box-border h-9 min-h-9 px-4 text-sm font-medium leading-none"
|
||||
)}>{i18n.t("site.logIn")}</a
|
||||
>
|
||||
<a
|
||||
href="/register"
|
||||
class={buttonClasses(
|
||||
"default",
|
||||
"sm",
|
||||
"box-border h-9 min-h-9 border border-transparent px-4 text-sm font-medium leading-none"
|
||||
)}>{i18n.t("site.getStarted")}</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="ml-0 lg:hidden"
|
||||
aria-label={mobileMenuOpen ? i18n.t("nav.closeMenu") : i18n.t("nav.open")}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
>
|
||||
{#if mobileMenuOpen}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg
|
||||
>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
><line x1="3" x2="21" y1="6" y2="6" /><line x1="3" x2="21" y1="12" y2="12" /><line
|
||||
x1="3"
|
||||
x2="21"
|
||||
y1="18"
|
||||
y2="18"
|
||||
/></svg
|
||||
>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<div class="absolute top-full right-0 left-0 bg-background/95 shadow-md backdrop-blur-md lg:hidden">
|
||||
<div class="border-t border-border/80 px-4 pt-2 pb-6">
|
||||
<nav class="mt-2 mb-4 flex flex-col space-y-1" aria-label={i18n.t("site.nav.mobile")}>
|
||||
{#each NAV_ITEMS as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="block py-2.5 text-base font-medium text-text transition-colors hover:text-link"
|
||||
onclick={(e) => onNavClick(e, item.href)}
|
||||
>
|
||||
{navTitle(item.href)}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="mt-4 space-y-3">
|
||||
{#if loggedIn}
|
||||
<p class="truncate text-center text-sm font-medium text-text-muted">
|
||||
{accountLabel}
|
||||
</p>
|
||||
<a
|
||||
href="/dashboard"
|
||||
class={buttonClasses(
|
||||
"default",
|
||||
"default",
|
||||
"box-border h-10 w-full min-h-10 text-sm font-medium"
|
||||
)}>{i18n.t("site.goToApp")}</a
|
||||
>
|
||||
{:else}
|
||||
<a
|
||||
href="/login"
|
||||
class={buttonClasses(
|
||||
"ghost",
|
||||
"default",
|
||||
"box-border h-10 w-full min-h-10 text-sm font-medium"
|
||||
)}>{i18n.t("site.logIn")}</a
|
||||
>
|
||||
<a
|
||||
href="/register"
|
||||
class={buttonClasses(
|
||||
"default",
|
||||
"default",
|
||||
"box-border h-10 w-full min-h-10 text-sm font-medium"
|
||||
)}>{i18n.t("site.getStarted")}</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -0,0 +1,253 @@
|
||||
import { DEMO_BOOKING_URL } from "$lib/site";
|
||||
|
||||
export type ProcessStep = {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
features: Array<{ text: string }>;
|
||||
image: string;
|
||||
};
|
||||
|
||||
export type Benefit = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: "shield" | "chart" | "database" | "search" | "cost" | "scale";
|
||||
};
|
||||
|
||||
export type MainBenefitStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
subtext: string;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
export type {
|
||||
PricingFeature,
|
||||
PricingPlan,
|
||||
PricingFaqItem
|
||||
} from "../pricing/pricing-data";
|
||||
|
||||
export {
|
||||
PRICING_PLANS,
|
||||
PRICING_FAQ,
|
||||
PRICING_CAPABILITIES as PLATFORM_CAPABILITIES,
|
||||
SALES_CALENDLY_URL,
|
||||
CONTACT_SALES_HREF,
|
||||
ANNUAL_DISCOUNT
|
||||
} from "../pricing/pricing-data";
|
||||
|
||||
/**
|
||||
* Mid-page product explanation — kept for ProductExplanationSection (not on homepage).
|
||||
* Homepage mid-page matches legacy: Image → HowItWorks → Benefits → [Pricing] → CTA.
|
||||
*/
|
||||
export const PRODUCT_EXPLANATION = {
|
||||
id: "product",
|
||||
eyebrow: "What Descrybe does",
|
||||
title: "Supplier feeds in. Ready listings out — export, WooCommerce, or API.",
|
||||
description:
|
||||
"Descrybe helps ecommerce teams turn supplier CSV and XML feeds into clean product catalogs. Map fields to your categories, enrich attributes and listing copy, then ship data through export feeds, WooCommerce sync, or the public API.",
|
||||
pipeline: [
|
||||
{ label: "Feeds in", detail: "CSV / XML from supplier URLs" },
|
||||
{ label: "Map", detail: "Match columns to your fields" },
|
||||
{ label: "Enrich", detail: "Categories, attributes, titles" },
|
||||
{ label: "Ship", detail: "Export · Woo · API" }
|
||||
]
|
||||
} as const;
|
||||
|
||||
/** Matches legacy `components/site/how-it-works/process-data.tsx`. */
|
||||
export const PROCESS_STEPS: ProcessStep[] = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Import Your Taxonomy",
|
||||
description: "Set up the categories and attributes your store already uses",
|
||||
features: [
|
||||
{ text: "Define required attributes for each category" },
|
||||
{ text: "Set per-category title formulas" },
|
||||
{ text: "Set product description and search-snippet templates" }
|
||||
],
|
||||
image: "/import_taxonomy.png"
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Import Supplier Data",
|
||||
description: "Connect a feed URL or upload a product file",
|
||||
features: [
|
||||
{ text: "Import from CSV, XML, or API sources" },
|
||||
{ text: "Schedule automated imports from your suppliers" },
|
||||
{ text: "Map source fields with a simple drag-and-drop interface" }
|
||||
],
|
||||
image: "/import_suppliers.png"
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Transform & Enhance",
|
||||
description: "Pick the products to process and let Descrybe:",
|
||||
features: [
|
||||
{ text: "Assign the right categories" },
|
||||
{ text: "Fill required product attributes" },
|
||||
{ text: "Create clear, search-friendly titles and descriptions" }
|
||||
],
|
||||
image: "/enhance_products.png"
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: "Export to Channels",
|
||||
description: "Push ready product data to the places you sell",
|
||||
features: [
|
||||
{ text: "Generate channel-specific product feeds" },
|
||||
{ text: "Keep full control over which fields you export" },
|
||||
{ text: "Stay up to date as supplier data changes" }
|
||||
],
|
||||
image: "/export_data.png"
|
||||
}
|
||||
];
|
||||
|
||||
/** Matches legacy `MAIN_BENEFIT` (passed on legacy page; not rendered by BenefitsSection). */
|
||||
export const MAIN_BENEFIT = {
|
||||
title: "Faster path from feed to listing",
|
||||
description:
|
||||
"Descrybe cuts the manual work of turning raw supplier data into store-ready product pages — categorize, fill attributes, and write listing copy at catalog scale.",
|
||||
stats: [
|
||||
{
|
||||
label: "Manual Process",
|
||||
value: "10 Products",
|
||||
subtext: "per hour per person",
|
||||
highlight: false
|
||||
},
|
||||
{
|
||||
label: "With Descrybe",
|
||||
value: "30 Products",
|
||||
subtext: "per minute",
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: "Time Savings",
|
||||
value: "99.4%",
|
||||
subtext: "efficiency increase",
|
||||
highlight: false
|
||||
}
|
||||
] as MainBenefitStat[],
|
||||
timeMetric: {
|
||||
label: "Save",
|
||||
value: "1,700+",
|
||||
unit: "hours monthly"
|
||||
}
|
||||
};
|
||||
|
||||
/** Matches legacy `STANDARD_BENEFITS`. */
|
||||
export const STANDARD_BENEFITS: Benefit[] = [
|
||||
{
|
||||
icon: "shield",
|
||||
title: "Catch incomplete listings early",
|
||||
description:
|
||||
"Check product data against your category rules so missing attributes and thin copy get fixed before they go live."
|
||||
},
|
||||
{
|
||||
icon: "chart",
|
||||
title: "Clearer listings, stronger conversion",
|
||||
description:
|
||||
"Titles and descriptions that highlight what shoppers care about — benefits, specs, and search terms that match your catalog."
|
||||
},
|
||||
{
|
||||
icon: "database",
|
||||
title: "One consistent product structure",
|
||||
description:
|
||||
"Keep the same category tree and attribute shape across exports and channels so customers can find products the same way everywhere."
|
||||
},
|
||||
{
|
||||
icon: "search",
|
||||
title: "Search-friendly product content",
|
||||
description:
|
||||
"Write titles, descriptions, and meta snippets that are easier for shoppers and search engines to understand."
|
||||
},
|
||||
{
|
||||
icon: "cost",
|
||||
title: "Less manual data entry",
|
||||
description:
|
||||
"Automate mapping, attribute fill, and listing copy so your team spends time on merchandising — not spreadsheet cleanup."
|
||||
},
|
||||
{
|
||||
icon: "scale",
|
||||
title: "Ready for every sales channel",
|
||||
description:
|
||||
"Shape export feeds and WooCommerce sync for the formats each channel expects, without rebuilding the catalog by hand."
|
||||
}
|
||||
];
|
||||
|
||||
/** Matches legacy `CTA_DATA` (badge/stats carried for parity; banner UI shows title/features/CTA). */
|
||||
export const CTA_DATA = {
|
||||
badge: {
|
||||
text: "Stop Wasting Time",
|
||||
icon: "⏱️"
|
||||
},
|
||||
titleLead: "Get Products To Market ",
|
||||
titleHighlight: "Faster",
|
||||
description:
|
||||
"Stop rebuilding product data from every supplier file. Map once, enrich with your rules, and publish listings that are ready to sell.",
|
||||
features: [
|
||||
{
|
||||
text: "Personalized demo",
|
||||
description: "See how Descrybe fits your feeds and store"
|
||||
},
|
||||
{
|
||||
text: "Expert consultation",
|
||||
description: "Talk through taxonomy, mapping, and export setup"
|
||||
},
|
||||
{
|
||||
text: "Clear next steps",
|
||||
description: "A practical plan for your catalog size and channels"
|
||||
}
|
||||
],
|
||||
stats: [
|
||||
{ value: "$140K", label: "Average annual savings" },
|
||||
{ value: "+98%", label: "Content Accuracy" },
|
||||
{ value: "180x", label: "Faster time-to-market" },
|
||||
{ value: "2.4x", label: "Product catalog growth" }
|
||||
],
|
||||
ctaButton: {
|
||||
text: "Apply For Access",
|
||||
href: DEMO_BOOKING_URL
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy gates pricing behind feature flags (default off for public visitors).
|
||||
* v2 keeps public pricing visible on the homepage and /pricing.
|
||||
*/
|
||||
export const PRICING_ENABLED = true;
|
||||
|
||||
export const FOOTER_DATA = {
|
||||
companyName: "Descrybe",
|
||||
companyDescription:
|
||||
"Descrybe turns supplier feeds into clean, channel-ready product catalogs — map fields, enrich attributes and listing copy, then export or sync to WooCommerce.",
|
||||
columns: [
|
||||
{
|
||||
title: "Navigation",
|
||||
links: [
|
||||
{ text: "Platform", href: "/#how-it-works" },
|
||||
{ text: "Solutions", href: "/#benefits" },
|
||||
{ text: "Contact Us", href: "/#cta" },
|
||||
...(PRICING_ENABLED ? [{ text: "Pricing", href: "/pricing" }] : [])
|
||||
]
|
||||
}
|
||||
],
|
||||
contactInfo: {
|
||||
email: "info@descrybe.io"
|
||||
},
|
||||
legalLinks: [
|
||||
{ text: "API Docs", href: "/docs" },
|
||||
{ text: "Privacy Policy", href: "/privacy" },
|
||||
{ text: "Cookie Policy", href: "/cookies" },
|
||||
{ text: "Terms of Service", href: "/terms" }
|
||||
] as Array<{ text: string; href: string }>
|
||||
};
|
||||
|
||||
/**
|
||||
* Public marketing primary nav.
|
||||
* Log in / Get started live in header actions (email auth in v2; Clerk in legacy).
|
||||
*/
|
||||
export const NAV_ITEMS = [
|
||||
{ title: "Home", href: "/" },
|
||||
...(PRICING_ENABLED ? [{ title: "Pricing", href: "/pricing" }] : []),
|
||||
{ title: "API Docs", href: "/docs" }
|
||||
] as Array<{ title: string; href: string }>;
|
||||
Reference in New Issue
Block a user