nipponboardspt 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.lovable/plan/nippon-boards-landing-page-2026-09-22.md +27 -0
  2. package/.lovable/project.json +5 -0
  3. package/.prettierignore +8 -0
  4. package/.prettierrc +6 -0
  5. package/AGENTS.md +10 -0
  6. package/README.md +134 -0
  7. package/bun.lock +1048 -0
  8. package/bunfig.toml +7 -0
  9. package/components.json +22 -0
  10. package/eslint.config.js +40 -0
  11. package/package.json +91 -0
  12. package/public/favicon.svg +7 -0
  13. package/public/robots.txt +14 -0
  14. package/roadmap.md +6 -0
  15. package/src/assets/nippon-ecu-bench.jpg +0 -0
  16. package/src/assets/nippon-hero-lab.jpg +0 -0
  17. package/src/assets/nippon-microsolder.jpg +0 -0
  18. package/src/components/Button.tsx +37 -0
  19. package/src/components/ui/accordion.tsx +51 -0
  20. package/src/components/ui/alert-dialog.tsx +115 -0
  21. package/src/components/ui/alert.tsx +49 -0
  22. package/src/components/ui/aspect-ratio.tsx +5 -0
  23. package/src/components/ui/avatar.tsx +47 -0
  24. package/src/components/ui/badge.tsx +32 -0
  25. package/src/components/ui/breadcrumb.tsx +101 -0
  26. package/src/components/ui/button.tsx +49 -0
  27. package/src/components/ui/calendar.tsx +177 -0
  28. package/src/components/ui/card.tsx +55 -0
  29. package/src/components/ui/carousel.tsx +240 -0
  30. package/src/components/ui/chart.tsx +331 -0
  31. package/src/components/ui/checkbox.tsx +26 -0
  32. package/src/components/ui/collapsible.tsx +11 -0
  33. package/src/components/ui/command.tsx +143 -0
  34. package/src/components/ui/context-menu.tsx +186 -0
  35. package/src/components/ui/dialog.tsx +104 -0
  36. package/src/components/ui/drawer.tsx +98 -0
  37. package/src/components/ui/dropdown-menu.tsx +187 -0
  38. package/src/components/ui/form.tsx +171 -0
  39. package/src/components/ui/hover-card.tsx +27 -0
  40. package/src/components/ui/input-otp.tsx +73 -0
  41. package/src/components/ui/input.tsx +22 -0
  42. package/src/components/ui/label.tsx +21 -0
  43. package/src/components/ui/menubar.tsx +228 -0
  44. package/src/components/ui/navigation-menu.tsx +120 -0
  45. package/src/components/ui/pagination.tsx +98 -0
  46. package/src/components/ui/popover.tsx +31 -0
  47. package/src/components/ui/progress.tsx +25 -0
  48. package/src/components/ui/radio-group.tsx +36 -0
  49. package/src/components/ui/resizable.tsx +37 -0
  50. package/src/components/ui/scroll-area.tsx +44 -0
  51. package/src/components/ui/select.tsx +152 -0
  52. package/src/components/ui/separator.tsx +24 -0
  53. package/src/components/ui/sheet.tsx +122 -0
  54. package/src/components/ui/sidebar.tsx +744 -0
  55. package/src/components/ui/skeleton.tsx +7 -0
  56. package/src/components/ui/slider.tsx +23 -0
  57. package/src/components/ui/sonner.tsx +23 -0
  58. package/src/components/ui/switch.tsx +27 -0
  59. package/src/components/ui/table.tsx +94 -0
  60. package/src/components/ui/tabs.tsx +53 -0
  61. package/src/components/ui/textarea.tsx +21 -0
  62. package/src/components/ui/toggle-group.tsx +57 -0
  63. package/src/components/ui/toggle.tsx +42 -0
  64. package/src/components/ui/tooltip.tsx +32 -0
  65. package/src/hooks/use-mobile.tsx +19 -0
  66. package/src/lib/error-capture.ts +81 -0
  67. package/src/lib/error-page.ts +30 -0
  68. package/src/lib/lovable-error-reporting.ts +59 -0
  69. package/src/lib/utils.ts +6 -0
  70. package/src/routeTree.gen.ts +69 -0
  71. package/src/router.tsx +16 -0
  72. package/src/routes/README.md +21 -0
  73. package/src/routes/__root.tsx +123 -0
  74. package/src/routes/index.tsx +314 -0
  75. package/src/server.ts +61 -0
  76. package/src/start.ts +29 -0
  77. package/src/styles.css +259 -0
  78. package/tsconfig.json +30 -0
  79. package/vite.config.ts +15 -0
@@ -0,0 +1,21 @@
1
+ # Routes
2
+
3
+ TanStack Start uses **file-based routing**. Every `.tsx` file in this directory
4
+ defines a route. Do **not** create `src/pages/`, `src/routes/_app/index.tsx`, or
5
+ `app/layout.tsx` — those are Next.js / Remix conventions. The only root layout
6
+ is `src/routes/__root.tsx`.
7
+
8
+ ## Conventions
9
+
10
+ | File | URL |
11
+ | --- | --- |
12
+ | `index.tsx` | `/` |
13
+ | `about.tsx` | `/about` |
14
+ | `users/index.tsx` | `/users` |
15
+ | `users/$id.tsx` | `/users/:id` (dynamic — bare `$`, no curly braces) |
16
+ | `posts/{-$category}.tsx` | `/posts/:category?` (optional segment) |
17
+ | `files/$.tsx` | `/files/*` (splat — read via `_splat` param, never `*`) |
18
+ | `_layout.tsx` | layout route (renders children via `<Outlet />`) |
19
+ | `__root.tsx` | app shell — wraps every page; preserve `<Outlet />` |
20
+
21
+ `routeTree.gen.ts` is auto-generated. Don't edit it by hand.
@@ -0,0 +1,123 @@
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
+ import {
3
+ Outlet,
4
+ Link,
5
+ createRootRouteWithContext,
6
+ useRouter,
7
+ HeadContent,
8
+ Scripts,
9
+ } from "@tanstack/react-router";
10
+ import { useEffect, type ReactNode } from "react";
11
+
12
+ import appCss from "../styles.css?url";
13
+ import { reportLovableError } from "../lib/lovable-error-reporting";
14
+
15
+ function NotFoundComponent() {
16
+ return (
17
+ <div className="flex min-h-screen items-center justify-center bg-background px-4">
18
+ <div className="max-w-md text-center">
19
+ <h1 className="text-7xl font-bold text-foreground">404</h1>
20
+ <h2 className="mt-4 text-xl font-semibold text-foreground">Page not found</h2>
21
+ <p className="mt-2 text-sm text-muted-foreground">
22
+ The page you're looking for doesn't exist or has been moved.
23
+ </p>
24
+ <div className="mt-6">
25
+ <Link
26
+ to="/"
27
+ className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
28
+ >
29
+ Go home
30
+ </Link>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
36
+
37
+ function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
38
+ console.error(error);
39
+ const router = useRouter();
40
+ useEffect(() => {
41
+ reportLovableError(error, { boundary: "tanstack_root_error_component" });
42
+ }, [error]);
43
+
44
+ return (
45
+ <div className="flex min-h-screen items-center justify-center bg-background px-4">
46
+ <div className="max-w-md text-center">
47
+ <h1 className="text-xl font-semibold tracking-tight text-foreground">
48
+ This page didn't load
49
+ </h1>
50
+ <p className="mt-2 text-sm text-muted-foreground">
51
+ Something went wrong on our end. You can try refreshing or head back home.
52
+ </p>
53
+ <div className="mt-6 flex flex-wrap justify-center gap-2">
54
+ <button
55
+ onClick={() => {
56
+ router.invalidate();
57
+ reset();
58
+ }}
59
+ className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
60
+ >
61
+ Try again
62
+ </button>
63
+ <a
64
+ href="/"
65
+ className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
66
+ >
67
+ Go home
68
+ </a>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
76
+ head: () => ({
77
+ meta: [
78
+ { charSet: "utf-8" },
79
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
80
+ { title: "Nippon Boards" },
81
+ { name: "description", content: "Laboratório de eletrónica e micro-soldadura." },
82
+ { name: "author", content: "Nippon Boards" },
83
+ { property: "og:type", content: "website" },
84
+ { name: "twitter:card", content: "summary_large_image" },
85
+ ],
86
+ links: [
87
+ {
88
+ rel: "stylesheet",
89
+ href: appCss,
90
+ },
91
+ { rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
92
+ ],
93
+ }),
94
+ shellComponent: RootShell,
95
+ component: RootComponent,
96
+ notFoundComponent: NotFoundComponent,
97
+ errorComponent: ErrorComponent,
98
+ });
99
+
100
+ function RootShell({ children }: { children: ReactNode }) {
101
+ return (
102
+ <html lang="pt-PT">
103
+ <head>
104
+ <HeadContent />
105
+ </head>
106
+ <body>
107
+ {children}
108
+ <Scripts />
109
+ </body>
110
+ </html>
111
+ );
112
+ }
113
+
114
+ function RootComponent() {
115
+ const { queryClient } = Route.useRouteContext();
116
+
117
+ return (
118
+ <QueryClientProvider client={queryClient}>
119
+ {/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */}
120
+ <Outlet />
121
+ </QueryClientProvider>
122
+ );
123
+ }
@@ -0,0 +1,314 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import {
3
+ ArrowDown,
4
+ ArrowRight,
5
+ Box,
6
+ Check,
7
+ CheckCircle2,
8
+ ChevronDown,
9
+ CircuitBoard,
10
+ Cpu,
11
+ Gamepad2,
12
+ Headphones,
13
+ Instagram,
14
+ Mail,
15
+ Menu,
16
+ MessageCircle,
17
+ Microscope,
18
+ Paperclip,
19
+ Power,
20
+ ShieldCheck,
21
+ Smartphone,
22
+ Upload,
23
+ Wrench,
24
+ X,
25
+ Zap,
26
+ } from "lucide-react";
27
+ import { useState, type FormEvent } from "react";
28
+ import { Button } from "../components/Button";
29
+ import heroImage from "../assets/nippon-hero-lab.jpg";
30
+ import ecuImage from "../assets/nippon-ecu-bench.jpg";
31
+ import solderImage from "../assets/nippon-microsolder.jpg";
32
+
33
+ export const Route = createFileRoute("/")({
34
+ head: () => ({
35
+ meta: [
36
+ { title: "Nippon Boards | Reparação Eletrónica e Micro-soldadura" },
37
+ {
38
+ name: "description",
39
+ content:
40
+ "Diagnóstico e reparação de placas eletrónicas ao nível do componente: automóvel, consolas, áudio, equipamentos premium e PCB rework.",
41
+ },
42
+ { property: "og:title", content: "Nippon Boards | Reparação ao nível do componente" },
43
+ {
44
+ property: "og:description",
45
+ content: "Laboratório de eletrónica especializado em diagnóstico, micro-soldadura e reconstrução de placas PCB.",
46
+ },
47
+ { property: "og:type", content: "website" },
48
+ { name: "twitter:card", content: "summary_large_image" },
49
+ ],
50
+ }),
51
+ component: Index,
52
+ });
53
+
54
+ const CONTACT = {
55
+ email: "orcamentos@nipponboards.pt",
56
+ phone: "+351 910 174 286",
57
+ whatsapp:
58
+ "https://wa.me/351910174286?text=" +
59
+ encodeURIComponent("Olá Nippon Boards, gostaria de pedir um diagnóstico para o meu equipamento."),
60
+ instagram: "https://www.instagram.com/nipponboards.pt",
61
+ };
62
+
63
+ const specialties = [
64
+ {
65
+ icon: Cpu,
66
+ number: "01",
67
+ title: "Eletrónica Automóvel & Clássicos",
68
+ description: "ECUs, quadrantes, módulos de conforto e rádios vintage. Diagnóstico rigoroso sem substituições desnecessárias.",
69
+ },
70
+ {
71
+ icon: Gamepad2,
72
+ number: "02",
73
+ title: "Consolas & Portáteis",
74
+ description: "Nintendo Switch, PlayStation e Xbox. Portas HDMI e USB-C, circuitos de potência e falhas de arranque.",
75
+ },
76
+ {
77
+ icon: Power,
78
+ number: "03",
79
+ title: "Equipamentos Premium & Fontes",
80
+ description: "Módulos de controlo, ferramentas a bateria e fontes industriais que justificam uma reparação especializada.",
81
+ },
82
+ {
83
+ icon: Headphones,
84
+ number: "04",
85
+ title: "Áudio Vintage & Hi-Fi",
86
+ description: "Amplificadores e auto-rádios clássicos. Restauro de condensadores e recuperação fiel do circuito original.",
87
+ },
88
+ {
89
+ icon: CircuitBoard,
90
+ number: "05",
91
+ title: "Reconstrução de Placas",
92
+ description: "Pistas rompidas, corrosão por líquidos, pads arrancados e substituição de componentes BGA.",
93
+ },
94
+ ];
95
+
96
+ const process = [
97
+ { icon: MessageCircle, title: "Contacto & Triagem", text: "Descreva o equipamento e o sintoma por formulário ou WhatsApp." },
98
+ { icon: Box, title: "Envio da Peça", text: "Envie apenas o módulo por transportadora ou CTT Expresso." },
99
+ { icon: Microscope, title: "Diagnóstico & Orçamento", text: "Analisamos o circuito em bancada e confirmamos a intervenção." },
100
+ { icon: Check, title: "Reparação & Teste", text: "Reparamos, testamos e devolvemos a peça devidamente protegida." },
101
+ ];
102
+
103
+ const faqs = [
104
+ ["Que equipamentos aceitam?", "Analisamos placas eletrónicas de várias áreas, desde automóvel e áudio a consolas, ferramentas e módulos de controlo. Envie fotografias para confirmarmos a viabilidade."],
105
+ ["O orçamento é gratuito?", "A triagem inicial é gratuita. Se o equipamento exigir diagnóstico técnico em bancada, as condições são sempre comunicadas antes do envio."],
106
+ ["Posso enviar o equipamento completo?", "Preferimos receber apenas a placa ou módulo eletrónico. Não realizamos desmontagem nem montagem de componentes no veículo."],
107
+ ["Quanto tempo demora a reparação?", "O prazo depende da complexidade da avaria e da disponibilidade de componentes. Receberá uma estimativa depois do diagnóstico."],
108
+ ];
109
+
110
+ function Brand({ light = false }: { light?: boolean }) {
111
+ return (
112
+ <a href="#inicio" className={`brand ${light ? "brand-light" : ""}`} aria-label="Nippon Boards — início">
113
+ NIPP<span className="brand-disc" aria-hidden="true" />N <small>boards</small>
114
+ </a>
115
+ );
116
+ }
117
+
118
+ function Index() {
119
+ const [menuOpen, setMenuOpen] = useState(false);
120
+ const [submitted, setSubmitted] = useState(false);
121
+ const [fileNames, setFileNames] = useState<string[]>([]);
122
+ const [mailto, setMailto] = useState(`mailto:${CONTACT.email}`);
123
+
124
+ function handleSubmit(event: FormEvent<HTMLFormElement>) {
125
+ event.preventDefault();
126
+ if (!event.currentTarget.checkValidity()) {
127
+ event.currentTarget.reportValidity();
128
+ return;
129
+ }
130
+ const data = new FormData(event.currentTarget);
131
+ const value = (key: string) => String(data.get(key) ?? "");
132
+ const body = [
133
+ `Nome: ${value("name")}`,
134
+ `Telefone / WhatsApp: ${value("phone")}`,
135
+ `E-mail: ${value("email")}`,
136
+ `Categoria: ${value("category")}`,
137
+ `Marca e modelo: ${value("model")}`,
138
+ `Já foi intervencionado: ${value("intervention") === "sim" ? "Sim" : "Não"}`,
139
+ "",
140
+ "Sintoma / problema:",
141
+ value("problem"),
142
+ "",
143
+ fileNames.length ? `Fotografias a anexar: ${fileNames.join(", ")}` : "Sem fotografias anexadas.",
144
+ ].join("\n");
145
+ setMailto(
146
+ `mailto:${CONTACT.email}?subject=${encodeURIComponent(`Pedido de diagnóstico — ${value("model") || "equipamento"}`)}&body=${encodeURIComponent(body)}`,
147
+ );
148
+ setSubmitted(true);
149
+ }
150
+
151
+ return (
152
+ <main className="overflow-hidden bg-background text-foreground">
153
+ <header className="fixed inset-x-0 top-0 z-50 border-b border-hero-foreground/10 bg-hero/95 backdrop-blur-md">
154
+ <div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-5 lg:px-8">
155
+ <Brand light />
156
+ <nav className="hidden items-center gap-8 lg:flex" aria-label="Navegação principal">
157
+ <a className="nav-link" href="#especialidades">Especialidades</a>
158
+ <a className="nav-link" href="#processo">Como funciona</a>
159
+ <a className="nav-link" href="#galeria">Galeria</a>
160
+ <a className="nav-link" href="#faq">FAQ</a>
161
+ </nav>
162
+ <div className="hidden lg:block">
163
+ <Button asChild><a href="#diagnostico">Pedir diagnóstico <ArrowRight size={16} /></a></Button>
164
+ </div>
165
+ <Button variant="outline" className="size-11 min-h-11 shrink-0 px-0 text-hero-foreground lg:hidden" onClick={() => setMenuOpen(!menuOpen)} aria-label={menuOpen ? "Fechar menu" : "Abrir menu"}>
166
+ {menuOpen ? <X size={20} /> : <Menu size={20} />}
167
+ </Button>
168
+ </div>
169
+ {menuOpen && (
170
+ <nav className="border-t border-hero-foreground/10 bg-hero px-5 py-5 lg:hidden" aria-label="Navegação móvel">
171
+ {[["Especialidades", "#especialidades"], ["Como funciona", "#processo"], ["Galeria", "#galeria"], ["FAQ", "#faq"], ["Pedir diagnóstico", "#diagnostico"]].map(([label, href]) => (
172
+ <a key={href} href={href} onClick={() => setMenuOpen(false)} className="block border-b border-hero-foreground/10 py-3 text-sm font-semibold uppercase text-hero-foreground">{label}</a>
173
+ ))}
174
+ </nav>
175
+ )}
176
+ </header>
177
+
178
+ <section id="inicio" className="hero-section relative flex min-h-[92vh] items-end pt-20">
179
+ <img src={heroImage} alt="Placa eletrónica complexa sob microscópio numa bancada técnica" width={1600} height={1000} className="absolute inset-0 size-full object-cover object-center" />
180
+ <div className="hero-overlay absolute inset-0" />
181
+ <div className="relative mx-auto w-full max-w-7xl px-5 pb-16 pt-24 lg:px-8 lg:pb-20">
182
+ <div className="max-w-4xl">
183
+ <div className="mb-7 flex items-center gap-3 text-xs font-bold uppercase text-primary-bright">
184
+ <span className="h-px w-10 bg-primary" /> Laboratório de Eletrónica & Micro-soldadura
185
+ </div>
186
+ <h1 className="max-w-4xl text-balance font-display text-4xl font-semibold leading-[1.06] text-hero-foreground sm:text-6xl lg:text-7xl">
187
+ Reparação eletrónica ao nível do <span className="text-primary-bright">componente.</span>
188
+ </h1>
189
+ <p className="mt-6 max-w-2xl text-balance text-lg leading-8 text-hero-muted sm:text-xl">
190
+ Recuperamos onde outros recomendam substituir. Diagnóstico em bancada, micro-soldadura e reconstrução de placas — do automóvel clássico à tecnologia premium.
191
+ </p>
192
+ <div className="mt-9 flex flex-col gap-3 sm:flex-row">
193
+ <Button asChild className="sm:min-w-56"><a href="#diagnostico">Pedir orçamento gratuito <ArrowRight size={17} /></a></Button>
194
+ <Button asChild variant="outline" className="sm:min-w-48"><a href={CONTACT.whatsapp} target="_blank" rel="noopener noreferrer"><MessageCircle size={17} /> Falar no WhatsApp</a></Button>
195
+ </div>
196
+ </div>
197
+ <div className="mt-16 flex flex-wrap gap-x-8 gap-y-3 border-t border-hero-foreground/15 pt-5 text-xs font-semibold uppercase text-hero-muted">
198
+ <span className="flex items-center gap-2"><ShieldCheck size={16} className="text-primary-bright" /> Diagnóstico técnico</span>
199
+ <span className="flex items-center gap-2"><Microscope size={16} className="text-primary-bright" /> Intervenção microscópica</span>
200
+ <span className="flex items-center gap-2"><Zap size={16} className="text-primary-bright" /> Testes em bancada</span>
201
+ </div>
202
+ </div>
203
+ <a href="#especialidades" aria-label="Ver especialidades" className="absolute bottom-8 right-8 hidden size-12 items-center justify-center border border-hero-foreground/25 text-hero-foreground lg:flex"><ArrowDown size={18} /></a>
204
+ </section>
205
+
206
+ <section id="especialidades" className="section-light py-20 lg:py-28">
207
+ <div className="mx-auto max-w-7xl px-5 lg:px-8">
208
+ <div className="grid gap-8 border-b border-border pb-12 lg:grid-cols-[1fr_1.1fr] lg:items-end">
209
+ <div><p className="eyebrow">Especialidades</p><h2 className="section-title">Uma bancada.<br />Múltiplos desafios.</h2></div>
210
+ <p className="max-w-xl text-lg leading-8 text-muted-foreground lg:justify-self-end">Quando uma placa falha, procuramos a causa — não apenas o componente óbvio. Cada reparação começa com medição, método e experiência.</p>
211
+ </div>
212
+ <div className="mt-10 grid gap-px overflow-hidden border border-border bg-border md:grid-cols-2 lg:grid-cols-3">
213
+ {specialties.map(({ icon: Icon, number, title, description }, index) => (
214
+ <article key={title} className={`specialty-card bg-background p-7 lg:p-8 ${index === 4 ? "lg:col-span-2" : ""}`}>
215
+ <div className="flex items-start justify-between"><Icon size={30} strokeWidth={1.5} className="text-primary" /><span className="font-mono text-xs text-muted-foreground">{number}</span></div>
216
+ <h3 className="mt-12 max-w-sm font-display text-xl font-semibold">{title}</h3>
217
+ <p className="mt-3 max-w-xl leading-7 text-muted-foreground">{description}</p>
218
+ </article>
219
+ ))}
220
+ </div>
221
+ </div>
222
+ </section>
223
+
224
+ <section id="processo" className="bg-hero py-20 text-hero-foreground lg:py-28">
225
+ <div className="mx-auto max-w-7xl px-5 lg:px-8">
226
+ <div className="max-w-2xl"><p className="eyebrow">Como funciona o envio</p><h2 className="section-title text-hero-foreground">Da primeira mensagem<br />ao teste final.</h2></div>
227
+ <div className="relative mt-14 grid gap-10 md:grid-cols-2 lg:grid-cols-4 lg:gap-6">
228
+ <div className="absolute left-0 right-0 top-7 hidden h-px bg-hero-foreground/15 lg:block" />
229
+ {process.map(({ icon: Icon, title, text }, index) => (
230
+ <article key={title} className="relative">
231
+ <div className="relative z-10 flex size-14 items-center justify-center border border-primary/60 bg-hero text-primary-bright"><Icon size={23} strokeWidth={1.5} /></div>
232
+ <p className="mt-7 font-mono text-xs text-primary-bright">0{index + 1}</p>
233
+ <h3 className="mt-3 font-display text-xl font-semibold">{title}</h3>
234
+ <p className="mt-3 max-w-xs leading-7 text-hero-muted">{text}</p>
235
+ </article>
236
+ ))}
237
+ </div>
238
+ </div>
239
+ </section>
240
+
241
+ <section id="galeria" className="section-light py-20 lg:py-28">
242
+ <div className="mx-auto max-w-7xl px-5 lg:px-8">
243
+ <div className="flex flex-col justify-between gap-6 sm:flex-row sm:items-end"><div><p className="eyebrow">Na bancada</p><h2 className="section-title">Precisão à vista.</h2></div><p className="max-w-md text-sm leading-6 text-muted-foreground">Imagens ilustrativas do ambiente e do tipo de intervenção. Serão substituídas por fotografias reais da oficina.</p></div>
244
+ <div className="mt-12 grid gap-5 lg:grid-cols-[1.2fr_.8fr]">
245
+ <figure className="group overflow-hidden bg-foreground"><img src={solderImage} alt="Imagem ilustrativa: micro-soldadura de precisão numa placa" width={1200} height={900} loading="lazy" className="aspect-[4/3] size-full object-cover transition-transform duration-700 group-hover:scale-[1.02]" /><figcaption className="flex items-center justify-between px-5 py-4 text-sm text-background"><span>Micro-soldadura sob microscópio</span><span className="font-mono text-xs opacity-60">PCB / 01</span></figcaption></figure>
246
+ <figure className="group overflow-hidden bg-foreground"><img src={ecuImage} alt="Imagem ilustrativa: ECU automóvel aberta numa bancada" width={1200} height={900} loading="lazy" className="aspect-[4/3] size-full object-cover transition-transform duration-700 group-hover:scale-[1.02] lg:aspect-auto lg:h-[calc(100%-52px)]" /><figcaption className="flex items-center justify-between px-5 py-4 text-sm text-background"><span>Diagnóstico de ECU automóvel</span><span className="font-mono text-xs opacity-60">AUTO / 02</span></figcaption></figure>
247
+ </div>
248
+ </div>
249
+ </section>
250
+
251
+ <section id="diagnostico" className="bg-surface py-20 lg:py-28">
252
+ <div className="mx-auto grid max-w-7xl gap-14 px-5 lg:grid-cols-[.72fr_1.28fr] lg:px-8">
253
+ <div>
254
+ <p className="eyebrow">Pedido de diagnóstico</p>
255
+ <h2 className="section-title">Conte-nos o que se passa.</h2>
256
+ <p className="mt-6 max-w-md text-lg leading-8 text-muted-foreground">Quanto mais informação e fotografias enviar, mais precisa será a nossa triagem inicial.</p>
257
+ <div className="mt-10 space-y-5 border-t border-border pt-8 text-sm text-muted-foreground">
258
+ <p className="flex gap-3"><CheckCircle2 size={19} className="shrink-0 text-primary" /> Resposta com indicação dos próximos passos</p>
259
+ <p className="flex gap-3"><CheckCircle2 size={19} className="shrink-0 text-primary" /> Aceitamos envios de todo o país</p>
260
+ <p className="flex gap-3"><CheckCircle2 size={19} className="shrink-0 text-primary" /> Os seus dados são usados apenas para este pedido</p>
261
+ </div>
262
+ <div className="mt-8 space-y-3 border-t border-border pt-8 text-sm">
263
+ <a href={`mailto:${CONTACT.email}`} className="flex items-center gap-3 font-semibold text-foreground transition-colors hover:text-primary"><Mail size={17} className="text-primary" /> {CONTACT.email}</a>
264
+ <a href={CONTACT.whatsapp} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 font-semibold text-foreground transition-colors hover:text-primary"><MessageCircle size={17} className="text-primary" /> {CONTACT.phone}</a>
265
+ </div>
266
+ </div>
267
+ {submitted ? (
268
+ <div className="flex min-h-96 flex-col items-start justify-center border border-border bg-background p-8 lg:p-12" role="status">
269
+ <div className="flex size-14 items-center justify-center bg-primary text-primary-foreground"><Check size={26} /></div>
270
+ <h3 className="mt-7 font-display text-3xl font-semibold">Pedido pronto a enviar.</h3>
271
+ <p className="mt-4 max-w-lg leading-7 text-muted-foreground">Abra o pedido no seu programa de e-mail para o enviar para <a className="font-semibold text-primary" href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a>. Se escolher fotografias, anexe-as antes de enviar.</p>
272
+ <div className="mt-7 flex flex-col gap-3 sm:flex-row">
273
+ <Button asChild><a href={mailto}>Enviar por e-mail <ArrowRight size={17} /></a></Button>
274
+ <Button variant="dark" onClick={() => setSubmitted(false)}>Fazer novo pedido</Button>
275
+ </div>
276
+ </div>
277
+ ) : (
278
+ <form onSubmit={handleSubmit} className="grid gap-5 border border-border bg-background p-6 shadow-form sm:grid-cols-2 lg:p-9">
279
+ <label className="field"><span>Nome completo *</span><input required maxLength={100} name="name" autoComplete="name" placeholder="O seu nome" /></label>
280
+ <label className="field"><span>Telefone / WhatsApp *</span><input required maxLength={20} name="phone" type="tel" autoComplete="tel" placeholder="+351 9xx xxx xxx" /></label>
281
+ <label className="field sm:col-span-2"><span>E-mail *</span><input required maxLength={255} name="email" type="email" autoComplete="email" placeholder="nome@email.pt" /></label>
282
+ <label className="field"><span>Categoria *</span><span className="select-wrap"><select required name="category" defaultValue=""><option value="" disabled>Selecione</option><option>Automóvel</option><option>Consola</option><option>Eletrodoméstico</option><option>Áudio</option><option>Outro</option></select><ChevronDown size={17} /></span></label>
283
+ <label className="field"><span>Marca e modelo exato *</span><input required maxLength={120} name="model" placeholder="Ex.: Nintendo Switch OLED" /></label>
284
+ <label className="field sm:col-span-2"><span>Sintoma / problema *</span><textarea required maxLength={1500} name="problem" rows={5} placeholder="Descreva o comportamento, quando começou e o que já tentou…" /></label>
285
+ <fieldset className="sm:col-span-2"><legend className="field-label">Já foi intervencionado por outra pessoa? *</legend><div className="mt-3 flex gap-6"><label className="radio"><input required type="radio" name="intervention" value="sim" /> <span>Sim</span></label><label className="radio"><input required type="radio" name="intervention" value="nao" /> <span>Não</span></label></div></fieldset>
286
+ <label className="field sm:col-span-2"><span>Fotografias da placa / defeito</span><span className="upload-box"><Upload size={22} className="text-primary" /><strong>Escolher até 3 imagens</strong><small>JPG, PNG ou WEBP · máximo 8 MB por imagem</small><input className="hidden" type="file" name="photos" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files = Array.from(event.target.files ?? []).slice(0, 3); if ((event.target.files?.length ?? 0) > 3) event.target.value = ""; setFileNames(files.map((file) => file.name)); }} /></span>{fileNames.length > 0 && <span className="mt-2 flex items-center gap-2 text-xs text-muted-foreground"><Paperclip size={14} /> {fileNames.join(", ")}</span>}</label>
287
+ <div className="sm:col-span-2"><Button type="submit" className="w-full sm:w-auto">Enviar pedido de diagnóstico <ArrowRight size={17} /></Button></div>
288
+ </form>
289
+ )}
290
+ </div>
291
+ </section>
292
+
293
+ <section id="faq" className="section-light py-20 lg:py-28">
294
+ <div className="mx-auto grid max-w-7xl gap-12 px-5 lg:grid-cols-[.7fr_1.3fr] lg:px-8">
295
+ <div><p className="eyebrow">FAQ</p><h2 className="section-title">Antes de enviar.</h2></div>
296
+ <div className="border-t border-border">
297
+ {faqs.map(([question, answer]) => <details key={question} className="faq-item group border-b border-border"><summary><span>{question}</span><span className="faq-plus">+</span></summary><p>{answer}</p></details>)}
298
+ </div>
299
+ </div>
300
+ </section>
301
+
302
+ <footer className="bg-hero text-hero-foreground">
303
+ <div className="mx-auto max-w-7xl px-5 py-14 lg:px-8">
304
+ <div className="grid gap-12 border-b border-hero-foreground/15 pb-12 md:grid-cols-3">
305
+ <div><Brand light /><p className="mt-5 max-w-xs text-sm leading-6 text-hero-muted">Diagnóstico e reparação eletrónica ao nível do componente, com método e precisão.</p></div>
306
+ <div><p className="footer-title">Navegação</p><div className="mt-4 grid gap-3 text-sm text-hero-muted"><a href="#especialidades">Especialidades</a><a href="#processo">Como funciona</a><a href="#galeria">Galeria</a><a href="#faq">Perguntas frequentes</a></div></div>
307
+ <div><p className="footer-title">Contacto</p><div className="mt-4 grid gap-3 text-sm text-hero-muted"><a href={`mailto:${CONTACT.email}`} className="flex items-center gap-2 transition-colors hover:text-hero-foreground"><Mail size={16} /> {CONTACT.email}</a><a href={CONTACT.whatsapp} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 transition-colors hover:text-hero-foreground"><MessageCircle size={16} /> {CONTACT.phone}</a><a href={CONTACT.instagram} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 transition-colors hover:text-hero-foreground"><Instagram size={16} /> @nipponboards.pt</a></div></div>
308
+ </div>
309
+ <div className="flex flex-col gap-6 py-7 text-xs leading-5 text-hero-muted md:flex-row md:items-center md:justify-between"><p>© 2026 Nippon Boards. Todos os direitos reservados.</p><p className="max-w-2xl md:text-right"><strong className="text-hero-foreground">Nota:</strong> Não realizamos montagem/desmontagem de componentes no veículo. Apenas reparação da placa eletrónica.</p></div>
310
+ </div>
311
+ </footer>
312
+ </main>
313
+ );
314
+ }
package/src/server.ts ADDED
@@ -0,0 +1,61 @@
1
+ import "./lib/error-capture";
2
+
3
+ import { consumeLastCapturedError } from "./lib/error-capture";
4
+ import { renderErrorPage } from "./lib/error-page";
5
+
6
+ type ServerEntry = {
7
+ fetch: (request: Request, env: unknown, ctx: unknown) => Promise<Response> | Response;
8
+ };
9
+
10
+ let serverEntryPromise: Promise<ServerEntry> | undefined;
11
+
12
+ async function getServerEntry(): Promise<ServerEntry> {
13
+ if (!serverEntryPromise) {
14
+ serverEntryPromise = import("@tanstack/react-start/server-entry").then(
15
+ (m) => (m.default ?? m) as ServerEntry,
16
+ );
17
+ }
18
+ return serverEntryPromise;
19
+ }
20
+
21
+ // h3 swallows in-handler throws into a normal 500 Response with body
22
+ // {"unhandled":true,"message":"HTTPError"} — try/catch alone never fires for those.
23
+ async function normalizeCatastrophicSsrResponse(response: Response): Promise<Response> {
24
+ if (response.status < 500) return response;
25
+ const contentType = response.headers.get("content-type") ?? "";
26
+ if (!contentType.includes("application/json")) return response;
27
+
28
+ const body = await response.clone().text();
29
+ if (!isH3SwallowedErrorBody(body)) return response;
30
+
31
+ console.error(consumeLastCapturedError() ?? new Error(`h3 swallowed SSR error: ${body}`));
32
+ return new Response(renderErrorPage(), {
33
+ status: 500,
34
+ headers: { "content-type": "text/html; charset=utf-8" },
35
+ });
36
+ }
37
+
38
+ function isH3SwallowedErrorBody(body: string): boolean {
39
+ try {
40
+ const payload = JSON.parse(body) as { unhandled?: unknown; message?: unknown };
41
+ return payload.unhandled === true && payload.message === "HTTPError";
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export default {
48
+ async fetch(request: Request, env: unknown, ctx: unknown) {
49
+ try {
50
+ const handler = await getServerEntry();
51
+ const response = await handler.fetch(request, env, ctx);
52
+ return await normalizeCatastrophicSsrResponse(response);
53
+ } catch (error) {
54
+ console.error(error);
55
+ return new Response(renderErrorPage(), {
56
+ status: 500,
57
+ headers: { "content-type": "text/html; charset=utf-8" },
58
+ });
59
+ }
60
+ },
61
+ };
package/src/start.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { createStart, createCsrfMiddleware, createMiddleware } from "@tanstack/react-start";
2
+
3
+ import { renderErrorPage } from "./lib/error-page";
4
+
5
+ const errorMiddleware = createMiddleware().server(async ({ next }) => {
6
+ try {
7
+ return await next();
8
+ } catch (error) {
9
+ if (error != null && typeof error === "object" && "statusCode" in error) {
10
+ throw error;
11
+ }
12
+ console.error(error);
13
+ return new Response(renderErrorPage(), {
14
+ status: 500,
15
+ headers: { "content-type": "text/html; charset=utf-8" },
16
+ });
17
+ }
18
+ });
19
+
20
+ // Start installs this automatically when src/start.ts is absent; defining the
21
+ // file opts out, so re-add it explicitly to keep server functions protected
22
+ // from cross-site requests.
23
+ const csrfMiddleware = createCsrfMiddleware({
24
+ filter: (ctx) => ctx.handlerType === "serverFn",
25
+ });
26
+
27
+ export const startInstance = createStart(() => ({
28
+ requestMiddleware: [errorMiddleware, csrfMiddleware],
29
+ }));