From 683368a354d0912acfc4092083c9e5e7c36f3e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20S=C3=B6derberg?= Date: Fri, 6 Mar 2026 15:33:05 +0100 Subject: [PATCH] Better UI --- app/admin/AdminForm.tsx | 116 ++++++++++- app/admin/actions.ts | 9 + app/api/submit/route.js | 127 ------------ app/beach/BeachClientPage.tsx | 220 +++++++++++++++----- app/beach/BeachRegistrationForm.tsx | 291 +++++++++++++++------------ app/beach/actions.ts | 130 ++++++++++++ app/components/HomeClientContent.tsx | 6 +- data/site-config.json.example | 28 +++ package-lock.json | 11 + package.json | 1 + public/images/player.svg | 226 +++++++++++++++++++++ public/images/player.webp | Bin 48680 -> 0 bytes public/images/volleyball-net.svg | 204 +++++++++++++++++++ public/images/volleyball-net.webp | Bin 97330 -> 0 bytes 14 files changed, 1060 insertions(+), 309 deletions(-) delete mode 100644 app/api/submit/route.js create mode 100644 app/beach/actions.ts create mode 100644 data/site-config.json.example create mode 100644 public/images/player.svg delete mode 100644 public/images/player.webp create mode 100644 public/images/volleyball-net.svg delete mode 100644 public/images/volleyball-net.webp diff --git a/app/admin/AdminForm.tsx b/app/admin/AdminForm.tsx index aa95acb..5ad4829 100644 --- a/app/admin/AdminForm.tsx +++ b/app/admin/AdminForm.tsx @@ -3,12 +3,50 @@ 'use client'; import { useActionState, useEffect, useState } from 'react'; +import { FaPlus, FaTrash, FaArrowUp, FaArrowDown } from 'react-icons/fa'; // Importera pilarna import { saveConfigAction } from './actions'; export default function AdminForm({ initialConfig }: { initialConfig: any }) { const [state, formAction, isPending] = useActionState(saveConfigAction, null); const [showToast, setShowToast] = useState(false); + const [classes, setClasses] = useState>( + Array.isArray(initialConfig.beachPage.classes) + ? initialConfig.beachPage.classes + : [ + { name: 'Grön', color: '#16a34a', desc: 'Nybörjare' }, + { name: 'Blå', color: '#2563eb', desc: 'Spelat lite' }, + { name: 'Svart', color: '#111827', desc: 'Spelat mycket' } + ] + ); + + const addClass = () => setClasses([...classes, { name: '', color: '#406185', desc: '' }]); + + const removeClass = (index: number) => { + setClasses(classes.filter((_, i) => i !== index)); + }; + + const updateClass = (index: number, field: keyof typeof classes[0], value: string) => { + const newClasses = [...classes]; + newClasses[index][field] = value; + setClasses(newClasses); + }; + + // Funktioner för att byta plats på klasserna + const moveUp = (index: number) => { + if (index === 0) return; + const newClasses = [...classes]; + [newClasses[index - 1], newClasses[index]] = [newClasses[index], newClasses[index - 1]]; + setClasses(newClasses); + }; + + const moveDown = (index: number) => { + if (index === classes.length - 1) return; + const newClasses = [...classes]; + [newClasses[index + 1], newClasses[index]] = [newClasses[index], newClasses[index + 1]]; + setClasses(newClasses); + }; + useEffect(() => { if (state?.success) { setShowToast(true); @@ -70,7 +108,83 @@ export default function AdminForm({ initialConfig }: { initialConfig: any }) { - + + +
+ {classes.map((c, index) => ( +
+ + {/* Sorteringspilar */} +
+ + +
+ +
+ {/* Färgplockare som ser ut som ett vanligt inputfält */} +
+ updateClass(index, 'color', e.target.value)} + className="w-16 h-16 -m-2 cursor-pointer outline-none" + title="Välj färg" + /> +
+ + updateClass(index, 'name', e.target.value)} + placeholder="Lagnamn (t.ex. Grön)" + required + className="w-1/3 border p-2 rounded focus:ring-2 focus:ring-[#406185] outline-none min-w-25" + /> + + updateClass(index, 'desc', e.target.value)} + placeholder="Beskrivning (t.ex. Nybörjare)" + className="flex-1 border p-2 rounded focus:ring-2 focus:ring-[#406185] outline-none min-w-30" + /> +
+ + +
+ ))} + {classes.length === 0 && ( +

Inga klasser inlagda. Besökarna kommer inte kunna välja nivå.

+ )} +
+ + {/* Dold JSON-sträng för Server Action */} + + + + diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 589c45c..db9f525 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -8,6 +8,14 @@ import { revalidatePath } from 'next/cache'; export async function saveConfigAction(prevState: any, formData: FormData) { const config = await getConfig(); + let parsedClasses = []; + try { + const classesJSON = formData.get('classesJSON') as string; + if (classesJSON) parsedClasses = JSON.parse(classesJSON); + } catch (e) { + console.error("Kunde inte parsa klasser", e); + } + const newConfig = { ...config, showBeachPromo: formData.get('showBeachPromo') === 'on', @@ -19,6 +27,7 @@ export async function saveConfigAction(prevState: any, formData: FormData) { otherInfo: formData.get('otherInfo') as string, prizesText: formData.get('prizesText') as string, isClosedOverride: formData.get('isClosedOverride') === 'on', + classes: parsedClasses, } }; diff --git a/app/api/submit/route.js b/app/api/submit/route.js deleted file mode 100644 index 2073c95..0000000 --- a/app/api/submit/route.js +++ /dev/null @@ -1,127 +0,0 @@ -// app/api/submit/route.js - -import nodemailer from 'nodemailer'; - -export async function POST(request) { - try { - const formData = await request.formData(); - - // 1. Extract form data (Matching your PHP POST variables) - const turnstileToken = formData.get('cf-turnstile-response'); - const email = formData.get('email'); - const name = formData.get('name'); - const team = formData.get('team'); - const tel = formData.get('tel'); - const count = formData.get('count'); - const level = formData.get('level'); - const message = formData.get('message'); - const honeypot = formData.get('website'); - - // 2. Security Checks (Honeypot & Turnstile) - if (honeypot) { - return Response.json({ success: false, message: 'Spam detekterat.' }, { status: 400 }); - } - - // Verify Turnstile - const turnstileRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - secret: process.env.TURNSTILE_SECRET || '', // Pulls from .env.local - response: turnstileToken?.toString() || '', - }), - }); - - const turnstileData = await turnstileRes.json(); - - if (!turnstileData.success) { - return Response.json({ success: false, message: 'Säkerhetsverifiering misslyckades (Turnstile).' }, { status: 400 }); - } - - let formattedTel = tel.replace(/\D/g, ''); - if (formattedTel.startsWith('46')) formattedTel = formattedTel.substring(2); - else if (formattedTel.startsWith('0')) formattedTel = formattedTel.substring(1); - - if (formattedTel.length === 9) { - formattedTel = `+46 (0)${formattedTel.substring(0, 2)}-${formattedTel.substring(2, 5)} ${formattedTel.substring(5, 7)} ${formattedTel.substring(7, 9)}`; - } else { - formattedTel = tel; - } - - // 4. Send Email via Nodemailer (Replicating PHPMailer) - const transporter = nodemailer.createTransport({ - host: '10.10.0.113', // Your internal SMTP Host - port: 25, - secure: false, - tls: { - rejectUnauthorized: false - } - }); - - const cleanMessage = message ? message.replace(/[\r\n;]/g, " ") : ""; - const dateStr = new Date().toISOString().replace('T', ' ').substring(0, 19); - - const emailHtml = ` - - -

Beach-anmälan

- - - - - - - -
Lagnamn:${team}
Kontakt:${name}
E-post:${email}
Telefon:${formattedTel}
Spelare:${count}
Nivå:${level}
-

Meddelande:
${message ? message.replace(/\n/g, '
') : ''}

-
- - - - - - - - - - - -
${team}${count}${level}${name}${formattedTel}${email}${dateStr}${cleanMessage}
- - - `; - - // 1. Send the data to the Club - await transporter.sendMail({ - from: `"Anmälan: ${name}" `, - to: 'beach@lerbergetsvolleyboll.se', - replyTo: email?.toString(), - subject: `Beach turnering: ${team}`, - html: emailHtml, - }); - - // 2. Send the friendly auto-reply to the Applicant - await transporter.sendMail({ - from: `"Lerbergets Volleybollsällskap" `, - to: email?.toString(), - subject: `Bekräftelse på anmälan: ${team}`, - html: ` -
-

Hej ${name}!

-

Tack för din anmälan vi återkommer så fort vi kan.

-
-

- Med vänliga hälsningar,
- Lerbergets Volleybollsällskap -

-
- `, - }); - - return Response.json({ success: true, message: 'Tack så mycket för anmälan! Vi svarar med en bekräftelse så fort vi kan.' }); - - } catch (error) { - console.error(error); - return Response.json({ success: false, message: 'Ett tekniskt fel uppstod. Försök igen senare.' }, { status: 500 }); - } -} \ No newline at end of file diff --git a/app/beach/BeachClientPage.tsx b/app/beach/BeachClientPage.tsx index e8ced9a..3dfcb63 100644 --- a/app/beach/BeachClientPage.tsx +++ b/app/beach/BeachClientPage.tsx @@ -3,6 +3,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { FaClipboardList, FaClock, FaInfoCircle, FaLock, FaMapMarkerAlt, FaTicketAlt, FaTrophy, FaUsers } from "react-icons/fa"; import { IoIosMail } from "react-icons/io"; import Footer from '../components/Footer'; import Header from '../components/Header'; @@ -16,6 +17,14 @@ export default function BeachClientPage({ config }: { config: any }) { isClosed: false, }); + const classesData = Array.isArray(config.beachPage?.classes) + ? config.beachPage.classes + : [ + { name: 'Grön', color: '#16a34a', desc: 'Nybörjare' }, + { name: 'Blå', color: '#2563eb', desc: 'Spelat lite' }, + { name: 'Svart', color: '#111827', desc: 'Spelat mycket' } + ]; + useEffect(() => { const updateTournamentInfo = () => { const matchStart = new Date(config.beachPage.matchStart); @@ -39,73 +48,184 @@ export default function BeachClientPage({ config }: { config: any }) {
-
-
-

Mat- & Sommarfesten

-

Beachturnering

-

Samla ihop ett kompisgäng och anmäl er!

+
+
+ {/* Logotyp med subtil hover-effekt */} +
+
+ Mat- & Sommarfesten +
+ + {/* Huvudrubrik med 3D-känsla */} +

+ Beach­turnering +

+

+ Samla ihop ett kompisgäng och anmäl er! +

+ + {/* Moderniserad "Stängd"-box */} {timeInfo.isClosed && ( -
-

Anmälan är stängd!

-

{config.beachPage.closeReason}

+
+ {/* Röd dekorationslist i toppen */} +
+ +
+ +
+ +

+ Anmälan är stängd +

+

+ {config.beachPage.closeReason} +

)} +
-
- {/* INFORMATION TEXT (Var, När, Regler, etc.) */} -

Var:

-

Kvickbadet i Höganäs

+
+
-

När:

-

{timeInfo.dateStr}

-

{timeInfo.matchStr}

-

- Samtidigt som Höganäs Mat- & Sommarfest -

+ {/* Större och tydligare badge för Turneringsinfo */} +
+ Turneringsinfo +
-

Regler:

-

Matcherna spelas 4v4 med inomhusregler, förutom poängräkningen som följer reglerna för beachvolleyboll.

+
-

Klasser:

-

- Turneringen kan komma att delas upp i tre olika klasser beroende på antal anmälda lag och baserat på nivå:
- Grön (Nybörjare),{' '} - Blå (Amatör),{' '} - Svart (Proffs) -

-

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.

+ {/* VAR & NÄR - Logistik */} +
+
+ +

Var

+
+

+ + Kvickbadet + i Höganäs +

-

Kostnad:

-

Gratis!!!

+
+ +

När

+
+
+

{timeInfo.dateStr}

+

{timeInfo.matchStr}

+

+ Samtidigt som Höganäs Mat- & Sommarfest +

+
+
-

Anmälan:

-

Anmälan görs genom anmälningsformuläret nedan. Du får ett bekräftelsemejl när vi kollat igenom er anmälan.

-

{timeInfo.deadlineText}

+ {/* REGLER & KLASSER - Spelet */} +
+
+ +

Regler

+
+

+ Matcherna spelas 4v4 med inomhusregler, + förutom poängräkningen som följer reglerna för beachvolley. +

- {/* Övrig info sektionen */} -

Övrig info:

- {(config.beachPage?.otherInfo || "").split('\n').map((line: string, i: number) => ( - line.trim() &&

{line}

- ))} +
+ +

Klasser

+
+
+

Turneringen kan delas upp i tre klasser beroende på antal anmälda lag och nivå:

+
+ {classesData.map((c: any, i: number) => ( + + {c.name.toUpperCase()} {c.desc && ({c.desc})} + + ))} +
+

Gör en gissning på er nivå i anmälan – skriv gärna om ni är osäkra.

+
+
- {/* Pris sektionen */} -

- {config.beachPage?.prizesText || "Fina priser till alla pallplatser! 🏆"} -

+ {/* KOSTNAD & ANMÄLAN */} +
+
+
+
+ +

Anmälan & Kostnad

+
+

Kostnad: Gratis!

+
+

Anmälan görs via formuläret nedan. Du får ett bekräftelsemejl när vi granskat er anmälan.

+

{timeInfo.deadlineText}

+
+
+ +
+ +
+

Övrig info:

+
+ {(config.beachPage?.otherInfo || "").split('\n').map((line: string, i: number) => ( + line.trim() &&

{line}

+ ))} +
+
+
+
+
+
+ + {/* Kompaktare PRIS-BANNER */} +
+
+ +

+ {config.beachPage?.prizesText || "Fina priser till alla pallplatser! 🏆"} +

+
+
{/* Rendera formuläret endast om anmälan är öppen */} - {!timeInfo.isClosed && } + {!timeInfo.isClosed && } -
-
-

Kontakta oss

- - - beach@lerbergetsvolleyboll.se - +
+
diff --git a/app/beach/BeachRegistrationForm.tsx b/app/beach/BeachRegistrationForm.tsx index 786a78c..5a814e6 100644 --- a/app/beach/BeachRegistrationForm.tsx +++ b/app/beach/BeachRegistrationForm.tsx @@ -3,157 +3,192 @@ '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(false); - const [wasValidated, setWasValidated] = useState(false); - const [alert, setAlert] = useState({ show: false, type: '', message: '' }); +import React, { useRef, useActionState, useEffect } from 'react'; +import { FaUser, FaEnvelope, FaPhone, FaUsers, FaTrophy, FaCheckCircle, FaExclamationTriangle } from "react-icons/fa"; +import { submitRegistration } from './actions'; +export default function BeachRegistrationForm({ config }: { config: any }) { + const [state, formAction, isPending] = useActionState(submitRegistration, null); const formRef = useRef(null); const turnstileRef = useRef(null); - const handleSubmit: React.FormEventHandler = async (e) => { - e.preventDefault(); - const form = formRef.current; - if (!form) return; + const classesData = Array.isArray(config.beachPage?.classes) + ? config.beachPage.classes + : [ + { name: 'Grön', color: '#16a34a', desc: 'Nybörjare' }, + { name: 'Blå', color: '#2563eb', desc: 'Spelat lite' }, + { name: 'Svart', color: '#111827', desc: 'Spelat mycket' } + ]; - 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); + useEffect(() => { + // Rensa endast vid lyckad inskickning + if (state?.success) { + formRef.current?.reset(); + turnstileRef.current?.reset(); + } else if (state?.success === false) { + // Återställ Turnstile så de kan försöka igen vid fel + turnstileRef.current?.reset(); } - }; + }, [state]); return ( -
-
-

- Anmälningsformulär -

+
+
-
- {/* 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) => ( -
+
+

+ Anmälnings­formulär +

+
+
+ + + +
+ } + name="name" + placeholder="Ditt namn" + required + minLength={2} + defaultValue={state?.values?.name || ''} + /> + } + name="email" + type="email" + placeholder="namn@domän.com" + required + defaultValue={state?.values?.email || ''} + /> +
+ +
+ } + name="tel" + type="tel" + placeholder="Telefonnummer" + required + pattern="[0-9+ \-]{8,}" + defaultValue={state?.values?.tel || ''} + /> + } + name="team" + placeholder="Lagnamn" + required + minLength={2} + defaultValue={state?.values?.team || ''} + /> +
+ +
+
+ { + e.currentTarget.value = e.currentTarget.value.replace(/\D/g, ''); + }} + className="w-full p-4 rounded-xl border-2 border-transparent bg-white focus:border-[#39979f] outline-none transition-all shadow-inner" + placeholder="Minst 2" + defaultValue={state?.values?.count || ''} /> -
-
-
{f.error}
- ))} -
- -
Glöm inte ange antal lagmedlemmar!
+
+ +
+ {classesData.map((c: any) => ( + + ))} +
+
- {/* 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) => ( - - - -
-
- ))} -
Glöm inte ange spelarnivå!
+