Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c813a36
chore: Update CODEOWNERS to include @david-bardina; remove unused ima…
AlejandroLuisHCMoeve Aug 13, 2025
3d33714
feat: Add email sending functionality with nodemailer; create .env.sa…
AlejandroLuisHCMoeve Aug 13, 2025
97b35bf
chore: Update dependencies in package.json and pnpm-lock.yaml; upgrad…
AlejandroLuisHCMoeve Aug 13, 2025
4546f8e
feat: Implement email sending API using nodemailer; handle POST reque…
AlejandroLuisHCMoeve Aug 13, 2025
e5fc104
chore: move background asset to public folder to allow render
AlejandroLuisHCMoeve Aug 13, 2025
11f0583
feat: Enhance contact form functionality; integrate inquiry type sele…
AlejandroLuisHCMoeve Aug 13, 2025
a424d6f
feat: Implement email sending API and tests; create handler for POST …
AlejandroLuisHCMoeve Aug 13, 2025
a1959f7
feat: Enhance contact form with loading state and error handling; upd…
AlejandroLuisHCMoeve Aug 13, 2025
b788cf5
fix: Update email subject formatting and improve contact form validat…
AlejandroLuisHCMoeve Aug 13, 2025
2179f28
fix: Enhance error responses in email sending API; include error code…
AlejandroLuisHCMoeve Aug 14, 2025
ed305b3
refactor: Replace email sending API implementation; migrate to React-…
AlejandroLuisHCMoeve Aug 14, 2025
06011e0
refactor: Replace sendEmail.js with TypeScript implementation in send…
AlejandroLuisHCMoeve Aug 14, 2025
d3b2f85
feat: Add tests for email sending API and ContactEmail component; imp…
AlejandroLuisHCMoeve Aug 14, 2025
4445b0b
fix: Update type casting for fetch mock in useContactForm tests to im…
AlejandroLuisHCMoeve Aug 14, 2025
cc58372
chore: Add package.json for API configuration and clean up useContact…
AlejandroLuisHCMoeve Aug 14, 2025
22c0a95
refactor: Replace ContactEmail component with a new implementation us…
AlejandroLuisHCMoeve Aug 14, 2025
e6489fb
chore: Remove package.json from API and dynamically import ContactEma…
AlejandroLuisHCMoeve Aug 14, 2025
6412d97
feat: Implement email sending API with React-based email rendering; a…
AlejandroLuisHCMoeve Aug 14, 2025
f6c3d34
feat: Enhance architecture with new API and email templates; add path…
AlejandroLuisHCMoeve Aug 14, 2025
0a23fc4
feat: Format availability labels in email API to Spanish day/time; up…
AlejandroLuisHCMoeve Aug 14, 2025
77660c6
feat: Add client-side validation to contact form; disable submit butt…
AlejandroLuisHCMoeve Aug 14, 2025
d9664ee
feat: Release version 0.6.0 with enhanced contact form featuring emai…
AlejandroLuisHCMoeve Aug 14, 2025
21697f4
docs: Update README.md to clarify project features, enhance email sen…
AlejandroLuisHCMoeve Aug 14, 2025
7e8759d
chore: Update .env.sample to replace VITE_MAILEROO_API_KEY with SMTP_…
AlejandroLuisHCMoeve Aug 14, 2025
fdc2fdc
Merge pull request #39 from AlejandroLuisHC/feature/improve-contact
AlejandroLuisHC Aug 14, 2025
3dca6ff
Merge branch 'main' into release/0.6.0
AlejandroLuisHC Aug 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# SMTP Config
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
MAIL_TO=

# Public
VITE_CONTACT_ENDPOINT=
3 changes: 2 additions & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
* @AlejandroLuisHC
* @AlejandroLuisHC
* @david-bardina
35 changes: 0 additions & 35 deletions .github/dependabot.yml

This file was deleted.

15 changes: 4 additions & 11 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

```
blocknroll/
├── api/ # Serverless functions API (email sending)
├── emails/ # Email templates
├── public/ # Static assets
├── src/
│ ├── assets/ # Images, docs, and other assets
Expand Down Expand Up @@ -109,6 +111,8 @@ const ROUTES = {
### Path Aliases Available:

- `@/` → `src/`
- `@api/` → `api/`
- `@emails/` → `emails/`
- `@components/` → `src/components/`
- `@pages/` → `src/pages/`
- `@layouts/` → `src/layouts/`
Expand Down Expand Up @@ -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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ 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

- **Modern Design**: Clean, responsive layout optimized for all devices
- **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
Expand All @@ -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

Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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:
Expand All @@ -159,7 +169,6 @@ npm run test:coverage
## 🎯 To-Do List

- [ ] Instagram app integration
- [ ] Booking/contact system
- [ ] SEO optimization
- [ ] Blog functionality
- [ ] Payment integration (?)
Expand Down
109 changes: 109 additions & 0 deletions api/send-email.js
Original file line number Diff line number Diff line change
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const line = (k, v) => (v ? `<p><strong>${esc(k)}:</strong> ${esc(v)}</p>` : "");
const details = detailsRows.map(([k, v]) => line(k, v)).join("");
html = `
<h2>New contact message</h2>
${summaryRows.map(([k, v]) => line(k, v)).join("")}
${details}
<hr />
<div>${esc(message)}</div>
`;
}

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" });
}
}


115 changes: 115 additions & 0 deletions api/send-email.test.js
Original file line number Diff line number Diff line change
@@ -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 () => "<html>ok</html>"),
}));

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();
});
});
Loading