5 Commits
14 changed files with 1149 additions and 790 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
+2 -2
View File
@@ -61,7 +61,7 @@ export default function BeachClientPage({ config, turnstileSiteKey }: { config:
<Image
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}
alt="Mat- & Sommarfesten"
alt="Mat- och Sommarfesten Höganäs Logo"
/>
</div>
@@ -123,7 +123,7 @@ export default function BeachClientPage({ config, turnstileSiteKey }: { config:
<p className="font-bold">{timeInfo.dateStr}</p>
<p>{timeInfo.matchStr}</p>
<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>
</div>
</div>
+106 -45
View File
@@ -2,8 +2,6 @@
'use server';
import nodemailer from 'nodemailer';
export async function submitRegistration(prevState: unknown, formData: FormData) {
// 1. Spara all rådata direkt för att kunna skicka tillbaka den vid fel
const rawData = {
@@ -21,9 +19,9 @@ export async function submitRegistration(prevState: unknown, formData: FormData)
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 secretKey = process.env.TURNSTILE_SECRET; // Matchar din .env exakt
const secretKey = process.env.TURNSTILE_SECRET;
if (!secretKey) {
console.error("Saknar TURNSTILE_SECRET i miljön.");
@@ -73,43 +71,97 @@ 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)}`
: rawData.tel;
// 5. Nodemailer setup
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
tls: {
rejectUnauthorized: false
// 5. Stalwart JMAP Setup
const jmapUrl = process.env.STALWART_JMAP_URL;
const jmapToken = process.env.STALWART_API_TOKEN;
const accountId = process.env.STALWART_ACCOUNT_ID;
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
};
}
// Hjälpfunktion för att bygga och skicka via Stalwart JMAP
async function sendMailJMAP(toEmail: string, replyToEmail: string, subject: string, htmlContent: string, fromName: string) {
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": {
from: [{ email: accountId, name: fromName }],
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"
}
}
},
"1"
]
]
};
const res = await fetch(jmapUrl as string, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jmapToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
throw new Error(`JMAP API Error: ${res.status} ${res.statusText}`);
}
return res.json();
}
// 6. Skapa och skicka mail
try {
const dateStr = new Date().toLocaleString('sv-SE', {
timeZone: 'Europe/Stockholm',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
const cleanMessage = rawData.message ? rawData.message.replace(/[\r\n;]/g, " ") : "";
// Mail till klubben
await transporter.sendMail({
from: `"Beach-anmälan: ${rawData.name}" <${process.env.SMTP_USER}>`,
to: 'beach@lerbergetsvolleyboll.se',
replyTo: rawData.email,
subject: `Anmälan: ${rawData.team}`,
html: `
// HTML för mail till klubben
const mailTillKlubbHtml = `
<html>
<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>
<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;'>Kontakt:</td><td>${rawData.name}</td></tr>
@@ -119,14 +171,11 @@ export async function submitRegistration(prevState: unknown, formData: FormData)
<tr><td style='padding: 5px; font-weight: bold;'>Nivå:</td><td>${rawData.level}</td></tr>
</table>
<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;'>
<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;">
Visa kopierbar tabell för Excel
</summary>
<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;'>
<tr style="background-color: #f9f9f9;">
@@ -144,26 +193,38 @@ export async function submitRegistration(prevState: unknown, formData: FormData)
</details>
</body>
</html>
`
});
`;
// Bekräftelse till användaren
await transporter.sendMail({
from: `"LVS" <${process.env.SMTP_USER}>`,
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;">
// HTML för bekräftelse till användaren
const mailTillAnvandareHtml = `
<div style="font-family: sans-serif; padding: 20px; color: #333;">
<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>Ses på stranden!<br><strong style="color: #fdb84b;">Lerbergets Volleybollsällskap</strong></p>
</div>`
});
</div>
`;
// Skicka mailen parallellt för prestanda (frivilligt, men oftast bättre än sekventiellt)
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
),
sendMailJMAP(
rawData.email, // To
'beach@lerbergetsvolleyboll.se', // ReplyTo
`Vi har tagit emot din anmälan för ${rawData.team}`, // Subject
mailTillAnvandareHtml, // HTML Content
"LVS" // From Name
)
]);
// 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.' };
} catch (e) {
console.error("Nodemailer error:", e);
console.error("JMAP Fetch error:", e);
return {
success: false,
message: 'Kunde inte skicka mail. Kontakta oss manuellt istället.',
+76 -5
View File
@@ -1,17 +1,88 @@
// app/beach/page.tsx
import { Metadata } from 'next';
import { getConfig } from '../lib/config';
import BeachClientPage from './BeachClientPage';
export const dynamic = 'force-dynamic';
export const metadata = {
title: 'Beachturnering',
};
export async function generateMetadata(): Promise<Metadata> {
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() {
const config = await getConfig();
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} />
</>
);
}
+17 -14
View File
@@ -12,7 +12,7 @@ import { FaFacebookSquare, FaInstagram } from "react-icons/fa";
import { IoIosMail } from "react-icons/io";
import Image from 'next/image';
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() {
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 justify-center flex-1">
<div className="w-fit">
<h1 className="uppercase text-[2.5rem] font-bold leading-[1.1] mb-2.5">LVS</h1>
<div className="bg-white w-full h-1.5"></div>
{/* Combine the abbreviation and full name for the H1 */}
<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>
<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]">
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>
<div className="flex flex-col gap-2.5 mt-10">
<HeroButton onClick={() => setActiveModal('member')}>Bli Medlem</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 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>
</section>
@@ -49,9 +52,9 @@ export default function HomeClientContent() {
<div className="flex flex-col items-center w-full my-7.5 gap-2">
<InfoCard
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"
href="https://www.laget.se/LVS/Event/Month"
href="https://www.svenskalag.se/lerbergetsvs/kalender/"
/>
<InfoCard
title="Prenumerera på träningstider"
@@ -67,23 +70,23 @@ export default function HomeClientContent() {
{/* Sociala Medier Ikoner */}
<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" />
</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" />
</SocialIcon>
<SocialIcon title="Laget.se" href="https://www.laget.se/LVS">
<Image src={laget} alt="Laget.se" className="h-18 w-18" />
<SocialIcon title="Svenskalag.se" href="https://www.svenskalag.se/lerbergetsvs" aria-label="Kolla träningstider på Svenskalag.se">
<Image src={svenskalag} alt="Svenskalag.se" className="h-18 w-18" />
</SocialIcon>
</div>
{/* Kontaktinformation */}
<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>
<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 */}
<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>
<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
</ModalAction>
</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>
<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.laget.se/lvs-level-7-8.ics">Level 7-8</ModalAction>
<ModalAction href="webcal://cal.laget.se/lvs-level-8-9.ics">Level 8-9</ModalAction>
<ModalAction href="webcal://cal.laget.se/lvs-level-10.ics">Level 10</ModalAction>
<ModalAction href="webcal://cal.laget.se/LVS.ics">Hela klubben</ModalAction>
<ModalAction href="webcal://cal.svenskalag.se/120351">Level 4-6</ModalAction>
<ModalAction href="webcal://cal.svenskalag.se/120353">Level 7-9</ModalAction>
<ModalAction href="webcal://cal.svenskalag.se/120355">Level 10</ModalAction>
<ModalAction href="webcal://cal.svenskalag.se/120187">Hela klubben</ModalAction>
</div>
</Modal>
);
+3 -2
View File
@@ -10,11 +10,12 @@ const montserrat = Montserrat({
});
export const metadata: Metadata = {
metadataBase: new URL('https://lerbergetsvolleyboll.se'),
title: {
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: {
icon: '/images/favicon.svg',
shortcut: '/favicon.svg',
+48 -3
View File
@@ -1,21 +1,66 @@
// app/page.tsx
import { Metadata } from 'next';
import BeachPromo from './components/BeachPromo';
import Footer from './components/Footer';
import Header from './components/Header';
import HomeClientContent from './components/HomeClientContent';
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() {
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 (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<main>
<Header />
{config.showBeachPromo && (
<BeachPromo />
)}
{config.showBeachPromo && <BeachPromo />}
<HomeClientContent />
<Footer />
</main>
</>
);
}
+838 -679
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -11,7 +11,6 @@
"dependencies": {
"@marsidev/react-turnstile": "^1.4.2",
"next": "^16",
"nodemailer": "^8.0.1",
"postcss": "^8.5.6",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -27,5 +26,9 @@
"eslint-config-next": "16.1.6",
"tailwindcss": "^4.2.1",
"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