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:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"date": "2025-10-22T13:11:03.062Z",
|
||||
"date": "2025-10-22T20:59:39.715Z",
|
||||
"preset": "node-server",
|
||||
"framework": {
|
||||
"name": "nuxt",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"id":"e0e6f75d-0441-44da-af83-0c8ad93a6ce1","timestamp":1761138655435}
|
||||
{"id":"45b20f41-8a8a-4096-94d4-3b6174a1f364","timestamp":1761166771198}
|
||||
@@ -87,6 +87,17 @@ const client_manifest = {
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
"_C_U-NUAd.js": {
|
||||
"resourceType": "script",
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "C_U-NUAd.js",
|
||||
"name": "composables",
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
"_CkzaQq3X.js": {
|
||||
"resourceType": "script",
|
||||
"module": true,
|
||||
@@ -216,17 +227,6 @@ const client_manifest = {
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
"_Qy3ajxTk.js": {
|
||||
"resourceType": "script",
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "Qy3ajxTk.js",
|
||||
"name": "composables",
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
"_R6Iy1jPP.js": {
|
||||
"resourceType": "script",
|
||||
"module": true,
|
||||
@@ -294,7 +294,7 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "D-Zvexq_.js",
|
||||
"file": "63DEGUI_.js",
|
||||
"name": "auth",
|
||||
"src": "middleware/auth.js",
|
||||
"isDynamicEntry": true,
|
||||
@@ -307,14 +307,14 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "ZrOCUSmD.js",
|
||||
"file": "C9SglkVL.js",
|
||||
"name": "error-404",
|
||||
"src": "node_modules/nuxt/dist/app/components/error-404.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_DlAUqK2U.js",
|
||||
"_Qy3ajxTk.js"
|
||||
"_C_U-NUAd.js"
|
||||
],
|
||||
"css": []
|
||||
},
|
||||
@@ -329,13 +329,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "BVRiFo7f.js",
|
||||
"file": "CW9krljs.js",
|
||||
"name": "error-500",
|
||||
"src": "node_modules/nuxt/dist/app/components/error-500.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_DlAUqK2U.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
],
|
||||
"css": []
|
||||
@@ -351,7 +351,7 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "CmbXHhwn.js",
|
||||
"file": "KrCelFbA.js",
|
||||
"name": "entry",
|
||||
"src": "node_modules/nuxt/dist/app/entry.js",
|
||||
"isEntry": true,
|
||||
@@ -361,14 +361,14 @@ const client_manifest = {
|
||||
"node_modules/nuxt/dist/app/components/error-500.vue"
|
||||
],
|
||||
"css": [
|
||||
"entry.DX4WLXSP.css"
|
||||
"entry.BpzTEo9t.css"
|
||||
],
|
||||
"assets": [
|
||||
"Harheimer TC.CKfYAfp1.svg"
|
||||
]
|
||||
},
|
||||
"entry.DX4WLXSP.css": {
|
||||
"file": "entry.DX4WLXSP.css",
|
||||
"entry.BpzTEo9t.css": {
|
||||
"file": "entry.BpzTEo9t.css",
|
||||
"resourceType": "style",
|
||||
"prefetch": true,
|
||||
"preload": true
|
||||
@@ -384,13 +384,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "D7LlSYAz.js",
|
||||
"file": "C3627_Er.js",
|
||||
"name": "anlagen",
|
||||
"src": "pages/anlagen.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js"
|
||||
"_C_U-NUAd.js"
|
||||
]
|
||||
},
|
||||
"pages/cms/benutzer.vue": {
|
||||
@@ -398,13 +398,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "CtoHBsGq.js",
|
||||
"file": "CzgMfPlN.js",
|
||||
"name": "benutzer",
|
||||
"src": "pages/cms/benutzer.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_C8kQt0fa.js",
|
||||
"_DaSgy0Cl.js"
|
||||
]
|
||||
@@ -414,13 +414,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "BlTx75vv.js",
|
||||
"file": "CIPPGKqt.js",
|
||||
"name": "einstellungen",
|
||||
"src": "pages/cms/einstellungen.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_CUq_0rkE.js",
|
||||
"_DUm-savV.js",
|
||||
"_YJHbYJtA.js",
|
||||
@@ -436,12 +436,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "G3v2TcOj.js",
|
||||
"file": "DxKvlgrz.js",
|
||||
"name": "geschichte",
|
||||
"src": "pages/cms/geschichte.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
@@ -450,31 +450,45 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "CcTzWjtb.js",
|
||||
"file": "CfIOs31W.js",
|
||||
"name": "index",
|
||||
"src": "pages/cms/index.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_DAACT36i.js",
|
||||
"_YJHbYJtA.js",
|
||||
"_DkeYb0_S.js",
|
||||
"_DUm-savV.js"
|
||||
]
|
||||
},
|
||||
"pages/cms/mitgliedschaftsantraege.vue": {
|
||||
"resourceType": "script",
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "DUL8f07u.js",
|
||||
"name": "mitgliedschaftsantraege",
|
||||
"src": "pages/cms/mitgliedschaftsantraege.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_C_U-NUAd.js"
|
||||
]
|
||||
},
|
||||
"pages/cms/satzung.vue": {
|
||||
"resourceType": "script",
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "CJOOzUp1.js",
|
||||
"file": "DzTYmaPw.js",
|
||||
"name": "satzung",
|
||||
"src": "pages/cms/satzung.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js"
|
||||
"_C_U-NUAd.js"
|
||||
]
|
||||
},
|
||||
"pages/cms/termine.vue": {
|
||||
@@ -482,13 +496,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "BP5itt78.js",
|
||||
"file": "DDYB2ueJ.js",
|
||||
"name": "termine",
|
||||
"src": "pages/cms/termine.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_R6Iy1jPP.js",
|
||||
"_CUq_0rkE.js",
|
||||
"_FF_cyd6S.js",
|
||||
@@ -500,12 +514,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "C8Sqpv2D.js",
|
||||
"file": "CPS6rtgg.js",
|
||||
"name": "tt-regeln",
|
||||
"src": "pages/cms/tt-regeln.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
@@ -514,12 +528,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "CfC7HTR7.js",
|
||||
"file": "lhUIkRXy.js",
|
||||
"name": "ueber-uns",
|
||||
"src": "pages/cms/ueber-uns.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
@@ -528,13 +542,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "Bu6whu8C.js",
|
||||
"file": "CJ4iaRop.js",
|
||||
"name": "galerie",
|
||||
"src": "pages/galerie.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js"
|
||||
"_C_U-NUAd.js"
|
||||
]
|
||||
},
|
||||
"pages/geschichte.vue": {
|
||||
@@ -542,12 +556,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "Bqdk1y3w.js",
|
||||
"file": "B23trXK4.js",
|
||||
"name": "geschichte",
|
||||
"src": "pages/geschichte.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
@@ -556,13 +570,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "DU1f7CIy.js",
|
||||
"file": "B2n3gjaF.js",
|
||||
"name": "impressum",
|
||||
"src": "pages/impressum.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_BteKZQ9T.js",
|
||||
"_Czdc6-TI.js"
|
||||
]
|
||||
@@ -598,7 +612,7 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "5IKOyEE8.js",
|
||||
"file": "B_w5gbrC.js",
|
||||
"name": "kontakt",
|
||||
"src": "pages/kontakt.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -608,7 +622,7 @@ const client_manifest = {
|
||||
"_DdHhmCne.js",
|
||||
"_BC4PNGtJ.js",
|
||||
"_C8kQt0fa.js",
|
||||
"_Qy3ajxTk.js"
|
||||
"_C_U-NUAd.js"
|
||||
]
|
||||
},
|
||||
"pages/login.vue": {
|
||||
@@ -616,13 +630,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "TnZylaYP.js",
|
||||
"file": "DCC-mN0A.js",
|
||||
"name": "login",
|
||||
"src": "pages/login.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_C8kQt0fa.js",
|
||||
"_DaSgy0Cl.js",
|
||||
"_CUq_0rkE.js"
|
||||
@@ -633,13 +647,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "DrS211Qg.js",
|
||||
"file": "BKO8ChwC.js",
|
||||
"name": "_slug_",
|
||||
"src": "pages/mannschaften/[slug].vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_jVj3QaoK.js"
|
||||
]
|
||||
},
|
||||
@@ -648,13 +662,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "48ve60fm.js",
|
||||
"file": "LPF2GIYR.js",
|
||||
"name": "damen",
|
||||
"src": "pages/mannschaften/damen.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js"
|
||||
"_C_U-NUAd.js"
|
||||
]
|
||||
},
|
||||
"pages/mannschaften/herren.vue": {
|
||||
@@ -662,12 +676,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "BEK-x74F.js",
|
||||
"file": "sVyj_WZX.js",
|
||||
"name": "herren",
|
||||
"src": "pages/mannschaften/herren.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
@@ -676,7 +690,7 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "CGF4oWdy.js",
|
||||
"file": "Bivc7aFF.js",
|
||||
"name": "index",
|
||||
"src": "pages/mannschaften/index.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -684,7 +698,7 @@ const client_manifest = {
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_jVj3QaoK.js",
|
||||
"_DkeYb0_S.js",
|
||||
"_Qy3ajxTk.js"
|
||||
"_C_U-NUAd.js"
|
||||
]
|
||||
},
|
||||
"pages/mannschaften/jugend.vue": {
|
||||
@@ -692,13 +706,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "BOTV4xuv.js",
|
||||
"file": "BZLaJF8o.js",
|
||||
"name": "jugend",
|
||||
"src": "pages/mannschaften/jugend.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js"
|
||||
"_C_U-NUAd.js"
|
||||
]
|
||||
},
|
||||
"pages/mannschaften/spielplaene.vue": {
|
||||
@@ -706,12 +720,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "DmeaandR.js",
|
||||
"file": "S5xR3JqC.js",
|
||||
"name": "spielplaene",
|
||||
"src": "pages/mannschaften/spielplaene.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_BteKZQ9T.js",
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Cx4UcKGu.js"
|
||||
@@ -722,13 +736,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "DBoACobG.js",
|
||||
"file": "rgKTeSYE.js",
|
||||
"name": "index",
|
||||
"src": "pages/mitgliederbereich/index.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_DkeYb0_S.js",
|
||||
"_DAACT36i.js",
|
||||
"_DaSgy0Cl.js"
|
||||
@@ -739,13 +753,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "CmqI4hAm.js",
|
||||
"file": "Cx_3U4cr.js",
|
||||
"name": "mitglieder",
|
||||
"src": "pages/mitgliederbereich/mitglieder.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_oN0_bS6A.js",
|
||||
"_CUq_0rkE.js",
|
||||
"_6EY4_GXp.js",
|
||||
@@ -762,13 +776,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "DNeqJiJt.js",
|
||||
"file": "pO5XAgdL.js",
|
||||
"name": "news",
|
||||
"src": "pages/mitgliederbereich/news.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_R6Iy1jPP.js",
|
||||
"_CUq_0rkE.js",
|
||||
"_KxVBmS-6.js",
|
||||
@@ -784,13 +798,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "C7eIgk1J.js",
|
||||
"file": "CDaMcsB4.js",
|
||||
"name": "profil",
|
||||
"src": "pages/mitgliederbereich/profil.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_CUq_0rkE.js",
|
||||
"_C8kQt0fa.js",
|
||||
"_DaSgy0Cl.js"
|
||||
@@ -801,7 +815,7 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "zfimBmFS.js",
|
||||
"file": "BlI1POL_.js",
|
||||
"name": "mitgliedschaft",
|
||||
"src": "pages/mitgliedschaft.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -813,7 +827,7 @@ const client_manifest = {
|
||||
"_DkeYb0_S.js",
|
||||
"_BteKZQ9T.js",
|
||||
"_Czdc6-TI.js",
|
||||
"_Qy3ajxTk.js"
|
||||
"_C_U-NUAd.js"
|
||||
]
|
||||
},
|
||||
"pages/passwort-vergessen.vue": {
|
||||
@@ -821,13 +835,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "D8fhH48e.js",
|
||||
"file": "DYva3pFh.js",
|
||||
"name": "passwort-vergessen",
|
||||
"src": "pages/passwort-vergessen.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_C8kQt0fa.js",
|
||||
"_DaSgy0Cl.js",
|
||||
"_CUq_0rkE.js"
|
||||
@@ -838,13 +852,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "DCJCzUpS.js",
|
||||
"file": "DeoKPvBx.js",
|
||||
"name": "registrieren",
|
||||
"src": "pages/registrieren.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_C8kQt0fa.js",
|
||||
"_DaSgy0Cl.js",
|
||||
"_CUq_0rkE.js"
|
||||
@@ -855,12 +869,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "B45eiNl0.js",
|
||||
"file": "CTuRK0lH.js",
|
||||
"name": "satzung",
|
||||
"src": "pages/satzung.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_BteKZQ9T.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
@@ -870,12 +884,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "98faI9Ca.js",
|
||||
"file": "DjW4xBSP.js",
|
||||
"name": "spielsysteme",
|
||||
"src": "pages/spielsysteme.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_DkeYb0_S.js",
|
||||
"_YJHbYJtA.js",
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
@@ -890,12 +904,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "CNecvSw-.js",
|
||||
"file": "BqZP274n.js",
|
||||
"name": "termine",
|
||||
"src": "pages/termine.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_YJHbYJtA.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
@@ -905,13 +919,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "BejXl4Ry.js",
|
||||
"file": "DXBW1M-0.js",
|
||||
"name": "anfaenger",
|
||||
"src": "pages/training/anfaenger.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_DaSgy0Cl.js"
|
||||
]
|
||||
},
|
||||
@@ -920,13 +934,13 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "BKvBGWYj.js",
|
||||
"file": "Q0RpvB7T.js",
|
||||
"name": "index",
|
||||
"src": "pages/training/index.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_C5SyyWEb.js"
|
||||
]
|
||||
},
|
||||
@@ -935,12 +949,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "B7Xj1aAc.js",
|
||||
"file": "BkzaDkuN.js",
|
||||
"name": "trainer",
|
||||
"src": "pages/training/trainer.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
@@ -949,12 +963,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "DInduCQ0.js",
|
||||
"file": "DjcJk1g8.js",
|
||||
"name": "tt-regeln",
|
||||
"src": "pages/tt-regeln.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_KxVBmS-6.js",
|
||||
"_BteKZQ9T.js",
|
||||
"_B4mSF5Ac.js",
|
||||
@@ -969,7 +983,7 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "BLO7WCWA.js",
|
||||
"file": "B0zS1aUD.js",
|
||||
"name": "ueber-uns",
|
||||
"src": "pages/ueber-uns.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -977,7 +991,7 @@ const client_manifest = {
|
||||
"node_modules/nuxt/dist/app/entry.js",
|
||||
"_CWEkTB1z.js",
|
||||
"_B4mSF5Ac.js",
|
||||
"_Qy3ajxTk.js"
|
||||
"_C_U-NUAd.js"
|
||||
]
|
||||
},
|
||||
"pages/verein/geschichte.vue": {
|
||||
@@ -985,12 +999,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "B1qBTDuC.js",
|
||||
"file": "KBGCy6kF.js",
|
||||
"name": "geschichte",
|
||||
"src": "pages/verein/geschichte.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
@@ -999,12 +1013,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "BZfUaD4r.js",
|
||||
"file": "BKEdaV_4.js",
|
||||
"name": "satzung",
|
||||
"src": "pages/verein/satzung.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
@@ -1013,12 +1027,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "D565ijOU.js",
|
||||
"file": "Bh7iK8Ct.js",
|
||||
"name": "tt-regeln",
|
||||
"src": "pages/verein/tt-regeln.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
@@ -1027,12 +1041,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "NvbKvUY5.js",
|
||||
"file": "3DraxWaO.js",
|
||||
"name": "ueber-uns",
|
||||
"src": "pages/verein/ueber-uns.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
},
|
||||
@@ -1041,12 +1055,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "BwIK_w7L.js",
|
||||
"file": "DF1dYnic.js",
|
||||
"name": "vereinsmeisterschaften",
|
||||
"src": "pages/vereinsmeisterschaften.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"_CrCcIvVp.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
@@ -1056,12 +1070,12 @@ const client_manifest = {
|
||||
"module": true,
|
||||
"prefetch": true,
|
||||
"preload": true,
|
||||
"file": "B78Xcyyd.js",
|
||||
"file": "BajRASxo.js",
|
||||
"name": "vorstand",
|
||||
"src": "pages/vorstand.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_Qy3ajxTk.js",
|
||||
"_C_U-NUAd.js",
|
||||
"node_modules/nuxt/dist/app/entry.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import process from 'node:process';globalThis._importMeta_=globalThis._importMeta_||{url:"file:///_entry.js",env:process.env};import { defineComponent, shallowRef, h, resolveComponent, hasInjectionContext, inject, computed, getCurrentInstance, createElementBlock, provide, cloneVNode, ref, Suspense, Fragment, createApp, shallowReactive, mergeProps, withCtx, createVNode, createTextVNode, unref, toDisplayString, toRef, onErrorCaptured, onServerPrefetch, resolveDynamicComponent, reactive, effectScope, isReadonly, isRef, isShallow, isReactive, toRaw, defineAsyncComponent, getCurrentScope, useSSRContext } from 'vue';
|
||||
import { p as parseQuery, c as createError$1, o as hasProtocol, q as isScriptProtocol, m as joinURL, w as withQuery, t as sanitizeStatusCode, v as withTrailingSlash, x as withoutTrailingSlash, y as getContext, $ as $fetch$1, z as createHooks, A as executeAsync, B as toRouteMatcher, C as createRouter$1, D as defu } from '../nitro/nitro.mjs';
|
||||
import { q as parseQuery, c as createError$1, t as hasProtocol, v as isScriptProtocol, o as joinURL, w as withQuery, x as sanitizeStatusCode, y as withTrailingSlash, z as withoutTrailingSlash, A as getContext, $ as $fetch$1, B as createHooks, C as executeAsync, D as toRouteMatcher, E as createRouter$1, F as defu } from '../nitro/nitro.mjs';
|
||||
import { b as baseURL } from '../routes/renderer.mjs';
|
||||
import { defineStore, createPinia, setActivePinia, shouldHydrate } from 'pinia';
|
||||
import { RouterView, useRoute as useRoute$1, useRouter as useRouter$1, createMemoryHistory, createRouter, START_LOCATION } from 'vue-router';
|
||||
@@ -538,7 +538,7 @@ const _routes = [
|
||||
{
|
||||
name: "mitgliedschaft",
|
||||
path: "/mitgliedschaft",
|
||||
component: () => import('./mitgliedschaft-C0k1hAkJ.mjs')
|
||||
component: () => import('./mitgliedschaft-ChvgsgBw.mjs')
|
||||
},
|
||||
{
|
||||
name: "training",
|
||||
@@ -639,6 +639,11 @@ const _routes = [
|
||||
meta: { ...__nuxt_page_meta$1 || {}, ...{ "middleware": "auth" } },
|
||||
component: () => import('./profil-Dm_3uuTL.mjs')
|
||||
},
|
||||
{
|
||||
name: "cms-mitgliedschaftsantraege",
|
||||
path: "/cms/mitgliedschaftsantraege",
|
||||
component: () => import('./mitgliedschaftsantraege-UTBvvWQL.mjs')
|
||||
},
|
||||
{
|
||||
name: "mitgliederbereich-mitglieder",
|
||||
path: "/mitgliederbereich/mitglieder",
|
||||
@@ -2064,6 +2069,22 @@ const _sfc_main$4 = {
|
||||
}),
|
||||
_: 1
|
||||
}, _parent));
|
||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||
to: "/cms/mitgliedschaftsantraege",
|
||||
onClick: ($event) => showCmsDropdown.value = false,
|
||||
class: "block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||
}, {
|
||||
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
||||
if (_push2) {
|
||||
_push2(` Mitgliedschaftsanträge `);
|
||||
} else {
|
||||
return [
|
||||
createTextVNode(" Mitgliedschaftsanträge ")
|
||||
];
|
||||
}
|
||||
}),
|
||||
_: 1
|
||||
}, _parent));
|
||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||
to: "/cms/benutzer",
|
||||
onClick: ($event) => showCmsDropdown.value = false,
|
||||
@@ -2647,6 +2668,22 @@ const _sfc_main$4 = {
|
||||
}),
|
||||
_: 1
|
||||
}, _parent));
|
||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||
to: "/cms/mitgliedschaftsantraege",
|
||||
onClick: ($event) => isMobileMenuOpen.value = false,
|
||||
class: "block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
}, {
|
||||
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
||||
if (_push2) {
|
||||
_push2(` Mitgliedschaftsanträge `);
|
||||
} else {
|
||||
return [
|
||||
createTextVNode(" Mitgliedschaftsanträge ")
|
||||
];
|
||||
}
|
||||
}),
|
||||
_: 1
|
||||
}, _parent));
|
||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||
to: "/cms/benutzer",
|
||||
onClick: ($event) => isMobileMenuOpen.value = false,
|
||||
@@ -2718,7 +2755,7 @@ const _sfc_main$3 = {
|
||||
computed(() => authStore.isAdmin);
|
||||
return (_ctx, _push, _parent, _attrs) => {
|
||||
const _component_NuxtLink = __nuxt_component_0$1;
|
||||
_push(`<footer${ssrRenderAttrs(mergeProps({ class: "fixed bottom-0 left-0 right-0 z-40 bg-gray-900 border-t border-gray-800 shadow-2xl" }, _attrs))}><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3"><div class="flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0"><p class="text-sm text-gray-400"> © ${ssrInterpolate(unref(currentYear))} Harheimer TC </p><div class="flex items-center space-x-6 text-sm relative">`);
|
||||
_push(`<footer${ssrRenderAttrs(mergeProps({ class: "fixed bottom-0 left-0 right-0 z-40 bg-gray-900 border-t border-gray-800 shadow-2xl" }, _attrs))}><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3"><div class="flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0"><p class="text-sm text-gray-400"> © ${ssrInterpolate(unref(currentYear))} Harheimer TC 1954 e.V. </p><div class="flex items-center space-x-6 text-sm relative">`);
|
||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||
to: "/impressum",
|
||||
class: "text-gray-400 hover:text-primary-400 transition-colors"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { d as defineEventHandler, r as readBody, c as createError } from '../../../nitro/nitro.mjs';
|
||||
import { r as readUsers, h as hashPassword, w as writeUsers } from '../../../_/auth.mjs';
|
||||
import nodemailer from 'nodemailer';
|
||||
import require$$1 from 'crypto';
|
||||
import crypto from 'crypto';
|
||||
import 'node:http';
|
||||
import 'node:https';
|
||||
import 'node:events';
|
||||
@@ -33,7 +33,7 @@ const resetPassword_post = defineEventHandler(async (event) => {
|
||||
message: "Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet."
|
||||
};
|
||||
}
|
||||
const tempPassword = require$$1.randomBytes(8).toString("hex");
|
||||
const tempPassword = crypto.randomBytes(8).toString("hex");
|
||||
const hashedPassword = await hashPassword(tempPassword);
|
||||
user.password = hashedPassword;
|
||||
user.passwordResetRequired = true;
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"reset-password.post.mjs","sources":["../../../../../../server/api/auth/reset-password.post.js"],"sourcesContent":null,"names":["crypto"],"mappings":";;;;;;;;;;;;;;;;;AAIA,2BAAA,kBAAA,CAAA,OAAA,KAAA,KAAA;AACA,EAAA,IAAA;AACA,IAAA,MAAA,IAAA,GAAA,MAAA,QAAA,CAAA,KAAA,CAAA;AACA,IAAA,MAAA,EAAA,OAAA,GAAA,IAAA;AAEA,IAAA,IAAA,CAAA,KAAA,EAAA;AACA,MAAA,MAAA,WAAA,CAAA;AAAA,QACA,UAAA,EAAA,GAAA;AAAA,QACA,OAAA,EAAA;AAAA,OACA,CAAA;AAAA,IACA;AAGA,IAAA,MAAA,KAAA,GAAA,MAAA,SAAA,EAAA;AACA,IAAA,MAAA,IAAA,GAAA,KAAA,CAAA,IAAA,CAAA,CAAA,CAAA,KAAA,CAAA,CAAA,MAAA,WAAA,EAAA,KAAA,KAAA,CAAA,WAAA,EAAA,CAAA;AAGA,IAAA,IAAA,CAAA,IAAA,EAAA;AACA,MAAA,OAAA;AAAA,QACA,OAAA,EAAA,IAAA;AAAA,QACA,OAAA,EAAA;AAAA,OACA;AAAA,IACA;AAGA,IAAA,MAAA,eAAAA,UAAA,CAAA,WAAA,CAAA,CAAA,CAAA,CAAA,SAAA,KAAA,CAAA;AACA,IAAA,MAAA,cAAA,GAAA,MAAA,YAAA,CAAA,YAAA,CAAA;AAGA,IAAA,IAAA,CAAA,QAAA,GAAA,cAAA;AACA,IAAA,IAAA,CAAA,qBAAA,GAAA,IAAA;AACA,IAAA,MAAA,YAAA,GAAA,MAAA,GAAA,CAAA,CAAA,CAAA,KAAA,EAAA,EAAA,KAAA,IAAA,CAAA,EAAA,GAAA,IAAA,GAAA,CAAA,CAAA;AACA,IAAA,MAAA,WAAA,YAAA,CAAA;AAGA,IAAA,MAAA,WAAA,GAAA,WAAA,eAAA,CAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,gBAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,GAAA;AAAA,MACA,MAAA,EAAA,KAAA;AAAA,MACA,IAAA,EAAA;AAAA,QACA,IAAA,EAAA,QAAA,GAAA,CAAA,SAAA;AAAA,QACA,IAAA,EAAA,QAAA,GAAA,CAAA;AAAA;AACA,KACA,CAAA;AAEA,IAAA,MAAA,WAAA,GAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,wBAAA;AAAA,MACA,IAAA,IAAA,CAAA,KAAA;AAAA,MACA,OAAA,EAAA,yCAAA;AAAA,MACA,IAAA,EAAA;AAAA;AAAA,iBAAA,EAEA,KAAA,IAAA,CAAA;AAAA;AAAA,sDAAA,EAEA,YAAA,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAAA;AAAA,KAOA;AAEA,IAAA,MAAA,WAAA,CAAA,SAAA,WAAA,CAAA;AAEA,IAAA,OAAA;AAAA,MACA,OAAA,EAAA,IAAA;AAAA,MACA,OAAA,EAAA;AAAA,KACA;AAAA,EACA,SAAA,KAAA,EAAA;AACA,IAAA,OAAA,CAAA,KAAA,CAAA,0BAAA,KAAA,CAAA;AAEA,IAAA,OAAA;AAAA,MACA,OAAA,EAAA,IAAA;AAAA,MACA,OAAA,EAAA;AAAA,KACA;AAAA,EACA;AACA,CAAA,CAAA;;;;"}
|
||||
{"version":3,"file":"reset-password.post.mjs","sources":["../../../../../../server/api/auth/reset-password.post.js"],"sourcesContent":null,"names":[],"mappings":";;;;;;;;;;;;;;;;;AAIA,2BAAA,kBAAA,CAAA,OAAA,KAAA,KAAA;AACA,EAAA,IAAA;AACA,IAAA,MAAA,IAAA,GAAA,MAAA,QAAA,CAAA,KAAA,CAAA;AACA,IAAA,MAAA,EAAA,OAAA,GAAA,IAAA;AAEA,IAAA,IAAA,CAAA,KAAA,EAAA;AACA,MAAA,MAAA,WAAA,CAAA;AAAA,QACA,UAAA,EAAA,GAAA;AAAA,QACA,OAAA,EAAA;AAAA,OACA,CAAA;AAAA,IACA;AAGA,IAAA,MAAA,KAAA,GAAA,MAAA,SAAA,EAAA;AACA,IAAA,MAAA,IAAA,GAAA,KAAA,CAAA,IAAA,CAAA,CAAA,CAAA,KAAA,CAAA,CAAA,MAAA,WAAA,EAAA,KAAA,KAAA,CAAA,WAAA,EAAA,CAAA;AAGA,IAAA,IAAA,CAAA,IAAA,EAAA;AACA,MAAA,OAAA;AAAA,QACA,OAAA,EAAA,IAAA;AAAA,QACA,OAAA,EAAA;AAAA,OACA;AAAA,IACA;AAGA,IAAA,MAAA,eAAA,MAAA,CAAA,WAAA,CAAA,CAAA,CAAA,CAAA,SAAA,KAAA,CAAA;AACA,IAAA,MAAA,cAAA,GAAA,MAAA,YAAA,CAAA,YAAA,CAAA;AAGA,IAAA,IAAA,CAAA,QAAA,GAAA,cAAA;AACA,IAAA,IAAA,CAAA,qBAAA,GAAA,IAAA;AACA,IAAA,MAAA,YAAA,GAAA,MAAA,GAAA,CAAA,CAAA,CAAA,KAAA,EAAA,EAAA,KAAA,IAAA,CAAA,EAAA,GAAA,IAAA,GAAA,CAAA,CAAA;AACA,IAAA,MAAA,WAAA,YAAA,CAAA;AAGA,IAAA,MAAA,WAAA,GAAA,WAAA,eAAA,CAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,gBAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,GAAA;AAAA,MACA,MAAA,EAAA,KAAA;AAAA,MACA,IAAA,EAAA;AAAA,QACA,IAAA,EAAA,QAAA,GAAA,CAAA,SAAA;AAAA,QACA,IAAA,EAAA,QAAA,GAAA,CAAA;AAAA;AACA,KACA,CAAA;AAEA,IAAA,MAAA,WAAA,GAAA;AAAA,MACA,IAAA,EAAA,OAAA,CAAA,GAAA,CAAA,SAAA,IAAA,wBAAA;AAAA,MACA,IAAA,IAAA,CAAA,KAAA;AAAA,MACA,OAAA,EAAA,yCAAA;AAAA,MACA,IAAA,EAAA;AAAA;AAAA,iBAAA,EAEA,KAAA,IAAA,CAAA;AAAA;AAAA,sDAAA,EAEA,YAAA,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAAA;AAAA,KAOA;AAEA,IAAA,MAAA,WAAA,CAAA,SAAA,WAAA,CAAA;AAEA,IAAA,OAAA;AAAA,MACA,OAAA,EAAA,IAAA;AAAA,MACA,OAAA,EAAA;AAAA,KACA;AAAA,EACA,SAAA,KAAA,EAAA;AACA,IAAA,OAAA,CAAA,KAAA,CAAA,0BAAA,KAAA,CAAA;AAEA,IAAA,OAAA;AAAA,MACA,OAAA,EAAA,IAAA;AAAA,MACA,OAAA,EAAA;AAAA,KACA;AAAA,EACA;AACA,CAAA,CAAA;;;;"}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { d as defineEventHandler, g as getCookie, c as createError, b as getQuery } from '../../nitro/nitro.mjs';
|
||||
import { d as defineEventHandler, g as getCookie, c as createError, f as getQuery } from '../../nitro/nitro.mjs';
|
||||
import { b as verifyToken, e as getUserById } from '../../_/auth.mjs';
|
||||
import { d as deleteNews } from '../../_/news.mjs';
|
||||
import 'node:http';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { d as defineEventHandler, g as getCookie, c as createError, b as getQuery } from '../../nitro/nitro.mjs';
|
||||
import { d as defineEventHandler, g as getCookie, c as createError, f as getQuery } from '../../nitro/nitro.mjs';
|
||||
import { b as verifyToken, e as getUserById } from '../../_/auth.mjs';
|
||||
import { d as deleteTermin } from '../../_/termine.mjs';
|
||||
import 'node:http';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRenderer, getRequestDependencies, getPreloadLinks, getPrefetchLinks } from 'vue-bundle-renderer/runtime';
|
||||
import { j as joinRelativeURL, u as useRuntimeConfig, e as getResponseStatusText, f as getResponseStatus, h as defineRenderHandler, b as getQuery, c as createError, i as destr, k as getRouteRules, l as relative, m as joinURL, n as useNitroApp } from '../nitro/nitro.mjs';
|
||||
import { j as joinRelativeURL, u as useRuntimeConfig, h as getResponseStatusText, i as getResponseStatus, k as defineRenderHandler, f as getQuery, c as createError, l as destr, m as getRouteRules, n as relative, o as joinURL, p as useNitroApp } from '../nitro/nitro.mjs';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
import { createHead as createHead$1, propsToString, renderSSRHead } from 'unhead/server';
|
||||
import { stringify, uneval } from 'devalue';
|
||||
@@ -272,7 +272,7 @@ async function renderInlineStyles(usedModules) {
|
||||
|
||||
const renderSSRHeadOptions = {"omitLineBreaks":true};
|
||||
|
||||
const entryFileName = "CmbXHhwn.js";
|
||||
const entryFileName = "KrCelFbA.js";
|
||||
|
||||
globalThis.__buildAssetsURL = buildAssetsURL;
|
||||
globalThis.__publicAssetsURL = publicAssetsURL;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import process from 'node:process';globalThis._importMeta_={url:import.meta.url,env:process.env};import 'node:http';
|
||||
import 'node:https';
|
||||
export { F as default } from './chunks/nitro/nitro.mjs';
|
||||
export { H as default } from './chunks/nitro/nitro.mjs';
|
||||
import 'node:events';
|
||||
import 'node:buffer';
|
||||
import 'node:fs';
|
||||
|
||||
144
DATENSCHUTZ.md
Normal file
144
DATENSCHUTZ.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Datenschutz und Verschlüsselung - Harheimer TC
|
||||
|
||||
## 🔒 **Verschlüsselung implementiert**
|
||||
|
||||
### **Verschlüsselte Daten:**
|
||||
- **Mitgliedschaftsanträge:** Alle persönlichen Daten (Name, Adresse, E-Mail, Telefon, Bankdaten)
|
||||
- **Benutzerdaten:** E-Mail, Name, Telefon (in `users.json`)
|
||||
- **Mitgliederdaten:** Name, E-Mail, Telefon, Adresse (in `members.json`)
|
||||
|
||||
### **Nicht verschlüsselte Daten:**
|
||||
- **News-Artikel:** Autor-Namen (öffentlich sichtbar)
|
||||
- **Konfiguration:** Vereinsdaten, Trainingszeiten (öffentlich)
|
||||
- **Termine:** Event-Daten (öffentlich)
|
||||
|
||||
## 🛡️ **Sicherheitsmaßnahmen**
|
||||
|
||||
### **Verschlüsselung:**
|
||||
- **Algorithmus:** AES-256-GCM
|
||||
- **Schlüsselableitung:** PBKDF2 mit 100.000 Iterationen
|
||||
- **Salt:** 64 Bytes zufällig generiert
|
||||
- **IV:** 16 Bytes zufällig generiert
|
||||
- **Auth-Tag:** 16 Bytes für Integrität
|
||||
|
||||
### **Passwort-Sicherheit:**
|
||||
- **Hashing:** bcryptjs mit Salt
|
||||
- **Rounds:** 10 (konfigurierbar)
|
||||
|
||||
### **Datenzugriff:**
|
||||
- **Authentifizierung:** JWT-Token
|
||||
- **Autorisierung:** Rollenbasierte Zugriffskontrolle
|
||||
- **Session-Management:** Sichere Cookies
|
||||
|
||||
## 📋 **Datenverarbeitung**
|
||||
|
||||
### **Mitgliedschaftsanträge:**
|
||||
1. **Eingabe:** Formular auf Website
|
||||
2. **Verschlüsselung:** Sofortige Verschlüsselung aller persönlichen Daten
|
||||
3. **Speicherung:** Verschlüsselte Dateien in `server/data/membership-applications/`
|
||||
4. **E-Mail:** Benachrichtigung an Vorstand/Trainer
|
||||
5. **Genehmigung:** Entschlüsselung und Übertragung in Mitgliederliste
|
||||
6. **Archivierung:** Original-Anträge bleiben verschlüsselt gespeichert
|
||||
|
||||
### **Benutzerdaten:**
|
||||
- **Registrierung:** E-Mail, Name, Telefon
|
||||
- **Login:** E-Mail, Passwort (gehashed)
|
||||
- **Profil:** Bearbeitbare Kontaktdaten
|
||||
- **Rollen:** admin, vorstand, mitglied
|
||||
|
||||
### **Mitgliederdaten:**
|
||||
- **Manuell hinzugefügt:** Direkt in `members.json`
|
||||
- **Aus Anträgen:** Nach Genehmigung übertragen
|
||||
- **Zugriff:** Nur für "vorstand" Rolle sichtbar
|
||||
|
||||
## 🔄 **Datenfluss**
|
||||
|
||||
```
|
||||
Mitgliedschaftsformular
|
||||
↓
|
||||
Verschlüsselung (AES-256-GCM)
|
||||
↓
|
||||
Speicherung (verschlüsselt)
|
||||
↓
|
||||
E-Mail-Benachrichtigung
|
||||
↓
|
||||
CMS-Verwaltung
|
||||
↓
|
||||
Genehmigung/Ablehnung
|
||||
↓
|
||||
Bei Genehmigung: Übertragung in Mitgliederliste
|
||||
```
|
||||
|
||||
## ⚖️ **GDPR-Konformität**
|
||||
|
||||
### **Rechtmäßigkeit:**
|
||||
- **Einwilligung:** Explizite Zustimmung im Formular
|
||||
- **Vertragserfüllung:** Mitgliedschaftsvertrag
|
||||
- **Berechtigtes Interesse:** Vereinsverwaltung
|
||||
|
||||
### **Betroffenenrechte:**
|
||||
- **Auskunft:** Über gespeicherte Daten
|
||||
- **Berichtigung:** Korrektur falscher Daten
|
||||
- **Löschung:** Recht auf Vergessenwerden
|
||||
- **Einschränkung:** Verarbeitung einschränken
|
||||
- **Datenübertragbarkeit:** Export der Daten
|
||||
|
||||
### **Datenspeicherung:**
|
||||
- **Zweckbindung:** Nur für Vereinszwecke
|
||||
- **Minimierung:** Nur notwendige Daten
|
||||
- **Genauigkeit:** Aktuelle und korrekte Daten
|
||||
- **Speicherbegrenzung:** Löschung nach Zweckerfüllung
|
||||
|
||||
## 🔧 **Technische Implementierung**
|
||||
|
||||
### **Verschlüsselungsfunktionen:**
|
||||
```javascript
|
||||
// Verschlüsselung
|
||||
encryptObject(data, password)
|
||||
|
||||
// Entschlüsselung
|
||||
decryptObject(encryptedData, password)
|
||||
|
||||
// Passwort-Hashing
|
||||
hashPassword(password, salt)
|
||||
|
||||
// Passwort-Verifikation
|
||||
verifyPassword(password, hash, salt)
|
||||
```
|
||||
|
||||
### **Environment-Variablen:**
|
||||
```bash
|
||||
# Verschlüsselungsschlüssel (MUSS in Produktion geändert werden!)
|
||||
ENCRYPTION_KEY=your_secure_encryption_key_here
|
||||
|
||||
# JWT-Secret
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
```
|
||||
|
||||
## 🚨 **Sicherheitshinweise**
|
||||
|
||||
### **Produktionsumgebung:**
|
||||
- **Verschlüsselungsschlüssel:** MUSS geändert werden!
|
||||
- **JWT-Secret:** MUSS geändert werden!
|
||||
- **HTTPS:** Zwingend erforderlich
|
||||
- **Backup:** Verschlüsselte Backups
|
||||
|
||||
### **Entwicklungsumgebung:**
|
||||
- **E-Mails:** Nur an `tsschulz@tsschulz.de`
|
||||
- **Test-Daten:** Keine echten persönlichen Daten
|
||||
- **Logs:** Keine sensiblen Daten in Logs
|
||||
|
||||
## 📞 **Kontakt**
|
||||
|
||||
Bei Fragen zum Datenschutz:
|
||||
- **E-Mail:** tsschulz@tsschulz.de
|
||||
- **Verantwortlicher:** Torsten Schulz
|
||||
|
||||
---
|
||||
|
||||
**Stand:** Januar 2025
|
||||
**Version:** 1.0
|
||||
**Letzte Aktualisierung:** Implementierung der Verschlüsselung
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0">
|
||||
<p class="text-sm text-gray-400">
|
||||
© {{ currentYear }} Harheimer TC
|
||||
© {{ currentYear }} Harheimer TC 1954 e.V.
|
||||
</p>
|
||||
<div class="flex items-center space-x-6 text-sm relative">
|
||||
<NuxtLink to="/impressum" class="text-gray-400 hover:text-primary-400 transition-colors">
|
||||
|
||||
149
components/MembershipNoQuestions.vue
Normal file
149
components/MembershipNoQuestions.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<section id="membership" class="py-16 sm:py-20 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
|
||||
Mitgliedschaft
|
||||
</h2>
|
||||
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
|
||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Werden Sie Teil unserer Tischtennis-Familie - Wählen Sie die passende Mitgliedschaft für sich
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.name"
|
||||
:class="[
|
||||
'relative bg-white rounded-2xl shadow-xl overflow-hidden',
|
||||
plan.popular ? 'ring-4 ring-primary-500 scale-105' : ''
|
||||
]"
|
||||
>
|
||||
<div v-if="plan.popular" class="absolute top-0 right-0 bg-primary-600 text-white px-4 py-1 text-sm font-semibold rounded-bl-lg">
|
||||
Beliebt
|
||||
</div>
|
||||
|
||||
<div :class="['h-2 bg-gradient-to-r', plan.gradient]" />
|
||||
|
||||
<div class="p-8">
|
||||
<div :class="['w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center mb-4', plan.gradient]">
|
||||
<component :is="plan.icon" :size="24" class="text-white" />
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-display font-bold text-gray-900 mb-2">
|
||||
{{ plan.name }}
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-6 min-h-[3rem]">
|
||||
{{ plan.description }}
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="flex items-baseline">
|
||||
<span class="text-5xl font-bold text-gray-900">{{ plan.price }}€</span>
|
||||
<span class="text-gray-600 ml-2">/ {{ plan.period }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 mb-8">
|
||||
<li v-for="feature in plan.features" :key="feature" class="flex items-start">
|
||||
<Check :size="20" class="text-primary-600 mr-3 flex-shrink-0 mt-0.5" />
|
||||
<span class="text-gray-700">{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<NuxtLink
|
||||
to="/kontakt"
|
||||
:class="[
|
||||
'block w-full text-center px-6 py-3 rounded-lg font-semibold transition-all duration-300',
|
||||
plan.popular
|
||||
? 'bg-primary-600 hover:bg-primary-700 text-white shadow-lg hover:shadow-xl'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-900'
|
||||
]"
|
||||
>
|
||||
Jetzt beitreten
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Satzung Download -->
|
||||
<div class="mt-16 bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||
<div class="text-center mb-8">
|
||||
<h3 class="text-3xl font-display font-bold text-gray-900 mb-4">
|
||||
Vereinsatzung
|
||||
</h3>
|
||||
<p class="text-xl text-gray-600">
|
||||
Laden Sie unsere aktuelle Vereinsatzung herunter
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<a
|
||||
href="/documents/satzung.pdf"
|
||||
target="_blank"
|
||||
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
<FileText :size="20" class="mr-2" />
|
||||
Satzung herunterladen (PDF)
|
||||
</a>
|
||||
<span class="text-sm text-gray-500">oder</span>
|
||||
<NuxtLink
|
||||
to="/satzung"
|
||||
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
<Eye :size="20" class="mr-2" />
|
||||
Online ansehen
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Check, Star, Heart, FileText, Eye, User, Users } from 'lucide-vue-next'
|
||||
|
||||
const config = ref(null)
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/config')
|
||||
config.value = response
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
|
||||
const plans = computed(() => {
|
||||
if (!config.value?.mitgliedschaft) return []
|
||||
|
||||
const icons = [Star, Check, Heart, User, Users]
|
||||
const gradients = [
|
||||
'from-blue-500 to-cyan-500',
|
||||
'from-primary-500 to-green-600',
|
||||
'from-orange-500 to-red-500',
|
||||
'from-purple-500 to-pink-500',
|
||||
'from-indigo-500 to-blue-500'
|
||||
]
|
||||
|
||||
return config.value.mitgliedschaft.map((m, index) => ({
|
||||
name: m.typ,
|
||||
description: m.beschreibung || '',
|
||||
price: m.preis.toString(),
|
||||
period: 'Jahr',
|
||||
features: m.features || [],
|
||||
icon: icons[index % icons.length],
|
||||
gradient: gradients[index % gradients.length],
|
||||
popular: index === 0
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -252,6 +252,11 @@
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors">
|
||||
Einstellungen
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/cms/mitgliedschaftsantraege"
|
||||
@click="showCmsDropdown = false"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors">
|
||||
Mitgliedschaftsanträge
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/cms/benutzer"
|
||||
@click="showCmsDropdown = false"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors">
|
||||
@@ -464,6 +469,10 @@
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
||||
Einstellungen
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/cms/mitgliedschaftsantraege" @click="isMobileMenuOpen = false"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
||||
Mitgliedschaftsanträge
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/cms/benutzer" @click="isMobileMenuOpen = false"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
|
||||
Benutzerverwaltung
|
||||
|
||||
30
env.example
30
env.example
@@ -1,16 +1,32 @@
|
||||
# SMTP-Konfiguration für E-Mail-Versand
|
||||
# Environment-Konfiguration für lokale Entwicklung
|
||||
# Diese Datei sollte in .env umbenannt werden und nicht in Git committet werden
|
||||
|
||||
# SMTP-Server (z.B. Gmail, GMX, etc.)
|
||||
# Node.js Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Server-Konfiguration
|
||||
PORT=3100
|
||||
NUXT_PUBLIC_BASE_URL=http://localhost:3100
|
||||
|
||||
# SMTP-Konfiguration für E-Mail-Versand (nur für lokale Tests)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=j.dichmann@gmx.de
|
||||
SMTP_PASS=your_email_password_here
|
||||
SMTP_USER=tsschulz@tsschulz.de
|
||||
SMTP_PASS=your_app_password_here
|
||||
|
||||
# Alternative für GMX:
|
||||
# SMTP_HOST=mail.gmx.net
|
||||
# SMTP_PORT=587
|
||||
|
||||
# Alternative für andere Provider:
|
||||
# SMTP_HOST=smtp.your-provider.com
|
||||
# SMTP_PORT=587
|
||||
# JWT Secret für lokale Entwicklung
|
||||
JWT_SECRET=local_development_secret_key_change_in_production
|
||||
|
||||
# Verschlüsselungsschlüssel für persönliche Daten (MUSS in Produktion geändert werden!)
|
||||
ENCRYPTION_KEY=local_development_encryption_key_change_in_production
|
||||
|
||||
# Datenbank/Datei-Pfade
|
||||
DATA_PATH=server/data
|
||||
|
||||
# Debug-Modi
|
||||
DEBUG=true
|
||||
VERBOSE_LOGGING=true
|
||||
|
||||
@@ -6,7 +6,29 @@ export default defineNuxtConfig({
|
||||
|
||||
nitro: {
|
||||
preset: 'node-server',
|
||||
dev: false
|
||||
dev: process.env.NODE_ENV !== 'production'
|
||||
},
|
||||
|
||||
// Erzwinge Dev-Port und Host zuverlässig für `npm run dev`
|
||||
devServer: {
|
||||
port: 3100,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
// Private keys (only available on server-side)
|
||||
jwtSecret: process.env.JWT_SECRET || 'local_development_secret_key_change_in_production',
|
||||
encryptionKey: process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production',
|
||||
smtpHost: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
smtpPort: process.env.SMTP_PORT || 587,
|
||||
smtpUser: process.env.SMTP_USER || 'tsschulz@tsschulz.de',
|
||||
smtpPass: process.env.SMTP_PASS || '',
|
||||
|
||||
// Public keys (exposed to client-side)
|
||||
public: {
|
||||
baseUrl: process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100',
|
||||
nodeEnv: process.env.NODE_ENV || 'development'
|
||||
}
|
||||
},
|
||||
|
||||
app: {
|
||||
@@ -37,6 +59,54 @@ export default defineNuxtConfig({
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
compatibilityDate: '2024-04-03'
|
||||
compatibilityDate: '2024-04-03',
|
||||
|
||||
hooks: {
|
||||
'build:before': async () => {
|
||||
const fs = await import('fs/promises')
|
||||
const path = await import('path')
|
||||
|
||||
// Erstelle uploads Verzeichnis im public Ordner
|
||||
const uploadsDir = path.join(process.cwd(), '.output', 'public', 'uploads')
|
||||
try {
|
||||
await fs.mkdir(uploadsDir, { recursive: true })
|
||||
console.log('✅ Uploads-Verzeichnis erstellt:', uploadsDir)
|
||||
} catch (error) {
|
||||
console.log('ℹ️ Uploads-Verzeichnis bereits vorhanden oder Fehler:', error.message)
|
||||
}
|
||||
|
||||
// Erstelle temp Verzeichnis für LaTeX-Kompilierung
|
||||
const tempDir = path.join(process.cwd(), '.output', 'temp')
|
||||
try {
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
console.log('✅ Temp-Verzeichnis erstellt:', tempDir)
|
||||
} catch (error) {
|
||||
console.log('ℹ️ Temp-Verzeichnis bereits vorhanden oder Fehler:', error.message)
|
||||
}
|
||||
},
|
||||
|
||||
'build:after': async () => {
|
||||
const fs = await import('fs/promises')
|
||||
const path = await import('path')
|
||||
|
||||
// Erstelle uploads Verzeichnis im public Ordner (nach dem Build)
|
||||
const uploadsDir = path.join(process.cwd(), '.output', 'public', 'uploads')
|
||||
try {
|
||||
await fs.mkdir(uploadsDir, { recursive: true })
|
||||
console.log('✅ Uploads-Verzeichnis nach Build erstellt:', uploadsDir)
|
||||
} catch (error) {
|
||||
console.log('ℹ️ Uploads-Verzeichnis bereits vorhanden oder Fehler:', error.message)
|
||||
}
|
||||
|
||||
// Erstelle temp Verzeichnis für LaTeX-Kompilierung (nach dem Build)
|
||||
const tempDir = path.join(process.cwd(), '.output', 'temp')
|
||||
try {
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
console.log('✅ Temp-Verzeichnis nach Build erstellt:', tempDir)
|
||||
} catch (error) {
|
||||
console.log('ℹ️ Temp-Verzeichnis bereits vorhanden oder Fehler:', error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^7.0.9",
|
||||
"nuxt": "^4.1.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.22"
|
||||
@@ -2718,6 +2719,24 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinia/nuxt": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.2.tgz",
|
||||
@@ -8254,6 +8273,12 @@
|
||||
"integrity": "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-ms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
|
||||
@@ -8375,6 +8400,24 @@
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^7.0.9",
|
||||
"nuxt": "^4.1.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.22"
|
||||
|
||||
317
pages/cms/mitgliedschaftsantraege.vue
Normal file
317
pages/cms/mitgliedschaftsantraege.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Fixed Header -->
|
||||
<div class="fixed top-16 left-0 right-0 bg-white shadow-sm border-b border-gray-200 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between py-3 sm:py-4">
|
||||
<h1 class="text-xl sm:text-3xl font-bold text-gray-900">
|
||||
Mitgliedschaftsanträge
|
||||
</h1>
|
||||
<button
|
||||
@click="refreshApplications"
|
||||
:disabled="loading"
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white text-sm sm:text-base font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{{ loading ? 'Lädt...' : 'Aktualisieren' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="pt-20 sm:pt-24">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Lade Anträge...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="applications.length === 0" class="text-center py-12">
|
||||
<div class="text-gray-400 text-6xl mb-4">📋</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Anträge vorhanden</h3>
|
||||
<p class="text-gray-600">Es wurden noch keine Mitgliedschaftsanträge eingereicht.</p>
|
||||
</div>
|
||||
|
||||
<!-- Applications List -->
|
||||
<div v-else class="space-y-6">
|
||||
<div
|
||||
v-for="application in applications"
|
||||
:key="application.id"
|
||||
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<!-- Application Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
{{ application.personalData.vorname }} {{ application.personalData.nachname }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
Eingereicht: {{ formatDate(application.timestamp) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- Status Badge -->
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-sm font-medium',
|
||||
getStatusClass(application.status)
|
||||
]"
|
||||
>
|
||||
{{ getStatusText(application.status) }}
|
||||
</span>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="viewApplication(application)"
|
||||
class="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
<button
|
||||
v-if="application.status === 'pending'"
|
||||
@click="approveApplication(application.id)"
|
||||
class="px-3 py-1 text-sm bg-green-100 hover:bg-green-200 text-green-700 rounded-lg transition-colors"
|
||||
>
|
||||
Genehmigen
|
||||
</button>
|
||||
<button
|
||||
v-if="application.status === 'pending'"
|
||||
@click="rejectApplication(application.id)"
|
||||
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Details -->
|
||||
<div class="px-6 py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">Kontaktdaten</h4>
|
||||
<div class="space-y-1 text-sm text-gray-600">
|
||||
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p>
|
||||
<p v-if="application.personalData.telefon_privat">
|
||||
<strong>Telefon:</strong> {{ application.personalData.telefon_privat }}
|
||||
</p>
|
||||
<p v-if="application.personalData.telefon_mobil">
|
||||
<strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">Antragsdetails</h4>
|
||||
<div class="space-y-1 text-sm text-gray-600">
|
||||
<p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
|
||||
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
|
||||
<p><strong>PDF:</strong> {{ application.metadata.pdfGenerated ? 'Generiert' : 'Nicht verfügbar' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Detail Modal -->
|
||||
<div
|
||||
v-if="selectedApplication"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
Antrag: {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}
|
||||
</h2>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Personal Data -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Persönliche Daten</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
|
||||
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p>
|
||||
<p v-if="selectedApplication.personalData.telefon_privat">
|
||||
<strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}
|
||||
</p>
|
||||
<p v-if="selectedApplication.personalData.telefon_mobil">
|
||||
<strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Details -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Antragsdetails</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
|
||||
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
|
||||
<p><strong>Volljährig:</strong> {{ selectedApplication.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
|
||||
<p><strong>Eingereicht:</strong> {{ formatDate(selectedApplication.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedApplication.status === 'pending'"
|
||||
@click="approveApplication(selectedApplication.id)"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Genehmigen
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedApplication.status === 'pending'"
|
||||
@click="rejectApplication(selectedApplication.id)"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const applications = ref([])
|
||||
const loading = ref(false)
|
||||
const selectedApplication = ref(null)
|
||||
|
||||
const loadApplications = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/membership/applications')
|
||||
applications.value = response
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Anträge:', error)
|
||||
alert('Fehler beim Laden der Anträge')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshApplications = () => {
|
||||
loadApplications()
|
||||
}
|
||||
|
||||
const viewApplication = (application) => {
|
||||
selectedApplication.value = application
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
selectedApplication.value = null
|
||||
}
|
||||
|
||||
const approveApplication = async (id) => {
|
||||
if (confirm('Antrag genehmigen?')) {
|
||||
try {
|
||||
await $fetch('/api/membership/update-status', {
|
||||
method: 'PUT',
|
||||
body: { id, status: 'approved' }
|
||||
})
|
||||
await loadApplications()
|
||||
alert('Antrag wurde genehmigt')
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Genehmigen:', error)
|
||||
alert('Fehler beim Genehmigen des Antrags')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rejectApplication = async (id) => {
|
||||
if (confirm('Antrag ablehnen?')) {
|
||||
try {
|
||||
await $fetch('/api/membership/update-status', {
|
||||
method: 'PUT',
|
||||
body: { id, status: 'rejected' }
|
||||
})
|
||||
await loadApplications()
|
||||
alert('Antrag wurde abgelehnt')
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Ablehnen:', error)
|
||||
alert('Fehler beim Ablehnen des Antrags')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'approved':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'rejected':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Ausstehend'
|
||||
case 'approved':
|
||||
return 'Genehmigt'
|
||||
case 'rejected':
|
||||
return 'Abgelehnt'
|
||||
default:
|
||||
return 'Unbekannt'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApplications()
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Mitgliedschaftsanträge - CMS - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,562 @@
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<Membership />
|
||||
<div class="min-h-full py-16 bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
|
||||
Mitgliedschaft
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||
|
||||
<!-- Mitgliedschaftspläne (ohne "Noch Fragen" Box) -->
|
||||
<div class="mb-12">
|
||||
<MembershipNoQuestions />
|
||||
</div>
|
||||
|
||||
<!-- Aufnahmeantrag Formular -->
|
||||
<div class="bg-white rounded-xl shadow-lg p-8">
|
||||
<h2 class="text-3xl font-display font-bold text-gray-900 mb-6">
|
||||
Beitrittserklärung
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="submitForm" class="space-y-8">
|
||||
<!-- Persönliche Daten -->
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
|
||||
Persönliche Daten
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="nachname" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nachname
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
|
||||
<input
|
||||
id="nachname"
|
||||
v-model="form.nachname"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="vorname" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Vorname
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
|
||||
<input
|
||||
id="vorname"
|
||||
v-model="form.vorname"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="strasse" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Straße und Hausnummer
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
|
||||
<input
|
||||
id="strasse"
|
||||
v-model="form.strasse"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="plz" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
PLZ
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
|
||||
<input
|
||||
id="plz"
|
||||
v-model="form.plz"
|
||||
type="text"
|
||||
required
|
||||
pattern="[0-9]{5}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="ort" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Wohnort
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
|
||||
<input
|
||||
id="ort"
|
||||
v-model="form.ort"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="geburtsdatum" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Geburtsdatum
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
|
||||
<input
|
||||
id="geburtsdatum"
|
||||
v-model="form.geburtsdatum"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="telefon_privat" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefon (privat)
|
||||
</label>
|
||||
<input
|
||||
id="telefon_privat"
|
||||
v-model="form.telefon_privat"
|
||||
type="tel"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
E-Mail
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="telefon_mobil" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefon (Mobil)
|
||||
</label>
|
||||
<input
|
||||
id="telefon_mobil"
|
||||
v-model="form.telefon_mobil"
|
||||
type="tel"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mitgliedschaftsart -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
|
||||
Mitgliedschaftsart
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="form.mitgliedschaftsart"
|
||||
type="radio"
|
||||
value="aktiv"
|
||||
class="mr-3 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-gray-700">Aktives Mitglied</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="form.mitgliedschaftsart"
|
||||
type="radio"
|
||||
value="passiv"
|
||||
class="mr-3 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-gray-700">Passives Mitglied</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beitragszahlung -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
|
||||
Beitragszahlung
|
||||
</h3>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<p class="text-gray-700 mb-4">
|
||||
Den derzeitigen jährlichen Mitgliedsbeitrag in Höhe von:
|
||||
</p>
|
||||
<ul class="space-y-2 text-gray-700">
|
||||
<li>• € 120,-- (Erwachsene)</li>
|
||||
<li>• € 72,-- (Jugendliche bis zum vollendeten 18. Lebensjahr)</li>
|
||||
<li>• € 30,-- (passive Mitglieder)</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 mt-4">
|
||||
bitte ich per Lastschrift jährlich von meinem Konto einzuziehen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="form.lastschrift_erlaubt"
|
||||
type="checkbox"
|
||||
required
|
||||
class="mr-3 mt-1 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-gray-700">
|
||||
Hierzu erteile ich das beigefügte SEPA-Lastschriftmandat.
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 italic mt-1">Pflichtfeld</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Bankdaten -->
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
|
||||
Bankdaten für SEPA-Lastschrift
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label for="kontoinhaber" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Kontoinhaber (Vorname und Name)
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
|
||||
<input
|
||||
id="kontoinhaber"
|
||||
v-model="form.kontoinhaber"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="iban" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
IBAN
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
|
||||
<input
|
||||
id="iban"
|
||||
v-model="form.iban"
|
||||
type="text"
|
||||
required
|
||||
placeholder="DE89 3704 0044 0532 0130 00"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="bic" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
BIC
|
||||
</label>
|
||||
<input
|
||||
id="bic"
|
||||
v-model="form.bic"
|
||||
type="text"
|
||||
placeholder="COBADEFFXXX"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="bank" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Kreditinstitut
|
||||
</label>
|
||||
<input
|
||||
id="bank"
|
||||
v-model="form.bank"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Datenschutz -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
|
||||
Datenschutz und Einverständniserklärung
|
||||
</h3>
|
||||
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<p class="text-sm text-gray-700 mb-4">
|
||||
Der Vereinsvorstand weist darauf hin, dass ausreichende technische Maßnahmen zur Gewährleistung des Datenschutzes getroffen wurden. Dennoch kann bei einer Veröffentlichung von personenbezogenen Mitgliederdaten im Internet ein umfassender Datenschutz nicht garantiert werden.
|
||||
</p>
|
||||
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="form.datenschutz_einverstanden"
|
||||
type="checkbox"
|
||||
required
|
||||
class="mr-3 mt-1 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm text-gray-700">
|
||||
Ich bestätige das Vorstehende zur Kenntnis genommen zu haben und willige ein, dass der Harheimer Tischtennis-Club 1954 e.V. allgemeine Daten zu meiner Person (Name, Fotografien, Mannschaft, Leistungsergebnisse, Turnierteilnahmen, Lizenzen u.ä.) auf der Homepage des Vereins veröffentlichen darf.
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 italic mt-1">Pflichtfeld</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vereinssatzung -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
|
||||
Vereinssatzung
|
||||
</h3>
|
||||
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="form.satzung_anerkannt"
|
||||
type="checkbox"
|
||||
required
|
||||
class="mr-3 mt-1 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-gray-700">
|
||||
Ich erkenne die Vereinssatzung (erhältlich beim Vorstand bzw. auf der Vereinshomepage) an.
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 italic mt-1">Pflichtfeld</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Hinweise -->
|
||||
<div class="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
|
||||
<h4 class="font-semibold text-gray-900 mb-2">Wichtige Hinweise:</h4>
|
||||
<ul class="text-sm text-gray-700 space-y-1">
|
||||
<li>• Die Mitgliedschaft im Harheimer Tischtennis-Club erlangt erst nach Bestätigung durch den Vorstand Wirksamkeit.</li>
|
||||
<li>• Die Beitragspflicht beginnt mit dem darauf folgenden Monat.</li>
|
||||
<li>• Sie können Ihre Einwilligung zur Datenveröffentlichung jederzeit widerrufen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center pt-6">
|
||||
<button
|
||||
type="button"
|
||||
@click="fillWithDummyData"
|
||||
:disabled="isGenerating"
|
||||
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
|
||||
>
|
||||
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Mit Testdaten füllen
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!isFormValid || isGenerating"
|
||||
class="px-8 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
|
||||
>
|
||||
<svg v-if="isGenerating" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="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>
|
||||
{{ isGenerating ? 'Formular wird erstellt...' : 'Beitrittsformular erstellen' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Noch Fragen Box -->
|
||||
<div class="mt-12 bg-gradient-to-r from-primary-600 to-primary-700 rounded-2xl p-8 sm:p-12 text-center">
|
||||
<h3 class="text-3xl font-display font-bold text-white mb-4">
|
||||
Noch Fragen zur Mitgliedschaft?
|
||||
</h3>
|
||||
<p class="text-xl text-primary-100 mb-6">
|
||||
Kontaktieren Sie uns - wir beraten Sie gerne persönlich
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/kontakt"
|
||||
class="inline-flex items-center px-8 py-4 bg-white text-primary-600 font-semibold rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Jetzt Kontakt aufnehmen
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Membership from '~/components/Membership.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const form = ref({
|
||||
// Persönliche Daten
|
||||
nachname: '',
|
||||
vorname: '',
|
||||
strasse: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
geburtsdatum: '',
|
||||
telefon_privat: '',
|
||||
email: '',
|
||||
telefon_mobil: '',
|
||||
|
||||
// Mitgliedschaftsart
|
||||
mitgliedschaftsart: 'aktiv',
|
||||
|
||||
// Bankdaten
|
||||
kontoinhaber: '',
|
||||
iban: '',
|
||||
bic: '',
|
||||
bank: '',
|
||||
|
||||
// Einverständnisse
|
||||
lastschrift_erlaubt: false,
|
||||
datenschutz_einverstanden: false,
|
||||
satzung_anerkannt: false
|
||||
})
|
||||
|
||||
const isGenerating = ref(false)
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.value.nachname &&
|
||||
form.value.vorname &&
|
||||
form.value.strasse &&
|
||||
form.value.plz &&
|
||||
form.value.ort &&
|
||||
form.value.geburtsdatum &&
|
||||
form.value.email &&
|
||||
form.value.mitgliedschaftsart &&
|
||||
form.value.kontoinhaber &&
|
||||
form.value.iban &&
|
||||
form.value.lastschrift_erlaubt &&
|
||||
form.value.datenschutz_einverstanden &&
|
||||
form.value.satzung_anerkannt
|
||||
})
|
||||
|
||||
const isVolljaehrig = computed(() => {
|
||||
if (!form.value.geburtsdatum) return true // Default zu volljährig
|
||||
|
||||
const heute = new Date()
|
||||
const geburtsdatum = new Date(form.value.geburtsdatum)
|
||||
const alter = heute.getFullYear() - geburtsdatum.getFullYear()
|
||||
const monatDiff = heute.getMonth() - geburtsdatum.getMonth()
|
||||
|
||||
if (monatDiff < 0 || (monatDiff === 0 && heute.getDate() < geburtsdatum.getDate())) {
|
||||
return alter - 1 >= 18
|
||||
}
|
||||
|
||||
return alter >= 18
|
||||
})
|
||||
|
||||
const fillWithDummyData = () => {
|
||||
// Dummy-Daten für Testzwecke
|
||||
form.value = {
|
||||
// Persönliche Daten
|
||||
nachname: 'Mustermann',
|
||||
vorname: 'Max',
|
||||
strasse: 'Musterstraße 123',
|
||||
plz: '60437',
|
||||
ort: 'Frankfurt am Main',
|
||||
geburtsdatum: '1990-05-15', // Volljährig
|
||||
telefon_privat: '069 12345678',
|
||||
email: 'max.mustermann@example.com',
|
||||
telefon_mobil: '0171 1234567',
|
||||
|
||||
// Mitgliedschaftsart
|
||||
mitgliedschaftsart: 'aktiv',
|
||||
|
||||
// Bankdaten
|
||||
kontoinhaber: 'Max Mustermann',
|
||||
iban: 'DE89 3704 0044 0532 0130 00',
|
||||
bic: 'COBADEFFXXX',
|
||||
bank: 'Commerzbank AG',
|
||||
|
||||
// Einverständnisse
|
||||
lastschrift_erlaubt: true,
|
||||
datenschutz_einverstanden: true,
|
||||
satzung_anerkannt: true
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
isGenerating.value = true
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/membership/generate-pdf', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...form.value,
|
||||
isVolljaehrig: isVolljaehrig.value
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
// PDF herunterladen über geschützten Endpoint
|
||||
try {
|
||||
const downloadResponse = await $fetch(response.downloadUrl, {
|
||||
method: 'GET',
|
||||
responseType: 'blob'
|
||||
})
|
||||
|
||||
// Blob zu Download-Link konvertieren
|
||||
const blob = new Blob([downloadResponse], {
|
||||
type: 'application/pdf'
|
||||
})
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `Beitrittserklärung_${form.value.nachname}_${form.value.vorname}.pdf`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
// Erfolgsmeldung
|
||||
alert('Beitrittsformular wurde erfolgreich erstellt und per E-Mail an den Vorstand/Trainer gesendet!')
|
||||
} catch (downloadError) {
|
||||
console.error('Download-Fehler:', downloadError)
|
||||
alert('Formular wurde erfolgreich erstellt und per E-Mail an den Vorstand/Trainer gesendet!\n\nFalls der Download fehlschlägt, können Sie das Formular auch später über den Link in der E-Mail herunterladen.')
|
||||
}
|
||||
|
||||
// Formular zurücksetzen
|
||||
form.value = {
|
||||
nachname: '',
|
||||
vorname: '',
|
||||
strasse: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
geburtsdatum: '',
|
||||
telefon_privat: '',
|
||||
email: '',
|
||||
telefon_mobil: '',
|
||||
mitgliedschaftsart: 'aktiv',
|
||||
kontoinhaber: '',
|
||||
iban: '',
|
||||
bic: '',
|
||||
bank: '',
|
||||
lastschrift_erlaubt: false,
|
||||
datenschutz_einverstanden: false,
|
||||
satzung_anerkannt: false
|
||||
}
|
||||
} else {
|
||||
alert('Fehler beim Erstellen des Formulars: ' + (response.error || 'Unbekannter Fehler'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler:', error)
|
||||
alert('Fehler beim Erstellen des Formulars. Bitte versuchen Sie es erneut.')
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: 'Mitgliedschaft - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
</script>
|
||||
BIN
public/uploads/Beitrittserklärung_Mustermann_Max-3.pdf
Normal file
BIN
public/uploads/Beitrittserklärung_Mustermann_Max-3.pdf
Normal file
Binary file not shown.
1
public/uploads/beitrittserklärung_1761170630561.data
Normal file
1
public/uploads/beitrittserklärung_1761170630561.data
Normal file
@@ -0,0 +1 @@
|
||||
gNCsLBXSvmB3axKuSGH1YPk8nnWkAXzK8gu81Xfc2MvGz4iZ4IBhCtl+geidl/ZkdZC+qYXgb3BnrY4dmRdVb++IJU3TpLdkRthdkVKuTZOwXE/YxqlNApFT+bWUw21V0riC1Clkx0zY4kW33zZHCDk+rpPfjW+fk8jRp9uvKFChu9SlT5DMriO/s3R0IU/fU5YN9DG1FCnZT1LkpH6Lr7FhbqHAo6VFpF6Xo4KkuyS+WMIvdxS/mf3tyx1Th1Wc3kE/ljHeJviRBOXTQlOK4DkJZfir4JAhPdgXzYZan2z9WDCVq5DjzsAGZ1x7FSJHbM62Fg3NWlrnnY+FtTkHaBHRfTa7tVSeCE/re0b03HOQtwzt12gxt9/LwYXiKidFaRpceXP7oJVurOJaW/KnquAJMRs9XaY6EZQ0G3+HrDy6yx4/uj9lnlhIAGAvWEAKrVDLk2HWc6B7ud7lbU3J+a8AYRMz2rvdkdXUs0NgPJ7CfyCoQTIcvK9VVL5h3+8o/54L6qQEAV2rmcCqt32/0XXB3+ZlkpXZFvqWBn3hAV7WhKou3R8wdxBZimPZn7gYb0R4IsN1+cYCblz3pzgehss0hIewHRy+nnzE/aw5zNeE/m2ia0YAJFGtt31F9JqmQc2kvtcQWn4MmB1rJYbV5Htw79ihCCU38ZfIWIThjea8Sbx1DzxKJyr7NlAx7XzlUs4m66QUQOd3O7GP2d3L6Q==
|
||||
BIN
public/uploads/beitrittserklärung_1761170630561.pdf
Normal file
BIN
public/uploads/beitrittserklärung_1761170630561.pdf
Normal file
Binary file not shown.
BIN
public/uploads/debug-beitritt-1761169973.pdf
Normal file
BIN
public/uploads/debug-beitritt-1761169973.pdf
Normal file
Binary file not shown.
BIN
public/uploads/debug-beitritt-1761170410.pdf
Normal file
BIN
public/uploads/debug-beitritt-1761170410.pdf
Normal file
Binary file not shown.
357
scripts/create-fillable-template.js
Normal file
357
scripts/create-fillable-template.js
Normal 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)
|
||||
})
|
||||
18
scripts/fetch-template.sh
Normal file
18
scripts/fetch-template.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Lädt das offizielle Aufnahmeantrag-PDF in server/templates/
|
||||
# Usage: ./scripts/fetch-template.sh [URL]
|
||||
# Default URL: https://harheimertc.de/Aufnahmeantrag%202025.pdf
|
||||
|
||||
TEMPLATE_URL=${1:-"https://harheimertc.de/Aufnahmeantrag%202025.pdf"}
|
||||
TEMPLATES_DIR="$(pwd)/server/templates"
|
||||
mkdir -p "$TEMPLATES_DIR"
|
||||
|
||||
echo "Lade Template von: $TEMPLATE_URL"
|
||||
curl -fL "$TEMPLATE_URL" -o "$TEMPLATES_DIR/Aufnahmeantrag 2025.pdf"
|
||||
|
||||
echo "Template gespeichert als: $TEMPLATES_DIR/Aufnahmeantrag 2025.pdf"
|
||||
chmod 644 "$TEMPLATES_DIR/Aufnahmeantrag 2025.pdf"
|
||||
|
||||
echo "Fertig."
|
||||
72
server/api/membership/applications.get.js
Normal file
72
server/api/membership/applications.get.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { decryptObject } from '../../utils/encryption.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const encryptionKey = config.encryptionKey
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Verschlüsselungsschlüssel nicht konfiguriert'
|
||||
})
|
||||
}
|
||||
|
||||
const dataDir = path.join(process.cwd(), 'server/data/membership-applications')
|
||||
|
||||
// Prüfen ob Verzeichnis existiert
|
||||
try {
|
||||
await fs.access(dataDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
// Alle Anträge laden
|
||||
const files = await fs.readdir(dataDir)
|
||||
const applications = []
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
try {
|
||||
const filePath = path.join(dataDir, file)
|
||||
const fileContent = await fs.readFile(filePath, 'utf8')
|
||||
const applicationData = JSON.parse(fileContent)
|
||||
|
||||
// Verschlüsselte Daten entschlüsseln
|
||||
const decryptedData = decryptObject(applicationData.encryptedData, encryptionKey)
|
||||
|
||||
applications.push({
|
||||
id: applicationData.id,
|
||||
timestamp: applicationData.timestamp,
|
||||
status: applicationData.status,
|
||||
metadata: applicationData.metadata,
|
||||
// Entschlüsselte persönliche Daten
|
||||
personalData: {
|
||||
nachname: decryptedData.nachname,
|
||||
vorname: decryptedData.vorname,
|
||||
email: decryptedData.email,
|
||||
telefon_privat: decryptedData.telefon_privat,
|
||||
telefon_mobil: decryptedData.telefon_mobil
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Fehler beim Laden von ${file}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nach Zeitstempel sortieren (neueste zuerst)
|
||||
applications.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
|
||||
return applications
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Mitgliedschaftsanträge:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Fehler beim Laden der Anträge'
|
||||
})
|
||||
}
|
||||
})
|
||||
100
server/api/membership/download/[id].get.js
Normal file
100
server/api/membership/download/[id].get.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { getUserFromToken } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Datei-ID aus der URL extrahieren
|
||||
const fileId = decodeURIComponent(getRouterParam(event, 'id'))
|
||||
if (!fileId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Datei-ID fehlt'
|
||||
})
|
||||
}
|
||||
|
||||
// Upload-Verzeichnis finden
|
||||
const uploadDir = path.join(process.cwd(), 'public', 'uploads')
|
||||
console.log('Upload-Verzeichnis:', uploadDir)
|
||||
|
||||
// Alle Dateien im Upload-Verzeichnis durchsuchen
|
||||
const files = await fs.readdir(uploadDir)
|
||||
console.log('Verfügbare Dateien:', files)
|
||||
console.log('Gesuchte Datei-ID:', fileId)
|
||||
|
||||
const matchingFile = files.find(file => file.includes(fileId))
|
||||
console.log('Gefundene Datei:', matchingFile)
|
||||
|
||||
if (!matchingFile) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Datei nicht gefunden'
|
||||
})
|
||||
}
|
||||
|
||||
// Prüfen ob der Benutzer berechtigt ist, diese Datei herunterzuladen
|
||||
const token = getCookie(event, 'auth_token')
|
||||
let isAuthorized = false
|
||||
|
||||
if (token) {
|
||||
// Authentifizierte Benutzer prüfen
|
||||
const user = await getUserFromToken(token)
|
||||
if (user && ['admin', 'vorstand'].includes(user.role)) {
|
||||
// Admin/Vorstand kann alle Dateien herunterladen
|
||||
isAuthorized = true
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfen ob es sich um eine aktuelle Session handelt (innerhalb der letzten 30 Minuten)
|
||||
const sessionKey = `download_${fileId}`
|
||||
const sessionValue = getCookie(event, sessionKey)
|
||||
|
||||
if (sessionValue === 'authorized') {
|
||||
// Session-basierte Berechtigung für Antragsteller
|
||||
isAuthorized = true
|
||||
}
|
||||
|
||||
if (!isAuthorized) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Keine Berechtigung für diesen Download'
|
||||
})
|
||||
}
|
||||
|
||||
const filePath = path.join(uploadDir, matchingFile)
|
||||
|
||||
// Datei lesen
|
||||
const fileBuffer = await fs.readFile(filePath)
|
||||
|
||||
// MIME-Type basierend auf Dateiendung bestimmen
|
||||
const ext = path.extname(matchingFile).toLowerCase()
|
||||
let mimeType = 'application/octet-stream'
|
||||
let filename = matchingFile
|
||||
|
||||
if (ext === '.pdf') {
|
||||
mimeType = 'application/pdf'
|
||||
} else if (ext === '.txt') {
|
||||
mimeType = 'text/plain'
|
||||
filename = matchingFile.replace('.txt', '.pdf') // Für Download als PDF benennen
|
||||
}
|
||||
|
||||
// Datei als Download senden
|
||||
setHeader(event, 'Content-Type', mimeType)
|
||||
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`)
|
||||
setHeader(event, 'Content-Length', fileBuffer.length.toString())
|
||||
|
||||
return fileBuffer
|
||||
|
||||
} catch (error) {
|
||||
console.error('Download-Fehler:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Interner Serverfehler'
|
||||
})
|
||||
}
|
||||
})
|
||||
708
server/api/membership/generate-pdf.post.js
Normal file
708
server/api/membership/generate-pdf.post.js
Normal file
@@ -0,0 +1,708 @@
|
||||
import { createRequire } from 'module'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { encrypt } from '../../utils/encryption.js'
|
||||
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
function generateLaTeXContent(data) {
|
||||
const heute = new Date().toLocaleDateString('de-DE')
|
||||
|
||||
// LaTeX-Inhalt mit korrekten Escapes generieren
|
||||
let latexContent = `\\documentclass[12pt,a4paper]{article}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[ngerman]{babel}
|
||||
\\usepackage{geometry}
|
||||
\\usepackage{enumitem}
|
||||
\\usepackage{xcolor}
|
||||
\\usepackage{helvet} % Für Sans-Serif Schriftart
|
||||
\\renewcommand{\\familydefault}{\\sfdefault} % Setzt Sans-Serif als Standard
|
||||
\\setlength{\\parindent}{0pt} % Keine Absatzeinrückung
|
||||
|
||||
\\geometry{margin=2cm}
|
||||
|
||||
\\title{\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
|
||||
\\vspace{0.3cm}
|
||||
\\Large Beitrittserklärung}
|
||||
\\date{}
|
||||
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
|
||||
\\vspace{0.1cm} % Reduzierter Abstand zwischen Titel und Text (1/3 der ursprünglichen Größe)
|
||||
|
||||
Hiermit beantrage ich,
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
Name: \\underline{${data.nachname} \\hspace{6cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
Vorname: \\underline{${data.vorname} \\hspace{6cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
Straße: \\underline{${data.strasse} \\hspace{6cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
PLZ/Wohnort: \\underline{${data.plz} ${data.ort} \\hspace{6cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
Geburtsdatum: \\underline{${new Date(data.geburtsdatum).toLocaleDateString('de-DE')} \\hspace{6cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
Telefon (privat): \\underline{${data.telefon_privat || ''} \\hspace{6cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
E-Mail: \\underline{${data.email} \\hspace{6cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
Telefon (Mobil): \\underline{${data.telefon_mobil || ''} \\hspace{6cm}}
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
meine Aufnahme in den Harheimer Tischtennis-Club 1954 e.V. als
|
||||
|
||||
\\vspace{0.3cm}
|
||||
|
||||
\\begin{itemize}[label={}]
|
||||
\\item[\\framebox(4,4){}] ${data.mitgliedschaftsart === 'aktiv' ? '\\framebox(4,4){X}' : '\\framebox(4,4){}'} aktives Mitglied
|
||||
\\item[\\framebox(4,4){}] ${data.mitgliedschaftsart === 'passiv' ? '\\framebox(4,4){X}' : '\\framebox(4,4){}'} passives Mitglied
|
||||
\\end{itemize}
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
Den derzeitigen jährlichen Mitgliedsbeitrag in Höhe von
|
||||
|
||||
\\begin{itemize}
|
||||
\\item € 120,-- (Erwachsene)
|
||||
\\item € 72,-- (Jugendliche bis zum vollendeten 18. Lebensjahr)
|
||||
\\item € 30,-- (passive Mitglieder)
|
||||
\\end{itemize}
|
||||
|
||||
bitte ich per Lastschrift jährlich von meinem Konto einzuziehen.
|
||||
Hierzu erteile ich beigefügtes SEPA-Lastschriftmandat.
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
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.
|
||||
|
||||
\\vspace{1cm}
|
||||
|
||||
Frankfurt/Main-Harheim, den \\underline{${heute} \\hspace{3cm}}
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
Unterschrift ${data.isVolljaehrig ? '' : '(bei Jugendlichen gesetzlicher Vertreter)'}
|
||||
|
||||
\\newpage
|
||||
|
||||
\\title{\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
|
||||
\\Large Erteilung eines SEPA-Lastschriftmandates}
|
||||
\\date{}
|
||||
\\maketitle
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
|
||||
Unsere Gläubiger-Identifikationsnummer: DE46ZZZ00000745362
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
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.
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
Hinweis: Ich kann innerhalb von acht Wochen, beginnend mit dem Belastungsdatum, die Erstattung des belasteten Betrages verlangen. Es gelten dabei die mit meinem Kreditinstitut vereinbarten Bedingungen.
|
||||
|
||||
\\vspace{1cm}
|
||||
|
||||
Kontoinhaber: \\underline{${data.kontoinhaber} \\hspace{6cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
IBAN: \\underline{${data.iban} \\hspace{6cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
BIC: \\underline{${data.bic || ''} \\hspace{6cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
Kreditinstitut: \\underline{${data.bank} \\hspace{6cm}}
|
||||
|
||||
\\vspace{1cm}
|
||||
|
||||
Frankfurt/Main-Harheim, den \\underline{${heute} \\hspace{3cm}}
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
Unterschrift des Kontoinhabers
|
||||
|
||||
\\newpage
|
||||
|
||||
\\title{\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
|
||||
\\Large Einwilligungserklärung}
|
||||
\\date{}
|
||||
\\maketitle
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
\\textbf{für die Veröffentlichung von Mitgliederdaten im Internet.}
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
Der Vereinsvorstand weist hiermit darauf hin, dass ausreichende technische Maßnahmen zur Gewährleistung des Datenschutzes getroffen wurden. Dennoch kann bei einer Veröffentlichung von personenbezogenen Mitgliederdaten im Internet ein umfassender Datenschutz nicht garantiert werden. Daher nimmt das Vereinsmitglied die Risiken für eine eventuelle Persönlichkeitsrechtsverletzung zur Kenntnis und ist sich bewusst, dass:
|
||||
|
||||
\\begin{itemize}
|
||||
\\item die personenbezogenen Daten auch in Staaten abrufbar sind, die keine der Bundesrepublik Deutschland vergleichbaren Datenschutzbestimmungen kennen,
|
||||
\\item die Vertraulichkeit, die Integrität (Unverletzlichkeit), die Authentizität (Echtheit) und die Verfügbarkeit der personenbezogenen Daten nicht garantiert ist.
|
||||
\\end{itemize}
|
||||
|
||||
Das Vereinsmitglied trifft die Entscheidung zur Veröffentlichung seiner Daten im Internet freiwillig und kann seine Einwilligung gegenüber dem Vereinsvorstand jederzeit widerrufen.
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
\\textbf{Erklärung:}
|
||||
|
||||
Ich bestätige das Vorstehende zur Kenntnis genommen zu haben und willige ein, dass der
|
||||
|
||||
\\textbf{Harheimer Tischtennis-Club 1954 e.V.}
|
||||
|
||||
folgende allgemeine Daten zu meiner Person:
|
||||
|
||||
Vorname: \\underline{${data.vorname} \\hspace{4cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
Zuname: \\underline{${data.nachname} \\hspace{4cm}}
|
||||
|
||||
Fotografien, sonstige Daten (Mannschaft, Leistungsergebnisse, Turnierteilnahmen, Lizenzen u.ä.) bzw. spezielle Daten von Funktionsträgern:
|
||||
|
||||
Anschrift: \\underline{${data.strasse}, ${data.plz} ${data.ort} \\hspace{4cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
Telefonnummer: \\underline{${data.telefon_privat || data.telefon_mobil || ''} \\hspace{4cm}} \\\\
|
||||
\\vspace{0.3cm}
|
||||
E-Mail-Adresse: \\underline{${data.email} \\hspace{4cm}}
|
||||
|
||||
wie angegeben auf der Homepage des Vereins (www.harheimertc.de) veröffentlichen darf.
|
||||
|
||||
\\vspace{1cm}
|
||||
|
||||
Datum: \\underline{${heute} \\hspace{6cm}}
|
||||
|
||||
\\vspace{0.5cm}
|
||||
|
||||
Unterschrift ${data.isVolljaehrig ? '' : '(bei Minderjährigen Unterschrift eines Erziehungsberechtigten)'}
|
||||
|
||||
\\end{document}`
|
||||
|
||||
// Doppelte Backslashes zu einfachen Backslashes konvertieren, aber \\vspace und \\hspace beibehalten
|
||||
// Erst alle \\vspace und \\hspace temporär ersetzen
|
||||
let result = latexContent.replace(/\\\\vspace/g, 'TEMP_VSPACE')
|
||||
result = result.replace(/\\\\hspace/g, 'TEMP_HSPACE')
|
||||
|
||||
// Dann alle anderen doppelten Backslashes ersetzen
|
||||
result = result.replace(/\\\\/g, '\\')
|
||||
|
||||
// Dann die temporären Platzhalter wieder zurückersetzen
|
||||
result = result.replace(/TEMP_VSPACE/g, '\\vspace')
|
||||
result = result.replace(/TEMP_HSPACE/g, '\\hspace')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function generateSimplePDF(data, filename, event) {
|
||||
// Fallback: HTML zu PDF mit puppeteer oder ähnlich
|
||||
// Für jetzt: Einfache Textdatei
|
||||
const textContent = `
|
||||
Harheimer Tischtennis-Club 1954 e.V.
|
||||
Beitrittserklärung
|
||||
|
||||
Antragsteller: ${data.vorname} ${data.nachname}
|
||||
Mitgliedschaftsart: ${data.mitgliedschaftsart}
|
||||
Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
|
||||
|
||||
Das ausgefüllte Formular ist als Anhang verfügbar.`
|
||||
|
||||
const textPath = path.join(process.cwd(), 'public', 'uploads', `${filename}.txt`)
|
||||
await fs.writeFile(textPath, textContent, 'utf8')
|
||||
|
||||
return `${filename}.txt`
|
||||
}
|
||||
|
||||
function getDataPath(filename) {
|
||||
// Immer den absoluten Pfad zum Projekt-Root verwenden
|
||||
// In der Entwicklung: process.cwd() ist bereits das Projekt-Root
|
||||
// In der Produktion: process.cwd() ist .output, daher ein Verzeichnis zurück
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const projectRoot = isDev ? process.cwd() : path.resolve(process.cwd(), '..')
|
||||
return path.join(projectRoot, 'server', 'data', filename)
|
||||
}
|
||||
|
||||
async function sendMembershipEmail(data, filename, event) {
|
||||
try {
|
||||
const configPath = getDataPath('config.json')
|
||||
const configData = await fs.readFile(configPath, 'utf8')
|
||||
const config = JSON.parse(configData)
|
||||
|
||||
let recipients = []
|
||||
let subject = `Neuer Mitgliedschaftsantrag - ${data.vorname} ${data.nachname}`
|
||||
|
||||
// Sammle alle verfügbaren E-Mail-Adressen
|
||||
const availableEmails = []
|
||||
|
||||
// Vorsitzender E-Mail hinzufügen (falls vorhanden)
|
||||
if (config.vorstand.vorsitzender.email && config.vorstand.vorsitzender.email.trim() !== '') {
|
||||
availableEmails.push(config.vorstand.vorsitzender.email)
|
||||
}
|
||||
|
||||
// Schriftführer E-Mail hinzufügen (falls vorhanden)
|
||||
if (config.vorstand.schriftfuehrer.email && config.vorstand.schriftfuehrer.email.trim() !== '') {
|
||||
availableEmails.push(config.vorstand.schriftfuehrer.email)
|
||||
}
|
||||
|
||||
// Fallback: Wenn keine E-Mails verfügbar sind, verwende tsschulz@tsschulz.de
|
||||
if (availableEmails.length === 0) {
|
||||
recipients = ['tsschulz@tsschulz.de']
|
||||
} else {
|
||||
recipients = availableEmails
|
||||
}
|
||||
|
||||
// In nicht-Produktionsumgebung: Alle E-Mails an tsschulz@tsschulz.de
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
recipients = ['tsschulz@tsschulz.de']
|
||||
}
|
||||
|
||||
const message = `Ein neuer Mitgliedschaftsantrag wurde eingereicht.
|
||||
|
||||
Antragsteller: ${data.vorname} ${data.nachname}
|
||||
Mitgliedschaftsart: ${data.mitgliedschaftsart}
|
||||
Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
|
||||
|
||||
Das ausgefüllte Formular ist als Anhang verfügbar.`
|
||||
|
||||
// E-Mail-Versand implementieren (hier würde normalerweise nodemailer verwendet)
|
||||
console.log('E-Mail würde gesendet werden an:', recipients)
|
||||
console.log('Betreff:', subject)
|
||||
console.log('Nachricht:', message)
|
||||
|
||||
return { success: true, recipients, subject, message }
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Senden der E-Mail:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
|
||||
// Validierung der Eingabedaten
|
||||
const requiredFields = ['vorname', 'nachname', 'strasse', 'plz', 'ort', 'geburtsdatum', 'email', 'mitgliedschaftsart', 'kontoinhaber', 'iban']
|
||||
for (const field of requiredFields) {
|
||||
if (!body[field]) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Feld '${field}' ist erforderlich`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Volljährigkeit prüfen
|
||||
const birthDate = new Date(body.geburtsdatum)
|
||||
const today = new Date()
|
||||
const age = today.getFullYear() - birthDate.getFullYear()
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth()
|
||||
const isVolljaehrig = age > 18 || (age === 18 && monthDiff >= 0)
|
||||
|
||||
const data = {
|
||||
...body,
|
||||
isVolljaehrig
|
||||
}
|
||||
|
||||
// Eindeutige Datei-ID generieren
|
||||
const timestamp = Date.now()
|
||||
const filename = `beitrittserklärung_${timestamp}`
|
||||
|
||||
// Temp-Verzeichnis erstellen
|
||||
const tempDir = path.join(process.cwd(), '.output', 'temp', 'latex')
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
|
||||
try {
|
||||
// PDF-Template-Funktion aktiv: versuche Original-PDF-Template herunterzuladen und zu befüllen
|
||||
// Versuch: Original-PDF-Template herunterladen und AcroForm-Felder befüllen
|
||||
async function fillPdfTemplate(data) {
|
||||
// Priorität: neues lokales Fillable-Template in server/templates, sonst ursprüngliches Template
|
||||
const fillablePath = path.join(process.cwd(), 'server', 'templates', 'mitgliedschaft-fillable.pdf')
|
||||
const localPath = (await fs.stat(fillablePath).then(() => fillablePath).catch(() => null)) || path.join(process.cwd(), 'server', 'templates', 'Aufnahmeantrag 2025.pdf')
|
||||
let arrayBuffer
|
||||
try {
|
||||
const localExists = await fs.stat(localPath).then(() => true).catch(() => false)
|
||||
if (localExists) {
|
||||
const buf = await fs.readFile(localPath)
|
||||
arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
|
||||
} else {
|
||||
const TEMPLATE_URL = process.env.MEMBERSHIP_TEMPLATE_URL || 'https://harheimertc.de/Aufnahmeantrag%202025.pdf'
|
||||
const res = await fetch(TEMPLATE_URL)
|
||||
if (!res.ok) throw new Error(`Template konnte nicht geladen werden: ${res.status}`)
|
||||
arrayBuffer = await res.arrayBuffer()
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error('Template-Laden fehlgeschlagen: ' + e.message)
|
||||
}
|
||||
|
||||
const pdfDoc = await PDFDocument.load(arrayBuffer)
|
||||
let form
|
||||
try {
|
||||
form = pdfDoc.getForm()
|
||||
} catch (e) {
|
||||
form = null
|
||||
}
|
||||
|
||||
if (!form || form.getFields().length === 0) {
|
||||
// Keine Formularfelder vorhanden -> positional filler verwenden
|
||||
// Koordinaten (in PDF-Punkten) müssen ggf. feinjustiert werden.
|
||||
const pages = pdfDoc.getPages()
|
||||
const firstPage = pages[0]
|
||||
const { width, height } = firstPage.getSize()
|
||||
|
||||
// Schätzwerte: (x, y) in Punkten von linker unteren Ecke
|
||||
// Diese Werte müssen nach Sichtprüfung justiert werden.
|
||||
// Angepasste Koordinaten — Text beginnt links an der Linie und sitzt oberhalb der Unterstreichung
|
||||
// Linke Spalte (nachname, vorname, etc.) etwas weiter links setzen
|
||||
const leftX = 156
|
||||
// Rechte Spalte (PLZ/Ort, Telefon, E-Mail) weiter nach rechts für klare Trennung
|
||||
const rightX = 470
|
||||
// Baseline für erstes Feld (von oben aus gerechnet)
|
||||
const baseY = height - 160
|
||||
const gap = 28
|
||||
// Kleine vertikale Verschiebung, damit Texte oberhalb der Unterstreichung beginnen
|
||||
const yOffset = 9
|
||||
|
||||
const coords = {
|
||||
// Row: Name (left) / Vorname (right)
|
||||
nachname: { x: leftX, y: baseY + yOffset },
|
||||
vorname: { x: rightX, y: baseY + yOffset },
|
||||
// Row: Straße (left) / PLZ/Ort (right)
|
||||
strasse: { x: leftX, y: baseY - gap + yOffset },
|
||||
plz_ort: { x: rightX, y: baseY - gap + yOffset },
|
||||
// Row: Geburtsdatum (left) / Telefon(privat) (right)
|
||||
geburtsdatum: { x: leftX, y: baseY - gap * 2 + yOffset },
|
||||
telefon: { x: rightX, y: baseY - gap * 2 + yOffset },
|
||||
// Row: E-Mail (left) / Telefon(Mobil) (right)
|
||||
email: { x: leftX, y: baseY - gap * 3 + yOffset },
|
||||
telefon_mobil: { x: rightX, y: baseY - gap * 3 + yOffset },
|
||||
// Membership checkbox positions (approx.)
|
||||
mitglied_checkbox_aktiv: { x: leftX - 40, y: baseY - gap * 6 + yOffset },
|
||||
mitglied_checkbox_passiv: { x: leftX - 40, y: baseY - gap * 7 + yOffset },
|
||||
// Account details on subsequent page(s)
|
||||
kontoinhaber: { x: leftX, y: baseY - gap * 12 + yOffset },
|
||||
iban: { x: leftX, y: baseY - gap * 13 + yOffset },
|
||||
bic: { x: leftX, y: baseY - gap * 14 + yOffset },
|
||||
bank: { x: leftX, y: baseY - gap * 15 + yOffset }
|
||||
}
|
||||
|
||||
const drawText = (page, text, x, y, size = 11) => {
|
||||
page.drawText(text || '', {
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
font: pdfDoc.embedStandardFont ? undefined : undefined,
|
||||
// default black
|
||||
color: undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Einbettung der Standard-Schrift (Helvetica)
|
||||
const helveticaFont = await pdfDoc.embedFont(PDFDocument.PDFName ? 'Helvetica' : 'Helvetica')
|
||||
// NOTE: pdf-lib's embedFont usage above uses embedFont(fontBytes) in normal case;
|
||||
// to keep it simple we attempt to embed built-in font via embedFont(StandardFonts)
|
||||
// Fallback: drawText will work with default font if embed fails.
|
||||
|
||||
// Zeichne die Felder
|
||||
try {
|
||||
// Zeichne Name / Vorname / Adresse / Kontakt (einmal)
|
||||
firstPage.drawText(data.nachname || '', { x: coords.nachname.x, y: coords.nachname.y, size: 11, font: helveticaFont })
|
||||
firstPage.drawText(data.vorname || '', { x: coords.vorname.x, y: coords.vorname.y, size: 11, font: helveticaFont })
|
||||
firstPage.drawText(data.strasse || '', { x: coords.strasse.x, y: coords.strasse.y, size: 11, font: helveticaFont })
|
||||
firstPage.drawText(`${data.plz || ''} ${data.ort || ''}`.trim(), { x: coords.plz_ort.x, y: coords.plz_ort.y, size: 11, font: helveticaFont })
|
||||
firstPage.drawText(new Date(data.geburtsdatum).toLocaleDateString('de-DE') || '', { x: coords.geburtsdatum.x, y: coords.geburtsdatum.y, size: 11, font: helveticaFont })
|
||||
firstPage.drawText(data.telefon_privat || data.telefon_mobil || '', { x: coords.telefon.x, y: coords.telefon.y, size: 11, font: helveticaFont })
|
||||
firstPage.drawText(data.email || '', { x: coords.email.x, y: coords.email.y, size: 11, font: helveticaFont })
|
||||
firstPage.drawText(data.telefon_mobil || '', { x: coords.telefon_mobil.x, y: coords.telefon_mobil.y, size: 11, font: helveticaFont })
|
||||
// Kontodaten evtl. auf andere Seite: falls mehrere Seiten vorhanden, nutze last page
|
||||
const lastPage = pages[pages.length - 1]
|
||||
lastPage.drawText(data.kontoinhaber || '', { x: coords.kontoinhaber.x, y: coords.kontoinhaber.y, size: 11, font: helveticaFont })
|
||||
lastPage.drawText(data.iban || '', { x: coords.iban.x, y: coords.iban.y, size: 11, font: helveticaFont })
|
||||
lastPage.drawText(data.bic || '', { x: coords.bic.x, y: coords.bic.y, size: 11, font: helveticaFont })
|
||||
lastPage.drawText(data.bank || '', { x: coords.bank.x, y: coords.bank.y, size: 11, font: helveticaFont })
|
||||
// Zeichne X in die passende Mitgliedschafts-Checkbox
|
||||
try {
|
||||
if (data.mitgliedschaftsart === 'aktiv') {
|
||||
firstPage.drawText('X', { x: coords.mitglied_checkbox_aktiv.x, y: coords.mitglied_checkbox_aktiv.y, size: 12, font: helveticaFont })
|
||||
} else if (data.mitgliedschaftsart === 'passiv') {
|
||||
firstPage.drawText('X', { x: coords.mitglied_checkbox_passiv.x, y: coords.mitglied_checkbox_passiv.y, size: 12, font: helveticaFont })
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Fehler beim Zeichnen der Checkbox:', e.message)
|
||||
}
|
||||
// Debug overlay: zeichne Marker an allen Koordinaten, wenn data.debug === true
|
||||
if (data && data.debug) {
|
||||
try {
|
||||
// Auffälliges Debug-Tag oben links
|
||||
const tagW = 100
|
||||
const tagH = 22
|
||||
firstPage.drawRectangle({ x: 40, y: height - 60, width: tagW, height: tagH, color: rgb(1, 0, 0), opacity: 0.25 })
|
||||
firstPage.drawText('DEBUG', { x: 48, y: height - 56, size: 12, color: rgb(1, 0, 0), font: helveticaFont })
|
||||
|
||||
// Markiere alle Koordinaten mit einem gefüllten roten Quadrat und Label
|
||||
const allCoords = Object.entries(coords)
|
||||
for (const [key, c] of allCoords) {
|
||||
// small filled square
|
||||
firstPage.drawRectangle({ x: c.x - 3, y: c.y - 3, width: 8, height: 8, color: rgb(1, 0, 0), opacity: 1 })
|
||||
// small label a bit to the right
|
||||
firstPage.drawText(key, { x: c.x + 8, y: c.y - 1, size: 7, color: rgb(0.6, 0, 0), font: helveticaFont })
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Debug overlay fehlgeschlagen:', e.message)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Fehler beim positional drawing:', e.message)
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save()
|
||||
return Buffer.from(pdfBytes)
|
||||
}
|
||||
|
||||
// Wenn Formularfelder existieren: befülle sie per AcroForm
|
||||
const fields = form.getFields()
|
||||
if (fields && fields.length > 0) {
|
||||
try {
|
||||
const byName = {}
|
||||
for (const f of fields) byName[f.getName().toLowerCase()] = f
|
||||
const setIf = (name, value) => {
|
||||
const f = byName[name]
|
||||
if (!f) return
|
||||
try {
|
||||
if (typeof f.setText === 'function') f.setText(String(value || ''))
|
||||
else if (typeof f.check === 'function' && (value === true || String(value).toLowerCase() === 'true')) f.check()
|
||||
} catch (e) {
|
||||
console.warn('Fehler beim Setzen Feld', name, e.message)
|
||||
}
|
||||
}
|
||||
setIf('nachname', data.nachname)
|
||||
setIf('vorname', data.vorname)
|
||||
setIf('strasse', data.strasse)
|
||||
setIf('plz_ort', `${data.plz || ''} ${data.ort || ''}`.trim())
|
||||
setIf('geburtsdatum', new Date(data.geburtsdatum).toLocaleDateString('de-DE'))
|
||||
setIf('telefon', data.telefon_privat || data.telefon_mobil)
|
||||
setIf('email', data.email)
|
||||
setIf('telefon_mobil', data.telefon_mobil)
|
||||
// Checkboxes
|
||||
if (byName['mitglied_aktiv'] && data.mitgliedschaftsart === 'aktiv') byName['mitglied_aktiv'].check && byName['mitglied_aktiv'].check()
|
||||
if (byName['mitglied_passiv'] && data.mitgliedschaftsart === 'passiv') byName['mitglied_passiv'].check && byName['mitglied_passiv'].check()
|
||||
const pdfBytes = await pdfDoc.save()
|
||||
return Buffer.from(pdfBytes)
|
||||
} catch (e) {
|
||||
console.warn('AcroForm-Füllung fehlgeschlagen, fallback auf positional:', e.message)
|
||||
}
|
||||
}
|
||||
const mapValue = (name) => {
|
||||
// einfache Heuristiken für Feldnamen
|
||||
name = name.toLowerCase()
|
||||
if (name.includes('nachname') || name.includes('zuname') || name.includes('name')) return data.nachname || ''
|
||||
if (name.includes('vorname') || name.includes('given')) return data.vorname || ''
|
||||
if (name.includes('str') || name.includes('straße') || name.includes('street')) return data.strasse || ''
|
||||
if (name.includes('plz')) return data.plz || ''
|
||||
if (name.includes('ort') || name.includes('stadt')) return data.ort || ''
|
||||
if (name.includes('geb') || name.includes('geburts')) return new Date(data.geburtsdatum).toLocaleDateString('de-DE')
|
||||
if (name.includes('telefon') || name.includes('tel')) return data.telefon_privat || data.telefon_mobil || ''
|
||||
if (name.includes('email')) return data.email || ''
|
||||
if (name.includes('kontoinhaber') || name.includes('kontoinh')) return data.kontoinhaber || ''
|
||||
if (name.includes('iban')) return data.iban || ''
|
||||
if (name.includes('bic')) return data.bic || ''
|
||||
if (name.includes('bank') || name.includes('kreditinstitut')) return data.bank || ''
|
||||
if (name.includes('mitglied') || name.includes('mitgliedschaft') || name.includes('art')) return data.mitgliedschaftsart || ''
|
||||
return ''
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
const fname = field.getName()
|
||||
const lower = fname.toLowerCase()
|
||||
try {
|
||||
// Textfelder
|
||||
if (typeof field.setText === 'function') {
|
||||
const val = mapValue(lower)
|
||||
field.setText(val)
|
||||
continue
|
||||
}
|
||||
|
||||
// Checkbox / Radio
|
||||
if (typeof field.check === 'function' || typeof field.isChecked === 'function') {
|
||||
// einfache Heuristik: bei Mitgliedschaftsart
|
||||
if (lower.includes('aktiv') || lower.includes('passiv') || lower.includes('mitglied')) {
|
||||
if (data.mitgliedschaftsart && lower.includes(data.mitgliedschaftsart)) {
|
||||
field.check && field.check()
|
||||
} else {
|
||||
if (lower.includes('aktiv') && data.mitgliedschaftsart === 'aktiv') field.check && field.check()
|
||||
if (lower.includes('passiv') && data.mitgliedschaftsart === 'passiv') field.check && field.check()
|
||||
}
|
||||
continue
|
||||
}
|
||||
const mapped = mapValue(lower)
|
||||
if (mapped === 'true' || mapped === 'ja' || mapped === 'checked') {
|
||||
field.check && field.check()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Fehler beim Befüllen Feld', fname, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save()
|
||||
return Buffer.from(pdfBytes)
|
||||
}
|
||||
|
||||
let usedTemplate = false
|
||||
const uploadsDir = path.join(process.cwd(), 'public', 'uploads')
|
||||
await fs.mkdir(uploadsDir, { recursive: true })
|
||||
try {
|
||||
const filled = await fillPdfTemplate(data)
|
||||
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
||||
await fs.writeFile(finalPdfPath, filled)
|
||||
// Zusätzlich: Kopie ins repo-root public/uploads legen, falls Nitro cwd anders ist
|
||||
try {
|
||||
const repoRoot = path.resolve(process.cwd(), '..')
|
||||
const repoUploads = path.join(repoRoot, 'public', 'uploads')
|
||||
await fs.mkdir(repoUploads, { recursive: true })
|
||||
await fs.copyFile(finalPdfPath, path.join(repoUploads, `${filename}.pdf`))
|
||||
} catch (e) {
|
||||
console.warn('Kopie in repo public/uploads fehlgeschlagen:', e.message)
|
||||
}
|
||||
usedTemplate = true
|
||||
} catch (templateError) {
|
||||
// Template konnte nicht verwendet werden -> weiter zum LaTeX-Fallback
|
||||
console.warn('Template-Füllung fehlgeschlagen, fahre mit LaTeX fort:', templateError.message)
|
||||
}
|
||||
|
||||
let emailResult
|
||||
if (usedTemplate) {
|
||||
// E-Mail senden
|
||||
emailResult = await sendMembershipEmail(data, filename, event)
|
||||
// Antragsdaten verschlüsselt speichern
|
||||
const encryptionKey = process.env.ENCRYPTION_KEY || 'default-key-change-in-production'
|
||||
const encryptedData = encrypt(JSON.stringify(data), encryptionKey)
|
||||
const dataPath = path.join(uploadsDir, `${filename}.data`)
|
||||
await fs.writeFile(dataPath, encryptedData, 'utf8')
|
||||
|
||||
// Download-Token setzen
|
||||
const downloadToken = Buffer.from(`${filename}:${Date.now()}`).toString('base64')
|
||||
setCookie(event, 'download_token', downloadToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 // 24 Stunden
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Beitrittsformular erfolgreich aus Template erzeugt und E-Mail gesendet.',
|
||||
downloadUrl: `/api/membership/download/${filename}.pdf`,
|
||||
emailSuccess: emailResult.success,
|
||||
emailMessage: emailResult.message,
|
||||
usedTemplate: true
|
||||
}
|
||||
}
|
||||
|
||||
// Falls Template nicht verwendet: weiter mit LaTeX-Fallback
|
||||
// LaTeX-Inhalt generieren
|
||||
const latexContent = generateLaTeXContent(data)
|
||||
|
||||
// LaTeX-Datei schreiben
|
||||
const texPath = path.join(tempDir, `${filename}.tex`)
|
||||
await fs.writeFile(texPath, latexContent, 'utf8')
|
||||
|
||||
// PDF mit pdflatex generieren
|
||||
const command = `cd "${tempDir}" && pdflatex -interaction=nonstopmode "${filename}.tex"`
|
||||
await execAsync(command)
|
||||
|
||||
// PDF-Datei in Uploads-Verzeichnis kopieren
|
||||
const pdfPath = path.join(tempDir, `${filename}.pdf`)
|
||||
await fs.mkdir(uploadsDir, { recursive: true })
|
||||
|
||||
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
||||
await fs.copyFile(pdfPath, finalPdfPath)
|
||||
// Kopie ins repo-root public/uploads für bessere Auffindbarkeit
|
||||
try {
|
||||
const repoRoot = path.resolve(process.cwd(), '..')
|
||||
const repoUploads = path.join(repoRoot, 'public', 'uploads')
|
||||
await fs.mkdir(repoUploads, { recursive: true })
|
||||
await fs.copyFile(finalPdfPath, path.join(repoUploads, `${filename}.pdf`))
|
||||
} catch (e) {
|
||||
console.warn('Kopie in repo public/uploads fehlgeschlagen:', e.message)
|
||||
}
|
||||
|
||||
// E-Mail senden
|
||||
emailResult = await sendMembershipEmail(data, filename, event)
|
||||
|
||||
// Antragsdaten verschlüsselt speichern
|
||||
const encryptionKey = process.env.ENCRYPTION_KEY || 'default-key-change-in-production'
|
||||
const encryptedData = encrypt(JSON.stringify(data), encryptionKey)
|
||||
const dataPath = path.join(uploadsDir, `${filename}.data`)
|
||||
await fs.writeFile(dataPath, encryptedData, 'utf8')
|
||||
|
||||
// Download-Berechtigung für den Antragsteller setzen
|
||||
const downloadToken = Buffer.from(`${filename}:${Date.now()}`).toString('base64')
|
||||
setCookie(event, 'download_token', downloadToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 // 24 Stunden
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Beitrittsformular erfolgreich erstellt und E-Mail gesendet.',
|
||||
downloadUrl: `/api/membership/download/${filename}.pdf`,
|
||||
emailSuccess: emailResult.success,
|
||||
emailMessage: emailResult.message,
|
||||
}
|
||||
|
||||
} catch (latexError) {
|
||||
console.error('LaTeX-Fehler:', latexError)
|
||||
|
||||
// Fallback: Einfache Textdatei generieren
|
||||
const fallbackFilename = await generateSimplePDF(data, filename, event)
|
||||
|
||||
// E-Mail senden (Fallback)
|
||||
const emailResult = await sendMembershipEmail(data, filename, event)
|
||||
|
||||
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
|
||||
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
|
||||
console.log('Betreff:', emailResult.subject || '')
|
||||
console.log('Nachricht:', emailResult.message || '')
|
||||
console.log('Upload-Verzeichnis:', path.join(process.cwd(), 'public', 'uploads'))
|
||||
|
||||
// Verfügbare Dateien auflisten
|
||||
const uploadsDir = path.join(process.cwd(), 'public', 'uploads')
|
||||
try {
|
||||
const files = await fs.readdir(uploadsDir)
|
||||
console.log('Verfügbare Dateien:', files)
|
||||
|
||||
// Gesuchte Datei finden
|
||||
const targetFile = files.find(file => file.startsWith(filename))
|
||||
console.log('Gesuchte Datei-ID:', filename)
|
||||
console.log('Gefundene Datei:', targetFile || 'Nicht gefunden')
|
||||
} catch (dirError) {
|
||||
console.error('Fehler beim Lesen des Upload-Verzeichnisses:', dirError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Beitrittsformular erfolgreich erstellt und E-Mail gesendet (Fallback-Lösung).',
|
||||
downloadUrl: `/api/membership/download/${fallbackFilename}`,
|
||||
emailSuccess: emailResult.success,
|
||||
emailMessage: emailResult.message,
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Generieren des PDFs:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Fehler beim Generieren des PDFs'
|
||||
})
|
||||
}
|
||||
})
|
||||
90
server/api/membership/update-status.put.js
Normal file
90
server/api/membership/update-status.put.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { decryptObject, encryptObject } from '../../utils/encryption.js'
|
||||
import { saveMember } from '../../utils/members.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const { id, status, notes } = await readBody(event)
|
||||
|
||||
if (!id || !status) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'ID und Status sind erforderlich'
|
||||
})
|
||||
}
|
||||
|
||||
if (!['pending', 'approved', 'rejected'].includes(status)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Ungültiger Status'
|
||||
})
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const encryptionKey = config.encryptionKey
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Verschlüsselungsschlüssel nicht konfiguriert'
|
||||
})
|
||||
}
|
||||
|
||||
const dataDir = path.join(process.cwd(), 'server/data/membership-applications')
|
||||
const filePath = path.join(dataDir, `${id}.json`)
|
||||
|
||||
// Antrag laden
|
||||
const fileContent = await fs.readFile(filePath, 'utf8')
|
||||
const applicationData = JSON.parse(fileContent)
|
||||
|
||||
// Status aktualisieren
|
||||
applicationData.status = status
|
||||
applicationData.updatedAt = new Date().toISOString()
|
||||
|
||||
if (notes) {
|
||||
applicationData.notes = notes
|
||||
}
|
||||
|
||||
// Wenn genehmigt: In Mitgliederliste einfügen
|
||||
if (status === 'approved') {
|
||||
try {
|
||||
const decryptedData = decryptObject(applicationData.encryptedData, encryptionKey)
|
||||
|
||||
const newMember = {
|
||||
firstName: decryptedData.vorname,
|
||||
lastName: decryptedData.nachname,
|
||||
email: decryptedData.email,
|
||||
phone: decryptedData.telefon_privat || decryptedData.telefon_mobil || '',
|
||||
address: `${decryptedData.strasse}, ${decryptedData.plz} ${decryptedData.ort}`,
|
||||
notes: `Mitgliedschaftsart: ${applicationData.metadata.mitgliedschaftsart} | Genehmigt: ${new Date().toLocaleDateString('de-DE')}`,
|
||||
source: 'membership_application',
|
||||
applicationId: id
|
||||
}
|
||||
|
||||
await saveMember(newMember)
|
||||
applicationData.memberId = newMember.id
|
||||
|
||||
console.log(`Mitgliedschaftsantrag ${id} wurde genehmigt und in Mitgliederliste eingefügt`)
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Einfügen in Mitgliederliste:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Speichern
|
||||
await fs.writeFile(filePath, JSON.stringify(applicationData, null, 2), 'utf8')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: status === 'approved' ? 'Antrag genehmigt und in Mitgliederliste eingefügt' : 'Status erfolgreich aktualisiert'
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren des Status:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Fehler beim Aktualisieren des Status'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -103,5 +103,12 @@
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMTM2NzMzLCJleHAiOjE3NjE3NDE1MzN9.rzxassQ4Uj-nXfL2y1sygzshW0YovdYR2GUjosXoPF8",
|
||||
"createdAt": "2025-10-22T12:38:53.918Z",
|
||||
"expiresAt": "2025-10-29T12:38:53.918Z"
|
||||
},
|
||||
{
|
||||
"id": "1761144008551",
|
||||
"userId": "1",
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYxMTQ0MDA4LCJleHAiOjE3NjE3NDg4MDh9.G5OtippK77mTioWV8Js-OpoJmaEPLA38VRjwRRj93oA",
|
||||
"createdAt": "2025-10-22T14:40:08.551Z",
|
||||
"expiresAt": "2025-10-29T14:40:08.551Z"
|
||||
}
|
||||
]
|
||||
@@ -8,6 +8,6 @@
|
||||
"phone": "",
|
||||
"active": true,
|
||||
"created": "2025-10-21T00:00:00.000Z",
|
||||
"lastLogin": "2025-10-22T12:38:53.919Z"
|
||||
"lastLogin": "2025-10-22T14:40:08.552Z"
|
||||
}
|
||||
]
|
||||
BIN
server/templates/Aufnahmeantrag 2025.pdf
Normal file
BIN
server/templates/Aufnahmeantrag 2025.pdf
Normal file
Binary file not shown.
BIN
server/templates/mitgliedschaft-fillable.pdf
Normal file
BIN
server/templates/mitgliedschaft-fillable.pdf
Normal file
Binary file not shown.
102
server/utils/encryption.js
Normal file
102
server/utils/encryption.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
// Verschlüsselungskonfiguration
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const IV_LENGTH = 16
|
||||
const SALT_LENGTH = 32
|
||||
|
||||
/**
|
||||
* Generiert einen Schlüssel aus einem Passwort und Salt
|
||||
*/
|
||||
function deriveKey(password, salt) {
|
||||
return crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha512')
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschlüsselt einen Text
|
||||
*/
|
||||
export function encrypt(text, password) {
|
||||
try {
|
||||
// Salt generieren
|
||||
const salt = crypto.randomBytes(SALT_LENGTH)
|
||||
|
||||
// Schlüssel ableiten
|
||||
const key = deriveKey(password, salt)
|
||||
|
||||
// IV generieren
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
|
||||
// Cipher erstellen
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
|
||||
// Verschlüsseln
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
|
||||
// Salt + IV + Verschlüsselter Text kombinieren
|
||||
const combined = Buffer.concat([
|
||||
salt,
|
||||
iv,
|
||||
Buffer.from(encrypted, 'hex')
|
||||
])
|
||||
|
||||
return combined.toString('base64')
|
||||
} catch (error) {
|
||||
console.error('Verschlüsselungsfehler:', error)
|
||||
throw new Error('Fehler beim Verschlüsseln der Daten')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entschlüsselt einen Text
|
||||
*/
|
||||
export function decrypt(encryptedData, password) {
|
||||
try {
|
||||
// Base64 dekodieren
|
||||
const combined = Buffer.from(encryptedData, 'base64')
|
||||
|
||||
// Komponenten extrahieren
|
||||
const salt = combined.subarray(0, SALT_LENGTH)
|
||||
const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH)
|
||||
const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH)
|
||||
|
||||
// Schlüssel ableiten
|
||||
const key = deriveKey(password, salt)
|
||||
|
||||
// Decipher erstellen
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
|
||||
// Entschlüsseln
|
||||
let decrypted = decipher.update(encrypted, null, 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
console.error('Entschlüsselungsfehler:', error)
|
||||
throw new Error('Fehler beim Entschlüsseln der Daten')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschlüsselt ein Objekt (konvertiert zu JSON)
|
||||
*/
|
||||
export function encryptObject(obj, password) {
|
||||
const jsonString = JSON.stringify(obj)
|
||||
return encrypt(jsonString, password)
|
||||
}
|
||||
|
||||
/**
|
||||
* Entschlüsselt ein Objekt (konvertiert von JSON)
|
||||
*/
|
||||
export function decryptObject(encryptedData, password) {
|
||||
const jsonString = decrypt(encryptedData, password)
|
||||
return JSON.parse(jsonString)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert einen sicheren Schlüssel für die Datenverschlüsselung
|
||||
*/
|
||||
export function generateEncryptionKey() {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
BIN
temp/Beitrittserklärung_Mustermann_Max-3.pdf
Normal file
BIN
temp/Beitrittserklärung_Mustermann_Max-3.pdf
Normal file
Binary file not shown.
26
temp/latex/test.tex
Normal file
26
temp/latex/test.tex
Normal file
@@ -0,0 +1,26 @@
|
||||
\documentclass[12pt,a4paper]{article}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage[ngerman]{babel}
|
||||
\usepackage{geometry}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{xcolor}
|
||||
\usepackage{helvet} % Für Sans-Serif Schriftart
|
||||
\renewcommand{\familydefault}{\sfdefault} % Setzt Sans-Serif als Standard
|
||||
\setlength{\parindent}{0pt} % Keine Absatzeinrückung
|
||||
|
||||
\geometry{margin=2cm}
|
||||
|
||||
\title{\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\
|
||||
\vspace{1cm}
|
||||
\Large Erteilung eines SEPA-Lastschriftmandates}
|
||||
\date{}
|
||||
|
||||
\begin{document}
|
||||
\maketitle
|
||||
|
||||
\vspace{1cm}
|
||||
|
||||
\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\
|
||||
Unsere Gläubiger-Identifikationsnummer: DE46ZZZ00000745362
|
||||
|
||||
\end{document}
|
||||
22
test-vspace.tex
Normal file
22
test-vspace.tex
Normal file
@@ -0,0 +1,22 @@
|
||||
\documentclass[12pt,a4paper]{article}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage[ngerman]{babel}
|
||||
\usepackage{geometry}
|
||||
\geometry{margin=2cm}
|
||||
|
||||
\title{\textbf{Test}\\
|
||||
\vspace{1cm}
|
||||
\Large Test-Titel}
|
||||
\date{}
|
||||
|
||||
\begin{document}
|
||||
\maketitle
|
||||
|
||||
\vspace{1cm}
|
||||
|
||||
Dies ist der Inhalt nach dem Header.
|
||||
|
||||
\end{document}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user