Almost done
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
data/*.json
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# 1. Install dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# 2. Build the app
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Vi mappar in miljövariabler under build om det behövs
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
RUN npm run build
|
||||
|
||||
# 3. Production image
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV production
|
||||
|
||||
# Skapa data-mappen för Micro-CMS
|
||||
RUN mkdir -p data
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,78 @@
|
||||
// app/admin/AdminForm.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useState } from 'react';
|
||||
import { saveConfigAction } from './actions';
|
||||
|
||||
export default function AdminForm({ initialConfig }: { initialConfig: any }) {
|
||||
const [state, formAction, isPending] = useActionState(saveConfigAction, null);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success) {
|
||||
setShowToast(true);
|
||||
const timer = setTimeout(() => setShowToast(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-8">
|
||||
{showToast && (
|
||||
<div className="fixed top-5 right-5 bg-green-500 text-white px-6 py-3 rounded-lg shadow-2xl font-bold z-50 animate-bounce">
|
||||
✓ Ändringar sparade!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Startsida */}
|
||||
<section className="border-b pb-6">
|
||||
<h2 className="text-xl font-bold mb-4 border-l-4 border-[#fdb84b] pl-2 text-[#406185]">Startsida</h2>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="showBeachPromo" defaultChecked={initialConfig.showBeachPromo} className="w-5 h-5 accent-[#406185]" />
|
||||
<span className="font-semibold">Visa Beach-Promo bar</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* Beach-Turnering */}
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-xl font-bold border-l-4 border-[#fdb84b] pl-2 text-[#406185]">Beach-Turnering</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1">Matchstart</label>
|
||||
<input type="datetime-local" name="matchStart" defaultValue={initialConfig.beachPage.matchStart} className="w-full border p-2 rounded focus:ring-2 focus:ring-[#406185] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1">Länk till Mat- & Sommarfesten</label>
|
||||
<input name="festivalLink" defaultValue={initialConfig.beachPage.festivalLink} className="w-full border p-2 rounded focus:ring-2 focus:ring-[#406185] outline-none" />
|
||||
</div>
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1">Övrig info (en rad per punkt på sajten)</label>
|
||||
<textarea name="otherInfo" defaultValue={initialConfig.beachPage.otherInfo} className="w-full border p-2 rounded h-24" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1">Pris-text (längst ner på infosidan)</label>
|
||||
<input name="prizesText" defaultValue={initialConfig.beachPage.prizesText} className="w-full border p-2 rounded" />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer text-red-600 bg-red-50 p-3 rounded-lg border border-red-100">
|
||||
<input type="checkbox" name="isClosedOverride" defaultChecked={initialConfig.beachPage.isClosedOverride} className="w-5 h-5 accent-red-600" />
|
||||
<span className="font-bold uppercase">Stäng anmälan manuellt</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<button type="submit" disabled={isPending} className="bg-[#406185] text-white py-4 rounded-lg font-bold text-lg hover:bg-black transition-all active:scale-95 disabled:bg-gray-400">
|
||||
{isPending ? 'Sparar...' : 'Spara inställningar'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// app/admin/actions.ts
|
||||
|
||||
'use server';
|
||||
|
||||
import { getConfig, updateConfig } from '../lib/config';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function saveConfigAction(prevState: any, formData: FormData) {
|
||||
const config = await getConfig();
|
||||
|
||||
const newConfig = {
|
||||
...config,
|
||||
showBeachPromo: formData.get('showBeachPromo') === 'on',
|
||||
beachPage: {
|
||||
...config.beachPage,
|
||||
matchStart: formData.get('matchStart') as string,
|
||||
festivalLink: formData.get('festivalLink') as string,
|
||||
closeReason: formData.get('closeReason') as string,
|
||||
otherInfo: formData.get('otherInfo') as string,
|
||||
prizesText: formData.get('prizesText') as string,
|
||||
isClosedOverride: formData.get('isClosedOverride') === 'on',
|
||||
}
|
||||
};
|
||||
|
||||
await updateConfig(newConfig);
|
||||
revalidatePath('/');
|
||||
revalidatePath('/beach');
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// app/admin/page.tsx
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getConfig } from '../lib/config';
|
||||
import AdminForm from './AdminForm';
|
||||
import { logoutAction } from '../login/actions';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Kontrollpanel',
|
||||
};
|
||||
|
||||
export default async function AdminPage() {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get('admin_session')?.value !== 'true') redirect('/login');
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col font-sans">
|
||||
<Header />
|
||||
|
||||
<div className="grow bg-gray-100 p-4 md:p-10">
|
||||
<div className="max-w-2xl mx-auto bg-white p-6 md:p-8 rounded-xl shadow-md">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-3xl font-bold text-[#406185]">Kontrollpanel</h1>
|
||||
</div>
|
||||
|
||||
<form action={logoutAction}>
|
||||
<button
|
||||
type="submit"
|
||||
className="text-sm font-bold text-gray-500 hover:text-red-600 border border-gray-300 hover:border-red-600 px-1 md:p-4 py-2 rounded-lg transition-all active:scale-90"
|
||||
>
|
||||
Logga ut
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<AdminForm initialConfig={config} />
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// app/beach/BeachClientPage.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IoIosMail } from "react-icons/io";
|
||||
import Footer from '../components/Footer';
|
||||
import Header from '../components/Header';
|
||||
import BeachRegistrationForm from './BeachRegistrationForm';
|
||||
|
||||
export default function BeachClientPage({ config }: { config: any }) {
|
||||
const [timeInfo, setTimeInfo] = useState({
|
||||
dateStr: 'Laddar...',
|
||||
matchStr: 'Laddar...',
|
||||
deadlineText: 'Laddar...',
|
||||
isClosed: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateTournamentInfo = () => {
|
||||
const matchStart = new Date(config.beachPage.matchStart);
|
||||
const now = new Date();
|
||||
const infoGathering = new Date(matchStart.getTime() - 30 * 60000);
|
||||
const closingDate = new Date(matchStart.getTime() - 24 * 60 * 60000);
|
||||
const dateOptions: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' };
|
||||
const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
|
||||
|
||||
setTimeInfo({
|
||||
dateStr: matchStart.toLocaleDateString('sv-SE', dateOptions),
|
||||
matchStr: `Matchstart kl.${matchStart.toLocaleTimeString('sv-SE', timeOptions)} (Infosamling kl.${infoGathering.toLocaleTimeString('sv-SE', timeOptions)})`,
|
||||
deadlineText: `Anmälan stänger den ${closingDate.toLocaleDateString('sv-SE', dateOptions)} kl.${closingDate.toLocaleTimeString('sv-SE', timeOptions)} (24 timmar innan start) eller om alla platser har blivit fyllda.`,
|
||||
isClosed: config.beachPage.isClosedOverride || now >= closingDate,
|
||||
});
|
||||
};
|
||||
updateTournamentInfo();
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col font-sans bg-white text-black">
|
||||
<Header />
|
||||
|
||||
<main className="grow flex flex-col items-center bg-[#c8e9f2] bg-[url('/images/volleyball-net.webp')] bg-cover bg-center bg-no-repeat w-full pb-20">
|
||||
<section className="flex flex-col items-center text-center w-full px-1.25">
|
||||
<h2 className="mt-12.5"><img className="max-w-62.5" src="/images/hk-mosf.webp" alt="Mat- & Sommarfesten" /></h2>
|
||||
<h1 className="font-beachday text-[clamp(45px,9vw,80px)] text-[#fdb84b] uppercase leading-none m-[20px_0_10px_0]">Beachturnering</h1>
|
||||
<p className="font-bold italic text-[25px] mb-5 text-black">Samla ihop ett kompisgäng och anmäl er!</p>
|
||||
|
||||
{timeInfo.isClosed && (
|
||||
<div className="bg-black p-5 rounded-[20px] my-5 mx-1.25 w-full max-w-150 shadow-xl border-l-8 border-red-600">
|
||||
<h3 className="text-red-500 text-[clamp(1.5rem,6.5vw,2rem)] font-bold pb-2.5 text-center">Anmälan är stängd!</h3>
|
||||
<p className="text-white text-center whitespace-pre-wrap">{config.beachPage.closeReason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#b1e0e8] p-[0_20px_20px_20px] rounded-[20px] my-7.5 mx-1.25 flex flex-col items-center text-center w-full max-w-200 shadow-lg">
|
||||
{/* INFORMATION TEXT (Var, När, Regler, etc.) */}
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Var:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px]"><a target="_blank" rel="noreferrer" className="font-medium text-[#cc8124] hover:underline" href="https://maps.app.goo.gl/vJcdSDCbvdGYXPNY6">Kvickbadet</a> i Höganäs</p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">När:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px]">{timeInfo.dateStr}</p>
|
||||
<p className="text-black text-[18px] m-[4px_10px]">{timeInfo.matchStr}</p>
|
||||
<p className="text-black text-[18px] m-[4px_10px]">
|
||||
Samtidigt som <a target="_blank" rel="noreferrer" className="font-medium text-[#cc8124] hover:underline" href={config.beachPage.festivalLink}>Höganäs Mat- & Sommarfest</a>
|
||||
</p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Regler:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px] max-w-150">Matcherna spelas 4v4 med <a target="_blank" rel="noreferrer" className="font-medium text-[#cc8124] 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="font-medium text-[#cc8124] hover:underline" href="https://www.volleyboll.se/forbundet/valkommen-till-volleyboll/grenar-och-spelformer/beachvolley">beachvolleyboll</a>.</p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Klasser:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px] max-w-150">
|
||||
Turneringen kan komma att delas upp i tre olika klasser beroende på antal anmälda lag och baserat på nivå:<br />
|
||||
<strong className="text-green-700">Grön</strong> (Nybörjare),{' '}
|
||||
<strong className="text-[#0f65bf]">Blå</strong> (Amatör),{' '}
|
||||
<strong className="text-black">Svart</strong> (Proffs)
|
||||
</p>
|
||||
<p className="text-black text-[18px] m-[4px_10px] max-w-150 italic">Gör en gissning i vilken nivå ni tror att ert lag skulle passa och skriv gärna något i anmälan om ni är osäkra.</p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Kostnad:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px]">Gratis!!!</p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Anmälan:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px] max-w-150">Anmälan görs genom anmälningsformuläret nedan. Du får ett bekräftelsemejl när vi kollat igenom er anmälan.</p>
|
||||
<p className="text-black text-[18px] m-[4px_10px] italic">{timeInfo.deadlineText}</p>
|
||||
|
||||
{/* Övrig info sektionen */}
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Övrig info:</h4>
|
||||
{(config.beachPage?.otherInfo || "").split('\n').map((line: string, i: number) => (
|
||||
line.trim() && <p key={i} className="text-black text-[18px] m-[4px_10px]">{line}</p>
|
||||
))}
|
||||
|
||||
{/* Pris sektionen */}
|
||||
<h4 className="font-beachday uppercase text-[30px] font-medium p-2.5 mt-5 text-black">
|
||||
{config.beachPage?.prizesText || "Fina priser till alla pallplatser! 🏆"}
|
||||
</h4>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Rendera formuläret endast om anmälan är öppen */}
|
||||
{!timeInfo.isClosed && <BeachRegistrationForm />}
|
||||
|
||||
<section className="w-full flex justify-center px-1.25 mt-5">
|
||||
<div className="bg-[#FAAC4F] flex flex-col justify-center items-center py-2.5 rounded-[20px] m-[10px_5px] w-full max-w-100 shadow-md">
|
||||
<h3 className="text-white text-[clamp(1.5rem,6.5vw,2rem)] text-center font-bold">Kontakta oss</h3>
|
||||
<a href="mailto:beach@lerbergetsvolleyboll.se?subject=Beach%20turnering" className="group flex justify-center items-center text-black font-bold text-[1.1rem] mt-2 relative pb-1">
|
||||
<IoIosMail className="h-7 w-7 mr-2" size={24} />
|
||||
<span>beach@lerbergetsvolleyboll.se</span>
|
||||
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-black transform scale-x-0 transition-transform duration-300 origin-left group-hover:scale-x-100" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// app/beach/BeachRegistrationForm.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { PiCheckCircle, PiWarningCircle } from "react-icons/pi";
|
||||
|
||||
interface AlertState {
|
||||
show: boolean;
|
||||
type: 'success' | 'danger' | '';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SubmitResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export default function BeachRegistrationForm() {
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [wasValidated, setWasValidated] = useState<boolean>(false);
|
||||
const [alert, setAlert] = useState<AlertState>({ show: false, type: '', message: '' });
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
const form = formRef.current;
|
||||
if (!form) return;
|
||||
|
||||
setWasValidated(true);
|
||||
if (!form.checkValidity()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setAlert({ show: false, type: '', message: '' });
|
||||
const formData = new FormData(form);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Network error: ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as SubmitResponse;
|
||||
const message = result.detail ? `${result.message} ${result.detail}` : result.message;
|
||||
|
||||
setAlert({
|
||||
show: true,
|
||||
type: result.success ? 'success' : 'danger',
|
||||
message: message,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
form.reset();
|
||||
setWasValidated(false);
|
||||
if (turnstileRef.current) turnstileRef.current.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
setAlert({
|
||||
show: true,
|
||||
type: 'danger',
|
||||
message: 'Ett tekniskt fel uppstod. Försök igen senare.',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="w-full flex justify-center px-1.25">
|
||||
<div className="bg-black p-5 rounded-[20px] my-7.5 mx-1.25 w-full max-w-180 shadow-2xl">
|
||||
<h2 className="font-beachday text-[clamp(1.875rem,5.2vw,50px)] text-white text-center m-[5px_0] uppercase tracking-wide">
|
||||
Anmälningsformulär
|
||||
</h2>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className={`group/form flex flex-col items-center w-full mt-5 ${wasValidated ? 'was-validated' : ''}`}
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
>
|
||||
{/* INPUT FÄLT (Namn, Email, Tel, Lagnamn, Antal) */}
|
||||
{[
|
||||
{ name: 'name', type: 'text', placeholder: 'Ditt namn', auto: 'name', error: 'Glöm inte ditt namn!' },
|
||||
{ name: 'email', type: 'email', placeholder: 'Email', auto: 'email', error: 'Emailadressen är inte giltig.' },
|
||||
{ name: 'tel', type: 'tel', placeholder: 'Telefonnummer', auto: 'tel', error: 'Glöm inte ett telefonnummer!' },
|
||||
{ name: 'team', type: 'text', placeholder: 'Lagnamn', auto: 'organization', error: 'Glöm inte ert lagnamn!' },
|
||||
].map((f) => (
|
||||
<div key={f.name} className="relative w-full mb-3.75">
|
||||
<input
|
||||
type={f.type} name={f.name} placeholder={f.placeholder} required autoComplete={f.auto}
|
||||
className="peer bg-transparent text-white border-0 border-b-[5px] border-white w-full h-12.5 outline-none text-[20px] pl-2.5 rounded-none focus:ring-0 group-[.was-validated]/form:invalid:border-[#e60b20] group-[.was-validated]/form:valid:border-[#07ba4c] transition-colors"
|
||||
/>
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block absolute right-2.5 top-2.25"><PiWarningCircle color='#dc3545' size={28} /></div>
|
||||
<div className="hidden group-[.was-validated]/form:peer-valid:block absolute right-2.5 top-2.25"><PiCheckCircle color='#198754' size={28} /></div>
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block text-[#e60b20] text-[14px] pl-2.5 mt-1 text-left w-full">{f.error}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="relative w-full mb-3.75">
|
||||
<input type="number" name="count" placeholder="Antal spelare" min="2" required
|
||||
className="peer bg-transparent text-white border-0 border-b-[5px] border-white w-full h-12.5 outline-none text-[20px] pl-2.5 rounded-none focus:ring-0 group-[.was-validated]/form:invalid:border-[#e60b20] group-[.was-validated]/form:valid:border-[#07ba4c] transition-colors" />
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block text-[#e60b20] text-[14px] pl-2.5 mt-1 text-left w-full">Glöm inte ange antal lagmedlemmar!</div>
|
||||
</div>
|
||||
|
||||
{/* RADIOS */}
|
||||
<div className="w-full mt-2.5 text-left group/radios">
|
||||
{[
|
||||
{ id: 'level-gron', val: 'Grön', label: 'Grön', color: 'text-green-700', accent: 'accent-green-600', desc: '(Nybörjare)' },
|
||||
{ id: 'level-bla', val: 'Blå', label: 'Blå', color: 'text-[#0f65bf]', accent: 'accent-[#0f65bf]', desc: '(Spelat lite)' },
|
||||
{ id: 'level-svart', val: 'Svart', label: 'Svart', color: 'text-black', accent: 'accent-black', desc: '(Spelat mycket)' },
|
||||
].map((l) => (
|
||||
<React.Fragment key={l.id}>
|
||||
<input type="radio" id={l.id} name="level" value={l.val} required className={`inline-block align-middle m-[6px_5px] w-4 h-4 ${l.accent} cursor-pointer`} />
|
||||
<label htmlFor={l.id} className="text-white inline-block align-middle cursor-pointer select-none">
|
||||
<strong className={`${l.color} bg-white px-0.75 rounded-sm font-bold`}>{l.label}</strong> {l.desc}
|
||||
</label>
|
||||
<br />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div className="hidden group-[.was-validated]/form:group-has-invalid/radios:block text-[#e60b20] text-[14px] pl-2.5 mt-2">Glöm inte ange spelarnivå!</div>
|
||||
</div>
|
||||
|
||||
<textarea name="message" autoComplete="off" placeholder="Övrig info..." className="w-full min-h-37.5 p-[12px_16px] border-[5px] border-white rounded-[10px] bg-transparent text-white text-[15px] resize-y outline-none mt-5" />
|
||||
|
||||
<div className="hidden">
|
||||
<input type="text" name="website" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
|
||||
{alert.show && (
|
||||
<div className={`w-full p-4 rounded-md text-center font-bold my-4 ${alert.type === 'success' ? 'bg-[#d1e7dd] text-[#0a3622] border-[#a3cfbb]' : 'bg-[#f8d7da] text-[#58151c] border-[#f1aeb5]'}`}>
|
||||
{alert.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full flex justify-center mt-2.5">
|
||||
<Turnstile ref={turnstileRef} siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''} options={{ theme: 'dark' }} />
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isSubmitting} className="bg-white text-black uppercase w-full max-w-75 h-12.5 text-[20px] font-bold rounded-[10px] my-5 flex justify-center items-center cursor-pointer hover:bg-gray-200 transition-all active:scale-95 disabled:opacity-70">
|
||||
{isSubmitting && (
|
||||
<svg className="animate-spin h-5 w-5 text-black mr-3" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Skicka anmälan</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+8
-340
@@ -1,344 +1,12 @@
|
||||
// app/beach/page.tsx
|
||||
import { getConfig } from '../lib/config';
|
||||
import BeachClientPage from './BeachClientPage';
|
||||
|
||||
'use client';
|
||||
export const metadata = {
|
||||
title: 'Beachturnering',
|
||||
};
|
||||
|
||||
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import Link from 'next/link';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { IoIosMail } from "react-icons/io";
|
||||
import { PiWarningCircle } from "react-icons/pi";
|
||||
import { PiCheckCircle } from "react-icons/pi";
|
||||
|
||||
interface TimeInfo {
|
||||
dateStr: string;
|
||||
matchStr: string;
|
||||
deadlineText: string;
|
||||
isClosed: boolean;
|
||||
}
|
||||
|
||||
interface AlertState {
|
||||
show: boolean;
|
||||
type: 'success' | 'danger' | '';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SubmitResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export default function BeachPage() {
|
||||
const [timeInfo, setTimeInfo] = useState<TimeInfo>({
|
||||
dateStr: 'Laddar datum...',
|
||||
matchStr: 'Laddar tider...',
|
||||
deadlineText: 'Anmälningar kan göras senast...',
|
||||
isClosed: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateTournamentInfo = () => {
|
||||
const matchStart = new Date('2026-06-13T10:00:00+02:00');
|
||||
const now = new Date();
|
||||
|
||||
const infoGathering = new Date(matchStart.getTime() - 30 * 60000);
|
||||
const closingDate = new Date(matchStart.getTime() - 24 * 60 * 60000);
|
||||
|
||||
const dateOptions: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' };
|
||||
const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
|
||||
|
||||
const dateStr = matchStart.toLocaleDateString('sv-SE', dateOptions);
|
||||
const matchTime = matchStart.toLocaleTimeString('sv-SE', timeOptions);
|
||||
const infoTime = infoGathering.toLocaleTimeString('sv-SE', timeOptions);
|
||||
const deadlineDate = closingDate.toLocaleDateString('sv-SE', dateOptions);
|
||||
const deadlineTime = closingDate.toLocaleTimeString('sv-SE', timeOptions);
|
||||
|
||||
setTimeInfo({
|
||||
dateStr,
|
||||
matchStr: `Matchstart kl.${matchTime} (Infosamling kl.${infoTime})`,
|
||||
deadlineText: `Anmälan stänger den ${deadlineDate} kl.${deadlineTime} (24 timmar innan start) eller om alla platser har blivit fyllda.`,
|
||||
isClosed: now >= closingDate,
|
||||
});
|
||||
};
|
||||
|
||||
updateTournamentInfo();
|
||||
const interval = setInterval(updateTournamentInfo, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [wasValidated, setWasValidated] = useState<boolean>(false);
|
||||
const [alert, setAlert] = useState<AlertState>({ show: false, type: '', message: '' });
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
const form = formRef.current;
|
||||
if (!form) return;
|
||||
|
||||
setWasValidated(true);
|
||||
|
||||
if (!form.checkValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setAlert({ show: false, type: '', message: '' });
|
||||
const formData = new FormData(form);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Network error: ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as SubmitResponse;
|
||||
const message = result.detail ? `${result.message} ${result.detail}` : result.message;
|
||||
|
||||
setAlert({
|
||||
show: true,
|
||||
type: result.success ? 'success' : 'danger',
|
||||
message: message,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
form.reset();
|
||||
setWasValidated(false);
|
||||
if (turnstileRef.current) turnstileRef.current.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('An error occurred:', error);
|
||||
setAlert({
|
||||
show: true,
|
||||
type: 'danger',
|
||||
message: 'Ett tekniskt fel uppstod. Försök igen senare.',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// SVG Icons for validation (Extracted from your original CSS)
|
||||
// const CheckmarkIcon = () => #198754
|
||||
|
||||
// const ErrorIcon = () => #dc3545
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col font-sans bg-white text-black">
|
||||
|
||||
<header className="bg-black flex justify-center py-2.5">
|
||||
<div className="w-85 mx-auto text-center">
|
||||
<Link href="/">
|
||||
<img src="/images/logo.svg" alt="Lerbergets Volleybollsällskap" className="max-h-45 min-h-20 my-5.75 mx-auto block" />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grow flex flex-col items-center bg-[#c8e9f2] bg-[url('/images/volleyball-net.webp')] bg-cover bg-center bg-no-repeat w-full pb-20">
|
||||
|
||||
<section className="flex flex-col items-center text-center w-full px-1.25">
|
||||
<h2 className="mt-12.5"><img className="max-w-62.5" src="/images/hk-mosf.webp" alt="Mat- & Sommarfesten" /></h2>
|
||||
<h1 className="font-beachday text-[clamp(45px,9vw,80px)] text-[#fdb84b] uppercase leading-none m-[20px_0_10px_0]" style={{ fontFamily: "'Beachday', sans-serif" }}>Beachturnering</h1>
|
||||
<p className="font-bold italic text-[25px] mb-5 text-black">Samla ihop ett kompisgäng och anmäl er!</p>
|
||||
|
||||
{timeInfo.isClosed && (
|
||||
<div className="bg-black p-5 rounded-[20px] my-5 mx-1.25 w-full max-w-150">
|
||||
<h3 className="text-red-500 text-[clamp(1.5rem,6.5vw,2rem)] font-bold pb-2.5">Anmälan är nu stängd!</h3>
|
||||
<p className="text-white text-center pb-2.5">Det är hela 26 lag anmälda och vi kan tyvärr inte ta emot fler anmälningar.</p>
|
||||
<p className="text-white text-center">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.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#b1e0e8] p-[0_20px_20px_20px] rounded-[20px] my-7.5 mx-1.25 flex flex-col items-center text-center w-full max-w-200">
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Var:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px]"><a target="_blank" rel="noreferrer" className="font-medium text-[#cc8124] hover:underline" href="https://maps.app.goo.gl/vJcdSDCbvdGYXPNY6">Kvickbadet</a> i Höganäs</p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">När:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px]">{timeInfo.dateStr}</p>
|
||||
<p className="text-black text-[18px] m-[4px_10px]">{timeInfo.matchStr}</p>
|
||||
<p className="text-black text-[18px] m-[4px_10px]">Samtidigt som <a target="_blank" rel="noreferrer" className="font-medium text-[#cc8124] hover:underline" href="https://www.kullahalvon.com/upptacka--uppleva/kultur--noje/evenemang-pa-kullahalvon/mat---sommarfesten.html">Höganäs Mat- & Sommarfest</a></p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Regler:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px] max-w-150">Matcherna spelas 4v4 med <a target="_blank" rel="noreferrer" className="font-medium text-[#cc8124] 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="font-medium text-[#cc8124] hover:underline" href="https://www.volleyboll.se/forbundet/valkommen-till-volleyboll/grenar-och-spelformer/beachvolley">beachvolleyboll</a>.</p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Klasser:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px] max-w-150">
|
||||
Turneringen kan komma att delas upp i tre olika klasser beroende på antal anmälda lag och baserat på nivå:<br />
|
||||
<strong className="text-green-700">Grön</strong> (Nybörjare),{' '}
|
||||
<strong className="text-[#0f65bf]">Blå</strong> (Amatör),{' '}
|
||||
<strong className="text-black">Svart</strong> (Proffs)
|
||||
</p>
|
||||
<p className="text-black text-[18px] m-[4px_10px] max-w-150 italic">Gör en gissning i vilken nivå ni tror att ert lag skulle passa och skriv gärna något i anmälan om ni är osäkra.</p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Kostnad:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px]">Gratis!!!</p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Anmälan:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px] max-w-150">Anmälan görs genom anmälningsformuläret nedan. Du får ett bekräftelsemejl när vi kollat igenom er anmälan.</p>
|
||||
<p className="text-black text-[18px] m-[4px_10px] italic">{timeInfo.deadlineText}</p>
|
||||
|
||||
<h4 className="text-black font-bold text-[1.25rem] mt-5 mb-0 italic">Övrig info:</h4>
|
||||
<p className="text-black text-[18px] m-[4px_10px]">Turneringen upskattas hålla på till ca 17-18 tiden</p>
|
||||
<p className="text-black text-[18px] m-[4px_10px]">Det kommer finnas duschmöjligheter i anslutning till stranden.</p>
|
||||
|
||||
<h4 className="font-beachday uppercase text-[30px] font-medium p-2.5 mt-5 text-black" style={{ fontFamily: "'Beachday', sans-serif" }}>
|
||||
Det kommer att finnas fina priser till alla pallplatser! 🏆
|
||||
</h4>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!timeInfo.isClosed && (
|
||||
<section className="w-full flex justify-center px-1.25">
|
||||
<div className="bg-black p-5 rounded-[20px] my-7.5 mx-1.25 w-full max-w-180">
|
||||
<h2 className="font-beachday text-[clamp(1.875rem,5.2vw,50px)] text-white text-center m-[5px_0] uppercase" style={{ fontFamily: "'Beachday', sans-serif" }}>Anmälningsformulär</h2>
|
||||
|
||||
{/* Form starts here - uses group/form to track the .was-validated state natively */}
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className={`group/form flex flex-col items-center w-full mt-5 ${wasValidated ? 'was-validated' : ''}`}
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
>
|
||||
|
||||
{/* NAME */}
|
||||
<div className="relative w-full mb-3.75">
|
||||
<input type="text" name="name" placeholder="Ditt namn" required autoComplete="name"
|
||||
className="peer bg-transparent text-white border-0 border-b-[5px] border-white w-full h-12.5 outline-none text-[20px] pl-2.5 rounded-none appearance-none shadow-none focus:ring-0 group-[.was-validated]/form:invalid:border-[#e60b20] group-[.was-validated]/form:valid:border-[#07ba4c] group-[.was-validated]/form:pr-10 transition-colors" />
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block absolute right-2.5 top-2.25"><PiWarningCircle color='#dc3545' size={28} /></div>
|
||||
<div className="hidden group-[.was-validated]/form:peer-valid:block absolute right-2.5 top-2.25"><PiCheckCircle color='#198754' size={28} /></div>
|
||||
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block text-[#e60b20] text-[14px] pl-2.5 mt-1 text-left w-full">Glöm inte ditt namn!</div>
|
||||
</div>
|
||||
|
||||
{/* EMAIL */}
|
||||
<div className="relative w-full mb-3.75">
|
||||
<input type="email" name="email" placeholder="Email" required autoComplete="email"
|
||||
className="peer bg-transparent text-white border-0 border-b-[5px] border-white w-full h-12.5 outline-none text-[20px] pl-2.5 rounded-none appearance-none shadow-none focus:ring-0 group-[.was-validated]/form:invalid:border-[#e60b20] group-[.was-validated]/form:valid:border-[#07ba4c] group-[.was-validated]/form:pr-10 transition-colors" />
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block absolute right-2.5 top-2.25"><PiWarningCircle color='#dc3545' size={28} /></div>
|
||||
<div className="hidden group-[.was-validated]/form:peer-valid:block absolute right-2.5 top-2.25"><PiCheckCircle color='#198754' size={28} /></div>
|
||||
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block text-[#e60b20] text-[14px] pl-2.5 mt-1 text-left w-full">Email addressen är inte giltig.</div>
|
||||
</div>
|
||||
|
||||
{/* PHONE */}
|
||||
<div className="relative w-full mb-3.75">
|
||||
<input type="tel" name="tel" placeholder="Telefonnummer" required autoComplete="tel"
|
||||
className="peer bg-transparent text-white border-0 border-b-[5px] border-white w-full h-12.5 outline-none text-[20px] pl-2.5 rounded-none appearance-none shadow-none focus:ring-0 group-[.was-validated]/form:invalid:border-[#e60b20] group-[.was-validated]/form:valid:border-[#07ba4c] group-[.was-validated]/form:pr-10 transition-colors" />
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block absolute right-2.5 top-2.25"><PiWarningCircle color='#dc3545' size={28} /></div>
|
||||
<div className="hidden group-[.was-validated]/form:peer-valid:block absolute right-2.5 top-2.25"><PiCheckCircle color='#198754' size={28} /></div>
|
||||
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block text-[#e60b20] text-[14px] pl-2.5 mt-1 text-left w-full">Glöm inte ett telefonnummer!</div>
|
||||
</div>
|
||||
|
||||
{/* TEAM */}
|
||||
<div className="relative w-full mb-3.75">
|
||||
<input type="text" name="team" placeholder="Lagnamn" required autoComplete="organization"
|
||||
className="peer bg-transparent text-white border-0 border-b-[5px] border-white w-full h-12.5 outline-none text-[20px] pl-2.5 rounded-none appearance-none shadow-none focus:ring-0 group-[.was-validated]/form:invalid:border-[#e60b20] group-[.was-validated]/form:valid:border-[#07ba4c] group-[.was-validated]/form:pr-10 transition-colors" />
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block absolute right-2.5 top-2.25"><PiWarningCircle color='#dc3545' size={28} /></div>
|
||||
<div className="hidden group-[.was-validated]/form:peer-valid:block absolute right-2.5 top-2.25"><PiCheckCircle color='#198754' size={28} /></div>
|
||||
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block text-[#e60b20] text-[14px] pl-2.5 mt-1 text-left w-full">Glöm inte ert Lagnamn!</div>
|
||||
</div>
|
||||
|
||||
{/* COUNT */}
|
||||
<div className="relative w-full mb-3.75">
|
||||
<input type="number" name="count" placeholder="Antal spelare" min="2" required autoComplete="off"
|
||||
className="peer bg-transparent text-white border-0 border-b-[5px] border-white w-full h-12.5 outline-none text-[20px] pl-2.5 rounded-none appearance-none shadow-none focus:ring-0 group-[.was-validated]/form:invalid:border-[#e60b20] group-[.was-validated]/form:valid:border-[#07ba4c] group-[.was-validated]/form:pr-10 transition-colors" />
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block absolute right-2.5 top-2.25"><PiWarningCircle color='#dc3545' size={28} /></div>
|
||||
<div className="hidden group-[.was-validated]/form:peer-valid:block absolute right-2.5 top-2.25"><PiCheckCircle color='#198754' size={28} /></div>
|
||||
|
||||
<div className="hidden group-[.was-validated]/form:peer-invalid:block text-[#e60b20] text-[14px] pl-2.5 mt-1 text-left w-full">Glöm inte ange antal lagmedlemmar!</div>
|
||||
</div>
|
||||
|
||||
{/* RADIOS */}
|
||||
<div className="w-full mt-2.5 text-left group/radios">
|
||||
<input type="radio" id="level-gron" name="level" value="Grön" required className="inline-block align-middle m-[6px_5px] w-4 h-4 accent-green-600 cursor-pointer" />
|
||||
<label htmlFor="level-gron" className="text-white inline-block align-middle cursor-pointer select-none">
|
||||
<strong className="text-green-700 bg-white px-0.75 rounded-sm font-bold">Grön</strong> (Nybörjare)
|
||||
</label>
|
||||
<br />
|
||||
|
||||
<input type="radio" id="level-bla" name="level" value="Blå" required className="inline-block align-middle m-[6px_5px] w-4 h-4 accent-[#0f65bf] cursor-pointer" />
|
||||
<label htmlFor="level-bla" className="text-white inline-block align-middle cursor-pointer select-none">
|
||||
<strong className="text-[#0f65bf] bg-white px-0.75 rounded-sm font-bold">Blå</strong> (Spelat lite)
|
||||
</label>
|
||||
<br />
|
||||
|
||||
<input type="radio" id="level-svart" name="level" value="Svart" required className="inline-block align-middle m-[6px_5px] w-4 h-4 accent-black cursor-pointer" />
|
||||
<label htmlFor="level-svart" className="text-white inline-block align-middle cursor-pointer select-none">
|
||||
<strong className="text-black bg-white px-0.75 rounded-sm font-bold">Svart</strong> (Spelat mycket)
|
||||
</label>
|
||||
<div className="hidden group-[.was-validated]/form:group-has-invalid/radios:block text-[#e60b20] text-[14px] pl-2.5 mt-2">Glöm inte ange spelarnivå!</div>
|
||||
</div>
|
||||
|
||||
{/* TEXTAREA */}
|
||||
<div className="w-full mt-2.5">
|
||||
<textarea name="message" placeholder="Övrig info..." autoComplete="off"
|
||||
className="w-full min-h-37.5 p-[12px_16px] border-[5px] border-white rounded-[10px] bg-transparent text-white text-[15px] resize-y outline-none appearance-none mt-5"></textarea>
|
||||
</div>
|
||||
|
||||
{/* HONEYPOT */}
|
||||
<div className="hidden">
|
||||
<label htmlFor="website">Website</label>
|
||||
<input type="text" name="website" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
|
||||
{/* ALERT */}
|
||||
{alert.show && (
|
||||
<div className={`w-full p-4 rounded-md text-center font-bold my-4 ${alert.type === 'success' ? 'bg-[#d1e7dd] text-[#0a3622] border border-[#a3cfbb]' : 'bg-[#f8d7da] text-[#58151c] border border-[#f1aeb5]'}`}>
|
||||
{alert.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TURNSTILE */}
|
||||
<div className="w-full flex justify-center mt-2.5">
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''}
|
||||
options={{ theme: 'dark' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SUBMIT BUTTON */}
|
||||
<button type="submit" className="bg-white text-black uppercase border-none w-full max-w-75 h-12.5 outline-none text-[20px] font-bold text-center rounded-[10px] my-5 flex justify-center items-center cursor-pointer hover:bg-gray-200 transition disabled:opacity-70" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<svg className="animate-spin h-5 w-5 text-black mr-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : null}
|
||||
<span>Skicka anmälan</span>
|
||||
</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="w-full flex justify-center px-1.25">
|
||||
<div className="bg-[#fdb84b] flex flex-col justify-center items-center py-2.5 rounded-[20px] m-[30px_5px_60px_5px] w-full max-w-100">
|
||||
<h3 className="text-black text-[clamp(1.5rem,6.5vw,2rem)] text-center font-bold m-[5px_0]">Kontakta oss</h3>
|
||||
<a href="mailto:beach@lerbergetsvolleyboll.se?subject=Beach%20turnering" className="flex justify-center items-center my-1.25 text-[clamp(1rem,5vw,1.1rem)] font-bold leading-[1.6] text-black hover:underline">
|
||||
<IoIosMail className='h-[clamp(1.5rem,5vw,2rem)] mr-2' />
|
||||
beach@lerbergetsvolleyboll.se
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-black text-center py-3.75">
|
||||
<p className="text-white font-bold">LVS - Lerbergets Volleybollsällskap {new Date().getFullYear()} ®</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
);
|
||||
export default async function BeachPage() {
|
||||
const config = await getConfig();
|
||||
return <BeachClientPage config={config} />;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// app/components/BeachPromo.tsx
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FaVolleyballBall } from "react-icons/fa";
|
||||
|
||||
export default function BeachPromo() {
|
||||
return (
|
||||
<section className="bg-[#fdb84b] flex justify-center py-4 transition-all duration-500 hover:brightness-105 group/promo cursor-pointer">
|
||||
<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
|
||||
</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
|
||||
<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
|
||||
className="text-[#406185] transition-all duration-700 ease-out group-hover/promo:rotate-360 group-hover/link:scale-125 group-hover/link:text-black self-center"
|
||||
size="0.8em"
|
||||
/>
|
||||
</Link>
|
||||
</h2>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// app/components/Footer.tsx
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-black text-center py-4 border-t border-white/10">
|
||||
<div className="group inline-block cursor-default">
|
||||
<p className="font-bold text-gray-400 transition-colors duration-300 group-hover:text-white">
|
||||
LVS - Lerbergets Volleybollsällskap {new Date().getFullYear()}
|
||||
<span className="inline-block ml-1 transition-transform duration-500 group-hover:rotate-360">®</span>
|
||||
</p>
|
||||
{/* En subtil linje som växer fram under footern när man hovrar */}
|
||||
<div className="h-0.5 w-0 bg-[#f1c50e] mx-auto transition-all duration-500 group-hover:w-full mt-1" />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// app/components/Header.tsx
|
||||
import Link from 'next/link';
|
||||
|
||||
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">
|
||||
<Link href="/" className="inline-block group">
|
||||
<img
|
||||
src="/images/logo.svg"
|
||||
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"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// app/components/HeroButton.tsx
|
||||
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
interface HeroButtonProps {
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function HeroButton({ href, onClick, children, className = "" }: HeroButtonProps) {
|
||||
const baseStyles = `
|
||||
text-[#406185] bg-white border-none py-3.5 px-7.5
|
||||
text-center font-bold uppercase w-fit text-[1.1rem]
|
||||
transition-all duration-300 hover:scale-105 active:scale-95
|
||||
shadow-md hover:shadow-lg cursor-pointer inline-block
|
||||
${className}
|
||||
`;
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button onClick={onClick} className={baseStyles}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (href) {
|
||||
const isExternal = href.startsWith('http');
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noreferrer" className={baseStyles}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={baseStyles}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// app/components/HomeClientContent.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import HeroButton from './HeroButton';
|
||||
import InfoCard from './InfoCard';
|
||||
import SocialIcon from './SocialIcon';
|
||||
import MemberModal from './Modals/Member';
|
||||
import SubscribeModal from './Modals/Subscribe';
|
||||
import { FaFacebookSquare, FaInstagram } from "react-icons/fa";
|
||||
import { IoIosMail } from "react-icons/io";
|
||||
|
||||
export default function HomeClientContent() {
|
||||
const [activeModal, setActiveModal] = useState<'member' | 'subscribe' | null>(null);
|
||||
const closeModals = () => setActiveModal(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero / Info Section */}
|
||||
<section className="bg-[#406185] flex justify-center text-white">
|
||||
<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>
|
||||
</div>
|
||||
<h4 className="italic text-xl my-2.5 font-bold">Lerbergets Volleybollsällskap</h4>
|
||||
<p className="max-w-200 leading-[1.1]">
|
||||
Klubben startade den 1 mars 2018 och har i snabb takt utvecklats...
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center p-2.5 justify-end">
|
||||
<img src="/images/player.webp" alt="Spelare" className="max-h-112.5 player-animation" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Träningstider Section */}
|
||||
<section id="traningstider" className="bg-white flex justify-center">
|
||||
<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"
|
||||
actionLabel="Se nästa träningstid"
|
||||
href="https://www.laget.se/LVS/Event/Month"
|
||||
/>
|
||||
<InfoCard
|
||||
title="Prenumerera på träningstider"
|
||||
description="Våra träningstider finns som en kalender man kan prenumerera på"
|
||||
actionLabel="Prenumerera"
|
||||
onClick={() => setActiveModal('subscribe')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-[#406185] flex justify-center text-white">
|
||||
<div className="flex flex-wrap-reverse justify-center gap-12 w-full max-w-300 my-16 px-5">
|
||||
|
||||
{/* Sociala Medier Ikoner */}
|
||||
<div className="flex flex-wrap justify-center items-center gap-10">
|
||||
<SocialIcon title="Instagram" href="https://www.instagram.com/lerbergets_volleyboll/">
|
||||
<FaInstagram className="h-20 w-20" />
|
||||
</SocialIcon>
|
||||
|
||||
<SocialIcon title="Facebook" href="https://www.facebook.com/lerbergetsvolleyboll">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* Klickbar Adress - Länkar till Google Maps */}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://maps.app.goo.gl/W81bT9X44Yfxsnoh7"
|
||||
className="text-[1rem] leading-[1.6] my-2 hover:text-[#f1c50e] transition-colors duration-300"
|
||||
>
|
||||
Höganäs Sportcenter, Friluftsvägen 10, 263 54 Lerberget
|
||||
</a>
|
||||
|
||||
{/* Levande E-postlänk med brand-gul färg */}
|
||||
<a
|
||||
href="mailto:info@lerbergetsvolleyboll.se?subject=Kontakta%20oss"
|
||||
className="group flex justify-center items-center text-[#f1c50e] font-bold text-[1.1rem] mt-2 relative pb-1"
|
||||
>
|
||||
<IoIosMail className="h-7 w-7 mr-2" size={24} />
|
||||
<span>info@lerbergetsvolleyboll.se</span>
|
||||
{/* Underlinje som expanderar vid hovring */}
|
||||
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-[#f1c50e] transform scale-x-0 transition-transform duration-300 origin-left group-hover:scale-x-100" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{activeModal === 'member' && <MemberModal onClose={closeModals} />}
|
||||
{activeModal === 'subscribe' && <SubscribeModal onClose={closeModals} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// app/components/InfoCard.tsx
|
||||
|
||||
import { HiOutlineArrowNarrowRight } from "react-icons/hi";
|
||||
|
||||
interface InfoCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function InfoCard({ title, description, actionLabel, href, onClick }: InfoCardProps) {
|
||||
const actionClasses = `
|
||||
group inline-flex items-center gap-2
|
||||
text-[#f1c50e] font-bold uppercase text-[1rem]
|
||||
tracking-wider transition-all duration-300
|
||||
relative pb-1
|
||||
`;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span>{actionLabel}</span>
|
||||
<HiOutlineArrowNarrowRight
|
||||
className="text-[1.2rem] transition-transform duration-300 group-hover:translate-x-2"
|
||||
/>
|
||||
<span className="absolute bottom-0 left-0 w-full h-0.5 bg-[#f1c50e] transform scale-x-0 transition-transform duration-300 origin-left group-hover:scale-x-100" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-[#406185] text-white w-full max-w-180 py-8 px-6 text-center shadow-lg rounded-sm">
|
||||
<h2 className="uppercase text-[clamp(1.5rem,8vw,2.25rem)] font-bold leading-tight mb-2">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mb-6 text-[1rem] leading-[1.4] opacity-90">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={actionClasses}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={actionClasses}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// app/components/Modals/Member.tsx
|
||||
|
||||
import { ModalProps } from "@/app/types";
|
||||
import Modal from "./Modal";
|
||||
import ModalAction from "./ModalAction";
|
||||
|
||||
export default function MemberModal({ onClose }: ModalProps) {
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<img src="/images/logo-black.svg" alt="Logo" className="h-25" />
|
||||
<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">
|
||||
Bli medlem
|
||||
</ModalAction>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// app/components/Modals/Modal.tsx
|
||||
|
||||
import { ModalProps } from "@/app/types";
|
||||
import React from "react";
|
||||
|
||||
|
||||
interface BaseModalProps extends ModalProps {
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Modal({ onClose, children }: BaseModalProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/60 flex justify-center items-center overflow-auto"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white p-5 w-[80vw] max-w-max min-w-75 rounded-[15px] relative flex flex-col items-center text-center font-semibold text-[#406185]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="absolute right-3 top-3 p-2 rounded-full transition-all duration-300 ease-in-out hover:bg-gray-100 hover:rotate-90 active:scale-90 group cursor-pointer"
|
||||
onClick={onClose}
|
||||
aria-label="Stäng"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 413.348 413.348"
|
||||
className="w-4 h-4 fill-black transition-colors duration-300 group-hover:fill-[#406185]"
|
||||
>
|
||||
<path d="m413.348 24.354-24.354-24.354-182.32 182.32-182.32-182.32-24.354 24.354 182.32 182.32-182.32 182.32 24.354 24.354 182.32-182.32 182.32 182.32 24.354-24.354-182.32-182.32z" />
|
||||
</svg>
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// app/components/Modals/ModalAction.tsx
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ModalActionProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
className?: string; // To allow for specific margins like mt-5
|
||||
}
|
||||
|
||||
export default function ModalAction({ href, children, className = "" }: ModalActionProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={href.startsWith('http') ? "_blank" : undefined}
|
||||
rel={href.startsWith('http') ? "noreferrer" : undefined}
|
||||
className={`
|
||||
text-white bg-[#406185] uppercase font-bold py-2.25 px-11.5
|
||||
rounded-[50px] text-[1rem] text-center transition-all duration-300
|
||||
hover:opacity-90 hover:scale-105 active:scale-95 shadow-md
|
||||
hover:shadow-lg inline-block ${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// app/components/Modals/Subscribe.tsx
|
||||
|
||||
import { ModalProps } from "@/app/types";
|
||||
import Modal from "./Modal";
|
||||
import ModalAction from "./ModalAction";
|
||||
|
||||
export default function SubscribeModal({ onClose }: ModalProps) {
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<h2 className="text-[#406185] my-2.5 text-2xl font-bold uppercase">Prenumerera</h2>
|
||||
<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>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// app/components/SocialIcon.tsx
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface SocialIconProps {
|
||||
href: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SocialIcon({ href, title, children }: SocialIconProps) {
|
||||
return (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={title}
|
||||
href={href}
|
||||
className="transition-all duration-300 hover:scale-110 active:scale-90 hover:brightness-110"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
// app/css.d.ts
|
||||
|
||||
declare module '*.css';
|
||||
+1
-3
@@ -19,7 +19,6 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* 1. Add your exact keyframes from the old site */
|
||||
@keyframes animateright {
|
||||
from {
|
||||
right: -300px;
|
||||
@@ -32,13 +31,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 2. Create a custom class to apply it */
|
||||
.player-animation {
|
||||
position: relative;
|
||||
animation: animateright 1.5s;
|
||||
}
|
||||
|
||||
:root {
|
||||
@theme {
|
||||
--font-beachday: 'Beachday', sans-serif;
|
||||
--font-sans: var(--font-montserrat), sans-serif;
|
||||
}
|
||||
+9
-1
@@ -10,8 +10,16 @@ const montserrat = Montserrat({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Lerbergets Volleybollsällskap',
|
||||
title: {
|
||||
template: '%s | LVS',
|
||||
default: 'Lerbergets Volleybollsällskap',
|
||||
},
|
||||
description: 'Klubben startade den 1 mars 2018 och har i snabb takt utvecklats...',
|
||||
icons: {
|
||||
icon: '/images/favicon.svg',
|
||||
shortcut: '/favicon.svg',
|
||||
apple: '/images/favicon.svg',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// app/lib/config.ts
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const configPath = path.join(process.cwd(), 'data', 'site-config.json');
|
||||
|
||||
export async function getConfig() {
|
||||
try {
|
||||
const data = await fs.readFile(configPath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
return {
|
||||
showBeachPromo: true,
|
||||
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.\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 upskattas 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! 🏆"
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export async function updateConfig(newConfig: any) {
|
||||
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// app/login/LoginForm.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { loginAction } from './actions';
|
||||
|
||||
export default function LoginForm() {
|
||||
const [state, formAction, isPending] = useActionState(loginAction, null);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="bg-white p-8 rounded-lg shadow-xl w-full max-w-sm transition-all duration-300">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center text-[#406185]">Admin Login</h1>
|
||||
|
||||
{state?.error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded mb-4 text-center text-sm font-bold animate-shake">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Lösenord"
|
||||
className="w-full border p-3 rounded mb-4 focus:ring-2 focus:ring-[#406185] outline-none transition-all"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full bg-[#406185] text-white p-3 rounded font-bold hover:bg-black disabled:bg-gray-400 transition-all active:scale-95"
|
||||
>
|
||||
{isPending ? 'Loggar in...' : 'Logga in'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// app/login/actions.ts
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function loginAction(prevState: any, formData: FormData) {
|
||||
const password = formData.get('password');
|
||||
|
||||
if (password === process.env.ADMIN_PASSWORD) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('admin_session', 'true', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 // 24 timmar
|
||||
});
|
||||
redirect('/admin');
|
||||
}
|
||||
|
||||
return { error: 'Felaktigt lösenord' };
|
||||
}
|
||||
|
||||
export async function logoutAction() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('admin_session');
|
||||
redirect('/login');
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// app/login/page.tsx
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Header from '../components/Header';
|
||||
import LoginForm from './LoginForm';
|
||||
import Footer from '../components/Footer';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Logga in',
|
||||
};
|
||||
|
||||
export default async function LoginPage() {
|
||||
const cookieStore = await cookies();
|
||||
const isLoggedIn = cookieStore.get('admin_session')?.value === 'true';
|
||||
|
||||
if (isLoggedIn) {
|
||||
redirect('/admin');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<div className="grow flex items-center justify-center bg-[#406185] p-5">
|
||||
<LoginForm />
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
-227
@@ -1,234 +1,21 @@
|
||||
// app/page.tsx
|
||||
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';
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { FaFacebookSquare, FaInstagram } from "react-icons/fa";
|
||||
import { IoIosMail } from "react-icons/io";
|
||||
|
||||
export default function Home() {
|
||||
const [isMedlemOpen, setIsMedlemOpen] = useState(false);
|
||||
const [isPrenumereraOpen, setIsPrenumereraOpen] = useState(false);
|
||||
export default async function Home() {
|
||||
const config = await getConfig();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col font-sans bg-white text-black">
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-black flex justify-center">
|
||||
<div className="w-85 mx-auto text-center">
|
||||
<Link href="/">
|
||||
<img
|
||||
src="/images/logo.svg"
|
||||
alt="Lerbergets Volleybollsällskap"
|
||||
className="max-h-45 min-h-20 my-5.75 mx-auto block"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="grow flex flex-col">
|
||||
|
||||
{/* Promo Section (Hidden) */}
|
||||
<section className="bg-[#fdb84b] justify-center">
|
||||
<div className="p-2.5">
|
||||
<h2 className="font-beachday text-black text-center text-[clamp(1.5rem,8vw,2.25rem)] uppercase leading-[1.1]">
|
||||
Kolla in vår <Link href="/beach" className="text-[#406185] underline">Beachturnering</Link>
|
||||
</h2>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero / Info Section */}
|
||||
<section className="bg-[#406185] flex justify-center text-white">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h4 className="italic text-xl my-2.5 leading-[1.2] font-bold">Lerbergets Volleybollsällskap</h4>
|
||||
<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
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2.5 mt-10">
|
||||
<button
|
||||
onClick={() => setIsMedlemOpen(true)}
|
||||
className="text-[#406185] bg-white border-none py-3.5 px-7.5 text-center font-bold uppercase w-fit text-[1.1rem]"
|
||||
>
|
||||
Bli Medlem
|
||||
</button>
|
||||
<Link
|
||||
href="#traningstider"
|
||||
className="text-[#406185] bg-white border-none py-3.5 px-7.5 text-center font-bold uppercase w-fit text-[1.1rem]"
|
||||
>
|
||||
Träningstider
|
||||
</Link>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://www.basesport.se/category/lerbergets-vbk"
|
||||
className="text-[#406185] bg-white border-none py-3.5 px-7.5 text-center font-bold uppercase w-fit text-[1.1rem]"
|
||||
>
|
||||
Klubbkollektion
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Image with restored animation class */}
|
||||
<div className="hidden md:flex items-center p-2.5 justify-end max-h-full">
|
||||
<img
|
||||
src="/images/player.webp"
|
||||
alt="Volleyboll spelare"
|
||||
className="max-h-112.5 player-animation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Träningstider Section - Restored to vertical stacking */}
|
||||
<section id="traningstider" className="bg-white flex justify-center">
|
||||
<div className="flex flex-col items-center w-full my-7.5 gap-2">
|
||||
|
||||
<div className="bg-[#406185] text-white w-full max-w-180 py-5 px-2.5 text-center">
|
||||
<h2 className="uppercase text-[clamp(1.5rem,8vw,2.25rem)] font-bold">Träningstider</h2>
|
||||
<p className="my-3.75 text-[1rem] leading-[1.1]">Här hittar du alltid uppdaterade träningstider från Laget.se</p>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://www.laget.se/LVS/Event/Month"
|
||||
className="text-[#f1c50e] border-b-2 border-[#f1c50e] font-bold uppercase"
|
||||
>
|
||||
Se nästa träningstid
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#406185] text-white w-full max-w-180 py-5 px-2.5 text-center">
|
||||
<h2 className="uppercase text-[clamp(1.5rem,8vw,2.25rem)] font-bold">Prenumerera på träningstider</h2>
|
||||
<p className="my-3.75 text-[1rem] leading-[1.1]">Våra träningstider finns som en kalender man kan prenumerera på genom knappen nedan</p>
|
||||
<button
|
||||
onClick={() => setIsPrenumereraOpen(true)}
|
||||
className="text-[#f1c50e] border-b-2 border-[#f1c50e] font-bold uppercase pb-0.5 cursor-pointer"
|
||||
>
|
||||
Prenumerera
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Kontakt Section */}
|
||||
<section className="bg-[#406185] flex justify-center text-white">
|
||||
<div className="flex flex-wrap-reverse justify-center gap-7.5 w-full max-w-300 my-10 px-5">
|
||||
|
||||
<div className="flex flex-wrap justify-center items-center gap-7.5 mx-5">
|
||||
<a target="_blank" rel="noreferrer" title="Instagram" href="https://www.instagram.com/lerbergets_volleyboll/">
|
||||
<FaInstagram className='h-20 w-20' />
|
||||
</a>
|
||||
<a target="_blank" rel="noreferrer" title="Facebook" href="https://www.facebook.com/lerbergetsvolleyboll">
|
||||
<FaFacebookSquare className='h-20 w-20' />
|
||||
</a>
|
||||
<a target="_blank" rel="noreferrer" title="Laget.se" href="https://www.laget.se/LVS">
|
||||
<img src="/images/laget.se.svg" alt="Laget.se" className="h-18 w-18" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<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]">Kontakta oss</h3>
|
||||
<p className="text-[1rem] leading-[1.6] my-1.25">Vi finns på Instagram, Facebook och laget.se</p>
|
||||
<p className="text-[1rem] leading-[1.6] my-1.25">Höganäs Sportcenter, Friluftsvägen 10, 263 54 Lerberget</p>
|
||||
<a
|
||||
href="mailto:info@lerbergetsvolleyboll.se?subject=Kontakta%20oss"
|
||||
className="flex justify-center items-center text-[#f1c50e] font-bold text-[1rem] leading-[1.6] my-1.25"
|
||||
>
|
||||
<IoIosMail color='#f1c50e' className='h-6 w-6' />
|
||||
info@lerbergetsvolleyboll.se
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-black text-[#lightgrey] text-center py-3.75">
|
||||
<p className="font-bold text-[#d3d3d3]">LVS - Lerbergets Volleybollsällskap {new Date().getFullYear()} ®</p>
|
||||
</footer>
|
||||
|
||||
{/* --- MODALS --- */}
|
||||
{/* Medlem Modal */}
|
||||
{isMedlemOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/60 flex justify-center items-center overflow-auto"
|
||||
onClick={() => setIsMedlemOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white p-5 w-[80vw] max-w-max min-w-75 rounded-[15px] relative flex flex-col items-center text-center font-semibold text-[#406185]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="absolute right-3.75 top-3.75 w-5 h-5 block mx-auto"
|
||||
onClick={() => setIsMedlemOpen(false)}
|
||||
>
|
||||
<svg viewBox="0 0 413.348 413.348" className="fill-black">
|
||||
<path d="m413.348 24.354-24.354-24.354-182.32 182.32-182.32-182.32-24.354 24.354 182.32 182.32-182.32 182.32 24.354 24.354 182.32-182.32 182.32 182.32 24.354-24.354-182.32-182.32z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img src="/images/logo-black.svg" alt="Logo" className="h-25" />
|
||||
<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>
|
||||
<a
|
||||
href="https://www.laget.se/LVS/Member"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-white bg-[#406185] uppercase font-bold py-2.25 px-11.5 mt-5 rounded-[50px] text-[1rem]"
|
||||
>
|
||||
Bli medlem
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<main>
|
||||
<Header />
|
||||
{config.showBeachPromo && (
|
||||
<BeachPromo />
|
||||
)}
|
||||
|
||||
{/* Prenumerera Modal */}
|
||||
{isPrenumereraOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/60 flex justify-center items-center overflow-auto"
|
||||
onClick={() => setIsPrenumereraOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white p-5 w-[80vw] max-w-max min-w-75 rounded-[15px] relative flex flex-col items-center text-center font-semibold text-[#406185]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="absolute right-3.75 top-3.75 w-5 h-5 block mx-auto"
|
||||
onClick={() => setIsPrenumereraOpen(false)}
|
||||
>
|
||||
<svg viewBox="0 0 413.348 413.348" className="fill-black">
|
||||
<path d="m413.348 24.354-24.354-24.354-182.32 182.32-182.32-182.32-24.354 24.354 182.32 182.32-182.32 182.32 24.354 24.354 182.32-182.32 182.32 182.32 24.354-24.354-182.32-182.32z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 className="text-[#406185] my-2.5 text-2xl font-bold uppercase">Prenumerera</h2>
|
||||
<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">
|
||||
<a href="webcal://cal.laget.se/lvs-level-5-6.ics" className="text-white bg-[#406185] uppercase font-bold py-2.25 px-11.5 rounded-[50px] text-[1rem]">Level 5-6</a>
|
||||
<a href="webcal://cal.laget.se/lvs-level-7-8.ics" className="text-white bg-[#406185] uppercase font-bold py-2.25 px-11.5 rounded-[50px] text-[1rem]">level 7-8</a>
|
||||
<a href="webcal://cal.laget.se/lvs-level-8-9.ics" className="text-white bg-[#406185] uppercase font-bold py-2.25 px-11.5 rounded-[50px] text-[1rem]">Level 8-9</a>
|
||||
<a href="webcal://cal.laget.se/lvs-level-10.ics" className="text-white bg-[#406185] uppercase font-bold py-2.25 px-11.5 rounded-[50px] text-[1rem]">Level 10</a>
|
||||
<a href="webcal://cal.laget.se/LVS.ics" className="text-white bg-[#406185] uppercase font-bold py-2.25 px-11.5 rounded-[50px] text-[1rem]">Hela klubben</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<HomeClientContent />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// app/types.ts
|
||||
|
||||
export interface ModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"showBeachPromo": true,
|
||||
"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.\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! 🏆"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
lvs-web:
|
||||
build: .
|
||||
container_name: lvs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- ADMIN_PASSWORD=super_secret_password
|
||||
- NEXT_PUBLIC_TURNSTILE_SITE_KEY=site_key
|
||||
- TURNSTILE_SECRET_KEY=secret_key
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: always
|
||||
Reference in New Issue
Block a user