Compare commits
@@ -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 }}
|
||||
@@ -1,5 +1,8 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
site-config.json
|
||||
!site-config.json.example
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
stages:
|
||||
- Build
|
||||
|
||||
variables:
|
||||
DOCKER_HOST: tcp://docker:2376
|
||||
DOCKER_TLS_VERIFY: 1
|
||||
DOCKER_CERT_PATH: /certs/client
|
||||
|
||||
"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 -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
|
||||
+7
-5
@@ -10,14 +10,16 @@ FROM node:alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# 3. Production image (Slimmad!)
|
||||
FROM node:alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ="Europe/Stockholm"
|
||||
|
||||
RUN mkdir -p data
|
||||
|
||||
@@ -26,7 +28,7 @@ COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
+27
-5
@@ -5,8 +5,9 @@
|
||||
import { useActionState, useEffect, useState } from 'react';
|
||||
import { FaPlus, FaTrash, FaArrowUp, FaArrowDown } from 'react-icons/fa'; // Importera pilarna
|
||||
import { saveConfigAction } from './actions';
|
||||
import { SiteConfig } from '../lib/config';
|
||||
|
||||
export default function AdminForm({ initialConfig }: { initialConfig: any }) {
|
||||
export default function AdminForm({ initialConfig }: { initialConfig: SiteConfig }) {
|
||||
const [state, formAction, isPending] = useActionState(saveConfigAction, null);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
@@ -49,9 +50,12 @@ export default function AdminForm({ initialConfig }: { initialConfig: any }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success) {
|
||||
setShowToast(true);
|
||||
const timer = setTimeout(() => setShowToast(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
const showTimer = setTimeout(() => setShowToast(true), 0);
|
||||
const hideTimer = setTimeout(() => setShowToast(false), 3000);
|
||||
return () => {
|
||||
clearTimeout(showTimer);
|
||||
clearTimeout(hideTimer);
|
||||
};
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
@@ -87,9 +91,17 @@ export default function AdminForm({ initialConfig }: { initialConfig: any }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1">Regeltext (HTML tillåtet)</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Du kan använda <b>{"<a target=\"_blank\" href=\"länk\">text</a>"}</b> för länkar och <b>{"<i>text</i>"}</b> för kursiv text.
|
||||
</p>
|
||||
<textarea name="rulesText" defaultValue={initialConfig.beachPage.rulesText} className="w-full border p-2 rounded h-48" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1">Meddelande vid stängd anmälan</label>
|
||||
<textarea name="closeReason" defaultValue={initialConfig.beachPage.closeReason} className="w-full border p-2 rounded h-20" />
|
||||
<textarea name="closeReason" defaultValue={initialConfig.beachPage.closeReason} className="w-full border p-2 rounded h-32" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -121,6 +133,11 @@ export default function AdminForm({ initialConfig }: { initialConfig: any }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1">Intro-text ovanför klasserna</label>
|
||||
<textarea name="classesIntroText" defaultValue={initialConfig.beachPage.classesIntroText} className="w-full border p-2 rounded h-16" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{classes.map((c, index) => (
|
||||
<div key={index} className="flex flex-col md:flex-row gap-3 items-center w-full">
|
||||
@@ -180,6 +197,11 @@ export default function AdminForm({ initialConfig }: { initialConfig: any }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm font-bold mb-1">Avslutande text under klasserna</label>
|
||||
<textarea name="classesOutroText" defaultValue={initialConfig.beachPage.classesOutroText} className="w-full border p-2 rounded h-16" />
|
||||
</div>
|
||||
|
||||
{/* Dold JSON-sträng för Server Action */}
|
||||
<input type="hidden" name="classesJSON" value={JSON.stringify(classes)} />
|
||||
</section>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { getConfig, updateConfig } from '../lib/config';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function saveConfigAction(prevState: any, formData: FormData) {
|
||||
export async function saveConfigAction(prevState: { success?: boolean } | null | unknown, formData: FormData) {
|
||||
const config = await getConfig();
|
||||
|
||||
let parsedClasses = [];
|
||||
@@ -26,6 +26,9 @@ export async function saveConfigAction(prevState: any, formData: FormData) {
|
||||
closeReason: formData.get('closeReason') as string,
|
||||
otherInfo: formData.get('otherInfo') as string,
|
||||
prizesText: formData.get('prizesText') as string,
|
||||
rulesText: formData.get('rulesText') as string,
|
||||
classesIntroText: formData.get('classesIntroText') as string,
|
||||
classesOutroText: formData.get('classesOutroText') as string,
|
||||
isClosedOverride: formData.get('isClosedOverride') === 'on',
|
||||
classes: parsedClasses,
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@ import { IoIosMail } from "react-icons/io";
|
||||
import Footer from '../components/Footer';
|
||||
import Header from '../components/Header';
|
||||
import BeachRegistrationForm from './BeachRegistrationForm';
|
||||
import { SiteConfig } from '../lib/config';
|
||||
import Image from 'next/image';
|
||||
import matsommar from "../../public/images/hk-mosf.webp";
|
||||
|
||||
export default function BeachClientPage({ config }: { config: any }) {
|
||||
|
||||
export default function BeachClientPage({ config, turnstileSiteKey }: { config: SiteConfig, turnstileSiteKey: string }) {
|
||||
const [timeInfo, setTimeInfo] = useState({
|
||||
dateStr: 'Laddar...',
|
||||
matchStr: 'Laddar...',
|
||||
@@ -54,16 +58,16 @@ export default function BeachClientPage({ config }: { config: any }) {
|
||||
{/* Logotyp med subtil hover-effekt */}
|
||||
<div className="relative group cursor-default">
|
||||
<div className="absolute -inset-4 bg-white/20 blur-2xl rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
|
||||
<img
|
||||
<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="/images/hk-mosf.webp"
|
||||
alt="Mat- & Sommarfesten"
|
||||
src={matsommar}
|
||||
alt="Mat- och Sommarfesten Höganäs Logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
Beach­turnering
|
||||
<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]">
|
||||
Höganäs Beach Open
|
||||
</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">
|
||||
Samla ihop ett kompisgäng och anmäl er!
|
||||
@@ -103,7 +107,7 @@ export default function BeachClientPage({ config }: { config: any }) {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-[#cc8124]">
|
||||
<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>
|
||||
<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">
|
||||
@@ -113,13 +117,13 @@ export default function BeachClientPage({ config }: { config: any }) {
|
||||
|
||||
<div className="flex items-center gap-3 text-[#cc8124] mt-6">
|
||||
<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 className="text-gray-700 text-lg space-y-1">
|
||||
<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>
|
||||
@@ -128,22 +132,21 @@ export default function BeachClientPage({ config }: { config: any }) {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-[#cc8124]">
|
||||
<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>
|
||||
<p className="text-gray-700 text-base leading-relaxed">
|
||||
Matcherna spelas 4v4 med <a target="_blank" rel="noreferrer" className="text-[#39979f] font-bold hover:underline" href="https://www.volleyboll.se/forbundet/valkommen-till-volleyboll/grenar-och-spelformer/volleyboll">inomhusregler</a>,
|
||||
förutom poängräkningen som följer reglerna för <a target="_blank" rel="noreferrer" className="text-[#39979f] font-bold hover:underline" href="https://www.volleyboll.se/forbundet/valkommen-till-volleyboll/grenar-och-spelformer/beachvolley">beachvolley</a>.
|
||||
Turneringsformen som kommer spelas är <span className='italic'>Double Elimination</span> vilket betyder att alla lag spelar minst två matcher under dagen.
|
||||
</p>
|
||||
<div
|
||||
className="text-gray-700 text-base leading-relaxed [&_a]:text-[#39979f] [&_a]:font-bold [&_a:hover]:underline [&_i]:italic [&_em]:italic"
|
||||
dangerouslySetInnerHTML={{ __html: config.beachPage?.rulesText || "" }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 text-[#cc8124] mt-6">
|
||||
<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 className="text-gray-700 text-sm space-y-3">
|
||||
<p className="leading-snug">Turneringen kan delas upp i tre klasser beroende på antal anmälda lag och nivå:</p>
|
||||
<p className="leading-snug">{config.beachPage?.classesIntroText}</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{classesData.map((c: any, i: number) => (
|
||||
{classesData.map((c: { name: string; color: string; desc?: string }, i: number) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2.5 py-1 rounded-md font-bold border text-xs tracking-wider"
|
||||
@@ -153,7 +156,7 @@ export default function BeachClientPage({ config }: { config: any }) {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="italic text-xs text-gray-500">Gör en gissning på er nivå i anmälan – skriv gärna om ni är osäkra.</p>
|
||||
<p className="italic text-xs text-gray-500">{config.beachPage?.classesOutroText}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,7 +166,7 @@ export default function BeachClientPage({ config }: { config: any }) {
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center gap-3 text-[#cc8124]">
|
||||
<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>
|
||||
<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">
|
||||
@@ -191,7 +194,7 @@ export default function BeachClientPage({ config }: { config: any }) {
|
||||
<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">
|
||||
<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! 🏆"}
|
||||
</h4>
|
||||
</div>
|
||||
@@ -200,7 +203,7 @@ export default function BeachClientPage({ config }: { config: any }) {
|
||||
</section>
|
||||
|
||||
{/* Rendera formuläret endast om anmälan är öppen */}
|
||||
{!timeInfo.isClosed && <BeachRegistrationForm config={config} />}
|
||||
{!timeInfo.isClosed && <BeachRegistrationForm config={config} turnstileSiteKey={turnstileSiteKey} />}
|
||||
|
||||
<section className="w-full flex justify-center px-4 mt-10 mb-20">
|
||||
<div className="bg-linear-to-br from-[#39979f] to-[#62b8bc] p-10 md:p-12 rounded-[40px] shadow-2xl w-full max-w-2xl text-center transform transition-transform duration-500 border border-blue-400/20 relative overflow-hidden group">
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
'use client';
|
||||
|
||||
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import React, { useRef, useActionState, useEffect } from 'react';
|
||||
import React, { useRef, useActionState, useEffect, useState } from 'react';
|
||||
import { FaUser, FaEnvelope, FaPhone, FaUsers, FaTrophy, FaCheckCircle, FaExclamationTriangle } from "react-icons/fa";
|
||||
import { submitRegistration } from './actions';
|
||||
import { SiteConfig } from '../lib/config';
|
||||
|
||||
export default function BeachRegistrationForm({ config }: { config: any }) {
|
||||
export default function BeachRegistrationForm({ config, turnstileSiteKey }: { config: SiteConfig, turnstileSiteKey: string }) {
|
||||
const [state, formAction, isPending] = useActionState(submitRegistration, null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
|
||||
const classesData = Array.isArray(config.beachPage?.classes)
|
||||
? config.beachPage.classes
|
||||
@@ -21,13 +23,13 @@ export default function BeachRegistrationForm({ config }: { config: any }) {
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// Rensa endast vid lyckad inskickning
|
||||
if (state?.success) {
|
||||
formRef.current?.reset();
|
||||
turnstileRef.current?.reset();
|
||||
setToken(null);
|
||||
} else if (state?.success === false) {
|
||||
// Återställ Turnstile så de kan försöka igen vid fel
|
||||
turnstileRef.current?.reset();
|
||||
setToken(null);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
@@ -107,7 +109,7 @@ export default function BeachRegistrationForm({ config }: { config: any }) {
|
||||
<div className="flex-2">
|
||||
<label className="font-bold text-[#39979f] mb-3 block text-sm uppercase">Välj klass</label>
|
||||
<div className={`grid grid-cols-2 md:grid-cols-${Math.min(classesData.length, 3)} gap-3`}>
|
||||
{classesData.map((c: any) => (
|
||||
{classesData.map((c: { name: string; color: string; desc?: string }) => (
|
||||
<LevelButton
|
||||
key={c.name}
|
||||
value={c.name}
|
||||
@@ -133,7 +135,9 @@ export default function BeachRegistrationForm({ config }: { config: any }) {
|
||||
<div className="flex justify-center py-2">
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={(token) => setToken(token)}
|
||||
onExpire={() => setToken(null)}
|
||||
options={{ theme: 'light' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -147,7 +151,7 @@ export default function BeachRegistrationForm({ config }: { config: any }) {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
disabled={isPending || !token}
|
||||
className="w-full py-5 bg-[#39979f] text-white rounded-2xl font-bold text-xl uppercase tracking-wide shadow-xl hover:bg-[#fdb84b] hover:text-[#39979f] transition-all active:scale-[0.98] disabled:bg-gray-300 disabled:text-gray-500"
|
||||
>
|
||||
{isPending ? 'Skickar...' : 'Skicka anmälan'}
|
||||
@@ -159,7 +163,7 @@ export default function BeachRegistrationForm({ config }: { config: any }) {
|
||||
}
|
||||
|
||||
// Uppdaterad med defaultChecked support
|
||||
function LevelButton({ value, label, desc, color, defaultChecked }: any) {
|
||||
function LevelButton({ value, label, desc, color, defaultChecked }: { value: string; label: string; desc?: string; color: string; defaultChecked?: boolean }) {
|
||||
return (
|
||||
<label className="cursor-pointer flex-1 relative" style={{ '--class-color': color } as React.CSSProperties}>
|
||||
<input
|
||||
@@ -179,7 +183,7 @@ function LevelButton({ value, label, desc, color, defaultChecked }: any) {
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroup({ icon, ...props }: any) {
|
||||
function InputGroup({ icon, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { icon: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-[#e6be8a] group-focus-within:text-[#39979f] transition-colors">
|
||||
|
||||
+113
-38
@@ -2,9 +2,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export async function submitRegistration(prevState: any, 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
|
||||
const rawData = {
|
||||
name: formData.get('name') as string,
|
||||
@@ -21,9 +19,9 @@ export async function submitRegistration(prevState: any, 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.");
|
||||
@@ -41,7 +39,7 @@ export async function submitRegistration(prevState: any, formData: FormData) {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
secret: secretKey,
|
||||
response: turnstileToken?.toString() || '',
|
||||
response: String(turnstileToken || ''),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -52,7 +50,7 @@ export async function submitRegistration(prevState: any, formData: FormData) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Säkerhetsverifieringen misslyckades. Ladda om sidan och försök igen.',
|
||||
values: rawData // Returnerar värdena så användaren slipper skriva om dem
|
||||
values: rawData
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -73,29 +71,97 @@ export async function submitRegistration(prevState: any, 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 || 'localhost',
|
||||
port: 25,
|
||||
secure: false,
|
||||
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().toISOString().replace('T', ' ').substring(0, 19);
|
||||
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'
|
||||
});
|
||||
const cleanMessage = rawData.message ? rawData.message.replace(/[\r\n;]/g, " ") : "";
|
||||
|
||||
// Mail till klubben
|
||||
await transporter.sendMail({
|
||||
from: `"Beach-anmälan: ${rawData.name}" <no-reply@lerbergetsvolleyboll.se>`,
|
||||
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>
|
||||
@@ -105,14 +171,11 @@ export async function submitRegistration(prevState: any, 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;">
|
||||
@@ -130,26 +193,38 @@ export async function submitRegistration(prevState: any, formData: FormData) {
|
||||
</details>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
});
|
||||
`;
|
||||
|
||||
// Bekräftelse till användaren
|
||||
await transporter.sendMail({
|
||||
from: `"LVS" <no-reply@lerbergetsvolleyboll.se>`,
|
||||
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.',
|
||||
|
||||
+79
-3
@@ -1,12 +1,88 @@
|
||||
// app/beach/page.tsx
|
||||
import { Metadata } from 'next';
|
||||
import { getConfig } from '../lib/config';
|
||||
import BeachClientPage from './BeachClientPage';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Beachturnering',
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
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();
|
||||
return <BeachClientPage config={config} />;
|
||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY || '';
|
||||
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,14 +9,14 @@ export default function BeachPromo() {
|
||||
<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">
|
||||
<span className="whitespace-nowrap transition-all duration-300 group-hover/promo:opacity-80">
|
||||
Kolla in vår
|
||||
Anmäl dig till
|
||||
</span>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<FaVolleyballBall
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// app/components/Header.tsx
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import logo from '../../public/images/logo.svg';
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="bg-black flex justify-center sticky top-0 z-40 shadow-lg">
|
||||
<div className="w-85 mx-auto text-center">
|
||||
<div className="w-fit mx-auto text-center">
|
||||
<Link href="/" className="inline-block group">
|
||||
<img
|
||||
src="/images/logo.svg"
|
||||
<Image
|
||||
src={logo}
|
||||
loading="eager"
|
||||
alt="Lerbergets Volleybollsällskap"
|
||||
className="max-h-32 min-h-16 my-4 md:max-h-52 md:min-h-24 md:my-6 mx-auto block transition-all duration-500 ease-out group-hover:scale-105 group-active:scale-95"
|
||||
className="w-auto max-h-24 min-h-16 my-4 md:max-h-32 md:min-h-24 md:my-6 mx-auto block transition-all duration-500 ease-out group-hover:scale-105 group-active:scale-95"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,9 @@ import MemberModal from './Modals/Member';
|
||||
import SubscribeModal from './Modals/Subscribe';
|
||||
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 svenskalag from "../../public/images/svenskalag.se.png";
|
||||
|
||||
export default function HomeClientContent() {
|
||||
const [activeModal, setActiveModal] = useState<'member' | 'subscribe' | null>(null);
|
||||
@@ -22,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 på 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. På 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">
|
||||
<img src="/images/player.svg" alt="Spelare" className="w-full h-auto" />
|
||||
<Image src={player} alt="Spelare" className="w-full h-auto" loading='eager' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -46,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"
|
||||
@@ -64,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">
|
||||
<img src="/images/laget.se.svg" 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 på Instagram, Facebook och laget.se</p>
|
||||
<p className="text-[1rem] leading-[1.6] opacity-90">Vi finns på Instagram, Facebook och Svenskalag.se</p>
|
||||
|
||||
{/* Klickbar Adress - Länkar till Google Maps */}
|
||||
<a
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
import { ModalProps } from "@/app/types";
|
||||
import Modal from "./Modal";
|
||||
import ModalAction from "./ModalAction";
|
||||
import Image from 'next/image';
|
||||
import logo from "../../../public/images/logo-black.svg";
|
||||
|
||||
export default function MemberModal({ onClose }: ModalProps) {
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<img src="/images/logo-black.svg" alt="Logo" className="h-25" />
|
||||
<Image src={logo} alt="Logo" className="h-25 max-w-fit" />
|
||||
<h2 className="text-[#406185] my-2.5 text-2xl font-bold uppercase">Registrera dig</h2>
|
||||
<p className="text-[#406185] my-2.5">Trycka på 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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
@@ -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',
|
||||
|
||||
+21
-2
@@ -5,11 +5,27 @@ import path from 'node:path';
|
||||
|
||||
const configPath = path.join(process.cwd(), 'data', 'site-config.json');
|
||||
|
||||
export interface SiteConfig {
|
||||
showBeachPromo: boolean;
|
||||
beachPage: {
|
||||
matchStart: string;
|
||||
festivalLink: string;
|
||||
closeReason: string;
|
||||
otherInfo: string;
|
||||
prizesText: string;
|
||||
rulesText?: string;
|
||||
classesIntroText?: string;
|
||||
classesOutroText?: string;
|
||||
isClosedOverride: boolean;
|
||||
classes: Array<{ name: string; color: string; desc: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getConfig() {
|
||||
try {
|
||||
const data = await fs.readFile(configPath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return {
|
||||
showBeachPromo: true,
|
||||
beachPage: {
|
||||
@@ -17,6 +33,9 @@ export async function getConfig() {
|
||||
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.",
|
||||
festivalLink: "https://www.kullahalvon.com/upptacka--uppleva/kultur--noje/evenemang-pa-kullahalvon/mat---sommarfesten.html",
|
||||
rulesText: "Matcherna spelas 4v4 med <a target=\"_blank\" href=\"https://www.volleyboll.se/forbundet/valkommen-till-volleyboll/grenar-och-spelformer/volleyboll\">inomhusregler</a>, förutom poängräkningen som följer reglerna för <a target=\"_blank\" href=\"https://www.volleyboll.se/forbundet/valkommen-till-volleyboll/grenar-och-spelformer/beachvolley\">beachvolley</a>. Turneringsformen som kommer spelas är <i>Double Elimination</i> vilket betyder att alla lag spelar minst två matcher under dagen.",
|
||||
classesIntroText: "Turneringen kommer att delas upp i flera olika klasser beroende på antal anmälda lag och dess nivå:",
|
||||
classesOutroText: "Gör en gissning på er nivå i anmälan – skriv gärna något extra om ni är osäkra. Vi meddelar om det skulle bli ändringar.",
|
||||
otherInfo: "Turneringen uppskattas hålla på till ca 17-18 tiden\r\nDet kommer finnas duschmöjligheter i anslutning till stranden.",
|
||||
prizesText: "Det kommer att finnas fina priser till alla pallplatser! 🏆"
|
||||
}
|
||||
@@ -25,6 +44,6 @@ export async function getConfig() {
|
||||
}
|
||||
|
||||
|
||||
export async function updateConfig(newConfig: any) {
|
||||
export async function updateConfig(newConfig: SiteConfig) {
|
||||
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2));
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function loginAction(prevState: any, formData: FormData) {
|
||||
export async function loginAction(prevState: { success?: boolean } | null | unknown, formData: FormData) {
|
||||
const password = formData.get('password');
|
||||
|
||||
if (password === process.env.ADMIN_PASSWORD) {
|
||||
|
||||
+48
-3
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,17 @@
|
||||
"beachPage": {
|
||||
"matchStart": "2026-06-13T10:00",
|
||||
"isClosedOverride": false,
|
||||
"closeReason": "Det är hela 26 lag anmälda och vi kan tyvärr inte ta emot fler anmälningar. Vi ä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",
|
||||
"otherInfo": "Turneringen uppskattas hålla på till ca 17-18 tiden\r\nDet kommer finnas duschmöjligheter i anslutning till stranden.",
|
||||
"prizesText": "Det kommer att finnas fina priser till alla pallplatser!",
|
||||
"rulesText": "Matcherna spelas 4v4 med <a target=\"_blank\" href=\"https://www.volleyboll.se/forbundet/valkommen-till-volleyboll/grenar-och-spelformer/volleyboll\">inomhusregler</a>, förutom poängräkningen som följer reglerna för <a target=\"_blank\" href=\"https://www.volleyboll.se/forbundet/valkommen-till-volleyboll/grenar-och-spelformer/beachvolley\">beachvolley</a>. Turneringsformen som kommer spelas är <i>Double Elimination</i> vilket betyder att alla lag spelar minst två matcher under dagen.",
|
||||
"classesIntroText": "Turneringen kommer att delas upp i flera olika klasser beroende på antal anmälda lag och dess nivå:",
|
||||
"classesOutroText": "Gör en gissning på er nivå i anmälan – skriv gärna något extra om ni är osäkra. Vi meddelar om det skulle bli ändringar.",
|
||||
"otherInfo": "Turneringen uppskattas hålla på till ca 17-18 tiden\r\nDet kommer finnas duschmöjligheter i anslutning till stranden.\r\nDet kommer fotograferas och filmas under dagen.",
|
||||
"prizesText": "Fina priser väntar för alla som kniper en pallplats!",
|
||||
"classes": [
|
||||
{
|
||||
"name": "Grön",
|
||||
"color": "#07be15",
|
||||
"color": "#16a34a",
|
||||
"desc": "Nybörjare"
|
||||
},
|
||||
{
|
||||
@@ -18,10 +21,15 @@
|
||||
"color": "#2563eb",
|
||||
"desc": "Spelat lite"
|
||||
},
|
||||
{
|
||||
"name": "Röd",
|
||||
"color": "#e22400",
|
||||
"desc": "Spelat mycket"
|
||||
},
|
||||
{
|
||||
"name": "Svart",
|
||||
"color": "#111827",
|
||||
"desc": "Spelat mycket"
|
||||
"desc": "Elit"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
TURNSTILE_SECRET=1x0000000000000000000000000000000AA
|
||||
|
||||
ADMIN_PASSWORD=PASSWORD
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_USER=USER
|
||||
SMTP_PASS=PASSWORD
|
||||
|
||||
Generated
+1101
-841
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -10,8 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@marsidev/react-turnstile": "^1.4.2",
|
||||
"next": "16.1.6",
|
||||
"nodemailer": "^8.0.1",
|
||||
"next": "^16",
|
||||
"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 |
Reference in New Issue
Block a user