Skip to content
Merged
5 changes: 3 additions & 2 deletions scripts/routeMeta.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
PRICE_PER_TB_MONTH,
PRICE_AMOUNT,
PRICE_PER_TB_SHORT_EUR,
PRICE_PER_TB_SHORT_EUR_ES,
} from "../src/lib/pricing.constants.mjs";

export const BASE_URL = "https://www.fil.one";
Expand Down Expand Up @@ -369,9 +370,9 @@ export const ROUTE_META = {
},
"/lp/es/barcelona": {
lang: "es",
title: `Fil One para Barcelona: Almacenamiento Europeo, ${PRICE_PER_TB_SHORT_EUR}, Sin Egress`,
title: `Fil One para Barcelona: Almacenamiento Europeo, ${PRICE_PER_TB_SHORT_EUR_ES}, Sin Egress`,
description:
`Almacenamiento de objetos compatible con S3 para equipos en Barcelona. Soberanía de datos en la UE, cero comisiones de egress, a ${PRICE_PER_TB_SHORT_EUR}. Intégralo en tu stack actual en minutos.`,
`Almacenamiento de objetos compatible con S3 para equipos en Barcelona. Soberanía de datos en la UE, cero comisiones de egress, a ${PRICE_PER_TB_SHORT_EUR_ES}. Intégralo en tu stack actual en minutos.`,
},
"/contact-sales": {
title: "Contact Sales · Fil One",
Expand Down
6 changes: 4 additions & 2 deletions src/components/CtaBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ interface CtaBannerProps {
*/
secondaryCta?: { label: string; href: string; onClick?: () => void };
note?: ReactNode;
/** Cap on the heading width (px). Longer translations need more room. */
headingMaxWidth?: number;
}

/**
* Dark closing CTA banner: a navy gradient card with a drifting grid texture
* and a breathing glow, a headline, and a glowing primary button. Tuned for
* the closing section of a page (sits on white, above the footer).
*/
const CtaBanner = ({ heading, subhead, cta, secondaryCta, note }: CtaBannerProps) => {
const CtaBanner = ({ heading, subhead, cta, secondaryCta, note, headingMaxWidth = 480 }: CtaBannerProps) => {
const { ref, inView } = useInView({ threshold: 0.05 });

return (
Expand Down Expand Up @@ -53,7 +55,7 @@ const CtaBanner = ({ heading, subhead, cta, secondaryCta, note }: CtaBannerProps
<div className="relative">
<h2
className="text-[26px] md:text-[32px] font-display font-medium text-white mx-auto mb-3"
style={{ letterSpacing: "-0.025em", lineHeight: "1.12", maxWidth: 480 }}
style={{ letterSpacing: "-0.025em", lineHeight: "1.12", maxWidth: headingMaxWidth }}
>
{heading}
</h2>
Expand Down
32 changes: 27 additions & 5 deletions src/components/IntegrationsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { SectionLabel, SectionHeading, SectionSub } from "@/components/LandingPrimitives";
import { useInView } from "@/hooks/useInView";

Expand All @@ -10,13 +11,34 @@ const INTEGRATIONS = [
interface IntegrationsSectionProps {
/** white (default) or the standard grey section treatment (zinc-50 + zinc-100 borders) */
tone?: "white" | "grey";
/** Section eyebrow; defaults to English. */
label?: ReactNode;
/** Section heading; defaults to English. */
heading?: ReactNode;
/** Supporting line under the heading; defaults to English. */
description?: ReactNode;
/** Docs link label; defaults to English. */
ctaLabel?: ReactNode;
}

/**
* Auto-scrolling marquee of supported integrations — S3-compatibility
* reassurance shared by the /lp/price and Barcelona landing pages.
*
* Copy is overridable so the Spanish pages can share the section rather than
* inlining a translated copy of it; the defaults are the English strings.
*/
const IntegrationsSection = ({ tone = "white" }: IntegrationsSectionProps) => {
const IntegrationsSection = ({
tone = "white",
label = "Integrations",
heading = (
<>
Works with your <span className="text-brand-500">existing stack</span>
</>
),
description = "S3 API compatible. If it talks to AWS, it talks to us.",
ctaLabel = "View documentation →",
}: IntegrationsSectionProps) => {
const { ref, inView } = useInView({ threshold: 0.05 });

return (
Expand All @@ -31,9 +53,9 @@ const IntegrationsSection = ({ tone = "white" }: IntegrationsSectionProps) => {
className={`flex flex-col gap-10 items-center text-center w-full max-w-container mx-auto reveal${inView ? " in-view" : ""}`}
>
<div className="flex flex-col gap-3 items-center">
<SectionLabel>Integrations</SectionLabel>
<SectionHeading>Works with your <span className="text-brand-500">existing stack</span></SectionHeading>
<SectionSub maxWidth={440}>S3 API compatible. If it talks to AWS, it talks to us.</SectionSub>
<SectionLabel>{label}</SectionLabel>
<SectionHeading>{heading}</SectionHeading>
<SectionSub maxWidth={440}>{description}</SectionSub>
</div>

<div className="marquee-mask w-full overflow-hidden">
Expand All @@ -54,7 +76,7 @@ const IntegrationsSection = ({ tone = "white" }: IntegrationsSectionProps) => {
</div>

<a href="https://docs.fil.one" target="_blank" rel="noopener noreferrer" className="btn-secondary">
View documentation →
{ctaLabel}
</a>
</div>
</section>
Expand Down
18 changes: 17 additions & 1 deletion src/components/PriceComparisonTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,25 @@ interface PriceComparisonTableProps {
centerFootnote?: boolean;
}

/**
* Parse a formatted currency string to a number, tolerating both the English
* ("€1,234.56") and Spanish ("1.234,56 €") conventions. Whichever of "." or ","
* comes last is the decimal separator when 1 to 2 digits follow it; otherwise
* every separator is thousands grouping. Returns NaN for non-numeric values,
* which falls through to the neutral tone.
*/
const parseAmount = (value: string) => {
const digits = value.replace(/[^0-9.,-]/g, "");
const lastSep = Math.max(digits.lastIndexOf("."), digits.lastIndexOf(","));
if (lastSep === -1) return parseFloat(digits);
const trailing = digits.length - lastSep - 1;
if (trailing < 1 || trailing > 2) return parseFloat(digits.replace(/[.,]/g, ""));
return parseFloat(`${digits.slice(0, lastSep).replace(/[.,]/g, "")}.${digits.slice(lastSep + 1)}`);
};

/** Zero reads as success, a large charge as danger, anything else neutral. */
const valueTone = (value: string) => {
const n = parseFloat(value.replace(/[^0-9.-]/g, ""));
const n = parseAmount(value);
if (n === 0) return "text-success-700";
if (n > 50) return "text-danger-600";
return "text-zinc-600";
Expand Down
6 changes: 6 additions & 0 deletions src/lib/pricing.constants.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ export const PRICE_PER_TB_SHORT_EUR: string;
export const PRICE_PER_TB_MONTH_EUR: string;
export const EUR_USD_RATE: number;
export const EUR_USD_RATE_SOURCE: string;
export function eurEs(amount: number, decimals?: number): string;
export const PRICE_DISPLAY_EUR_ES: string;
export const PRICE_PER_TB_SHORT_EUR_ES: string;
export const PRICE_PER_TB_MONTH_EUR_ES: string;
export const EUR_USD_RATE_ES: string;
export const EUR_USD_RATE_SOURCE_ES: string;
26 changes: 26 additions & 0 deletions src/lib/pricing.constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,29 @@ export const EUR_USD_RATE = 1.17;

/** Where and when EUR_USD_RATE was taken, for the comparison-table footnote. */
export const EUR_USD_RATE_SOURCE = "ECB rate, May 2026";

/* ── Spanish-locale EUR formatting ──────────────────────────────────────────
* Spanish writes a decimal comma with the symbol after the number and a
* non-breaking space ("4,99 €"), not "€4.99". The Spanish pages format every
* figure this way, so the helper is shared rather than hand-written per page.
* ─────────────────────────────────────────────────────────────────────────── */

/** Format a EUR amount the Spanish way, e.g. 49.9 -> "49,90 €". Pass
* `decimals: 0` for whole-euro figures like "197 €". */
export const eurEs = (amount, decimals = 2) =>
`${amount.toFixed(decimals).replace(".", ",")} €`;

/** The bare EUR price, Spanish format, e.g. "4,99 €". */
export const PRICE_DISPLAY_EUR_ES = eurEs(PRICE_PER_TB_EUR);

/** The short per-TB EUR rate, Spanish format, e.g. "4,99 €/TB". */
export const PRICE_PER_TB_SHORT_EUR_ES = `${PRICE_DISPLAY_EUR_ES}/TB`;

/** The full per-TB EUR rate for inline Spanish copy, e.g. "4,99 €/TB al mes". */
export const PRICE_PER_TB_MONTH_EUR_ES = `${PRICE_PER_TB_SHORT_EUR_ES} al mes`;

/** EUR_USD_RATE in Spanish format with the symbol after the number, "1,17 $". */
export const EUR_USD_RATE_ES = `${EUR_USD_RATE.toFixed(2).replace(".", ",")} $`;

/** Spanish rendering of EUR_USD_RATE_SOURCE, for the ES comparison footnote. */
export const EUR_USD_RATE_SOURCE_ES = "BCE, mayo de 2026";
12 changes: 12 additions & 0 deletions src/lib/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export {
EUR_USD_RATE,
/** Where and when EUR_USD_RATE was taken. */
EUR_USD_RATE_SOURCE,
/** Format a EUR amount the Spanish way, e.g. 49.9 -> "49,90 €". */
eurEs,
/** The bare EUR price, Spanish format, e.g. "4,99 €". */
PRICE_DISPLAY_EUR_ES,
/** The short per-TB EUR rate, Spanish format, e.g. "4,99 €/TB". */
PRICE_PER_TB_SHORT_EUR_ES,
/** The full per-TB EUR rate for Spanish copy, e.g. "4,99 €/TB al mes". */
PRICE_PER_TB_MONTH_EUR_ES,
/** EUR_USD_RATE in Spanish format, e.g. "1,17 $". */
EUR_USD_RATE_ES,
/** Spanish rendering of EUR_USD_RATE_SOURCE. */
EUR_USD_RATE_SOURCE_ES,
} from "./pricing.constants.mjs";

import { PRICE_PER_TB } from "./pricing.constants.mjs";
Expand Down
Loading
Loading