Refactor environment configuration for local development; update SMTP settings and add JWT secret, encryption key, and debug options. Enhance Nuxt configuration for development server and runtime settings. Introduce new membership application form with validation and PDF generation functionality. Update footer and navigation components to include new membership links. Revise user and session data in JSON files.

This commit is contained in:
Torsten Schulz (local)
2025-10-23 01:31:45 +02:00
parent de73ceb62f
commit 7cd39bb452
43 changed files with 3350 additions and 457 deletions

View File

@@ -0,0 +1,357 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
import fs from 'fs'
async function create() {
const pdfDoc = await PDFDocument.create()
const page = pdfDoc.addPage([595.28, 841.89]) // A4
const helv = await pdfDoc.embedFont(StandardFonts.Helvetica)
const helvBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold)
const { width, height } = page.getSize()
// left column moved further left to align with checkboxes
const leftX = 48
const rightX = 320
// baseY is the vertical anchor for page-1 content. Increase it by ~5.2mm (≈14.739pt)
const baseY = height - 160 + 14.739
// shift to apply only to labels and input fields (move those 1cm down)
const labelsShift = -28.35
const gap = 36
const labelWidth = 100
const fieldXOffset = 100
// Make fields 10% narrower
const fieldWidth = Math.round(180 * 0.9)
// shrink most fields by another 20% on request (applied selectively later)
const fieldShrinkFactor = 0.8
const fieldHeight = 14
// raise date fields by 0.4cm (≈11.34pt) when requested; add extra 1.2mm (≈3.4016pt) per user request
const dateRaise = 11.34 + 3.4016 // now ≈14.7416pt
// fixed checkbox positions (do not move with leftX)
const cbX = 48
const cbYOffset = 5
// Header: centered club name and a full-width horizontal bar underneath (~2mm high)
const headerText = 'Harheimer Tischtennis-Club 1954 e.V.'
const headerSize = 20
const textWidth = helv.widthOfTextAtSize(headerText, headerSize)
const headerX = (width - textWidth) / 2
const headerY = height - 72
page.drawText(headerText, { x: headerX, y: headerY, size: headerSize, font: helv })
// draw full-width bar directly under the header; 2mm ≈ 5.67 points
const barHeight = 5.67
const barY = headerY - 20
page.drawRectangle({ x: 0, y: barY, width: width, height: barHeight, color: rgb(0,0,0) })
// Labels and lines
// Labels left-aligned in their columns
// Add form title above the fields
const titleText = 'Beitrittserklärung'
const titleSize = 14
const titleWidth = helvBold.widthOfTextAtSize(titleText, titleSize)
// left-align title above the left labels and move up ~0.7cm (≈20pt)
const titleX = leftX
// move title down by 0.5cm (≈14.17pt)
const titleY = baseY + 24 + 20 - 14.17
page.drawText(titleText, { x: titleX, y: titleY, size: titleSize, font: helvBold })
const subtitle = 'Hiermit beantrage ich,'
const subtitleSize = 12
const subtitleX = leftX
const subtitleY = titleY - 18 - 14.17
page.drawText(subtitle, { x: subtitleX, y: subtitleY, size: subtitleSize, font: helv })
// apply same vertical shift as fields so labels align
const fieldsShift = -14.17
page.drawText('Name:', { x: leftX, y: baseY + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('Vorname:', { x: rightX, y: baseY + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('Straße:', { x: leftX, y: baseY - gap + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('PLZ/Wohnort:', { x: rightX, y: baseY - gap + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('Geburtsdatum:', { x: leftX, y: baseY - gap * 2 + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('Telefon(privat):', { x: rightX, y: baseY - gap * 2 + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('E-Mail:', { x: leftX, y: baseY - gap * 3 + fieldsShift + labelsShift, size: 12, font: helv })
page.drawText('Telefon(Mobil):', { x: rightX, y: baseY - gap * 3 + fieldsShift + labelsShift, size: 12, font: helv })
// Create form fields
const form = pdfDoc.getForm()
// Place fields on the same baseline as their labels
// We need to move only the input fields on page 1 up by 0.6cm (≈17.01pt) without moving labels.
const labelToFieldYDelta = 2 // small vertical offset so field baseline matches label visually
const lift = 0 // original lift value
// previously raised inputs by 17.01pt (0.6cm); move them down by 5.67pt (0.2cm)
const inputFieldRaise = 11.34 // net upward offset now ~11.34pt
form.createTextField('nachname').addToPage(page, { x: leftX + fieldXOffset, y: baseY - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('vorname').addToPage(page, { x: rightX + fieldXOffset, y: baseY - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('strasse').addToPage(page, { x: leftX + fieldXOffset, y: baseY - gap - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('plz_ort').addToPage(page, { x: rightX + fieldXOffset, y: baseY - gap - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('geburtsdatum').addToPage(page, { x: leftX + fieldXOffset, y: baseY - gap * 2 - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('telefon').addToPage(page, { x: rightX + fieldXOffset, y: baseY - gap * 2 - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('email').addToPage(page, { x: leftX + fieldXOffset, y: baseY - gap * 3 - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
form.createTextField('telefon_mobil').addToPage(page, { x: rightX + fieldXOffset, y: baseY - gap * 3 - fieldHeight + labelToFieldYDelta + lift + fieldsShift + labelsShift + inputFieldRaise, width: Math.round(fieldWidth * fieldShrinkFactor), height: fieldHeight, font: helv })
// read membership amounts from config (fall back to defaults)
let erw = 120, jug = 72, passv = 30
try {
const cfg = JSON.parse(fs.readFileSync('server/data/config.json', 'utf8'))
const ms = cfg.mitgliedschaft || []
erw = (ms.find(m => /Erwachsene/i.test(m.typ)) || ms.find(m => m.typ === 'Erwachsene') || {}).preis || erw
jug = (ms.find(m => /Jugend|Kinder/i.test(m.typ)) || ms.find(m => m.typ === 'Kinder/Jugend') || {}).preis || jug
passv = (ms.find(m => /Passiv/i.test(m.typ)) || {}).preis || passv
} catch (e) {
console.error('Could not read config for membership amounts', e)
}
const paraLines = [
'Den derzeitigen jährlichen Mitgliedsbeitrag in Höhe von',
`${erw},-- (Erwachsene)`,
`${jug},-- (Jugendliche bis zum vollendeten 18. Lebensjahr)`,
`${passv},-- (passive Mitglieder)`,
'bitte ich per Lastschrift jährlich von meinem Konto einzuziehen.',
'Hierzu erteile ich beigefügtes SEPA-Lastschriftmandat.',
'Mir ist bekannt, dass die Mitgliedschaft im Harheimer Tischtennis-Club erst nach Bestätigung durch den',
'Vorstand Wirksamkeit erlangt. Die Beitragspflicht beginnt mit dem darauf folgenden Monat.',
'Ich erkenne die Vereinssatzung (erhältlich beim Vorstand bzw. auf der Vereinshomepage) an.'
]
// compute intermediate line position and move it down by additional 0.3cm ≈ 8.5pt
const intermediateY = baseY - gap * 3 + fieldsShift - 6 - 14.17 - 8.5 - 8.5 + labelsShift
page.drawText('meine Aufnahme in den Harheimer Tischtennis-Club 1954 e.V. als', { x: leftX, y: intermediateY, size: 12, font: helv })
// membership checkboxes
// Keep checkbox labels and boxes at fixed left positions, but move them down by 0.8cm ≈ 22.68pt
const cbLift = -22.68
page.drawText('aktives Mitglied', { x: cbX + 16, y: baseY - gap * 5 + cbLift + labelsShift, size: 12, font: helv })
page.drawText('passives Mitglied', { x: cbX + 16, y: baseY - gap * 6 + cbLift + labelsShift, size: 12, font: helv })
form.createCheckBox('mitglied_aktiv').addToPage(page, { x: cbX, y: baseY - gap * 5 - cbYOffset + cbLift + labelsShift, width: 12, height: 12 })
form.createCheckBox('mitglied_passiv').addToPage(page, { x: cbX, y: baseY - gap * 6 - cbYOffset + cbLift + labelsShift, width: 12, height: 12 })
// place the paragraph below the checkboxes with extra spacing before and after the cost lines (~0.3cm)
try {
// increase gap around cost lines for better readability (~0.6cm)
const beforeCostGap = 17
const afterCostGap = 17
const paraStartY = baseY - gap * 6 + cbLift - 28 - beforeCostGap
const lineHeight = 14
// insert explicit gaps before and after the cost lines (lines 1-3)
let y = paraStartY
// first line
page.drawText(paraLines[0], { x: leftX, y: y, size: 11, font: helv })
y -= lineHeight
// gap before costs
y -= beforeCostGap
// cost lines (1..3)
for (let k = 1; k <= 3; k++) {
page.drawText(paraLines[k], { x: leftX + 6, y: y, size: 11, font: helv })
y -= lineHeight
}
// gap after costs
y -= afterCostGap
// remaining lines
for (let i = 4; i < paraLines.length; i++) {
page.drawText(paraLines[i], { x: leftX, y: y, size: 11, font: helv })
y -= lineHeight
}
// shift following content down by afterCostGap if needed (none after currently)
// now add signature/city line 2 cm below the current text
const twoCm = 56.7
const signY = y - twoCm
const sigText = 'Frankfurt/Main-Harheim, den'
const sigSize = 12
page.drawText(sigText + ' ', { x: leftX, y: signY, size: sigSize, font: helv })
const sigStartX = leftX + helv.widthOfTextAtSize(sigText + ' ', sigSize)
const sigWidth = 220
// draw thin line for signature
page.drawRectangle({ x: sigStartX, y: signY - 2, width: sigWidth, height: 1, color: rgb(0,0,0) })
// draw 'Datum' centered under the signature line
const datum = 'Datum'
const datumSize = 11
const datumX = sigStartX + sigWidth / 2 - helv.widthOfTextAtSize(datum, datumSize) / 2
page.drawText(datum, { x: datumX, y: signY - 18, size: datumSize, font: helv })
// create a date form field on page 1 centered under the date line
const dateFieldWidth = 120
const dateFieldX = sigStartX + sigWidth / 2 - dateFieldWidth / 2
// position date field so its bottom edge is exactly on the signature line
const signatureLineY = signY - 2 // the 1pt-high rectangle was drawn at signY-2
// raise date fields by 0.4cm (≈11.34pt) upward relative to the line
const dateRaise = 11.34
// For page 1 we need to move only the date input up by 5.2mm (≈14.739pt)
const signDatumExtraRaise = 14.739
form.createTextField('sign_datum').addToPage(page, { x: dateFieldX, y: signatureLineY - fieldHeight + signDatumExtraRaise, width: dateFieldWidth, height: fieldHeight, font: helv })
// second signature line 3cm below first
const threeCm = 85.04
const secondY = signY - threeCm
const line2Width = 300
page.drawRectangle({ x: leftX, y: secondY - 2, width: line2Width, height: 1, color: rgb(0,0,0) })
const label2 = 'Unterschrift (bei Jugendlichen gesetzlicher Vertreter)'
page.drawText(label2, { x: leftX, y: secondY - 18, size: 11, font: helv })
} catch (e) {
// ignore
}
// footer: right-aligned 'Seite 1 von 3' 2cm from bottom
const footerY = 56.7
const footerText = 'Seite 1 von 3'
const footerSize = 10
const footerWidth = helv.widthOfTextAtSize(footerText, footerSize)
page.drawText(footerText, { x: width - footerWidth - leftX, y: footerY, size: footerSize, font: helv })
// --- Add a second page with the same header and horizontal bar ---
const page2 = pdfDoc.addPage([595.28, 841.89])
const { width: width2, height: height2 } = page2.getSize()
const textWidth2 = helv.widthOfTextAtSize(headerText, headerSize)
const headerX2 = (width2 - textWidth2) / 2
const headerY2 = height2 - 72
page2.drawText(headerText, { x: headerX2, y: headerY2, size: headerSize, font: helv })
const barY2 = headerY2 - 20
page2.drawRectangle({ x: 0, y: barY2, width: width2, height: barHeight, color: rgb(0,0,0) })
// --- Page 2: SEPA-Lastschriftmandat text and fields ---
// move SEPA section slightly up so title sits closer to the header bar
const sepaYStart = headerY2 - 54
const sepaLeft = leftX
// increase line gap for SEPA section (~22pt)
const lineGap = 22
let sy = sepaYStart
const small = 11
page2.drawText('Erteilung eines SEPA-Lastschriftmandates', { x: sepaLeft, y: sy, size: 12, font: helvBold })
sy -= lineGap
// draw these two lines as a tight block (no extra gap)
page2.drawText('Harheimer Tischtennis-Club 1954 e.V.', { x: sepaLeft, y: sy, size: small, font: helv })
sy -= 12
page2.drawText('Unsere Gläubiger-Identifikationsnummer: DE46ZZZ00000745362', { x: sepaLeft, y: sy, size: small, font: helv })
// add 0.7cm vertical gap (≈19.8pt) before the authorization paragraph
sy -= 19.8
// draw the authorization text as wrapped lines within page margins
const wrapMaxWidth = width2 - sepaLeft - 48
function drawWrapped(text, x, y, size, font) {
const words = text.split(' ')
let line = ''
let curY = y
for (const w of words) {
const test = line ? line + ' ' + w : w
const testWidth = helv.widthOfTextAtSize(test, size)
if (testWidth > wrapMaxWidth) {
page2.drawText(line, { x, y: curY, size, font })
line = w
curY -= size + 4
} else {
line = test
}
}
if (line) {
page2.drawText(line, { x, y: curY, size, font })
curY -= size + 4
}
return curY
}
sy = drawWrapped('Hiermit ermächtige ich den Harheimer Tischtennis-Club 1954 e.V. die jährlichen Mitgliedsbeiträge von meinem untenstehenden Konto per Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die vom Harheimer Tischtennis-Club 1954 e.V. auf mein Konto gezogenen Lastschriften einzulösen.', sepaLeft, sy, small, helv)
sy -= lineGap * 0.2
// draw mandate reference and validity with a small gap
page2.drawText('Mandatsreferenz: HTC0000 _ _ _', { x: sepaLeft, y: sy, size: small, font: helv })
// add 0.7cm space after Mandatsreferenz as requested
sy -= 19.8
page2.drawText('Dieses Mandat gilt für die zugrundeliegende Beitrittserklärung ab sofort.', { x: sepaLeft, y: sy, size: small, font: helv })
sy -= lineGap * 0.6
// Draw labeled lines and create text fields for mandate details
const fieldHeight2 = 14
const fieldWidth2 = 380
const labelOffset = 0
// place SEPA inputs 1cm left (≈28.35pt) from previous and slightly up; adjust to align with labels
const inputX = sepaLeft + 220 - 28.35
// reduce vertical raise so inputs sit on same baseline as labels (previously too high)
const inputYAdjust = 6
// apply labelsShift only to the SEPA form labels/inputs so paragraphs remain unaffected
let syFields = sy + labelsShift
page2.drawText('Mitglied (Vorname und Name)', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_mitglied').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('Kontoinhaber (Vorname und Name):', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_kontoinhaber').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('Straße und Hausnummer:', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_strasse').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('PLZ und Ort:', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_plz_ort').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('Kreditinstitut:', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_bank').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('IBAN:', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
form.createTextField('sepa_iban').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: Math.round(fieldWidth2 * fieldShrinkFactor), height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
page2.drawText('BIC:', { x: sepaLeft + labelOffset, y: syFields, size: small, font: helv })
// BIC remains full width as requested
form.createTextField('sepa_bic').addToPage(page2, { x: inputX, y: syFields - fieldHeight2 + 2 + inputYAdjust, width: 220, height: fieldHeight2, font: helv })
syFields -= lineGap * 1.1
// add signature and date lines 2cm below last field
const twoCm = 56.7
const signY2 = syFields - twoCm
// date text and line
const sigDateText = 'Frankfurt/Main-Harheim, den'
page2.drawText(sigDateText + ' ', { x: sepaLeft, y: signY2, size: 12, font: helv })
const sigDateStartX = sepaLeft + helv.widthOfTextAtSize(sigDateText + ' ', 12)
const sigDateWidth = 160
page2.drawRectangle({ x: sigDateStartX, y: signY2 - 2, width: sigDateWidth, height: 1, color: rgb(0,0,0) })
page2.drawText('Datum', { x: sigDateStartX + sigDateWidth / 2 - helv.widthOfTextAtSize('Datum', 11) / 2, y: signY2 - 18, size: 11, font: helv })
// create a date form field on page 2 centered under the date line
const sepaDateFieldWidth = 120
const sepaDateFieldX = sigDateStartX + sigDateWidth / 2 - sepaDateFieldWidth / 2
// position sepa date field so its bottom edge is on the signature line
// raise SEPA date field by the same amount so its top/bottom alignment matches requested position
form.createTextField('sepa_datum').addToPage(page2, { x: sepaDateFieldX, y: signY2 - 2 - fieldHeight2 + dateRaise, width: sepaDateFieldWidth, height: fieldHeight2, font: helv })
// footer on page 2: right-aligned 'Seite 2 von 3' 2cm from bottom
const footerText2 = 'Seite 2 von 3'
const footerSize2 = 10
const footerWidth2 = helv.widthOfTextAtSize(footerText2, footerSize2)
page2.drawText(footerText2, { x: width2 - footerWidth2 - leftX, y: 56.7, size: footerSize2, font: helv })
// signature line
// move signature label/line 2cm (≈56.7pt) further down
const signLineY = signY2 - 36 - 56.7
const signLineWidth = 300
page2.drawRectangle({ x: sepaLeft, y: signLineY - 2, width: signLineWidth, height: 1, color: rgb(0,0,0) })
page2.drawText('Unterschrift des Kontoinhabers', { x: sepaLeft, y: signLineY - 18, size: 11, font: helv })
// no form field for signature on page 2 (physical signature)
const pdfBytes = await pdfDoc.save()
fs.writeFileSync('server/templates/mitgliedschaft-fillable.pdf', pdfBytes)
console.log('Wrote server/templates/mitgliedschaft-fillable.pdf')
// --- Add a third page with same header/bar/footer and title 'Einwilligungserklärung' ---
const page3 = pdfDoc.addPage([595.28, 841.89])
const { width: width3, height: height3 } = page3.getSize()
const headerX3 = (width3 - textWidth) / 2
const headerY3 = height3 - 72
page3.drawText(headerText, { x: headerX3, y: headerY3, size: headerSize, font: helv })
const barY3 = headerY3 - 20
page3.drawRectangle({ x: 0, y: barY3, width: width3, height: barHeight, color: rgb(0,0,0) })
// title for page 3
const page3Title = 'Einwilligungserklärung'
const page3TitleSize = 14
const page3TitleX = leftX
const page3TitleY = headerY3 - 48
page3.drawText(page3Title, { x: page3TitleX, y: page3TitleY, size: page3TitleSize, font: helvBold })
// footer on page 3
const footerText3 = 'Seite 3 von 3'
const footerSize3 = 10
const footerWidth3 = helv.widthOfTextAtSize(footerText3, footerSize3)
page3.drawText(footerText3, { x: width3 - footerWidth3 - leftX, y: 56.7, size: footerSize3, font: helv })
}
create().catch(e => {
console.error(e)
process.exit(1)
})