From a1f63588a0b85334158353686732858b9c2701e3 Mon Sep 17 00:00:00 2001 From: joaomiranda Date: Wed, 11 Feb 2026 16:31:54 +0000 Subject: [PATCH] first commit --- src/app/LeadCaptureForm.tsx | 122 +++++++++ src/app/api/demo-request/route.ts | 41 +++ src/app/api/waiting-list/route.ts | 40 +++ src/app/blog/[slug]/BlogPostTracker.tsx | 18 ++ src/app/blog/[slug]/page.tsx | 149 +++++++++++ src/app/blog/page.tsx | 79 ++++++ src/app/casos-de-uso/CasosDeUsoTabs.tsx | 123 +++++++++ src/app/casos-de-uso/page.tsx | 33 +++ src/app/contactos/DemoRequestForm.tsx | 102 ++++++++ src/app/contactos/WaitingListForm.tsx | 93 +++++++ src/app/contactos/page.tsx | 52 ++++ src/app/faq/page.tsx | 84 ++++++ src/app/globals.css | 82 +++++- src/app/layout.tsx | 50 ++-- src/app/page.tsx | 304 ++++++++++++++++++---- src/app/performance/page.tsx | 138 ++++++++++ src/app/privacidade/page.tsx | 81 ++++++ src/app/produto/page.tsx | 93 +++++++ src/app/robots.ts | 15 ++ src/app/sitemap.ts | 37 +++ src/app/sobre/page.tsx | 94 +++++++ src/app/tecnologia/page.tsx | 131 ++++++++++ src/app/treinadores-e-clubes/page.tsx | 117 +++++++++ src/app/treinos-guiados/page.tsx | 133 ++++++++++ src/components/Button.tsx | 62 +++++ src/components/CTASection.tsx | 42 +++ src/components/Card.tsx | 24 ++ src/components/Container.tsx | 19 ++ src/components/FAQAccordion.tsx | 69 +++++ src/components/Footer.tsx | 128 +++++++++ src/components/HUDPreviewSVG.tsx | 204 +++++++++++++++ src/components/Header.tsx | 103 ++++++++ src/components/PageHero.tsx | 34 +++ src/components/Section.tsx | 25 ++ src/components/Tabs.tsx | 57 ++++ src/components/TestimonialPlaceholder.tsx | 36 +++ src/lib/analytics.ts | 9 + src/lib/blog-data.ts | 244 +++++++++++++++++ src/lib/constants.ts | 6 + src/lib/submissions.ts | 47 ++++ 40 files changed, 3238 insertions(+), 82 deletions(-) create mode 100644 src/app/LeadCaptureForm.tsx create mode 100644 src/app/api/demo-request/route.ts create mode 100644 src/app/api/waiting-list/route.ts create mode 100644 src/app/blog/[slug]/BlogPostTracker.tsx create mode 100644 src/app/blog/[slug]/page.tsx create mode 100644 src/app/blog/page.tsx create mode 100644 src/app/casos-de-uso/CasosDeUsoTabs.tsx create mode 100644 src/app/casos-de-uso/page.tsx create mode 100644 src/app/contactos/DemoRequestForm.tsx create mode 100644 src/app/contactos/WaitingListForm.tsx create mode 100644 src/app/contactos/page.tsx create mode 100644 src/app/faq/page.tsx create mode 100644 src/app/performance/page.tsx create mode 100644 src/app/privacidade/page.tsx create mode 100644 src/app/produto/page.tsx create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts create mode 100644 src/app/sobre/page.tsx create mode 100644 src/app/tecnologia/page.tsx create mode 100644 src/app/treinadores-e-clubes/page.tsx create mode 100644 src/app/treinos-guiados/page.tsx create mode 100644 src/components/Button.tsx create mode 100644 src/components/CTASection.tsx create mode 100644 src/components/Card.tsx create mode 100644 src/components/Container.tsx create mode 100644 src/components/FAQAccordion.tsx create mode 100644 src/components/Footer.tsx create mode 100644 src/components/HUDPreviewSVG.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/PageHero.tsx create mode 100644 src/components/Section.tsx create mode 100644 src/components/Tabs.tsx create mode 100644 src/components/TestimonialPlaceholder.tsx create mode 100644 src/lib/analytics.ts create mode 100644 src/lib/blog-data.ts create mode 100644 src/lib/constants.ts create mode 100644 src/lib/submissions.ts diff --git a/src/app/LeadCaptureForm.tsx b/src/app/LeadCaptureForm.tsx new file mode 100644 index 0000000..5359fe4 --- /dev/null +++ b/src/app/LeadCaptureForm.tsx @@ -0,0 +1,122 @@ +"use client"; + +import React, { useState } from "react"; +import { track } from "@/lib/analytics"; + +export default function LeadCaptureForm() { + const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [nome, setNome] = useState(""); + const [email, setEmail] = useState(""); + const [perfil, setPerfil] = useState(""); + const [objetivo, setObjetivo] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setStatus("loading"); + track("submit_lead_capture", { perfil }); + + try { + const res = await fetch("/api/waiting-list", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nome, email, perfil, objetivo }), + }); + if (!res.ok) throw new Error("Erro ao submeter."); + setStatus("success"); + setNome(""); + setEmail(""); + setPerfil(""); + setObjetivo(""); + } catch { + setStatus("error"); + } + }; + + if (status === "success") { + return ( +
+

Inscrição confirmada!

+

+ Entraremos em contacto em breve. Obrigado pelo interesse. +

+
+ ); + } + + return ( +
+
+
+ + setNome(e.target.value)} + className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition" + /> +
+
+ + setEmail(e.target.value)} + className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition" + /> +
+
+
+ + +
+
+ + setObjetivo(e.target.value)} + placeholder="Ex: Melhorar pacing na maratona" + className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition" + /> +
+ + {status === "error" && ( +
+ Ocorreu um erro. Tenta novamente. +
+ )} + + +
+ ); +} diff --git a/src/app/api/demo-request/route.ts b/src/app/api/demo-request/route.ts new file mode 100644 index 0000000..c4ab972 --- /dev/null +++ b/src/app/api/demo-request/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { addDemoRequest } from "@/lib/submissions"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { nome, email, papel, organizacao, numAtletas, mensagem } = body; + + if (!nome || !email || !papel) { + return NextResponse.json( + { error: "Nome, email e papel são obrigatórios." }, + { status: 400 } + ); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: "Email inválido." }, + { status: 400 } + ); + } + + addDemoRequest({ + nome, + email, + papel, + organizacao: organizacao ?? "", + numAtletas: numAtletas ?? "", + mensagem: mensagem ?? "", + createdAt: new Date().toISOString(), + }); + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json( + { error: "Erro ao processar o pedido." }, + { status: 500 } + ); + } +} diff --git a/src/app/api/waiting-list/route.ts b/src/app/api/waiting-list/route.ts new file mode 100644 index 0000000..7c42a4e --- /dev/null +++ b/src/app/api/waiting-list/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { addWaitingListEntry } from "@/lib/submissions"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { nome, email, perfil, objetivo, distancia } = body; + + if (!nome || !email || !perfil) { + return NextResponse.json( + { error: "Nome, email e perfil são obrigatórios." }, + { status: 400 } + ); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: "Email inválido." }, + { status: 400 } + ); + } + + addWaitingListEntry({ + nome, + email, + perfil, + distancia: distancia ?? "", + objetivo: objetivo ?? "", + createdAt: new Date().toISOString(), + }); + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json( + { error: "Erro ao processar o pedido." }, + { status: 500 } + ); + } +} diff --git a/src/app/blog/[slug]/BlogPostTracker.tsx b/src/app/blog/[slug]/BlogPostTracker.tsx new file mode 100644 index 0000000..fa22388 --- /dev/null +++ b/src/app/blog/[slug]/BlogPostTracker.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; +import { track } from "@/lib/analytics"; + +export default function BlogPostTracker({ + slug, + title, +}: { + slug: string; + title: string; +}) { + useEffect(() => { + track("blog_post_open", { slug, title }); + }, [slug, title]); + + return null; +} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..39dde60 --- /dev/null +++ b/src/app/blog/[slug]/page.tsx @@ -0,0 +1,149 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import PageHero from "@/components/PageHero"; +import Section from "@/components/Section"; +import CTASection from "@/components/CTASection"; +import { getPostBySlug, getAllPosts } from "@/lib/blog-data"; +import BlogPostTracker from "./BlogPostTracker"; + +interface PageProps { + params: Promise<{ slug: string }>; +} + +export async function generateStaticParams() { + return getAllPosts().map((post) => ({ slug: post.slug })); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + const post = getPostBySlug(slug); + if (!post) return { title: "Artigo não encontrado" }; + return { + title: post.title, + description: post.description, + }; +} + +export default async function BlogPostPage({ params }: PageProps) { + const { slug } = await params; + const post = getPostBySlug(slug); + if (!post) notFound(); + + // Simple markdown-like rendering for the body + const renderBody = (body: string) => { + return body.split("\n").map((line, i) => { + if (line.startsWith("## ")) { + return ( +

+ {line.replace("## ", "")} +

+ ); + } + if (line.startsWith("### ")) { + return ( +

+ {line.replace("### ", "")} +

+ ); + } + if (line.startsWith("> ")) { + return ( +
+ {line.replace("> ", "")} +
+ ); + } + if (line.startsWith("- **")) { + const match = line.match(/- \*\*(.+?)\*\*\s*[—–-]\s*(.+)/); + if (match) { + return ( +
  • + + + {match[1]} — {match[2]} + +
  • + ); + } + } + if (line.startsWith("- ")) { + return ( +
  • + + {line.replace("- ", "")} +
  • + ); + } + if (line.startsWith("**Exemplo")) { + return ( +

    {line.replace(/\*\*/g, "")}

    + ); + } + if (line.trim() === "") { + return
    ; + } + return ( +

    + {line} +

    + ); + }); + }; + + return ( + <> + + + +
    + + · + {post.readingTime} de leitura +
    +
    + {post.tags.map((tag) => ( + + {tag} + + ))} +
    +
    + +
    +
    + {renderBody(post.body)} +
    + +
    + + + + + Voltar ao blog + +
    +
    + + + + ); +} diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx new file mode 100644 index 0000000..71e8ea3 --- /dev/null +++ b/src/app/blog/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import PageHero from "@/components/PageHero"; +import Section from "@/components/Section"; +import { getAllPosts } from "@/lib/blog-data"; + +export const metadata: Metadata = { + title: "Blog", + description: + "Artigos sobre performance, treino e biomecânica para corredores e treinadores.", +}; + +function BlogPostCard({ + slug, + title, + description, + date, + tags, + readingTime, +}: { + slug: string; + title: string; + description: string; + date: string; + tags: string[]; + readingTime: string; +}) { + return ( +
    +
    + {tags.map((tag) => ( + + {tag} + + ))} +
    +

    + {title} +

    +

    + {description} +

    +
    + + {readingTime} de leitura +
    +
    + ); +} + +export default function BlogPage() { + const posts = getAllPosts(); + + return ( + <> + + +
    +
    + {posts.map((post) => ( + + ))} +
    +
    + + ); +} diff --git a/src/app/casos-de-uso/CasosDeUsoTabs.tsx b/src/app/casos-de-uso/CasosDeUsoTabs.tsx new file mode 100644 index 0000000..85e934b --- /dev/null +++ b/src/app/casos-de-uso/CasosDeUsoTabs.tsx @@ -0,0 +1,123 @@ +"use client"; + +import Tabs from "@/components/Tabs"; +import Card from "@/components/Card"; +import HUDPreviewSVG from "@/components/HUDPreviewSVG"; + +const casosDeUso = [ + { + id: "pista", + label: "Pista", + content: ( +
    +
    +
    +

    Treino de pista com precisão absoluta

    +

    + A pista é o laboratório do corredor. Distâncias exatas, superfície uniforme e controlo total. O HUD acrescenta uma camada de informação que elimina a necessidade de relógio ou cronómetro manual. +

    +
      + {["Splits por volta automáticos", "Contagem regressiva de intervalos", "Ritmo atual vs. ritmo-alvo", "Recuperação cronometrada"].map((item, i) => ( +
    • + + {item} +
    • + ))} +
    +
    + +
    +
    + {[ + { title: "Intervalos curtos", desc: "200m–400m com recuperação programada." }, + { title: "Intervalos longos", desc: "800m–2000m para VO2máx e limiar." }, + { title: "Tempo runs", desc: "Ritmo sustentado com controlo visual." }, + ].map((item, i) => ( + +

    {item.title}

    +

    {item.desc}

    +
    + ))} +
    +
    + ), + }, + { + id: "estrada", + label: "Estrada", + content: ( +
    +
    +
    +

    Provas e longões com estratégia

    +

    + Na estrada, cada segundo conta. O HUD mantém o teu plano de splits sempre visível, permitindo ajustes instantâneos sem perder o ritmo. +

    +
      + {["Plano de splits por km visível", "Delta acumulado face ao objetivo", "Alertas de zona cardíaca", "Estimativa de tempo final"].map((item, i) => ( +
    • + + {item} +
    • + ))} +
    +
    + +
    +
    + {[ + { title: "5K–10K", desc: "Ritmo agressivo com gestão de esforço." }, + { title: "Meia-maratona", desc: "Even ou negative splits com controlo total." }, + { title: "Maratona", desc: "Gestão energética e pacing conservador nos primeiros km." }, + ].map((item, i) => ( + +

    {item.title}

    +

    {item.desc}

    +
    + ))} +
    +
    + ), + }, + { + id: "trail", + label: "Trail", + content: ( +
    +
    +
    +

    Trail com gestão de esforço inteligente

    +

    + No trail, o ritmo é irrelevante em subidas íngremes. A potência torna-se a métrica dominante. O HUD adapta-se ao contexto e mostra o que realmente importa. +

    +
      + {["Potência como métrica principal em subida", "Desnível acumulado em tempo real", "Alertas de esforço excessivo", "Gestão energética para ultras"].map((item, i) => ( +
    • + + {item} +
    • + ))} +
    +
    + +
    +
    + {[ + { title: "Trail curto", desc: "Até 25 km. Gestão de subidas com watts." }, + { title: "Trail longo", desc: "25–80 km. Pacing por esforço, não por ritmo." }, + { title: "Ultra", desc: "80+ km. Monitorização de fadiga a longo prazo." }, + ].map((item, i) => ( + +

    {item.title}

    +

    {item.desc}

    +
    + ))} +
    +
    + ), + }, +]; + +export default function CasosDeUsoTabs() { + return ; +} diff --git a/src/app/casos-de-uso/page.tsx b/src/app/casos-de-uso/page.tsx new file mode 100644 index 0000000..d569f9e --- /dev/null +++ b/src/app/casos-de-uso/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import PageHero from "@/components/PageHero"; +import Section from "@/components/Section"; +import CTASection from "@/components/CTASection"; +import Button from "@/components/Button"; +import CasosDeUsoTabs from "./CasosDeUsoTabs"; + +export const metadata: Metadata = { + title: "Casos de Uso", + description: + "De intervalos na pista a ultras de montanha — o HUD adapta-se ao teu contexto de corrida.", +}; + +export default function CasosDeUsoPage() { + return ( + <> + + + + +
    + +
    + + + + ); +} diff --git a/src/app/contactos/DemoRequestForm.tsx b/src/app/contactos/DemoRequestForm.tsx new file mode 100644 index 0000000..d4049ad --- /dev/null +++ b/src/app/contactos/DemoRequestForm.tsx @@ -0,0 +1,102 @@ +"use client"; + +import React, { useState } from "react"; +import { track } from "@/lib/analytics"; + +export default function DemoRequestForm() { + const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [nome, setNome] = useState(""); + const [email, setEmail] = useState(""); + const [organizacao, setOrganizacao] = useState(""); + const [papel, setPapel] = useState(""); + const [numAtletas, setNumAtletas] = useState(""); + const [mensagem, setMensagem] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setStatus("loading"); + track("submit_demo_request", { papel }); + + try { + const res = await fetch("/api/demo-request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nome, email, organizacao, papel, numAtletas, mensagem }), + }); + if (!res.ok) throw new Error("Erro ao submeter."); + setStatus("success"); + } catch { + setStatus("error"); + } + }; + + if (status === "success") { + return ( +
    +

    Pedido enviado!

    +

    + Entraremos em contacto em breve para agendar a demonstração. +

    +
    + ); + } + + return ( +
    +
    +
    + + setNome(e.target.value)} + className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition" /> +
    +
    + + setEmail(e.target.value)} + className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition" /> +
    +
    +
    +
    + + setOrganizacao(e.target.value)} + className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition" /> +
    +
    + + +
    +
    +
    + + setNumAtletas(e.target.value)} + placeholder="Ex: 15-20" + className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 transition" /> +
    +
    + +