7 Commits
17 changed files with 1248 additions and 818 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Publish Docker image
on:
release:
types: [published]
jobs:
push_to_registry:
name: Build and Push Docker image
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: git.stws.cc
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: git.stws.cc/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Build and push Docker images
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-24
View File
@@ -1,24 +0,0 @@
stages:
- Build
"Build Docker":
stage: Build
image: docker:24.0.5
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
script:
- echo "Building Docker-image..."
- >
docker build
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
-t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
- docker push $CI_REGISTRY_IMAGE:latest
rules:
- if: $CI_COMMIT_TAG
+10 -10
View File
@@ -61,13 +61,13 @@ export default function BeachClientPage({ config, turnstileSiteKey }: { config:
<Image <Image
className="w-full max-w-62.5 md:max-w-75 relative drop-shadow-2xl transform hover:scale-105 transition-transform duration-500" className="w-full max-w-62.5 md:max-w-75 relative drop-shadow-2xl transform hover:scale-105 transition-transform duration-500"
src={matsommar} src={matsommar}
alt="Mat- & Sommarfesten" alt="Mat- och Sommarfesten Höganäs Logo"
/> />
</div> </div>
{/* Huvudrubrik med 3D-känsla */} {/* Huvudrubrik med 3D-känsla */}
<h1 className="font-beachday text-[clamp(45px,9vw,80px)] text-[#fdb84b] uppercase leading-[0.9] mt-8 mb-4 drop-shadow-[0_4px_4px_rgba(64,97,133,0.3)] tracking-wide wrap-break-word w-full max-w-full"> <h1 className="font-beachday text-[clamp(80px,14vw,120px)] text-[#fdb84b] uppercase leading-[0.8] mt-8 mb-12 tracking-widest [text-shadow:4px_4px_0_#39979f]">
Beach&shy;turnering Höganäs Beach Open
</h1> </h1>
<p className="text-xl md:text-2xl font-bold text-[#39979f] bg-white/60 px-6 py-2 rounded-full backdrop-blur-sm shadow-sm"> <p className="text-xl md:text-2xl font-bold text-[#39979f] bg-white/60 px-6 py-2 rounded-full backdrop-blur-sm shadow-sm">
Samla ihop ett kompisgäng och anmäl er! Samla ihop ett kompisgäng och anmäl er!
@@ -107,7 +107,7 @@ export default function BeachClientPage({ config, turnstileSiteKey }: { config:
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3 text-[#cc8124]"> <div className="flex items-center gap-3 text-[#cc8124]">
<FaMapMarkerAlt size={24} /> <FaMapMarkerAlt size={24} />
<h4 className="font-beachday text-2xl uppercase tracking-[0.05em] text-black">Var</h4> <h4 className="font-beachday text-2xl uppercase tracking-wider text-black">Var</h4>
</div> </div>
<p className="text-gray-700 text-lg leading-relaxed"> <p className="text-gray-700 text-lg leading-relaxed">
<a target="_blank" rel="noreferrer" className="font-bold text-[#39979f] hover:underline decoration-2" href="https://maps.app.goo.gl/vJcdSDCbvdGYXPNY6"> <a target="_blank" rel="noreferrer" className="font-bold text-[#39979f] hover:underline decoration-2" href="https://maps.app.goo.gl/vJcdSDCbvdGYXPNY6">
@@ -117,13 +117,13 @@ export default function BeachClientPage({ config, turnstileSiteKey }: { config:
<div className="flex items-center gap-3 text-[#cc8124] mt-6"> <div className="flex items-center gap-3 text-[#cc8124] mt-6">
<FaClock size={24} /> <FaClock size={24} />
<h4 className="font-beachday text-2xl uppercase tracking-[0.05em] text-black">När</h4> <h4 className="font-beachday text-2xl uppercase tracking-wider text-black">När</h4>
</div> </div>
<div className="text-gray-700 text-lg space-y-1"> <div className="text-gray-700 text-lg space-y-1">
<p className="font-bold">{timeInfo.dateStr}</p> <p className="font-bold">{timeInfo.dateStr}</p>
<p>{timeInfo.matchStr}</p> <p>{timeInfo.matchStr}</p>
<p className="text-sm italic pt-3 border-t border-blue-100 mt-2"> <p className="text-sm italic pt-3 border-t border-blue-100 mt-2">
Samtidigt som <a target="_blank" rel="noreferrer" className="text-[#39979f] hover:underline" href={config.beachPage.festivalLink}>Höganäs Mat- & Sommarfest</a> Samtidigt som <a target="_blank" rel="noopener noreferrer" className="text-[#39979f] hover:underline" href={config.beachPage.festivalLink}>Höganäs Mat- & Sommarfest</a>
</p> </p>
</div> </div>
</div> </div>
@@ -132,7 +132,7 @@ export default function BeachClientPage({ config, turnstileSiteKey }: { config:
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3 text-[#cc8124]"> <div className="flex items-center gap-3 text-[#cc8124]">
<FaClipboardList size={24} /> <FaClipboardList size={24} />
<h4 className="font-beachday text-2xl uppercase tracking-[0.05em] text-black">Regler</h4> <h4 className="font-beachday text-2xl uppercase tracking-wider text-black">Regler</h4>
</div> </div>
<div <div
className="text-gray-700 text-base leading-relaxed [&_a]:text-[#39979f] [&_a]:font-bold [&_a:hover]:underline [&_i]:italic [&_em]:italic" className="text-gray-700 text-base leading-relaxed [&_a]:text-[#39979f] [&_a]:font-bold [&_a:hover]:underline [&_i]:italic [&_em]:italic"
@@ -141,7 +141,7 @@ export default function BeachClientPage({ config, turnstileSiteKey }: { config:
<div className="flex items-center gap-3 text-[#cc8124] mt-6"> <div className="flex items-center gap-3 text-[#cc8124] mt-6">
<FaUsers size={24} /> <FaUsers size={24} />
<h4 className="font-beachday text-2xl uppercase tracking-[0.05em] text-black">Klasser</h4> <h4 className="font-beachday text-2xl uppercase tracking-wider text-black">Klasser</h4>
</div> </div>
<div className="text-gray-700 text-sm space-y-3"> <div className="text-gray-700 text-sm space-y-3">
<p className="leading-snug">{config.beachPage?.classesIntroText}</p> <p className="leading-snug">{config.beachPage?.classesIntroText}</p>
@@ -166,7 +166,7 @@ export default function BeachClientPage({ config, turnstileSiteKey }: { config:
<div className="flex-1 space-y-3"> <div className="flex-1 space-y-3">
<div className="flex items-center gap-3 text-[#cc8124]"> <div className="flex items-center gap-3 text-[#cc8124]">
<FaTicketAlt size={24} /> <FaTicketAlt size={24} />
<h4 className="font-beachday text-2xl uppercase tracking-[0.05em] text-black">Anmälan & Kostnad</h4> <h4 className="font-beachday text-2xl uppercase tracking-wider text-black">Anmälan & Kostnad</h4>
</div> </div>
<p className="text-2xl font-black text-green-700 uppercase tracking-wider">Kostnad: Gratis!</p> <p className="text-2xl font-black text-green-700 uppercase tracking-wider">Kostnad: Gratis!</p>
<div className="text-gray-700 text-sm space-y-2"> <div className="text-gray-700 text-sm space-y-2">
@@ -194,7 +194,7 @@ export default function BeachClientPage({ config, turnstileSiteKey }: { config:
<div className="mt-10 w-full max-w-2xl bg-linear-to-r from-[#39979f] to-[#62b8bc] py-4 px-8 rounded-2xl shadow-xl border-b-4 border-black/20"> <div className="mt-10 w-full max-w-2xl bg-linear-to-r from-[#39979f] to-[#62b8bc] py-4 px-8 rounded-2xl shadow-xl border-b-4 border-black/20">
<div className="flex items-center justify-center gap-4"> <div className="flex items-center justify-center gap-4">
<FaTrophy className="text-[#fdb84b] shrink-0" size={28} /> <FaTrophy className="text-[#fdb84b] shrink-0" size={28} />
<h4 className="font-beachday uppercase text-[clamp(1.1rem,4vw,1.8rem)] tracking-[0.05em] font-medium text-white text-center leading-none"> <h4 className="font-beachday uppercase text-[clamp(1.1rem,4vw,1.8rem)] tracking-wider font-medium text-white text-center leading-none">
{config.beachPage?.prizesText || "Fina priser till alla pallplatser! 🏆"} {config.beachPage?.prizesText || "Fina priser till alla pallplatser! 🏆"}
</h4> </h4>
</div> </div>
+179 -47
View File
@@ -2,8 +2,6 @@
'use server'; 'use server';
import nodemailer from 'nodemailer';
export async function submitRegistration(prevState: unknown, formData: FormData) { export async function submitRegistration(prevState: unknown, formData: FormData) {
// 1. Spara all rådata direkt för att kunna skicka tillbaka den vid fel // 1. Spara all rådata direkt för att kunna skicka tillbaka den vid fel
const rawData = { const rawData = {
@@ -21,9 +19,9 @@ export async function submitRegistration(prevState: unknown, formData: FormData)
return { success: false, message: 'Spam detekterat.', values: rawData }; return { success: false, message: 'Spam detekterat.', values: rawData };
} }
// 2. Hämta Turnstile Token och Secret (MATCHING .env NAME) // 2. Hämta Turnstile Token och Secret
const turnstileToken = formData.get('cf-turnstile-response'); const turnstileToken = formData.get('cf-turnstile-response');
const secretKey = process.env.TURNSTILE_SECRET; // Matchar din .env exakt const secretKey = process.env.TURNSTILE_SECRET;
if (!secretKey) { if (!secretKey) {
console.error("Saknar TURNSTILE_SECRET i miljön."); console.error("Saknar TURNSTILE_SECRET i miljön.");
@@ -73,43 +71,166 @@ export async function submitRegistration(prevState: unknown, formData: FormData)
? `+46 (0)${cleanTel.substring(0, 2)}-${cleanTel.substring(2, 5)} ${cleanTel.substring(5, 7)} ${cleanTel.substring(7, 9)}` ? `+46 (0)${cleanTel.substring(0, 2)}-${cleanTel.substring(2, 5)} ${cleanTel.substring(5, 7)} ${cleanTel.substring(7, 9)}`
: rawData.tel; : rawData.tel;
// 5. Nodemailer setup // 5. Stalwart JMAP Setup
const transporter = nodemailer.createTransport({ const jmapUrl = process.env.STALWART_JMAP_URL;
host: process.env.SMTP_HOST, const jmapToken = process.env.STALWART_API_TOKEN;
port: 587, const accountId = process.env.STALWART_ACCOUNT_ID;
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
tls: {
rejectUnauthorized: false
}
});
if (!jmapUrl || !jmapToken || !accountId) {
console.error("Saknar JMAP-konfiguration i miljön.");
return {
success: false,
message: 'E-postserver är inte konfigurerad. Kontakta administratör.',
values: rawData
};
}
async function sendMailJMAP(toEmail: string, replyToEmail: string, subject: string, htmlContent: string, fromName: string, fromEmail: string) {
// 1. Hämta Mapp-ID (Drafts/Utkast) och Identitets-ID från Stalwart
const setupPayload = {
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:submission"],
methodCalls: [
["Mailbox/query", { accountId: accountId, filter: { role: "drafts" } }, "getMailbox"],
["Identity/get", { accountId: accountId }, "getIdentity"]
]
};
const setupRes = await fetch(jmapUrl as string, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${jmapToken}` },
body: JSON.stringify(setupPayload)
});
const setupData = await setupRes.json();
const mailboxRes = setupData.methodResponses.find((r: any) => r[0] === "Mailbox/query");
const identityRes = setupData.methodResponses.find((r: any) => r[0] === "Identity/get");
let mailboxId = mailboxRes && mailboxRes[1].ids && mailboxRes[1].ids.length > 0 ? mailboxRes[1].ids[0] : null;
// ---- NY LOGIK HÄR: Hitta rätt identitet baserat på fromEmail ----
let identityId = null;
let actualFromEmail = fromEmail;
if (identityRes && identityRes[1].list) {
// Leta efter identiteten som matchar adressen vi vill skicka från
const matchedIdentity = identityRes[1].list.find((i: any) => i.email === fromEmail);
if (matchedIdentity) {
identityId = matchedIdentity.id;
} else if (identityRes[1].list.length > 0) {
// Fallback om den exakta adressen inte fanns i listan
identityId = identityRes[1].list[0].id;
actualFromEmail = identityRes[1].list[0].email;
}
}
// Om ingen mapp med rollen "drafts" finns, hämta första bästa mapp
if (!mailboxId) {
const fallbackRes = await fetch(jmapUrl as string, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${jmapToken}` },
body: JSON.stringify({
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
methodCalls: [["Mailbox/query", { accountId: accountId }, "0"]]
})
});
const fallbackData = await fallbackRes.json();
mailboxId = fallbackData.methodResponses[0][1].ids[0];
}
if (!mailboxId || !identityId) {
throw new Error("Kunde inte hitta mailboxId eller identityId i Stalwart.");
}
// 2. Skapa och skicka mailet
const payload = {
using: [
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission"
],
methodCalls: [
[
"Email/set",
{
accountId: accountId,
create: {
"draft-1": {
mailboxIds: { [mailboxId as string]: true },
from: [{ email: actualFromEmail, name: fromName }], // Nu använder vi rätt adress
to: [{ email: toEmail }],
replyTo: [{ email: replyToEmail }],
subject: subject,
bodyValues: {
"body-html": {
value: htmlContent,
isTruncated: false,
isEncodingProblem: false
}
},
htmlBody: [{ partId: "body-html", type: "text/html" }]
}
}
},
"0"
],
[
"EmailSubmission/set",
{
accountId: accountId,
create: {
"sub-1": {
emailId: "#draft-1",
identityId: identityId // Nu använder vi rätt ID
}
}
},
"1"
]
]
};
const res = await fetch(jmapUrl as string, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jmapToken}`
},
body: JSON.stringify(payload)
});
const data = await res.json();
const hasErrors = data.methodResponses.some(
(response: any) =>
response[0] === "error" ||
(response[1] && response[1].notCreated)
);
if (hasErrors) {
console.error("--- JMAP METHOD ERROR ---");
console.error(JSON.stringify(data.methodResponses, null, 2));
console.error("-------------------------");
throw new Error("JMAP accepterade anropet, men vägrade skapa/skicka mailet.");
}
return data;
}
// 6. Skapa och skicka mail
try { try {
const dateStr = new Date().toLocaleString('sv-SE', { const dateStr = new Date().toLocaleString('sv-SE', {
timeZone: 'Europe/Stockholm', timeZone: 'Europe/Stockholm',
year: 'numeric', year: 'numeric', month: '2-digit', day: '2-digit',
month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}); });
const cleanMessage = rawData.message ? rawData.message.replace(/[\r\n;]/g, " ") : ""; const cleanMessage = rawData.message ? rawData.message.replace(/[\r\n;]/g, " ") : "";
// Mail till klubben // HTML för mail till klubben
await transporter.sendMail({ const mailTillKlubbHtml = `
from: `"Beach-anmälan: ${rawData.name}" <${process.env.SMTP_USER}>`,
to: 'beach@lerbergetsvolleyboll.se',
replyTo: rawData.email,
subject: `Anmälan: ${rawData.team}`,
html: `
<html> <html>
<body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 800px; padding: 20px;'> <body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 800px; padding: 20px;'>
<h2 style='color: #39979f;'>Beach-anmälan</h2> <h2 style='color: #39979f;'>Beach-anmälan</h2>
<table style='width: 100%; max-width: 600px; border-collapse: collapse;'> <table style='width: 100%; max-width: 600px; border-collapse: collapse;'>
<tr><td style='padding: 5px; font-weight: bold; width: 150px;'>Lagnamn:</td><td>${rawData.team}</td></tr> <tr><td style='padding: 5px; font-weight: bold; width: 150px;'>Lagnamn:</td><td>${rawData.team}</td></tr>
<tr><td style='padding: 5px; font-weight: bold;'>Kontakt:</td><td>${rawData.name}</td></tr> <tr><td style='padding: 5px; font-weight: bold;'>Kontakt:</td><td>${rawData.name}</td></tr>
@@ -119,14 +240,11 @@ export async function submitRegistration(prevState: unknown, formData: FormData)
<tr><td style='padding: 5px; font-weight: bold;'>Nivå:</td><td>${rawData.level}</td></tr> <tr><td style='padding: 5px; font-weight: bold;'>Nivå:</td><td>${rawData.level}</td></tr>
</table> </table>
<p><strong>Meddelande:</strong><br>${rawData.message ? rawData.message.replace(/\n/g, '<br>') : '<em>Inget meddelande</em>'}</p> <p><strong>Meddelande:</strong><br>${rawData.message ? rawData.message.replace(/\n/g, '<br>') : '<em>Inget meddelande</em>'}</p>
<hr style='border: none; border-top: 1px solid #ddd; margin: 30px 0;'> <hr style='border: none; border-top: 1px solid #ddd; margin: 30px 0;'>
<details style="margin-top: 20px;"> <details style="margin-top: 20px;">
<summary style="background-color: #fdb84b; color: #fff; padding: 12px 20px; border-radius: 8px; font-weight: bold; cursor: pointer; display: inline-block; font-size: 14px; text-transform: uppercase; letter-spacing: 1px;"> <summary style="background-color: #fdb84b; color: #fff; padding: 12px 20px; border-radius: 8px; font-weight: bold; cursor: pointer; display: inline-block; font-size: 14px; text-transform: uppercase; letter-spacing: 1px;">
Visa kopierbar tabell för Excel Visa kopierbar tabell för Excel
</summary> </summary>
<div style="margin-top: 15px; overflow-x: auto;"> <div style="margin-top: 15px; overflow-x: auto;">
<table border='1' style='border-collapse: collapse; font-family: Calibri, sans-serif; font-size: 11pt; width: 100%; text-align: left;'> <table border='1' style='border-collapse: collapse; font-family: Calibri, sans-serif; font-size: 11pt; width: 100%; text-align: left;'>
<tr style="background-color: #f9f9f9;"> <tr style="background-color: #f9f9f9;">
@@ -144,26 +262,40 @@ export async function submitRegistration(prevState: unknown, formData: FormData)
</details> </details>
</body> </body>
</html> </html>
` `;
});
// Bekräftelse till användaren // HTML för bekräftelse till användaren
await transporter.sendMail({ const mailTillAnvandareHtml = `
from: `"LVS" <${process.env.SMTP_USER}>`, <div style="font-family: sans-serif; padding: 20px; color: #333;">
to: rawData.email,
replyTo: `"LVS Beach" <beach@lerbergetsvolleyboll.se>`,
subject: `Vi har tagit emot din anmälan för ${rawData.team}`,
html: `<div style="font-family: sans-serif; padding: 20px; color: #333;">
<h2 style="color: #39979f;">Hej ${rawData.name}!</h2> <h2 style="color: #39979f;">Hej ${rawData.name}!</h2>
<p>Tack för din anmälan till beachturneringen. Vi återkommer så snart vi har granskat era uppgifter.</p> <p>Tack för din anmälan till beachturneringen. Vi återkommer så snart vi har granskat era uppgifter.</p>
<p>Ses på stranden!<br><strong style="color: #fdb84b;">Lerbergets Volleybollsällskap</strong></p> <p>Ses på stranden!<br><strong style="color: #fdb84b;">Lerbergets Volleybollsällskap</strong></p>
</div>` </div>
}); `;
// Skicka mailen parallellt
await Promise.all([
sendMailJMAP(
'beach@lerbergetsvolleyboll.se', // To
rawData.email, // ReplyTo
`Anmälan: ${rawData.team}`, // Subject
mailTillKlubbHtml, // HTML Content
`Beach-anmälan: ${rawData.name}`,// From Name
'beach@lerbergetsvolleyboll.se' // From Email (VIKTIGT!)
),
sendMailJMAP(
rawData.email, // To
'beach@lerbergetsvolleyboll.se', // ReplyTo
`Vi har tagit emot din anmälan för ${rawData.team}`, // Subject
mailTillAnvandareHtml, // HTML Content
"LVS Beach", // From Name
'beach@lerbergetsvolleyboll.se' // From Email (VIKTIGT!)
)
]);
// Returnerar INGA värden här, vilket gör att formuläret kan rensas rent
return { success: true, message: 'Tack! Din anmälan har skickats. Håll utkik i din inkorg efter bekräftelse.' }; return { success: true, message: 'Tack! Din anmälan har skickats. Håll utkik i din inkorg efter bekräftelse.' };
} catch (e) { } catch (e) {
console.error("Nodemailer error:", e); console.error("JMAP Fetch error:", e);
return { return {
success: false, success: false,
message: 'Kunde inte skicka mail. Kontakta oss manuellt istället.', message: 'Kunde inte skicka mail. Kontakta oss manuellt istället.',
+76 -5
View File
@@ -1,17 +1,88 @@
// app/beach/page.tsx // app/beach/page.tsx
import { Metadata } from 'next';
import { getConfig } from '../lib/config'; import { getConfig } from '../lib/config';
import BeachClientPage from './BeachClientPage'; import BeachClientPage from './BeachClientPage';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const metadata = { export async function generateMetadata(): Promise<Metadata> {
title: 'Beachturnering', const config = await getConfig();
}; const matchDate = new Date(config.beachPage.matchStart);
const year = matchDate.getFullYear();
const dateFormatted = matchDate.toLocaleDateString('sv-SE', { day: 'numeric', month: 'long' });
const isClosed = config.beachPage.isClosedOverride;
const title = isClosed
? `Höganäs Beach Open ${year} (Fulltecknat)`
: `Anmälan: Höganäs Beach Open ${year} - ${dateFormatted}`;
const description = isClosed
? `Turneringen den ${dateFormatted} är nu fullbokad. Välkommen ner till Kvickbadet för att titta på beachvolleyboll i toppklass!`
: `Välkommen till Höganäs Beach Open den ${dateFormatted}! Turneringsinfo, regler och anmälan för beachvolleyboll vid Kvickbadet.`;
return {
title: title,
description: description,
openGraph: {
title: title,
description: description,
url: 'https://lerbergetsvolleyboll.se/beach',
siteName: 'Lerbergets Volleybollsällskap',
locale: 'sv_SE',
type: 'website',
images: [
{
url: '/images/beach-tournament.webp',
width: 1200,
height: 630,
alt: `Höganäs Beach Open ${year}`,
},
],
},
twitter: {
card: 'summary_large_image',
title: title,
description: description,
images: ['/images/beach-tournament.webp'],
},
};
}
export default async function BeachPage() { export default async function BeachPage() {
const config = await getConfig(); const config = await getConfig();
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY || ''; const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY || '';
return <BeachClientPage config={config} turnstileSiteKey={turnstileSiteKey} />; const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Event',
'name': 'Höganäs Beach Open',
'startDate': config.beachPage.matchStart,
'location': {
'@type': 'Place',
'name': 'Kvickbadet',
'address': {
'@type': 'PostalAddress',
'addressLocality': 'Höganäs',
'addressCountry': 'SE',
},
},
'eventStatus': config.beachPage.isClosedOverride
? 'https://schema.org/EventScheduled'
: 'https://schema.org/EventScheduled',
'organizer': {
'@type': 'Organization',
'name': 'Lerbergets Volleybollsällskap',
'url': 'https://lerbergetsvolleyboll.se'
}
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<BeachClientPage config={config} turnstileSiteKey={turnstileSiteKey} />
</>
);
} }
+2 -2
View File
@@ -9,14 +9,14 @@ export default function BeachPromo() {
<div className="p-2.5"> <div className="p-2.5">
<h2 className="font-beachday text-black text-center text-[clamp(1.2rem,6vw,2.25rem)] uppercase tracking-tight flex items-baseline justify-center flex-wrap gap-x-3 md:gap-x-5"> <h2 className="font-beachday text-black text-center text-[clamp(1.2rem,6vw,2.25rem)] uppercase tracking-tight flex items-baseline justify-center flex-wrap gap-x-3 md:gap-x-5">
<span className="whitespace-nowrap transition-all duration-300 group-hover/promo:opacity-80"> <span className="whitespace-nowrap transition-all duration-300 group-hover/promo:opacity-80">
Kolla in vår Anmäl dig till
</span> </span>
<Link <Link
href="/beach" href="/beach"
className="relative text-[#406185] transition-all duration-300 inline-flex items-center gap-3 group/link hover:tracking-wider hover:-translate-y-1" className="relative text-[#406185] transition-all duration-300 inline-flex items-center gap-3 group/link hover:tracking-wider hover:-translate-y-1"
> >
<span className="relative"> <span className="relative">
Beachturnering Höganäs Beach Open
<span className="absolute -bottom-1 left-0 w-full h-1 bg-[#406185] transform scale-x-0 transition-transform duration-300 origin-left group-hover/link:scale-x-100" /> <span className="absolute -bottom-1 left-0 w-full h-1 bg-[#406185] transform scale-x-0 transition-transform duration-300 origin-left group-hover/link:scale-x-100" />
</span> </span>
<FaVolleyballBall <FaVolleyballBall
+17 -14
View File
@@ -12,7 +12,7 @@ import { FaFacebookSquare, FaInstagram } from "react-icons/fa";
import { IoIosMail } from "react-icons/io"; import { IoIosMail } from "react-icons/io";
import Image from 'next/image'; import Image from 'next/image';
import player from "../../public/images/player.svg"; import player from "../../public/images/player.svg";
import laget from "../../public/images/laget.se.svg"; import svenskalag from "../../public/images/svenskalag.se.png";
export default function HomeClientContent() { export default function HomeClientContent() {
const [activeModal, setActiveModal] = useState<'member' | 'subscribe' | null>(null); const [activeModal, setActiveModal] = useState<'member' | 'subscribe' | null>(null);
@@ -25,21 +25,24 @@ export default function HomeClientContent() {
<div className="flex flex-col md:flex-row max-w-300 my-12.5 mx-5 w-full"> <div className="flex flex-col md:flex-row max-w-300 my-12.5 mx-5 w-full">
<div className="flex flex-col justify-center flex-1"> <div className="flex flex-col justify-center flex-1">
<div className="w-fit"> <div className="w-fit">
<h1 className="uppercase text-[2.5rem] font-bold leading-[1.1] mb-2.5">LVS</h1> {/* Combine the abbreviation and full name for the H1 */}
<div className="bg-white w-full h-1.5"></div> <h1 className="uppercase text-[2.5rem] font-bold leading-[1.1] mb-2.5">
LVS <span className="sr-only">Lerbergets Volleybollsällskap</span>
</h1>
<div className="bg-white w-full h-1.5" aria-hidden="true"></div>
</div> </div>
<h4 className="italic text-xl my-2.5 font-bold">Lerbergets Volleybollsällskap</h4> <h2 className="italic text-xl my-2.5 font-bold">Lerbergets Volleybollsällskap</h2>
<p className="max-w-200 leading-[1.1]"> <p className="max-w-200 leading-[1.1]">
Klubben startade den 1 mars 2018 och har i snabb takt utvecklats med både kidsvolley, 4-mannavolley och ett gott gäng killar och tjejer som tränar tillsammans minst två gånger i veckan och sommaren spelas det beachvolley Klubben startade den 1 mars 2018 och har i snabb takt utvecklats med både <strong>kidsvolley</strong>, <strong>4-mannavolley</strong> och träning för alla nivåer. sommaren flyttar vi ner till stranden för <strong>beachvolleyboll</strong> i Höganäs.
</p> </p>
<div className="flex flex-col gap-2.5 mt-10"> <div className="flex flex-col gap-2.5 mt-10">
<HeroButton onClick={() => setActiveModal('member')}>Bli Medlem</HeroButton> <HeroButton onClick={() => setActiveModal('member')}>Bli Medlem</HeroButton>
<HeroButton href="#traningstider">Träningstider</HeroButton> <HeroButton href="#traningstider">Träningstider</HeroButton>
<HeroButton href="https://www.basesport.se/category/lerbergets-vbk">Klubbkollektion</HeroButton> <HeroButton href="https://www.basesport.se/category/lerbergets-vbs">Klubbkollektion</HeroButton>
</div> </div>
</div> </div>
<div className="hidden md:flex md:relative w-64 md:w-75 player-animation"> <div className="hidden md:flex md:relative w-64 md:w-75 player-animation">
<Image src={player} alt="Spelare" className="w-full h-auto" /> <Image src={player} alt="Spelare" className="w-full h-auto" loading='eager' />
</div> </div>
</div> </div>
</section> </section>
@@ -49,9 +52,9 @@ export default function HomeClientContent() {
<div className="flex flex-col items-center w-full my-7.5 gap-2"> <div className="flex flex-col items-center w-full my-7.5 gap-2">
<InfoCard <InfoCard
title="Träningstider" title="Träningstider"
description="Här hittar du alltid uppdaterade träningstider från Laget.se" description="Här hittar du alltid uppdaterade träningstider från Svenskalag.se"
actionLabel="Se nästa träningstid" actionLabel="Se nästa träningstid"
href="https://www.laget.se/LVS/Event/Month" href="https://www.svenskalag.se/lerbergetsvs/kalender/"
/> />
<InfoCard <InfoCard
title="Prenumerera på träningstider" title="Prenumerera på träningstider"
@@ -67,23 +70,23 @@ export default function HomeClientContent() {
{/* Sociala Medier Ikoner */} {/* Sociala Medier Ikoner */}
<div className="flex flex-wrap justify-center items-center gap-10"> <div className="flex flex-wrap justify-center items-center gap-10">
<SocialIcon title="Instagram" href="https://www.instagram.com/lerbergets_volleyboll/"> <SocialIcon title="Instagram" href="https://www.instagram.com/lerbergets_volleyboll/" aria-label="Följ LVS på Instagram">
<FaInstagram className="h-20 w-20" /> <FaInstagram className="h-20 w-20" />
</SocialIcon> </SocialIcon>
<SocialIcon title="Facebook" href="https://www.facebook.com/lerbergetsvolleyboll"> <SocialIcon title="Facebook" href="https://www.facebook.com/lerbergetsvolleyboll" aria-label="Följ LVS på Facebook">
<FaFacebookSquare className="h-20 w-20" /> <FaFacebookSquare className="h-20 w-20" />
</SocialIcon> </SocialIcon>
<SocialIcon title="Laget.se" href="https://www.laget.se/LVS"> <SocialIcon title="Svenskalag.se" href="https://www.svenskalag.se/lerbergetsvs" aria-label="Kolla träningstider på Svenskalag.se">
<Image src={laget} alt="Laget.se" className="h-18 w-18" /> <Image src={svenskalag} alt="Svenskalag.se" className="h-18 w-18" />
</SocialIcon> </SocialIcon>
</div> </div>
{/* Kontaktinformation */} {/* Kontaktinformation */}
<div className="text-center p-2.5 flex flex-col justify-center items-center"> <div className="text-center p-2.5 flex flex-col justify-center items-center">
<h3 className="uppercase font-bold text-[1.875rem] leading-[1.2] mb-4">Kontakta oss</h3> <h3 className="uppercase font-bold text-[1.875rem] leading-[1.2] mb-4">Kontakta oss</h3>
<p className="text-[1rem] leading-[1.6] opacity-90">Vi finns Instagram, Facebook och laget.se</p> <p className="text-[1rem] leading-[1.6] opacity-90">Vi finns Instagram, Facebook och Svenskalag.se</p>
{/* Klickbar Adress - Länkar till Google Maps */} {/* Klickbar Adress - Länkar till Google Maps */}
<a <a
+1 -1
View File
@@ -13,7 +13,7 @@ export default function MemberModal({ onClose }: ModalProps) {
<h2 className="text-[#406185] my-2.5 text-2xl font-bold uppercase">Registrera dig</h2> <h2 className="text-[#406185] my-2.5 text-2xl font-bold uppercase">Registrera dig</h2>
<p className="text-[#406185] my-2.5">Trycka knappen nedan för att starta registreringen. Du kommer att skickas till laget.se</p> <p className="text-[#406185] my-2.5">Trycka knappen nedan för att starta registreringen. Du kommer att skickas till laget.se</p>
<ModalAction href="https://www.laget.se/LVS/Member" className="mt-5"> <ModalAction href="https://www.svenskalag.se/lerbergetsvs/formular/borja-trana-i-lerbergets-volleybollsallskap/38396" className="mt-5">
Bli medlem Bli medlem
</ModalAction> </ModalAction>
</Modal> </Modal>
+4 -5
View File
@@ -11,11 +11,10 @@ export default function SubscribeModal({ onClose }: ModalProps) {
<p className="text-[#406185] my-2.5">Välj vilken LEVEL du vill prenumerera .</p> <p className="text-[#406185] my-2.5">Välj vilken LEVEL du vill prenumerera .</p>
<div className="flex flex-row flex-wrap justify-center gap-6.25 mt-5 max-w-162.5"> <div className="flex flex-row flex-wrap justify-center gap-6.25 mt-5 max-w-162.5">
<ModalAction href="webcal://cal.laget.se/lvs-level-5-6.ics">Level 5-6</ModalAction> <ModalAction href="webcal://cal.svenskalag.se/120351">Level 4-6</ModalAction>
<ModalAction href="webcal://cal.laget.se/lvs-level-7-8.ics">Level 7-8</ModalAction> <ModalAction href="webcal://cal.svenskalag.se/120353">Level 7-9</ModalAction>
<ModalAction href="webcal://cal.laget.se/lvs-level-8-9.ics">Level 8-9</ModalAction> <ModalAction href="webcal://cal.svenskalag.se/120355">Level 10</ModalAction>
<ModalAction href="webcal://cal.laget.se/lvs-level-10.ics">Level 10</ModalAction> <ModalAction href="webcal://cal.svenskalag.se/120187">Hela klubben</ModalAction>
<ModalAction href="webcal://cal.laget.se/LVS.ics">Hela klubben</ModalAction>
</div> </div>
</Modal> </Modal>
); );
+3 -2
View File
@@ -10,11 +10,12 @@ const montserrat = Montserrat({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL('https://lerbergetsvolleyboll.se'),
title: { title: {
template: '%s | LVS', template: '%s | LVS',
default: 'Lerbergets Volleybollsällskap', default: 'LVS - Lerbergets Volleybollsällskap',
}, },
description: 'Klubben startade den 1 mars 2018 och har i snabb takt utvecklats...', description: 'Volleybollklubben i Höganäs för barn, ungdomar och vuxna.',
icons: { icons: {
icon: '/images/favicon.svg', icon: '/images/favicon.svg',
shortcut: '/favicon.svg', shortcut: '/favicon.svg',
+1 -1
View File
@@ -29,7 +29,7 @@ export async function getConfig() {
return { return {
showBeachPromo: true, showBeachPromo: true,
beachPage: { beachPage: {
matchStart: "2026-06-13T10:00", matchStart: "2027-06-12T10:00",
isClosedOverride: false, isClosedOverride: false,
closeReason: "Det är hela 26 lag anmälda och vi kan tyvärr inte ta emot fler anmälningar.\r\n\r\nVi är glada över förväntan och bjuder in alla andra till att komma och kolla men vi har tyvärr inte kapaciteten till att ha med fler lag.", closeReason: "Det är hela 26 lag anmälda och vi kan tyvärr inte ta emot fler anmälningar.\r\n\r\nVi är glada över förväntan och bjuder in alla andra till att komma och kolla men vi har tyvärr inte kapaciteten till att ha med fler lag.",
festivalLink: "https://www.kullahalvon.com/upptacka--uppleva/kultur--noje/evenemang-pa-kullahalvon/mat---sommarfesten.html", festivalLink: "https://www.kullahalvon.com/upptacka--uppleva/kultur--noje/evenemang-pa-kullahalvon/mat---sommarfesten.html",
+53 -8
View File
@@ -1,21 +1,66 @@
// app/page.tsx // app/page.tsx
import { Metadata } from 'next';
import BeachPromo from './components/BeachPromo'; import BeachPromo from './components/BeachPromo';
import Footer from './components/Footer'; import Footer from './components/Footer';
import Header from './components/Header'; import Header from './components/Header';
import HomeClientContent from './components/HomeClientContent'; import HomeClientContent from './components/HomeClientContent';
import { getConfig } from './lib/config'; import { getConfig } from './lib/config';
export async function generateMetadata(): Promise<Metadata> {
const config = await getConfig();
const beachSuffix = config.showBeachPromo ? ' Just nu: Anmälan öppen till Höganäs Beach Open!' : '';
return {
title: 'LVS Volleyboll för alla i Höganäs',
description: `Välkommen till Lerbergets Volleybollsällskap. Vi erbjuder kidsvolley och inomhusvolleyboll för alla åldrar i Höganäs.${beachSuffix}`,
openGraph: {
title: 'LVS | Lerbergets Volleybollsällskap',
description: 'Häng med och spela volleyboll med oss i Höganäs!',
url: 'https://lerbergetsvolleyboll.se',
siteName: 'Lerbergets Volleybollsällskap',
locale: 'sv_SE',
type: 'website',
},
};
}
export default async function Home() { export default async function Home() {
const config = await getConfig(); const config = await getConfig();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SportsOrganization',
'name': 'Lerbergets Volleybollsällskap',
'alternateName': 'LVS',
'url': 'https://lerbergetsvolleyboll.se',
'logo': 'https://lerbergetsvolleyboll.se/images/favicon.svg',
'address': {
'@type': 'PostalAddress',
'streetAddress': 'Friluftsvägen 14',
'addressLocality': 'Höganäs',
'postalCode': '263 54',
'addressCountry': 'SE',
},
'sameAs': [
'https://www.instagram.com/lerbergets_volleyboll/',
'https://www.facebook.com/lerbergetsvolleyboll',
'https://www.laget.se/LVS'
]
};
return ( return (
<main> <>
<Header /> <script
{config.showBeachPromo && ( type="application/ld+json"
<BeachPromo /> dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
)} />
<HomeClientContent /> <main>
<Footer /> <Header />
</main> {config.showBeachPromo && <BeachPromo />}
<HomeClientContent />
<Footer />
</main>
</>
); );
} }
+3 -3
View File
@@ -2,6 +2,6 @@ TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET=1x0000000000000000000000000000000AA TURNSTILE_SECRET=1x0000000000000000000000000000000AA
ADMIN_PASSWORD=PASSWORD ADMIN_PASSWORD=PASSWORD
SMTP_HOST=smtp.example.com STALWART_JMAP_URL=https://mail.example.com/jmap/
SMTP_USER=USER STALWART_ACCOUNT_ID=ID
SMTP_PASS=PASSWORD STALWART_API_TOKEN=API_XXXXXXXXXXXXXXXXXXXXX
+852 -693
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -11,7 +11,6 @@
"dependencies": { "dependencies": {
"@marsidev/react-turnstile": "^1.4.2", "@marsidev/react-turnstile": "^1.4.2",
"next": "^16", "next": "^16",
"nodemailer": "^8.0.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
@@ -27,5 +26,9 @@
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "^5" "typescript": "^5"
},
"allowScripts": {
"sharp@0.34.5": true,
"unrs-resolver@1.12.2": true
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB