diff --git a/.env.sample b/.env.sample
new file mode 100644
index 0000000..1c14150
--- /dev/null
+++ b/.env.sample
@@ -0,0 +1,9 @@
+# SMTP Config
+SMTP_HOST=
+SMTP_PORT=
+SMTP_USER=
+SMTP_PASS=
+MAIL_TO=
+
+# Public
+VITE_CONTACT_ENDPOINT=
\ No newline at end of file
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 9a91606..157e827 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1,2 @@
-* @AlejandroLuisHC
\ No newline at end of file
+* @AlejandroLuisHC
+* @david-bardina
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index 25b374b..0000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-version: 2
-updates:
- # Enable version updates for npm (pnpm)
- - package-ecosystem: "npm"
- directory: "/"
- schedule:
- interval: "weekly"
- day: "monday"
- time: "09:00"
- open-pull-requests-limit: 5
- commit-message:
- prefix: "deps"
- include: "scope"
- reviewers:
- - "alher"
- labels:
- - "dependencies"
- - "automated"
-
- # Enable version updates for GitHub Actions
- - package-ecosystem: "github-actions"
- directory: "/"
- schedule:
- interval: "weekly"
- day: "monday"
- time: "09:00"
- open-pull-requests-limit: 3
- commit-message:
- prefix: "ci"
- include: "scope"
- reviewers:
- - "alher"
- labels:
- - "github-actions"
- - "automated"
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1b475d6..c1fdbdd 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -4,6 +4,8 @@
```
blocknroll/
+├── api/ # Serverless functions API (email sending)
+├── emails/ # Email templates
├── public/ # Static assets
├── src/
│ ├── assets/ # Images, docs, and other assets
@@ -109,6 +111,8 @@ const ROUTES = {
### Path Aliases Available:
- `@/` → `src/`
+- `@api/` → `api/`
+- `@emails/` → `emails/`
- `@components/` → `src/components/`
- `@pages/` → `src/pages/`
- `@layouts/` → `src/layouts/`
@@ -151,14 +155,3 @@ const ROUTES = {
- Automated testing
- Build verification
- Coverage reporting
-
-## 🔄 Migration Benefits
-
-The new architecture provides:
-
-1. **Better separation** of concerns
-2. **Easier navigation** for new developers
-3. **Scalable routing** for multi-page apps
-4. **Consistent layouts** across pages
-5. **Cleaner imports** with path aliases
-6. **Future-proof** structure for growth
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1660b39..c2a617a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## [0.6.0] (2025-08-14)
+
+### Features
+
+- Add better contact form with email sending service:
+ - Add email templates
+ - Use nodemailer and maileroo to send emails
+ - Configure vercel serverless functions to manage email sending
+
## [0.5.0] (2025-07-25)
### Features
diff --git a/README.md b/README.md
index 7d31c75..8fbf09e 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ A modern and welcoming landing page built with React and TypeScript.
## 🏐 About This Project
-This is a responsive, multilingual website template designed specifically for Block n' Roll beach volleyball club.
+This is a responsive, multilingual website designed specifically for Block n' Roll beach volleyball club.
### Key Features
@@ -12,7 +12,7 @@ This is a responsive, multilingual website template designed specifically for Bl
- **Multilingual Support**: Built-in internationalization (Spanish/English/Catalan)
- **Service Showcase**: Flexible sections for training programs and pricing
- **Dynamic Gallery**: Future Instagram integration with smart fallback to sample images
-- **Contact Section**: User-friendly contact and inquiry section
+- **Contact Section**: User-friendly contact and inquiry section with email sending service
- **Performance Optimized**: Fast loading with modern build tools
## 🚀 Technologies Used
@@ -23,6 +23,10 @@ This is a responsive, multilingual website template designed specifically for Bl
- **Bootstrap** - CSS framework for responsive design
- **react-i18next** - Internationalization library
- **Vitest** - Testing framework
+- **Vercel** - Serverless functions and deployment
+- **Nodemailer** - Email sending library
+- **Maileroo** - SMTP provider
+- **React Email** - Email templates
## 📦 Installation
@@ -52,7 +56,7 @@ This is a responsive, multilingual website template designed specifically for Bl
4. **Open your browser**
Navigate to `http://localhost:3000`
-## 🏗️ Project Structure
+## 🏗️ Main Project Structure
```
src/
@@ -142,6 +146,12 @@ The gallery should automatically display Instagram posts when configured:
- ✅ Manual refresh capability
- ✅ Redirect to post/account
+## 📧 Email Sending
+
+Emails are sent from a Serverless Function (`api/send-email.js`) using Nodemailer over SMTP.
+
+Note: Works with any SMTP provider (e.g., Maileroo, Gmail, etc.) by setting the variables accordingly.
+
## 🧪 Testing
The project includes comprehensive testing:
@@ -159,7 +169,6 @@ npm run test:coverage
## 🎯 To-Do List
- [ ] Instagram app integration
-- [ ] Booking/contact system
- [ ] SEO optimization
- [ ] Blog functionality
- [ ] Payment integration (?)
diff --git a/api/send-email.js b/api/send-email.js
new file mode 100644
index 0000000..22df9df
--- /dev/null
+++ b/api/send-email.js
@@ -0,0 +1,109 @@
+import React from "react";
+import nodemailer from "nodemailer";
+import { render } from "@react-email/render";
+import ContactEmail from "../emails/ContactEmail.js";
+
+export default async function handler(req, res) {
+ if (req.method !== "POST") {
+ res.status(405).json({ ok: false, error: "Method Not Allowed" });
+ return;
+ }
+
+ try {
+ const smtpHost = process.env.SMTP_HOST;
+ const smtpPort = Number(process.env.SMTP_PORT || 587);
+ const smtpUser = process.env.SMTP_USER;
+ const smtpPass = process.env.SMTP_PASS;
+ const mailTo = process.env.MAIL_TO;
+
+ if (!smtpHost || !smtpUser || !smtpPass || !mailTo) {
+ res.status(500).json({ ok: false, error: "Missing SMTP configuration", code: "MISSING_SMTP_CONFIG" });
+ return;
+ }
+
+ const { name, email, phone, message, meta } = (req.body || {});
+
+ if (!message || !name) {
+ res.status(400).json({ ok: false, error: "Missing message or name", code: "BAD_REQUEST" });
+ return;
+ }
+
+ const transporter = nodemailer.createTransport({
+ host: smtpHost,
+ port: smtpPort,
+ secure: smtpPort === 465,
+ auth: { user: smtpUser, pass: smtpPass },
+ });
+
+ const inquiryType = (meta?.inquiryType === "talk" ? "talk" : "join");
+ const subject = `BnR Web - ${inquiryType === "join" ? "Join" : "Info"} - ${name || "Web user"} - ${email || ""}`;
+
+ const summaryRows = [
+ ["Tipo de consulta", inquiryType === "join" ? "Apuntarse a entrenamientos" : "Información"],
+ ["Nombre", name || "-"],
+ ["Email", email || "-"],
+ ];
+ if (phone) summaryRows.push(["Teléfono", phone]);
+
+ const detailsRows = [];
+ // Format availability keys like "mon_18_1930" to human-readable Spanish labels
+ const formatAvailabilityLabel = (key) => {
+ if (typeof key !== "string") return String(key);
+ const [dayKey, ...rest] = key.split("_");
+ const slotKey = rest.join("_");
+ const dayMap = { mon: "Lun", tue: "Mar", wed: "Mié", thu: "Jue", fri: "Vie" };
+ const slotMap = { "18_1930": "18:00-19:30", "1930_21": "19:30-21:00", "21_2230": "21:00-22:30" };
+ const day = dayMap[dayKey] || dayKey;
+ const slot = slotMap[slotKey] || slotKey?.replace("_", ":")?.replace("_", ":");
+ return slot ? `${day} ${slot}` : day;
+ };
+ if (inquiryType === "join") {
+ if (meta?.players != null) detailsRows.push(["Número de jugadores", String(meta.players)]);
+ if (meta?.level) detailsRows.push(["Nivel estimado", String(meta.level)]);
+ if (meta?.packageType) detailsRows.push(["Paquete", String(meta.packageType) === "one_per_week" ? "Una vez por semana" : String(meta.packageType) === "two_per_week" ? "Dos veces por semana" : "Privados"]);
+ if (Array.isArray(meta?.availability)) {
+ const formatted = meta.availability.map(formatAvailabilityLabel);
+ detailsRows.push(["Disponibilidad", formatted.length ? formatted : ["Sin preferencia"]]);
+ }
+ }
+
+ let html;
+ try {
+ html = await render(
+ React.createElement(ContactEmail, {
+ title: "New contact message",
+ summary: summaryRows,
+ details: detailsRows.length ? detailsRows : undefined,
+ message,
+ })
+ );
+ } catch (renderError) {
+ console.error("render error:", renderError);
+ const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">");
+ const line = (k, v) => (v ? `
${esc(k)}: ${esc(v)}
` : "");
+ const details = detailsRows.map(([k, v]) => line(k, v)).join("");
+ html = `
+ New contact message
+ ${summaryRows.map(([k, v]) => line(k, v)).join("")}
+ ${details}
+
+ ${esc(message)}
+ `;
+ }
+
+ const info = await transporter.sendMail({
+ from: smtpUser,
+ to: mailTo,
+ replyTo: email || undefined,
+ subject,
+ html,
+ });
+
+ res.status(200).json({ ok: true, id: info.messageId });
+ } catch (error) {
+ console.error("send-email error:", error);
+ res.status(500).json({ ok: false, error: "Email send failed", code: "SEND_FAILED" });
+ }
+}
+
+
diff --git a/api/send-email.test.js b/api/send-email.test.js
new file mode 100644
index 0000000..830a438
--- /dev/null
+++ b/api/send-email.test.js
@@ -0,0 +1,115 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import handler from "./send-email.js";
+import { render } from "@react-email/render";
+
+// Hoisted placeholders so they can be referenced inside vi.mock factories
+const { sendMailMock, createTransportMock } = vi.hoisted(() => ({
+ sendMailMock: vi.fn(),
+ createTransportMock: vi.fn(),
+}));
+
+vi.mock("nodemailer", () => ({
+ default: {
+ createTransport: (...args) => {
+ createTransportMock(...args);
+ return { sendMail: sendMailMock };
+ },
+ },
+}));
+
+vi.mock("@react-email/render", () => ({
+ render: vi.fn(async () => "ok"),
+}));
+
+const createRes = () => {
+ const res = {
+ status: vi.fn().mockReturnThis(),
+ json: vi.fn(),
+ };
+ return res;
+};
+
+describe("api/send-email", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubEnv("SMTP_HOST", "smtp.example.com");
+ vi.stubEnv("SMTP_PORT", "587");
+ vi.stubEnv("SMTP_USER", "user@example.com");
+ vi.stubEnv("SMTP_PASS", "password");
+ vi.stubEnv("MAIL_TO", "dest@example.com");
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("returns 405 for non-POST methods", async () => {
+ const req = { method: "GET" };
+ const res = createRes();
+ await handler(req, res);
+ expect(res.status).toHaveBeenCalledWith(405);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ ok: false }));
+ });
+
+ it("returns 500 when SMTP config is missing", async () => {
+ vi.unstubAllEnvs(); // simulate missing env
+ const req = { method: "POST", body: { name: "John", message: "Hi" } };
+ const res = createRes();
+ await handler(req, res);
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ code: "MISSING_SMTP_CONFIG" })
+ );
+ });
+
+ it("returns 400 when required fields are missing", async () => {
+ const req = { method: "POST", body: { name: "", message: "" } };
+ const res = createRes();
+ await handler(req, res);
+ expect(res.status).toHaveBeenCalledWith(400);
+ });
+
+ it("sends email and formats availability labels to Spanish day/time", async () => {
+ const req = {
+ method: "POST",
+ body: {
+ name: "Jane",
+ email: "jane@example.com",
+ message: "Hello world",
+ meta: {
+ inquiryType: "join",
+ availability: ["mon_18_1930", "wed_1930_21", "thu_21_2230"],
+ },
+ },
+ };
+ const res = createRes();
+ await handler(req, res);
+
+ // Response
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(sendMailMock).toHaveBeenCalled();
+
+ // Render call and props
+ const element = render.mock.calls[0][0];
+ expect(element).toBeTruthy();
+ const props = element.props || {};
+ expect(Array.isArray(props.details)).toBe(true);
+ const availabilityRow = props.details.find((row) => row[0] === "Disponibilidad");
+ expect(availabilityRow).toBeTruthy();
+ expect(Array.isArray(availabilityRow[1])).toBe(true);
+ expect(availabilityRow[1]).toEqual(["Lun 18:00-19:30", "Mié 19:30-21:00", "Jue 21:00-22:30"]);
+ });
+
+ it("falls back to simple HTML when render throws", async () => {
+ render.mockImplementationOnce(async () => {
+ throw new Error("render boom");
+ });
+
+ const req = { method: "POST", body: { name: "John", email: "john@example.com", message: "Hi" } };
+ const res = createRes();
+ await handler(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(sendMailMock).toHaveBeenCalled();
+ });
+});
diff --git a/emails/ContactEmail.js b/emails/ContactEmail.js
new file mode 100644
index 0000000..fd124c6
--- /dev/null
+++ b/emails/ContactEmail.js
@@ -0,0 +1,148 @@
+import React from "react";
+import {
+ Html,
+ Head,
+ Preview,
+ Body,
+ Container,
+ Section,
+ Text,
+ Heading,
+ Hr,
+ Row,
+ Column,
+} from "@react-email/components";
+
+const ContactEmail = ({ title, summary, details, message }) =>
+ React.createElement(
+ Html,
+ null,
+ React.createElement(Head, null),
+ React.createElement(Preview, null, title),
+ React.createElement(
+ Body,
+ { style: { backgroundColor: "#f6f9fc", fontFamily: "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif" } },
+ React.createElement(
+ Container,
+ { style: { margin: "0 auto", padding: "24px", maxWidth: "640px" } },
+ React.createElement(
+ Section,
+ { style: { backgroundColor: "#ffffff", border: "1px solid #e5e7eb", borderRadius: 12, padding: 24 } },
+ React.createElement(Heading, { as: "h2", style: { margin: 0, marginBottom: 16, color: "#111827" } }, title),
+ React.createElement(
+ Section,
+ null,
+ ...summary.map(([k, v], idx) =>
+ React.createElement(Row, {
+ key: k,
+ style: { backgroundColor: idx % 2 ? "#f9fafb" : "transparent", padding: "10px 0", borderBottom: "1px solid #f1f5f9" },
+ children: [
+ React.createElement(
+ Column,
+ { style: { width: 180 } },
+ React.createElement(Text, { style: { margin: 0, fontWeight: 600, color: "#6b7280" } }, k)
+ ),
+ React.createElement(
+ Column,
+ null,
+ Array.isArray(v)
+ ? React.createElement(
+ "div",
+ null,
+ ...v.map((item, i) =>
+ React.createElement(
+ "span",
+ {
+ key: i,
+ style: {
+ display: "inline-block",
+ backgroundColor: "#eef2ff",
+ color: "#3730a3",
+ padding: "4px 10px",
+ borderRadius: 9999,
+ fontSize: 12,
+ marginRight: 6,
+ marginBottom: 6,
+ },
+ },
+ item
+ )
+ )
+ )
+ : React.createElement(Text, { style: { margin: 0, color: "#111827" } }, v || "-")
+ ),
+ ],
+ })
+ )
+ ),
+ details && details.length
+ ? React.createElement(
+ Section,
+ null,
+ React.createElement(Hr, null),
+ React.createElement(Heading, { as: "h3", style: { margin: 0, marginBottom: 8, fontSize: 16, color: "#111827" } }, "Detalles"),
+ ...(details || []).map(([k, v], idx) =>
+ React.createElement(Row, {
+ key: k,
+ style: { backgroundColor: idx % 2 ? "#f9fafb" : "transparent", padding: "10px 0", borderBottom: "1px solid #f1f5f9" },
+ children: [
+ React.createElement(
+ Column,
+ { style: { width: 180 } },
+ React.createElement(Text, { style: { margin: 0, fontWeight: 600, color: "#6b7280" } }, k)
+ ),
+ React.createElement(
+ Column,
+ null,
+ Array.isArray(v)
+ ? React.createElement(
+ "div",
+ null,
+ ...v.map((item, i) =>
+ React.createElement(
+ "span",
+ {
+ key: i,
+ style: {
+ display: "inline-block",
+ backgroundColor: "#eef2ff",
+ color: "#3730a3",
+ padding: "4px 10px",
+ borderRadius: 9999,
+ fontSize: 12,
+ marginRight: 6,
+ marginBottom: 6,
+ },
+ },
+ item
+ )
+ )
+ )
+ : React.createElement(Text, { style: { margin: 0, color: "#111827" } }, v || "-")
+ ),
+ ],
+ })
+ )
+ )
+ : null,
+ message
+ ? React.createElement(
+ React.Fragment,
+ null,
+ React.createElement(Hr, null),
+ React.createElement(
+ Section,
+ null,
+ React.createElement(Heading, { as: "h3", style: { margin: 0, marginBottom: 8, fontSize: 16, color: "#111827" } }, "Mensaje"),
+ React.createElement(Text, { style: { whiteSpace: "pre-wrap", margin: 0 } }, message)
+ )
+ )
+ : null
+ )
+ )
+ )
+ );
+
+export default ContactEmail;
+
+
diff --git a/package.json b/package.json
index 4a4e4b2..3ff8b4f 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "blocknroll",
"private": true,
- "version": "0.5.0",
+ "version": "0.6.0",
"description": "Block n' Roll - Club de Voleibol de Playa en San Sebastián",
"type": "module",
"author": "Alejandro L. Herrero ",
@@ -22,11 +22,14 @@
"test:cover": "vitest run --coverage"
},
"dependencies": {
+ "@react-email/components": "^0.5.0",
+ "@react-email/render": "^1.2.0",
"bootstrap": "^5.3.2",
"flag-icons": "^7.5.0",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.522.0",
+ "nodemailer": "^7.0.5",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.3",
@@ -34,15 +37,15 @@
},
"devDependencies": {
"@eslint/js": "^9.25.0",
- "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
- "@vitejs/plugin-react": "^4.4.1",
+ "@vitejs/plugin-react": "^5.0.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
- "eslint": "^9.25.0",
+ "eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9486853..fa23268 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,12 @@ importers:
.:
dependencies:
+ '@react-email/components':
+ specifier: ^0.5.0
+ version: 0.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@react-email/render':
+ specifier: ^1.2.0
+ version: 1.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
bootstrap:
specifier: ^5.3.2
version: 5.3.7(@popperjs/core@2.11.8)
@@ -23,6 +29,9 @@ importers:
lucide-react:
specifier: ^0.522.0
version: 0.522.0(react@19.1.0)
+ nodemailer:
+ specifier: ^7.0.5
+ version: 7.0.5
react:
specifier: ^19.1.0
version: 19.1.0
@@ -40,8 +49,8 @@ importers:
specifier: ^9.25.0
version: 9.29.0
'@testing-library/jest-dom':
- specifier: ^6.6.3
- version: 6.6.3
+ specifier: ^6.6.4
+ version: 6.6.4
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -55,8 +64,8 @@ importers:
specifier: ^19.1.2
version: 19.1.6(@types/react@19.1.8)
'@vitejs/plugin-react':
- specifier: ^4.4.1
- version: 4.6.0(vite@6.3.5)
+ specifier: ^5.0.0
+ version: 5.0.0(vite@6.3.5)
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4)
@@ -64,14 +73,14 @@ importers:
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4)
eslint:
- specifier: ^9.25.0
- version: 9.29.0
+ specifier: ^9.33.0
+ version: 9.33.0
eslint-plugin-react-hooks:
specifier: ^5.2.0
- version: 5.2.0(eslint@9.29.0)
+ version: 5.2.0(eslint@9.33.0)
eslint-plugin-react-refresh:
specifier: ^0.4.19
- version: 0.4.20(eslint@9.29.0)
+ version: 0.4.20(eslint@9.33.0)
globals:
specifier: ^16.0.0
version: 16.2.0
@@ -83,7 +92,7 @@ importers:
version: 5.8.3
typescript-eslint:
specifier: ^8.30.1
- version: 8.35.0(eslint@9.29.0)(typescript@5.8.3)
+ version: 8.35.0(eslint@9.33.0)(typescript@5.8.3)
vite:
specifier: ^6.3.5
version: 6.3.5
@@ -111,18 +120,22 @@ packages:
resolution: {integrity: sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==}
engines: {node: '>=6.9.0'}
- '@babel/core@7.27.4':
- resolution: {integrity: sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==}
+ '@babel/core@7.28.0':
+ resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==}
engines: {node: '>=6.9.0'}
- '@babel/generator@7.27.5':
- resolution: {integrity: sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==}
+ '@babel/generator@7.28.0':
+ resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.27.2':
resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-globals@7.28.0':
+ resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-module-imports@7.27.1':
resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
engines: {node: '>=6.9.0'}
@@ -158,6 +171,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/parser@7.28.0':
+ resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/plugin-transform-react-jsx-self@7.27.1':
resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
engines: {node: '>=6.9.0'}
@@ -178,14 +196,18 @@ packages:
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
- '@babel/traverse@7.27.4':
- resolution: {integrity: sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==}
+ '@babel/traverse@7.28.0':
+ resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==}
engines: {node: '>=6.9.0'}
'@babel/types@7.27.6':
resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==}
engines: {node: '>=6.9.0'}
+ '@babel/types@7.28.2':
+ resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
+ engines: {node: '>=6.9.0'}
+
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
@@ -378,20 +400,16 @@ packages:
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
- '@eslint/config-array@0.20.1':
- resolution: {integrity: sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
- '@eslint/config-helpers@0.2.3':
- resolution: {integrity: sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==}
+ '@eslint/config-array@0.21.0':
+ resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/core@0.14.0':
- resolution: {integrity: sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==}
+ '@eslint/config-helpers@0.3.1':
+ resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/core@0.15.0':
- resolution: {integrity: sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==}
+ '@eslint/core@0.15.2':
+ resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/eslintrc@3.3.1':
@@ -402,12 +420,16 @@ packages:
resolution: {integrity: sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/js@9.33.0':
+ resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@eslint/object-schema@2.1.6':
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/plugin-kit@0.3.2':
- resolution: {integrity: sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==}
+ '@eslint/plugin-kit@0.3.5':
+ resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@humanfs/core@0.19.1':
@@ -438,6 +460,9 @@ packages:
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
'@jridgewell/gen-mapping@0.3.8':
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
engines: {node: '>=6.0.0'}
@@ -456,6 +481,9 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+ '@jridgewell/trace-mapping@0.3.30':
+ resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -478,8 +506,133 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
- '@rolldown/pluginutils@1.0.0-beta.19':
- resolution: {integrity: sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==}
+ '@react-email/body@0.1.0':
+ resolution: {integrity: sha512-o1bcSAmDYNNHECbkeyceCVPGmVsYvT+O3sSO/Ct7apKUu3JphTi31hu+0Nwqr/pgV5QFqdoT5vdS3SW5DJFHgQ==}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/button@0.2.0':
+ resolution: {integrity: sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/code-block@0.1.0':
+ resolution: {integrity: sha512-jSpHFsgqnQXxDIssE4gvmdtFncaFQz5D6e22BnVjcCPk/udK+0A9jRwGFEG8JD2si9ZXBmU4WsuqQEczuZn4ww==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/code-inline@0.0.5':
+ resolution: {integrity: sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/column@0.0.13':
+ resolution: {integrity: sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/components@0.5.0':
+ resolution: {integrity: sha512-esRbP+yMmSkNP9hcpiy2RwpDnvSmlxJcJ1HHbzSwlACGlCHTap+ma344QovvzhpVRhMccyWemdClLG822UvVpQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/container@0.0.15':
+ resolution: {integrity: sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/font@0.0.9':
+ resolution: {integrity: sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw==}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/head@0.0.12':
+ resolution: {integrity: sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/heading@0.0.15':
+ resolution: {integrity: sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/hr@0.0.11':
+ resolution: {integrity: sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/html@0.0.11':
+ resolution: {integrity: sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/img@0.0.11':
+ resolution: {integrity: sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/link@0.0.12':
+ resolution: {integrity: sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/markdown@0.0.15':
+ resolution: {integrity: sha512-UQA9pVm5sbflgtg3EX3FquUP4aMBzmLReLbGJ6DZQZnAskBF36aI56cRykDq1o+1jT+CKIK1CducPYziaXliag==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/preview@0.0.13':
+ resolution: {integrity: sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/render@1.2.0':
+ resolution: {integrity: sha512-5fpbV16VYR9Fmk8t7xiwPNAjxjdI8XzVtlx9J9OkhOsIHdr2s5DwAj8/MXzWa9qRYJyLirQ/l7rBSjjgyRAomw==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/row@0.0.12':
+ resolution: {integrity: sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/section@0.0.16':
+ resolution: {integrity: sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/tailwind@1.2.2':
+ resolution: {integrity: sha512-heO9Khaqxm6Ulm6p7HQ9h01oiiLRrZuuEQuYds/O7Iyp3c58sMVHZGIxiRXO/kSs857NZQycpjewEVKF3jhNTw==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@react-email/text@0.1.5':
+ resolution: {integrity: sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || ^19.0.0-rc
+
+ '@rolldown/pluginutils@1.0.0-beta.30':
+ resolution: {integrity: sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw==}
'@rollup/rollup-android-arm-eabi@4.44.0':
resolution: {integrity: sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==}
@@ -581,12 +734,15 @@ packages:
cpu: [x64]
os: [win32]
+ '@selderee/plugin-htmlparser2@0.11.0':
+ resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
+
'@testing-library/dom@10.4.0':
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
engines: {node: '>=18'}
- '@testing-library/jest-dom@6.6.3':
- resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==}
+ '@testing-library/jest-dom@6.6.4':
+ resolution: {integrity: sha512-xDXgLjVunjHqczScfkCJ9iyjdNOVHvvCdqHSSxwM9L0l/wHkTRum67SDc020uAlCoqktJplgO2AAQeLP1wgqDQ==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@testing-library/react@16.3.0':
@@ -704,11 +860,11 @@ packages:
resolution: {integrity: sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@vitejs/plugin-react@4.6.0':
- resolution: {integrity: sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==}
- engines: {node: ^14.18.0 || >=16.0.0}
+ '@vitejs/plugin-react@5.0.0':
+ resolution: {integrity: sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
- vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/coverage-v8@3.2.4':
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
@@ -845,10 +1001,6 @@ packages:
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
engines: {node: '>=12'}
- chalk@3.0.0:
- resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
- engines: {node: '>=8'}
-
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -911,6 +1063,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+ deepmerge@4.3.1:
+ resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+ engines: {node: '>=0.10.0'}
+
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -921,6 +1077,19 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+ dom-serializer@2.0.0:
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+ domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+ domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
+
+ domutils@3.2.2:
+ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -933,6 +1102,10 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
@@ -976,8 +1149,8 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- eslint@9.29.0:
- resolution: {integrity: sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==}
+ eslint@9.33.0:
+ resolution: {integrity: sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
peerDependencies:
@@ -1013,6 +1186,9 @@ packages:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
engines: {node: '>=12.0.0'}
+ fast-deep-equal@2.0.1:
+ resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1087,10 +1263,6 @@ packages:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
hasBin: true
- globals@11.12.0:
- resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
- engines: {node: '>=4'}
-
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@@ -1116,6 +1288,13 @@ packages:
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+ html-to-text@9.0.5:
+ resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
+ engines: {node: '>=14'}
+
+ htmlparser2@8.0.2:
+ resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
+
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -1241,6 +1420,9 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ leac@0.6.0:
+ resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -1283,6 +1465,16 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
+ marked@7.0.4:
+ resolution: {integrity: sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==}
+ engines: {node: '>= 16'}
+ hasBin: true
+
+ md-to-react-email@5.0.5:
+ resolution: {integrity: sha512-OvAXqwq57uOk+WZqFFNCMZz8yDp8BD3WazW1wAKHUrPbbdr89K9DWS6JXY09vd9xNdPNeurI8DU/X4flcfaD8A==}
+ peerDependencies:
+ react: ^18.0 || ^19.0
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -1324,6 +1516,10 @@ packages:
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
+ nodemailer@7.0.5:
+ resolution: {integrity: sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==}
+ engines: {node: '>=6.0.0'}
+
nwsapi@2.2.20:
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
@@ -1349,6 +1545,9 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+ parseley@0.12.1:
+ resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
+
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -1368,6 +1567,9 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
+ peberminta@0.9.0:
+ resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1387,10 +1589,19 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
+ prettier@3.6.2:
+ resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
+ engines: {node: '>=14'}
+ hasBin: true
+
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ prismjs@1.30.0:
+ resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
+ engines: {node: '>=6'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1422,6 +1633,9 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+ react-promise-suspense@0.3.4:
+ resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==}
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -1480,6 +1694,9 @@ packages:
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
+ selderee@0.11.0:
+ resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
+
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -1805,18 +2022,18 @@ snapshots:
'@babel/compat-data@7.27.5': {}
- '@babel/core@7.27.4':
+ '@babel/core@7.28.0':
dependencies:
'@ampproject/remapping': 2.3.0
'@babel/code-frame': 7.27.1
- '@babel/generator': 7.27.5
+ '@babel/generator': 7.28.0
'@babel/helper-compilation-targets': 7.27.2
- '@babel/helper-module-transforms': 7.27.3(@babel/core@7.27.4)
+ '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0)
'@babel/helpers': 7.27.6
- '@babel/parser': 7.27.5
+ '@babel/parser': 7.28.0
'@babel/template': 7.27.2
- '@babel/traverse': 7.27.4
- '@babel/types': 7.27.6
+ '@babel/traverse': 7.28.0
+ '@babel/types': 7.28.2
convert-source-map: 2.0.0
debug: 4.4.1
gensync: 1.0.0-beta.2
@@ -1825,12 +2042,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/generator@7.27.5':
+ '@babel/generator@7.28.0':
dependencies:
- '@babel/parser': 7.27.5
- '@babel/types': 7.27.6
- '@jridgewell/gen-mapping': 0.3.8
- '@jridgewell/trace-mapping': 0.3.25
+ '@babel/parser': 7.28.0
+ '@babel/types': 7.28.2
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.30
jsesc: 3.1.0
'@babel/helper-compilation-targets@7.27.2':
@@ -1841,19 +2058,21 @@ snapshots:
lru-cache: 5.1.1
semver: 6.3.1
+ '@babel/helper-globals@7.28.0': {}
+
'@babel/helper-module-imports@7.27.1':
dependencies:
- '@babel/traverse': 7.27.4
- '@babel/types': 7.27.6
+ '@babel/traverse': 7.28.0
+ '@babel/types': 7.28.2
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-transforms@7.27.3(@babel/core@7.27.4)':
+ '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)':
dependencies:
- '@babel/core': 7.27.4
+ '@babel/core': 7.28.0
'@babel/helper-module-imports': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
- '@babel/traverse': 7.27.4
+ '@babel/traverse': 7.28.0
transitivePeerDependencies:
- supports-color
@@ -1868,20 +2087,24 @@ snapshots:
'@babel/helpers@7.27.6':
dependencies:
'@babel/template': 7.27.2
- '@babel/types': 7.27.6
+ '@babel/types': 7.28.2
'@babel/parser@7.27.5':
dependencies:
'@babel/types': 7.27.6
- '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.27.4)':
+ '@babel/parser@7.28.0':
dependencies:
- '@babel/core': 7.27.4
+ '@babel/types': 7.28.2
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
'@babel/helper-plugin-utils': 7.27.1
- '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.27.4)':
+ '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.0)':
dependencies:
- '@babel/core': 7.27.4
+ '@babel/core': 7.28.0
'@babel/helper-plugin-utils': 7.27.1
'@babel/runtime@7.27.6': {}
@@ -1889,18 +2112,18 @@ snapshots:
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
- '@babel/parser': 7.27.5
- '@babel/types': 7.27.6
+ '@babel/parser': 7.28.0
+ '@babel/types': 7.28.2
- '@babel/traverse@7.27.4':
+ '@babel/traverse@7.28.0':
dependencies:
'@babel/code-frame': 7.27.1
- '@babel/generator': 7.27.5
- '@babel/parser': 7.27.5
+ '@babel/generator': 7.28.0
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.28.0
'@babel/template': 7.27.2
- '@babel/types': 7.27.6
+ '@babel/types': 7.28.2
debug: 4.4.1
- globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -1909,6 +2132,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
+ '@babel/types@7.28.2':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.27.1
+
'@bcoe/v8-coverage@1.0.2': {}
'@csstools/color-helpers@5.0.2': {}
@@ -2006,14 +2234,14 @@ snapshots:
'@esbuild/win32-x64@0.25.5':
optional: true
- '@eslint-community/eslint-utils@4.7.0(eslint@9.29.0)':
+ '@eslint-community/eslint-utils@4.7.0(eslint@9.33.0)':
dependencies:
- eslint: 9.29.0
+ eslint: 9.33.0
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {}
- '@eslint/config-array@0.20.1':
+ '@eslint/config-array@0.21.0':
dependencies:
'@eslint/object-schema': 2.1.6
debug: 4.4.1
@@ -2021,13 +2249,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@eslint/config-helpers@0.2.3': {}
+ '@eslint/config-helpers@0.3.1': {}
- '@eslint/core@0.14.0':
- dependencies:
- '@types/json-schema': 7.0.15
-
- '@eslint/core@0.15.0':
+ '@eslint/core@0.15.2':
dependencies:
'@types/json-schema': 7.0.15
@@ -2047,11 +2271,13 @@ snapshots:
'@eslint/js@9.29.0': {}
+ '@eslint/js@9.33.0': {}
+
'@eslint/object-schema@2.1.6': {}
- '@eslint/plugin-kit@0.3.2':
+ '@eslint/plugin-kit@0.3.5':
dependencies:
- '@eslint/core': 0.15.0
+ '@eslint/core': 0.15.2
levn: 0.4.1
'@humanfs/core@0.19.1': {}
@@ -2078,6 +2304,11 @@ snapshots:
'@istanbuljs/schema@0.1.3': {}
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/trace-mapping': 0.3.30
+
'@jridgewell/gen-mapping@0.3.8':
dependencies:
'@jridgewell/set-array': 1.2.1
@@ -2095,6 +2326,11 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/trace-mapping@0.3.30':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.0
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2114,7 +2350,119 @@ snapshots:
'@popperjs/core@2.11.8': {}
- '@rolldown/pluginutils@1.0.0-beta.19': {}
+ '@react-email/body@0.1.0(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/button@0.2.0(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/code-block@0.1.0(react@19.1.0)':
+ dependencies:
+ prismjs: 1.30.0
+ react: 19.1.0
+
+ '@react-email/code-inline@0.0.5(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/column@0.0.13(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/components@0.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@react-email/body': 0.1.0(react@19.1.0)
+ '@react-email/button': 0.2.0(react@19.1.0)
+ '@react-email/code-block': 0.1.0(react@19.1.0)
+ '@react-email/code-inline': 0.0.5(react@19.1.0)
+ '@react-email/column': 0.0.13(react@19.1.0)
+ '@react-email/container': 0.0.15(react@19.1.0)
+ '@react-email/font': 0.0.9(react@19.1.0)
+ '@react-email/head': 0.0.12(react@19.1.0)
+ '@react-email/heading': 0.0.15(react@19.1.0)
+ '@react-email/hr': 0.0.11(react@19.1.0)
+ '@react-email/html': 0.0.11(react@19.1.0)
+ '@react-email/img': 0.0.11(react@19.1.0)
+ '@react-email/link': 0.0.12(react@19.1.0)
+ '@react-email/markdown': 0.0.15(react@19.1.0)
+ '@react-email/preview': 0.0.13(react@19.1.0)
+ '@react-email/render': 1.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@react-email/row': 0.0.12(react@19.1.0)
+ '@react-email/section': 0.0.16(react@19.1.0)
+ '@react-email/tailwind': 1.2.2(react@19.1.0)
+ '@react-email/text': 0.1.5(react@19.1.0)
+ react: 19.1.0
+ transitivePeerDependencies:
+ - react-dom
+
+ '@react-email/container@0.0.15(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/font@0.0.9(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/head@0.0.12(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/heading@0.0.15(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/hr@0.0.11(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/html@0.0.11(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/img@0.0.11(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/link@0.0.12(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/markdown@0.0.15(react@19.1.0)':
+ dependencies:
+ md-to-react-email: 5.0.5(react@19.1.0)
+ react: 19.1.0
+
+ '@react-email/preview@0.0.13(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/render@1.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ html-to-text: 9.0.5
+ prettier: 3.6.2
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ react-promise-suspense: 0.3.4
+
+ '@react-email/row@0.0.12(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/section@0.0.16(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/tailwind@1.2.2(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@react-email/text@0.1.5(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+
+ '@rolldown/pluginutils@1.0.0-beta.30': {}
'@rollup/rollup-android-arm-eabi@4.44.0':
optional: true
@@ -2176,6 +2524,11 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.44.0':
optional: true
+ '@selderee/plugin-htmlparser2@0.11.0':
+ dependencies:
+ domhandler: 5.0.3
+ selderee: 0.11.0
+
'@testing-library/dom@10.4.0':
dependencies:
'@babel/code-frame': 7.27.1
@@ -2187,14 +2540,14 @@ snapshots:
lz-string: 1.5.0
pretty-format: 27.5.1
- '@testing-library/jest-dom@6.6.3':
+ '@testing-library/jest-dom@6.6.4':
dependencies:
'@adobe/css-tools': 4.4.3
aria-query: 5.3.2
- chalk: 3.0.0
css.escape: 1.5.1
dom-accessibility-api: 0.6.3
lodash: 4.17.21
+ picocolors: 1.1.1
redent: 3.0.0
'@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
@@ -2252,15 +2605,15 @@ snapshots:
dependencies:
csstype: 3.1.3
- '@typescript-eslint/eslint-plugin@8.35.0(@typescript-eslint/parser@8.35.0(eslint@9.29.0)(typescript@5.8.3))(eslint@9.29.0)(typescript@5.8.3)':
+ '@typescript-eslint/eslint-plugin@8.35.0(@typescript-eslint/parser@8.35.0(eslint@9.33.0)(typescript@5.8.3))(eslint@9.33.0)(typescript@5.8.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
- '@typescript-eslint/parser': 8.35.0(eslint@9.29.0)(typescript@5.8.3)
+ '@typescript-eslint/parser': 8.35.0(eslint@9.33.0)(typescript@5.8.3)
'@typescript-eslint/scope-manager': 8.35.0
- '@typescript-eslint/type-utils': 8.35.0(eslint@9.29.0)(typescript@5.8.3)
- '@typescript-eslint/utils': 8.35.0(eslint@9.29.0)(typescript@5.8.3)
+ '@typescript-eslint/type-utils': 8.35.0(eslint@9.33.0)(typescript@5.8.3)
+ '@typescript-eslint/utils': 8.35.0(eslint@9.33.0)(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.35.0
- eslint: 9.29.0
+ eslint: 9.33.0
graphemer: 1.4.0
ignore: 7.0.5
natural-compare: 1.4.0
@@ -2269,14 +2622,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.35.0(eslint@9.29.0)(typescript@5.8.3)':
+ '@typescript-eslint/parser@8.35.0(eslint@9.33.0)(typescript@5.8.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.35.0
'@typescript-eslint/types': 8.35.0
'@typescript-eslint/typescript-estree': 8.35.0(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.35.0
debug: 4.4.1
- eslint: 9.29.0
+ eslint: 9.33.0
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
@@ -2299,12 +2652,12 @@ snapshots:
dependencies:
typescript: 5.8.3
- '@typescript-eslint/type-utils@8.35.0(eslint@9.29.0)(typescript@5.8.3)':
+ '@typescript-eslint/type-utils@8.35.0(eslint@9.33.0)(typescript@5.8.3)':
dependencies:
'@typescript-eslint/typescript-estree': 8.35.0(typescript@5.8.3)
- '@typescript-eslint/utils': 8.35.0(eslint@9.29.0)(typescript@5.8.3)
+ '@typescript-eslint/utils': 8.35.0(eslint@9.33.0)(typescript@5.8.3)
debug: 4.4.1
- eslint: 9.29.0
+ eslint: 9.33.0
ts-api-utils: 2.1.0(typescript@5.8.3)
typescript: 5.8.3
transitivePeerDependencies:
@@ -2328,13 +2681,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.35.0(eslint@9.29.0)(typescript@5.8.3)':
+ '@typescript-eslint/utils@8.35.0(eslint@9.33.0)(typescript@5.8.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0)
+ '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0)
'@typescript-eslint/scope-manager': 8.35.0
'@typescript-eslint/types': 8.35.0
'@typescript-eslint/typescript-estree': 8.35.0(typescript@5.8.3)
- eslint: 9.29.0
+ eslint: 9.33.0
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
@@ -2344,12 +2697,12 @@ snapshots:
'@typescript-eslint/types': 8.35.0
eslint-visitor-keys: 4.2.1
- '@vitejs/plugin-react@4.6.0(vite@6.3.5)':
+ '@vitejs/plugin-react@5.0.0(vite@6.3.5)':
dependencies:
- '@babel/core': 7.27.4
- '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.4)
- '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.4)
- '@rolldown/pluginutils': 1.0.0-beta.19
+ '@babel/core': 7.28.0
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.0)
+ '@rolldown/pluginutils': 1.0.0-beta.30
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 6.3.5
@@ -2511,11 +2864,6 @@ snapshots:
loupe: 3.1.4
pathval: 2.0.1
- chalk@3.0.0:
- dependencies:
- ansi-styles: 4.3.0
- supports-color: 7.2.0
-
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -2565,12 +2913,32 @@ snapshots:
deep-is@0.1.4: {}
+ deepmerge@4.3.1: {}
+
dequal@2.0.3: {}
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
+ dom-serializer@2.0.0:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+
+ domelementtype@2.3.0: {}
+
+ domhandler@5.0.3:
+ dependencies:
+ domelementtype: 2.3.0
+
+ domutils@3.2.2:
+ dependencies:
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.172: {}
@@ -2579,6 +2947,8 @@ snapshots:
emoji-regex@9.2.2: {}
+ entities@4.5.0: {}
+
entities@6.0.1: {}
es-module-lexer@1.7.0: {}
@@ -2615,13 +2985,13 @@ snapshots:
escape-string-regexp@4.0.0: {}
- eslint-plugin-react-hooks@5.2.0(eslint@9.29.0):
+ eslint-plugin-react-hooks@5.2.0(eslint@9.33.0):
dependencies:
- eslint: 9.29.0
+ eslint: 9.33.0
- eslint-plugin-react-refresh@0.4.20(eslint@9.29.0):
+ eslint-plugin-react-refresh@0.4.20(eslint@9.33.0):
dependencies:
- eslint: 9.29.0
+ eslint: 9.33.0
eslint-scope@8.4.0:
dependencies:
@@ -2632,16 +3002,16 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
- eslint@9.29.0:
+ eslint@9.33.0:
dependencies:
- '@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0)
+ '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0)
'@eslint-community/regexpp': 4.12.1
- '@eslint/config-array': 0.20.1
- '@eslint/config-helpers': 0.2.3
- '@eslint/core': 0.14.0
+ '@eslint/config-array': 0.21.0
+ '@eslint/config-helpers': 0.3.1
+ '@eslint/core': 0.15.2
'@eslint/eslintrc': 3.3.1
- '@eslint/js': 9.29.0
- '@eslint/plugin-kit': 0.3.2
+ '@eslint/js': 9.33.0
+ '@eslint/plugin-kit': 0.3.5
'@humanfs/node': 0.16.6
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
@@ -2696,6 +3066,8 @@ snapshots:
expect-type@1.2.2: {}
+ fast-deep-equal@2.0.1: {}
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -2769,8 +3141,6 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
- globals@11.12.0: {}
-
globals@14.0.0: {}
globals@16.2.0: {}
@@ -2789,6 +3159,21 @@ snapshots:
dependencies:
void-elements: 3.1.0
+ html-to-text@9.0.5:
+ dependencies:
+ '@selderee/plugin-htmlparser2': 0.11.0
+ deepmerge: 4.3.1
+ dom-serializer: 2.0.0
+ htmlparser2: 8.0.2
+ selderee: 0.11.0
+
+ htmlparser2@8.0.2:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ entities: 4.5.0
+
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.3
@@ -2920,6 +3305,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
+ leac@0.6.0: {}
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -2961,6 +3348,13 @@ snapshots:
dependencies:
semver: 7.7.2
+ marked@7.0.4: {}
+
+ md-to-react-email@5.0.5(react@19.1.0):
+ dependencies:
+ marked: 7.0.4
+ react: 19.1.0
+
merge2@1.4.1: {}
micromatch@4.0.8:
@@ -2990,6 +3384,8 @@ snapshots:
node-releases@2.0.19: {}
+ nodemailer@7.0.5: {}
+
nwsapi@2.2.20: {}
optionator@0.9.4:
@@ -3019,6 +3415,11 @@ snapshots:
dependencies:
entities: 6.0.1
+ parseley@0.12.1:
+ dependencies:
+ leac: 0.6.0
+ peberminta: 0.9.0
+
path-exists@4.0.0: {}
path-key@3.1.1: {}
@@ -3032,6 +3433,8 @@ snapshots:
pathval@2.0.1: {}
+ peberminta@0.9.0: {}
+
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -3046,12 +3449,16 @@ snapshots:
prelude-ls@1.2.1: {}
+ prettier@3.6.2: {}
+
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
ansi-styles: 5.2.0
react-is: 17.0.2
+ prismjs@1.30.0: {}
+
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
@@ -3073,6 +3480,10 @@ snapshots:
react-is@17.0.2: {}
+ react-promise-suspense@0.3.4:
+ dependencies:
+ fast-deep-equal: 2.0.1
+
react-refresh@0.17.0: {}
react-router-dom@7.6.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
@@ -3140,6 +3551,10 @@ snapshots:
scheduler@0.26.0: {}
+ selderee@0.11.0:
+ dependencies:
+ parseley: 0.12.1
+
semver@6.3.1: {}
semver@7.7.2: {}
@@ -3253,12 +3668,12 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
- typescript-eslint@8.35.0(eslint@9.29.0)(typescript@5.8.3):
+ typescript-eslint@8.35.0(eslint@9.33.0)(typescript@5.8.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.35.0(@typescript-eslint/parser@8.35.0(eslint@9.29.0)(typescript@5.8.3))(eslint@9.29.0)(typescript@5.8.3)
- '@typescript-eslint/parser': 8.35.0(eslint@9.29.0)(typescript@5.8.3)
- '@typescript-eslint/utils': 8.35.0(eslint@9.29.0)(typescript@5.8.3)
- eslint: 9.29.0
+ '@typescript-eslint/eslint-plugin': 8.35.0(@typescript-eslint/parser@8.35.0(eslint@9.33.0)(typescript@5.8.3))(eslint@9.33.0)(typescript@5.8.3)
+ '@typescript-eslint/parser': 8.35.0(eslint@9.33.0)(typescript@5.8.3)
+ '@typescript-eslint/utils': 8.35.0(eslint@9.33.0)(typescript@5.8.3)
+ eslint: 9.33.0
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
diff --git a/src/assets/img/Logo-official-no-text.png b/public/logo-official-no-text.png
similarity index 100%
rename from src/assets/img/Logo-official-no-text.png
rename to public/logo-official-no-text.png
diff --git a/src/assets/img/net-couple-blurry-gpt.png b/src/assets/img/net-couple-blurry-gpt.png
deleted file mode 100644
index c7c44f1..0000000
Binary files a/src/assets/img/net-couple-blurry-gpt.png and /dev/null differ
diff --git a/src/components/Contact.test.tsx b/src/components/Contact.test.tsx
index 0751ef1..2eec163 100644
--- a/src/components/Contact.test.tsx
+++ b/src/components/Contact.test.tsx
@@ -13,13 +13,6 @@ vi.mock("react-i18next", () => ({
"contact.subtitle": "Ready to start your beach volleyball journey?",
"contact.form.title": "Send us a message",
"contact.form.name": "Full Name",
- "contact.form.email": "Email Address",
- "contact.form.phone": "Phone Number",
- "contact.form.program": "Program Interest",
- "contact.form.programs.basic": "Basic Training",
- "contact.form.programs.competitive": "Competitive",
- "contact.form.programs.elite": "Elite Program",
- "contact.form.programs.other": "Other",
"contact.form.message": "Message",
"contact.form.send": "Send Message",
"contact.info.title": "Contact Information",
@@ -52,9 +45,6 @@ vi.mock("../hooks/useContactForm", () => ({
useContactForm: () => ({
formData: {
name: "",
- email: "",
- phone: "",
- program: "basic",
message: "",
},
handleSubmit: mockHandleSubmit,
@@ -98,14 +88,4 @@ describe("Contact Component - Business Logic Tests", () => {
// The actual submission is handled by the useContactForm hook,
// which is tested separately. This tests the integration exists.
});
-
- it("includes program selection options", () => {
- render();
-
- // Test that all program options are available (business logic)
- expect(screen.getByText("Basic Training")).toBeInTheDocument();
- expect(screen.getByText("Competitive")).toBeInTheDocument();
- expect(screen.getByText("Elite Program")).toBeInTheDocument();
- expect(screen.getByText("Other")).toBeInTheDocument();
- });
});
diff --git a/src/components/Contact.tsx b/src/components/Contact.tsx
index 8672242..e8cc5f8 100644
--- a/src/components/Contact.tsx
+++ b/src/components/Contact.tsx
@@ -1,15 +1,8 @@
import { useTranslation } from "react-i18next";
import { MapPin, Phone, Mail, Clock, Send } from "lucide-react";
-import { ContactInfo, FormField, ModernCard } from "./ui";
+import { ContactInfo, FormField, ModernCard, AvailabilityGrid } from "./ui";
import { useContactForm } from "../hooks/useContactForm";
-// Types
-interface ProgramOption {
- value: string;
- label: string;
-}
-
-// Sub-components
const ContactSectionHeader = () => {
const { t } = useTranslation();
@@ -25,18 +18,11 @@ const ContactSectionHeader = () => {
const ContactFormSection = () => {
const { t } = useTranslation();
- const { formData, handleSubmit, handleChange } = useContactForm();
-
- const programOptions: ProgramOption[] = [
- { value: "basic", label: t("contact.form.programs.basic") },
- { value: "competitive", label: t("contact.form.programs.competitive") },
- { value: "elite", label: t("contact.form.programs.elite") },
- { value: "other", label: t("contact.form.programs.other") },
- ];
+ const { formData, handleSubmit, handleChange, toggleAvailability, status, submitError, isValid } = useContactForm();
return (
-
+
@@ -45,78 +31,132 @@ const ContactFormSection = () => {
{t("contact.form.title")}
-
- Get in touch with our team
+
+ {t("contact.form.description")}
diff --git a/src/components/ui/AvailabilityGrid.tsx b/src/components/ui/AvailabilityGrid.tsx
new file mode 100644
index 0000000..379c295
--- /dev/null
+++ b/src/components/ui/AvailabilityGrid.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+interface AvailabilityGridProps {
+ selected: string[];
+ onToggle: (slotKey: string) => void;
+}
+
+const dayKeys = ["mon", "tue", "wed", "thu", "fri"] as const;
+const slotKeys = ["18_1930", "1930_21", "21_2230"] as const;
+type DayKey = typeof dayKeys[number];
+type SlotKey = typeof slotKeys[number];
+
+const AvailabilityGrid: React.FC = ({ selected, onToggle }) => {
+ const { t } = useTranslation();
+ const isChecked = (dayKey: DayKey, slotKey: SlotKey) => {
+ const key = `${dayKey}_${slotKey}`;
+ return selected.includes(key);
+ };
+
+ const handleChange = (dayKey: DayKey, slotKey: SlotKey) => () => {
+ const key = `${dayKey}_${slotKey}`;
+ onToggle(key);
+ };
+
+ return (
+
+ );
+};
+
+export default AvailabilityGrid;
+
+
diff --git a/src/components/ui/ContactInfo.tsx b/src/components/ui/ContactInfo.tsx
index dedce25..bff4ad7 100644
--- a/src/components/ui/ContactInfo.tsx
+++ b/src/components/ui/ContactInfo.tsx
@@ -16,7 +16,7 @@ const ContactInfo = ({
return (
diff --git a/src/components/ui/FormField.tsx b/src/components/ui/FormField.tsx
index 6381514..5cc480a 100644
--- a/src/components/ui/FormField.tsx
+++ b/src/components/ui/FormField.tsx
@@ -1,3 +1,4 @@
+import type React from "react";
import type { ChangeEvent } from "react";
interface FormFieldProps {
@@ -13,6 +14,9 @@ interface FormFieldProps {
options?: { value: string; label: string }[];
rows?: number;
className?: string;
+ pattern?: string;
+ inputMode?: React.HTMLAttributes
["inputMode"];
+ invalidFeedback?: string;
}
const FormField = ({
@@ -26,6 +30,9 @@ const FormField = ({
options,
rows = 4,
className = "",
+ pattern,
+ inputMode,
+ invalidFeedback,
}: FormFieldProps) => {
const baseClasses = type === "select" ? "form-select" : "form-control";
@@ -33,45 +40,62 @@ const FormField = ({
switch (type) {
case "textarea":
return (
-
+ <>
+
+ {invalidFeedback && (
+ {invalidFeedback}
+ )}
+ >
);
case "select":
return (
-
+ <>
+
+ {invalidFeedback && (
+ {invalidFeedback}
+ )}
+ >
);
default:
return (
-
+ <>
+
+ {invalidFeedback && (
+ {invalidFeedback}
+ )}
+ >
);
}
};
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index a5ac363..47575da 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -7,3 +7,4 @@ export { default as ContactInfo } from "./ContactInfo";
export { default as SocialLink } from "./SocialLink";
export { default as FormField } from "./FormField";
export { default as GalleryImage } from "./GalleryImage";
+export { default as AvailabilityGrid } from "./AvailabilityGrid";
diff --git a/src/hooks/useContactForm.test.ts b/src/hooks/useContactForm.test.ts
index bc52d23..7ebc012 100644
--- a/src/hooks/useContactForm.test.ts
+++ b/src/hooks/useContactForm.test.ts
@@ -2,44 +2,34 @@ import { renderHook, act } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { useContactForm } from "./useContactForm";
-// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
- t: (key: string) => {
- const translations: Record = {
- "contact.form.success": "Thank you! We'll get back to you soon.",
- "contact.form.error": "Please check your input and try again.",
- };
- return translations[key] || key;
- },
+ t: (key: string) => key,
}),
}));
describe("useContactForm Hook", () => {
beforeEach(() => {
vi.clearAllMocks();
- // Mock console.log to capture form submission
- vi.spyOn(console, "log").mockImplementation(() => {});
- // Mock window.open since jsdom doesn't implement it
- vi.spyOn(window, "open").mockImplementation(() => null);
- // Mock window.alert
+ vi.stubEnv("VITE_CONTACT_ENDPOINT", "/api/send-email");
+ vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async () => ({ ok: true }),
+ } as unknown as Response);
vi.spyOn(window, "alert").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
+ vi.unstubAllEnvs();
});
it("initializes with correct default values", () => {
const { result } = renderHook(() => useContactForm());
- expect(result.current.formData).toEqual({
- name: "",
- email: "",
- phone: "",
- program: "basic",
- message: "",
- });
+ expect(result.current.formData.inquiryType).toBe("join");
+ expect(result.current.formData.fullName).toBe("");
+ expect(result.current.formData.email).toBe("");
});
it("updates form data when handleChange is called", () => {
@@ -47,20 +37,20 @@ describe("useContactForm Hook", () => {
act(() => {
result.current.handleChange({
- target: { name: "name", value: "John Doe" },
+ target: { name: "fullName", value: "John Doe" },
} as React.ChangeEvent);
});
- expect(result.current.formData.name).toBe("John Doe");
+ expect(result.current.formData.fullName).toBe("John Doe");
});
- it("handles form submission with valid data", () => {
+ it("handles form submission with valid data", async () => {
const { result } = renderHook(() => useContactForm());
// Set up form data
act(() => {
result.current.handleChange({
- target: { name: "name", value: "John Doe" },
+ target: { name: "fullName", value: "John Doe" },
} as React.ChangeEvent);
});
@@ -70,61 +60,38 @@ describe("useContactForm Hook", () => {
} as React.ChangeEvent);
});
- act(() => {
- result.current.handleChange({
- target: { name: "message", value: "Test message" },
- } as React.ChangeEvent);
- });
-
- // Submit form
- act(() => {
- result.current.handleSubmit({
+ await act(async () => {
+ await result.current.handleSubmit({
preventDefault: vi.fn(),
} as unknown as React.FormEvent);
});
- // Verify console.log was called with email URLs
- expect(console.log).toHaveBeenCalledWith(
- "Mailto URL:",
- expect.stringContaining("mailto:blocknroll.bcnclub@gmail.com")
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/send-email",
+ expect.objectContaining({ method: "POST" })
);
- expect(console.log).toHaveBeenCalledWith(
- "Gmail URL:",
- expect.stringContaining("https://mail.google.com/mail/")
- );
-
- // Verify window.open was called to open the email client
- expect(window.open).toHaveBeenCalled();
-
- // Verify alert was called for success message
- expect(window.alert).toHaveBeenCalledWith("contact.form.successMessage");
+ // Success is now indicated via internal state, not alert
+ expect(result.current.formData.fullName).toBe("");
});
- it("resets form after successful submission", () => {
+ it("resets form after successful submission", async () => {
const { result } = renderHook(() => useContactForm());
// Fill form data
act(() => {
result.current.handleChange({
- target: { name: "name", value: "John Doe" },
+ target: { name: "fullName", value: "John Doe" },
} as React.ChangeEvent);
});
- // Submit form
- act(() => {
- result.current.handleSubmit({
+ await act(async () => {
+ await result.current.handleSubmit({
preventDefault: vi.fn(),
} as unknown as React.FormEvent);
});
// Check that form is reset to initial values
- expect(result.current.formData).toEqual({
- name: "",
- email: "",
- phone: "",
- program: "basic", // Should remain as default
- message: "",
- });
+ expect(result.current.formData.fullName).toBe("");
});
it("handles different input types correctly", () => {
@@ -133,100 +100,66 @@ describe("useContactForm Hook", () => {
// Test select input
act(() => {
result.current.handleChange({
- target: { name: "program", value: "competitive" },
+ target: { name: "packageType", value: "two_per_week" },
} as React.ChangeEvent);
});
- expect(result.current.formData.program).toBe("competitive");
-
- // Test textarea input
- act(() => {
- result.current.handleChange({
- target: { name: "message", value: "Long message text" },
- } as React.ChangeEvent);
- });
-
- expect(result.current.formData.message).toBe("Long message text");
+ expect(result.current.formData.packageType).toBe("two_per_week");
});
- it("handles form submission with different program values", () => {
+ it("handles form submission with different program values", async () => {
const { result } = renderHook(() => useContactForm());
- // Test with competitive program
+ // Test with two_per_week
act(() => {
result.current.handleChange({
- target: { name: "program", value: "competitive" },
+ target: { name: "packageType", value: "two_per_week" },
} as React.ChangeEvent);
});
act(() => {
result.current.handleChange({
- target: { name: "name", value: "Test User" },
+ target: { name: "fullName", value: "Test User" },
} as React.ChangeEvent);
});
// Submit form
const mockEvent = { preventDefault: vi.fn() };
- act(() => {
- result.current.handleSubmit(mockEvent as unknown as React.FormEvent);
+ await act(async () => {
+ await result.current.handleSubmit(mockEvent as unknown as React.FormEvent);
});
// Verify form resets properly
- expect(result.current.formData.program).toBe("basic");
- expect(result.current.formData.name).toBe("");
+ expect(result.current.formData.fullName).toBe("");
});
- it("handles form submission with all fields filled", () => {
+ it("handles form submission with all fields filled", async () => {
const { result } = renderHook(() => useContactForm());
- // Fill all form fields
+ // Fill required fields
act(() => {
result.current.handleChange({
- target: { name: "name", value: "John Doe" },
+ target: { name: "fullName", value: "John Doe" },
} as React.ChangeEvent);
});
- act(() => {
- result.current.handleChange({
- target: { name: "email", value: "john@example.com" },
- } as React.ChangeEvent);
- });
-
- act(() => {
- result.current.handleChange({
- target: { name: "phone", value: "123456789" },
- } as React.ChangeEvent);
- });
-
- act(() => {
- result.current.handleChange({
- target: { name: "message", value: "Test message" },
- } as React.ChangeEvent);
- });
-
// Submit form
const mockEvent = { preventDefault: vi.fn() };
- act(() => {
- result.current.handleSubmit(mockEvent as unknown as React.FormEvent);
+ await act(async () => {
+ await result.current.handleSubmit(mockEvent as unknown as React.FormEvent);
});
// Verify form resets to initial state
- expect(result.current.formData).toEqual({
- name: "",
- email: "",
- phone: "",
- program: "basic",
- message: "",
- });
+ expect(result.current.formData.fullName).toBe("");
});
- it("handles form submission with empty optional fields", () => {
+ it("handles form submission with empty optional fields", async () => {
const { result } = renderHook(() => useContactForm());
// Fill only required fields
act(() => {
result.current.handleChange({
- target: { name: "name", value: "Jane Doe" },
+ target: { name: "fullName", value: "Jane Doe" },
} as React.ChangeEvent);
});
@@ -235,49 +168,29 @@ describe("useContactForm Hook", () => {
target: { name: "email", value: "jane@example.com" },
} as React.ChangeEvent);
});
-
- // Leave phone and message empty
+
// Submit form
const mockEvent = { preventDefault: vi.fn() };
- act(() => {
- result.current.handleSubmit(mockEvent as unknown as React.FormEvent);
+ await act(async () => {
+ await result.current.handleSubmit(mockEvent as unknown as React.FormEvent);
});
// Verify form resets properly
- expect(result.current.formData.name).toBe("");
- expect(result.current.formData.email).toBe("");
- expect(result.current.formData.phone).toBe("");
- expect(result.current.formData.message).toBe("");
+ expect(result.current.formData.fullName).toBe("");
});
- it("handles timeout callback for email client confirmation", async () => {
+ it("sets error status when API returns non-ok", async () => {
const { result } = renderHook(() => useContactForm());
+ (globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
+ ok: false,
+ json: async () => ({ error: "boom" }),
+ } as unknown as Response);
- // Mock window.open to succeed
- vi.spyOn(window, "open").mockReturnValue(null);
-
- // Mock confirm for the timeout callback
- vi.spyOn(window, "confirm").mockReturnValue(false);
-
- // Set up form data
- act(() => {
- result.current.handleChange({
- target: { name: "name", value: "John Doe" },
- } as React.ChangeEvent);
+ await act(async () => {
+ await result.current.handleSubmit({ preventDefault: vi.fn() } as unknown as React.FormEvent);
});
- // Submit form
- act(() => {
- result.current.handleSubmit({
- preventDefault: vi.fn(),
- } as unknown as React.FormEvent);
- });
-
- // Wait for the timeout (2000ms) and check if confirm was called
- await new Promise((resolve) => setTimeout(resolve, 2100));
-
- expect(window.confirm).toHaveBeenCalledWith(
- expect.stringContaining("Did your email client open?")
- );
+ expect(result.current.submitError).not.toBeNull();
+ expect(result.current.status).toBe("error");
});
});
diff --git a/src/hooks/useContactForm.ts b/src/hooks/useContactForm.ts
index 6877984..d457f5f 100644
--- a/src/hooks/useContactForm.ts
+++ b/src/hooks/useContactForm.ts
@@ -1,123 +1,66 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
-import type { FormData } from "../types";
+import type { FormData, PackageType } from "../types";
export const useContactForm = () => {
const { t } = useTranslation();
const [formData, setFormData] = useState({
- name: "",
+ inquiryType: "join",
+ fullName: "",
email: "",
phone: "",
- program: "basic",
- message: "",
+ players: 1,
+ level: undefined,
+ packageType: "one_per_week",
+ availability: [],
});
+ const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
+ const [submitError, setSubmitError] = useState(null);
- const handleSubmit = (e: React.FormEvent) => {
+ // Basic client-side validation
+ const isNonEmptyName = (value?: string) => Boolean(value && value.trim().length >= 2);
+ const isValidEmail = (value?: string) => Boolean(value && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value));
+ const isValid = isNonEmptyName(formData.fullName) && isValidEmail(formData.email);
+
+ const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- // Create email content
- const emailTo = "blocknroll.bcnclub@gmail.com";
- const programKey = `contact.form.programs.${formData.program}`;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const subject = `Contact Form: ${formData.name} - ${t(programKey as any)}`;
- const emailContent = `Name: ${formData.name}
-Email: ${formData.email}
-Phone: ${formData.phone || "Not provided"}
- Program Interest: ${
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- t(programKey as any)
+ try {
+ setStatus("loading");
+ setSubmitError(null);
+ const endpoint = import.meta.env.VITE_CONTACT_ENDPOINT || "/api/send-email";
+ const message = buildEmailMessage(formData);
+ const payload = {
+ name: formData.fullName,
+ message,
+ email: formData.email,
+ phone: formData.phone,
+ meta: formData,
+ };
+ const res = await fetch(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ if (!res.ok) throw new Error("Request failed");
+
+ setFormData({
+ inquiryType: "join",
+ fullName: "",
+ email: "",
+ phone: "",
+ players: 1,
+ level: undefined,
+ packageType: "one_per_week",
+ availability: [],
+ });
+ setStatus("success");
+ } catch (error) {
+ console.error("Submit error:", error);
+ setStatus("error");
+ setSubmitError(t("contact.form.errorMessage"));
}
-
-Message:
-${formData.message}
-
---
-This message was sent from the Block n' Roll contact form.`;
-
- // Create different URL options
- const mailtoUrl = `mailto:${emailTo}?subject=${encodeURIComponent(
- subject
- )}&body=${encodeURIComponent(emailContent)}`;
- const gmailUrl = `https://mail.google.com/mail/?view=cm&fs=1&to=${emailTo}&su=${encodeURIComponent(
- subject
- )}&body=${encodeURIComponent(emailContent)}`;
-
- // Debug: Log the URLs
- console.log("Mailto URL:", mailtoUrl);
- console.log("Gmail URL:", gmailUrl);
-
- // Try multiple methods
- const tryEmailClient = () => {
- try {
- // Method 1: Try native mailto
- window.open(mailtoUrl, "_self");
-
- // If that doesn't work, offer alternatives
- setTimeout(() => {
- const userChoice = confirm(
- "Did your email client open? Click 'OK' if yes, or 'Cancel' to try Gmail web interface."
- );
-
- if (!userChoice) {
- // User wants Gmail interface
- window.open(gmailUrl, "_blank");
- }
- }, 2000);
- } catch (error) {
- console.error("Error opening email client:", error);
- showFallbackOptions();
- }
- };
-
- const showFallbackOptions = () => {
- const choice = confirm(
- "Email client couldn't open automatically. Would you like to:\n\n" +
- "✅ OK = Open Gmail in browser\n" +
- "❌ Cancel = Copy email content to clipboard"
- );
-
- if (choice) {
- // Open Gmail
- window.open(gmailUrl, "_blank");
- } else {
- // Copy to clipboard
- const fullEmailText = `To: ${emailTo}\nSubject: ${subject}\n\n${emailContent}`;
-
- if (navigator.clipboard) {
- navigator.clipboard.writeText(fullEmailText).then(() => {
- alert(
- "✅ Email content copied to clipboard!\n\nNow open your email client and paste (Ctrl+V)"
- );
- });
- } else {
- // Fallback for older browsers
- const textArea = document.createElement("textarea");
- textArea.value = fullEmailText;
- document.body.appendChild(textArea);
- textArea.select();
- document.execCommand("copy");
- document.body.removeChild(textArea);
- alert(
- "✅ Email content copied to clipboard!\n\nNow open your email client and paste (Ctrl+V)"
- );
- }
- }
- };
-
- // Execute the email attempt
- tryEmailClient();
-
- // Show success message
- alert(t("contact.form.successMessage"));
-
- // Reset form
- setFormData({
- name: "",
- email: "",
- phone: "",
- program: "basic",
- message: "",
- });
};
const handleChange = (
@@ -125,15 +68,89 @@ This message was sent from the Block n' Roll contact form.`;
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) => {
+ const { name, value } = e.target;
+ if (name === "players") {
+ setFormData({ ...formData, players: Number(value) });
+ return;
+ }
+ if (name === "packageType") {
+ setFormData({ ...formData, packageType: value as PackageType });
+ return;
+ }
+ if (name === "inquiryType") {
+ const newType = value as FormData["inquiryType"];
+ setFormData((prev) => ({
+ ...prev,
+ inquiryType: newType,
+ }));
+ return;
+ }
setFormData({
...formData,
- [e.target.name]: e.target.value,
+ [name]: value,
+ });
+ };
+
+ const toggleAvailability = (slotKey: string) => {
+ setFormData((prev) => {
+ const exists = prev.availability?.includes(slotKey);
+ const next = exists
+ ? (prev.availability || []).filter((k) => k !== slotKey)
+ : [ ...(prev.availability || []), slotKey ];
+ return { ...prev, availability: next };
});
};
+ const buildEmailMessage = (data: FormData) => {
+ if (data.inquiryType === "talk") {
+ return [
+ t("contact.email.typeTalk"),
+ `${t("contact.email.name")}: ${data.fullName}`,
+ `${t("contact.email.email")}: ${data.email}`,
+ data.phone ? `${t("contact.email.phone")}: ${data.phone}` : undefined,
+ ]
+ .filter(Boolean)
+ .join("\n");
+ }
+
+ const availabilityText = (data.availability || []).length
+ ? (data.availability || []).join(", ")
+ : t("contact.email.availabilityNone");
+
+ return [
+ t("contact.email.typeJoin"),
+ `${t("contact.email.name")}: ${data.fullName}`,
+ `${t("contact.email.email")}: ${data.email}`,
+ data.phone ? `${t("contact.email.phone")}: ${data.phone}` : undefined,
+ data.players ? `${t("contact.email.players")}: ${data.players}` : undefined,
+ data.level ? `${t("contact.email.level")}: ${data.level}` : undefined,
+ data.packageType ? `${t("contact.email.package")}: ${readablePackage(data.packageType)}` : undefined,
+ data.packageType !== "private" ? `${t("contact.email.availability")}: ${availabilityText}` : undefined,
+ ]
+ .filter(Boolean)
+ .join("\n");
+ };
+
+ const readablePackage = (p?: PackageType) => {
+ switch (p) {
+ case "one_per_week":
+ return t("contact.form.packages.onePerWeek");
+ case "two_per_week":
+ return t("contact.form.packages.twoPerWeek");
+ case "private":
+ return t("contact.form.packages.private");
+ default:
+ return "";
+ }
+ };
+
return {
formData,
handleSubmit,
handleChange,
+ toggleAvailability,
+ status,
+ submitError,
+ isValid,
};
};
diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json
index 64f5164..0c1db1a 100644
--- a/src/i18n/locales/ca.json
+++ b/src/i18n/locales/ca.json
@@ -157,9 +157,10 @@
"contact": {
"badge": "Contacte",
"title": "T'interessa unir-te?",
- "subtitle": "Ens encantaria tenir-te a la família Block n' Roll! Omple el formulari sense compromís o escriu-nos.",
+ "subtitle": "Ens encantaria tenir-te a la família Block n' Roll! Contacta amb nosaltres!",
"form": {
"title": "Formulari sense compromís",
+ "description": "Enviant aquest formulari, no comprometeu res. Ens contactarem tan aviat com sigui possible!",
"name": "Nom complet",
"email": "Email",
"phone": "Telèfon",
@@ -171,6 +172,32 @@
"phonePlaceholder": "+34 600 000 000",
"messagePlaceholder": "Explica'ns el teu nivell, disponibilitat o qualsevol pregunta...",
"required": "*",
+ "errorMessage": "Hi ha hagut un problema en enviar la teva sol·licitud. Torna-ho a intentar.",
+ "inquiryTypeLabel": "Tipus de consulta",
+ "inquiryOptions": {
+ "join": "Vull unir-me al club",
+ "talk": "Vull més informació"
+ },
+ "playersLabel": "Quants jugadors sois?",
+ "levelLabel": "Nivell estimat",
+ "packageLabel": "Quin paquet t'interessa?",
+ "levels": {
+ "iniciacion": "Iniciació",
+ "basico": "Bàsic",
+ "intermedio": "Intermedi",
+ "avanzado": "Avançat"
+ },
+ "packages": {
+ "onePerWeek": "1 entrenament/setmana",
+ "twoPerWeek": "2 entrenaments/setmana",
+ "private": "Entrenament privat"
+ },
+ "selectPlaceholder": "- Selecciona -",
+ "availabilityLabel": "Disponibilitat horària (Dl–Dv)",
+ "availability": {
+ "days": { "mon": "Dl", "tue": "Dt", "wed": "Dc", "thu": "Dj", "fri": "Dv" },
+ "slots": { "18_1930": "18:00-19:30", "1930_21": "19:30-21:00", "21_2230": "21:00-22:30" }
+ },
"programs": {
"basic": "1 Entrenament/Setmana",
"competitive": "2 Entrenaments/Setmana",
@@ -179,6 +206,18 @@
},
"successMessage": "Gràcies! Et contactarem aviat per coordinar la teva sessió gratuïta."
},
+ "email": {
+ "typeJoin": "Tipus de consulta: Vull unir-me al club",
+ "typeTalk": "Tipus de consulta: Vull més informació",
+ "name": "Nom",
+ "email": "Email",
+ "phone": "Telèfon",
+ "players": "Quants jugadors sois?",
+ "level": "Nivell estimat",
+ "package": "Quin paquet t'interessa?",
+ "availability": "Disponibilitat horària",
+ "availabilityNone": "Sense preferència"
+ },
"info": {
"title": "Informació de contacte",
"description": "Som aquí per resoldre tots els teus dubtes. Escriu-nos i t'ho expliquem tot!",
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 1756277..9b0023d 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -157,9 +157,10 @@
"contact": {
"badge": "Contact",
"title": "Interested in joining?",
- "subtitle": "We'd love to have you in the Block n' Roll family! Fill out the form without commitment or write to us.",
+ "subtitle": "We'd love to have you in the Block n' Roll family! Get in touch with our team!",
"form": {
"title": "No commitment form",
+ "description": "By sending this form, you don't commit to anything. We'll contact you as soon as possible!",
"name": "Full name",
"email": "Email",
"phone": "Phone",
@@ -171,6 +172,32 @@
"phonePlaceholder": "+34 600 000 000",
"messagePlaceholder": "Tell us your level, availability or any questions...",
"required": "*",
+ "errorMessage": "There was a problem sending your request. Please try again.",
+ "inquiryTypeLabel": "Type of inquiry",
+ "inquiryOptions": {
+ "join": "I want to join the club",
+ "talk": "I want more information"
+ },
+ "playersLabel": "How many players are you?",
+ "levelLabel": "Estimated level",
+ "packageLabel": "What package are you interested in?",
+ "levels": {
+ "iniciacion": "Beginner",
+ "basico": "Basic",
+ "intermedio": "Intermediate",
+ "avanzado": "Advanced"
+ },
+ "packages": {
+ "onePerWeek": "1 training/week",
+ "twoPerWeek": "2 trainings/week",
+ "private": "Private training"
+ },
+ "selectPlaceholder": "- Select -",
+ "availabilityLabel": "Availability time (Mon–Fri)",
+ "availability": {
+ "days": { "mon": "Mon", "tue": "Tue", "wed": "Wed", "thu": "Thu", "fri": "Fri" },
+ "slots": { "18_1930": "18:00-19:30", "1930_21": "19:30-21:00", "21_2230": "21:00-22:30" }
+ },
"programs": {
"basic": "1 Training/Week",
"competitive": "2 Trainings/Week",
@@ -179,6 +206,18 @@
},
"successMessage": "Thank you! We'll contact you soon to coordinate your free session."
},
+ "email": {
+ "typeJoin": "Type of inquiry: I want to join the club",
+ "typeTalk": "Type of inquiry: I want more information",
+ "name": "Name",
+ "email": "Email",
+ "phone": "Phone",
+ "players": "How many players are you?",
+ "level": "Estimated level",
+ "package": "What package are you interested in?",
+ "availability": "Availability time",
+ "availabilityNone": "No preference"
+ },
"info": {
"title": "Contact information",
"description": "We're here to answer all your questions. Write to us and we'll tell you everything!",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 7bb9b6c..b847ba8 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -157,9 +157,10 @@
"contact": {
"badge": "Contacto",
"title": "¿Te interesa unirte?",
- "subtitle": "¡Nos encantaría tenerte en la familia Block n' Roll! Rellena el formulario sin compromiso o escríbenos.",
+ "subtitle": "¡Nos encantaría tenerte en la familia Block n' Roll! ¡Ponte en contacto con nosotros!",
"form": {
"title": "Formulario sin compromiso",
+ "description": "Enviando este formulario, no te comprometes a nada. ¡Te atenderemos lo antes posible!",
"name": "Nombre completo",
"email": "Email",
"phone": "Teléfono",
@@ -171,6 +172,32 @@
"phonePlaceholder": "+34 600 000 000",
"messagePlaceholder": "Cuéntanos tu nivel, disponibilidad o cualquier pregunta...",
"required": "*",
+ "errorMessage": "Hubo un problema al enviar tu solicitud. Inténtalo de nuevo.",
+ "inquiryTypeLabel": "Tipo de consulta",
+ "inquiryOptions": {
+ "join": "Quiero unirme al club",
+ "talk": "Quiero más información"
+ },
+ "playersLabel": "¿Cuántos jugadores sois?",
+ "levelLabel": "Nivel estimado",
+ "packageLabel": "¿Qué paquete te interesa?",
+ "levels": {
+ "iniciacion": "Iniciación",
+ "basico": "Básico",
+ "intermedio": "Intermedio",
+ "avanzado": "Avanzado"
+ },
+ "packages": {
+ "onePerWeek": "1 entrenamiento/semana",
+ "twoPerWeek": "2 entrenamientos/semana",
+ "private": "Entrenamiento privado"
+ },
+ "selectPlaceholder": "- Selecciona -",
+ "availabilityLabel": "Disponibilidad horaria (L-V)",
+ "availability": {
+ "days": { "mon": "Lun", "tue": "Mar", "wed": "Mié", "thu": "Jue", "fri": "Vie" },
+ "slots": { "18_1930": "18:00-19:30", "1930_21": "19:30-21:00", "21_2230": "21:00-22:30" }
+ },
"programs": {
"basic": "1 Entreno/Semana",
"competitive": "2 Entrenos/Semana",
@@ -179,6 +206,18 @@
},
"successMessage": "¡Gracias! Te contactaremos pronto para coordinar tu sesión gratuita."
},
+ "email": {
+ "typeJoin": "Tipo de consulta: Quiero unirme al club",
+ "typeTalk": "Tipo de consulta: Quiero más información",
+ "name": "Nombre",
+ "email": "Email",
+ "phone": "Teléfono",
+ "players": "¿Cuántos jugadores sois?",
+ "level": "Nivel estimado",
+ "package": "¿Qué paquete te interesa?",
+ "availability": "Disponibilidad horaria",
+ "availabilityNone": "Sin preferencia"
+ },
"info": {
"title": "Información de contacto",
"description": "Estamos aquí para resolver todas tus dudas. ¡Escríbenos y te contamos todo!",
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index bb4639c..15d86c6 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -154,6 +154,7 @@ export interface TranslationSchema {
subtitle: string;
form: {
title: string;
+ description: string;
name: string;
email: string;
phone: string;
@@ -165,6 +166,41 @@ export interface TranslationSchema {
phonePlaceholder: string;
messagePlaceholder: string;
required: string;
+ inquiryTypeLabel: string;
+ inquiryOptions: {
+ join: string;
+ talk: string;
+ };
+ playersLabel: string;
+ levelLabel: string;
+ packageLabel: string;
+ levels: {
+ iniciacion: string;
+ basico: string;
+ intermedio: string;
+ avanzado: string;
+ };
+ packages: {
+ onePerWeek: string;
+ twoPerWeek: string;
+ private: string;
+ };
+ selectPlaceholder: string;
+ availabilityLabel: string;
+ availability: {
+ days: {
+ mon: string;
+ tue: string;
+ wed: string;
+ thu: string;
+ fri: string;
+ };
+ slots: {
+ '18_1930': string;
+ '1930_21': string;
+ '21_2230': string;
+ };
+ };
programs: {
basic: string;
competitive: string;
@@ -172,6 +208,19 @@ export interface TranslationSchema {
other: string;
};
successMessage: string;
+ errorMessage: string;
+ };
+ email: {
+ typeJoin: string;
+ typeTalk: string;
+ name: string;
+ email: string;
+ phone: string;
+ players: string;
+ level: string;
+ package: string;
+ availability: string;
+ availabilityNone: string;
};
info: {
title: string;
diff --git a/src/styles/components/buttons.css b/src/styles/components/buttons.css
index fec7e1f..37a854c 100644
--- a/src/styles/components/buttons.css
+++ b/src/styles/components/buttons.css
@@ -143,6 +143,26 @@
box-shadow: 0 0 0 2px rgba(115, 115, 115, 0.2);
}
+/* Loading state for submit buttons */
+.btn[data-loading="true"] {
+ pointer-events: none;
+ opacity: .85;
+}
+
+.btn[data-loading="true"]::after {
+ content: "";
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid rgba(255,255,255,.6);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: btn-spin .6s linear infinite;
+}
+
+@keyframes btn-spin {
+ to { transform: rotate(360deg); }
+}
+
/* Touch device optimizations */
@media (hover: none) and (pointer: coarse) {
.btn-modern {
diff --git a/src/styles/components/forms.css b/src/styles/components/forms.css
index 0f7de6c..3ad266c 100644
--- a/src/styles/components/forms.css
+++ b/src/styles/components/forms.css
@@ -100,6 +100,28 @@
color: white;
}
+/* Show invalid feedback messages when invalid */
+input:invalid ~ .invalid-feedback,
+textarea:invalid ~ .invalid-feedback,
+select:invalid ~ .invalid-feedback {
+ display: block;
+}
+
+/* Highlight invalid fields */
+.form-control:invalid,
+.form-select:invalid,
+textarea.form-control:invalid {
+ border-color: #dc3545 !important; /* bootstrap danger */
+ box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.1);
+}
+
+.form-control:focus:invalid,
+.form-select:focus:invalid,
+textarea.form-control:focus:invalid {
+ border-color: #dc3545 !important;
+ box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.15);
+}
+
/* Form Section Spacing */
.form-section-spacing {
padding-top: 6rem;
diff --git a/src/styles/components/hero.css b/src/styles/components/hero.css
index c9d6a88..e284f3d 100644
--- a/src/styles/components/hero.css
+++ b/src/styles/components/hero.css
@@ -252,7 +252,7 @@
right: 5%;
width: 900px;
height: 900px;
- background-image: url("../../assets/img/logo-official-no-text.png");
+ background-image: url("/logo-official-no-text.png");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
diff --git a/src/styles/components/services.css b/src/styles/components/services.css
index 580ccee..26c7625 100644
--- a/src/styles/components/services.css
+++ b/src/styles/components/services.css
@@ -103,8 +103,8 @@
display: flex;
align-items: center;
gap: var(--space-md);
- flex-shrink: 0;
- min-width: 280px;
+ flex: 0 0 300px;
+ min-width: 300px;
}
.service-icon-badge {
@@ -141,6 +141,7 @@
.service-info-right {
flex-grow: 1;
+ min-width: 0;
}
.service-block-description {
@@ -188,6 +189,7 @@
.service-info-left {
min-width: auto;
+ flex: 1 1 auto;
width: 100%;
justify-content: center;
text-align: center;
diff --git a/src/test/contact-form-integration.test.tsx b/src/test/contact-form-integration.test.tsx
deleted file mode 100644
index 2352900..0000000
--- a/src/test/contact-form-integration.test.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { vi, describe, it, expect, beforeEach } from "vitest";
-import Contact from "../components/Contact";
-
-// Mock react-i18next
-vi.mock("react-i18next", () => ({
- useTranslation: () => ({
- t: (key: string) => {
- const translations: Record = {
- "contact.badge": "Contact Us",
- "contact.title": "Get in Touch",
- "contact.subtitle": "Ready to start your beach volleyball journey?",
- "contact.form.title": "Send us a message",
- "contact.form.name": "Full Name",
- "contact.form.email": "Email Address",
- "contact.form.phone": "Phone Number",
- "contact.form.program": "Program Interest",
- "contact.form.programs.basic": "Basic Training",
- "contact.form.programs.competitive": "Competitive",
- "contact.form.programs.elite": "Elite Program",
- "contact.form.programs.other": "Other",
- "contact.form.message": "Message",
- "contact.form.send": "Send Message",
- "contact.info.title": "Contact Information",
- "contact.info.description": "Get in touch with us today",
- "contact.info.location.content": "123 Beach Volleyball Court",
- "contact.info.phone.content": "+1 (555) 123-4567",
- "contact.info.email.content": "info@blocknroll.com",
- "contact.info.schedule.content": "Mon-Sun: 8AM-8PM",
- };
- return translations[key] || key;
- },
- }),
-}));
-
-// Mock lucide-react icons
-vi.mock("lucide-react", () => ({
- MapPin: () => 📍,
- Phone: () => 📞,
- Mail: () => ✉️,
- Clock: () => 🕒,
- Send: () => 📧,
- Star: () => ⭐,
-}));
-
-describe("Contact Form - Business Workflows", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it("displays contact page with essential elements", () => {
- render();
-
- // Test core business elements are present
- expect(screen.getByText("Send us a message")).toBeInTheDocument();
- expect(screen.getByText("Contact Information")).toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: /send message/i })
- ).toBeInTheDocument();
- });
-
- it("shows all contact information", () => {
- render();
-
- // Test business-critical contact information is displayed
- expect(screen.getByText("123 Beach Volleyball Court")).toBeInTheDocument();
- expect(screen.getByText("+1 (555) 123-4567")).toBeInTheDocument();
- expect(screen.getByText("info@blocknroll.com")).toBeInTheDocument();
- expect(screen.getByText("Mon-Sun: 8AM-8PM")).toBeInTheDocument();
- });
-
- it("includes all program options for users", () => {
- render();
-
- // Test business logic: all programs are available for selection
- expect(screen.getByText("Basic Training")).toBeInTheDocument();
- expect(screen.getByText("Competitive")).toBeInTheDocument();
- expect(screen.getByText("Elite Program")).toBeInTheDocument();
- expect(screen.getByText("Other")).toBeInTheDocument();
- });
-});
diff --git a/src/types/index.ts b/src/types/index.ts
index e9b1617..1fcbcf5 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -17,12 +17,21 @@ export interface BackgroundElement {
color: string;
}
+export type InquiryType = "join" | "talk";
+
+export type PackageType = "one_per_week" | "two_per_week" | "private";
+
+export type EstimatedLevel = "Iniciación" | "Básico" | "Intermedio" | "Avanzado";
+
export interface FormData {
- name: string;
+ inquiryType: InquiryType;
+ fullName: string;
email: string;
- phone: string;
- program: string;
- message: string;
+ phone?: string;
+ players?: number; // 1 to 8
+ level?: EstimatedLevel;
+ packageType?: PackageType;
+ availability?: string[]; // list of keys like "mon_18_1930"
}
export interface ContactInfo {
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 019cba7..30b2d6d 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -27,6 +27,8 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
+ "@api/*": ["./api/*"],
+ "@emails/*": ["./emails/*"],
"@components/*": ["./src/components/*"],
"@pages/*": ["./src/pages/*"],
"@layouts/*": ["./src/layouts/*"],
diff --git a/vitest.config.ts b/vitest.config.ts
index df18f87..66fe505 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -31,12 +31,9 @@ export default defineConfig({
"src/layouts/index.ts", // Layout index file
"src/pages/index.ts", // Pages index file
// Exclude simple UI components with no business logic
- "src/components/ui/GalleryImage.tsx", // Simple image component
- "src/components/ui/StatCard.tsx", // Simple display component
- "src/components/ui/FeatureCard.tsx", // Simple display component
- "src/components/ui/IconButton.tsx", // Simple button wrapper
- "src/components/ui/PricingCard.tsx", // Simple display component
- "src/components/ui/SocialLink.tsx", // Simple link component
+ "src/components/ui/**",
+ // Exclude emails
+ "emails/**",
],
// Set coverage thresholds for remaining files
thresholds: {