Compare commits

..

No commits in common. "08efc258a0db63c8a586f373875dd4308adce1c1" and "37364c54e4b7598050ceeb25d6022ae03e02a4d5" have entirely different histories.

33 changed files with 113 additions and 2004 deletions

View file

@ -18,10 +18,6 @@ TAR_EXCLUDE := --exclude='.git' \
--exclude='./.env' \
--exclude='./.DS_Store'
# Hook-Sync: pb_hooks nach jedem Deploy auf DS kopieren
HOOKS_SRC := pocketbase/pb_hooks
HOOKS_DST := /volume1/docker/checkflo/pocketbase/data/pb_hooks
.PHONY: help check-ssh start stop restart status logs logs-f logs-app \
shell-pb pb-admin deploy setup-db seed-demo
@ -62,14 +58,6 @@ check-ssh:
deploy: check-ssh
@echo "→ Sync zu DS..."
@COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(DS_PATH)/"
@echo "→ .env auf DS aktualisieren..."
@if [ -f .env ]; then \
grep -E "BREVO_KEY|TURNSTILE_SECRET" .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \
fi
@echo "→ PocketBase Hooks synchronisieren..."
@for f in $(HOOKS_SRC)/*.pb.js; do \
cat "$$f" | ssh $(DS_HOST) "cat > $(HOOKS_DST)/$$(basename $$f)"; \
done
@echo "→ Docker rebuild + restart..."
@ssh $(DS_HOST) " \
cd $(DS_PATH) && \

336
app/package-lock.json generated
View file

@ -8,15 +8,13 @@
"name": "app",
"version": "0.0.1",
"dependencies": {
"pocketbase": "^0.26.9",
"qrcode": "^1.5.4"
"pocketbase": "^0.26.9"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/qrcode": "^1.5.6",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
@ -2662,26 +2660,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -2726,30 +2704,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@ -3021,15 +2975,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
@ -3067,17 +3012,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -3088,24 +3022,6 @@
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@ -3258,15 +3174,6 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@ -3330,12 +3237,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -3374,12 +3275,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@ -3670,19 +3565,6 @@
"node": ">=10"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -3808,15 +3690,6 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -4200,15 +4073,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@ -4866,18 +4730,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@ -5074,42 +4926,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -5117,15 +4933,6 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -5190,15 +4997,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/pocketbase": {
"version": "0.26.9",
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.9.tgz",
@ -5267,23 +5065,6 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -5400,15 +5181,6 @@
"regjsparser": "bin/parser"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -5419,12 +5191,6 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -5621,12 +5387,6 @@
"node": ">=20.0.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
@ -5879,20 +5639,6 @@
"node": ">= 0.4"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@ -5995,18 +5741,6 @@
"node": ">=4"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
@ -6299,13 +6033,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
@ -6646,12 +6373,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@ -6894,26 +6615,6 @@
"workbox-core": "7.4.1"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -6921,41 +6622,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",

View file

@ -16,7 +16,6 @@
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/qrcode": "^1.5.6",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
@ -24,7 +23,6 @@
"vite-plugin-pwa": "^1.3.0"
},
"dependencies": {
"pocketbase": "^0.26.9",
"qrcode": "^1.5.4"
"pocketbase": "^0.26.9"
}
}

View file

@ -9,7 +9,6 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -1,9 +0,0 @@
<svg width="520" height="100" viewBox="0 0 520 100" xmlns="http://www.w3.org/2000/svg">
<!-- Icon: Blaues Quadrat mit Schild/Haken -->
<rect x="0" y="8" width="84" height="84" rx="18" fill="#1B4FBF"/>
<path d="M42 22 C42 22 23 44 23 58 C23 69.5 31.6 78 42 78 C52.4 78 61 69.5 61 58 C61 44 42 22 42 22Z" fill="white"/>
<circle cx="35" cy="53" r="5" fill="#1B4FBF" opacity="0.4"/>
<!-- Firmenname -->
<text x="100" y="50" font-family="Arial, Helvetica, sans-serif" font-size="30" font-weight="700" fill="#1B4FBF">Hafner</text>
<text x="100" y="78" font-family="Arial, Helvetica, sans-serif" font-size="22" font-weight="400" fill="#4a5568">Prüftechnik GmbH</text>
</svg>

Before

Width:  |  Height:  |  Size: 683 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M112,224a95.2,95.2,0,0,1-29-48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M192,152c0,31.67,13.31,59,40,72H61A103.65,103.65,0,0,1,32,152c0-28.21,11.23-50.89,29.47-69.64a8,8,0,0,1,8.67-1.81L95.52,90.83a16,16,0,0,0,20.82-9l21-53.11c4.15-10,15.47-15.32,25.63-11.53a20,20,0,0,1,11.51,26.4L153.13,96.69a16,16,0,0,0,8.93,20.76L187,127.29a8,8,0,0,1,5,7.43Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="43.93" y1="105.57" x2="192.8" y2="165.12" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

Before

Width:  |  Height:  |  Size: 790 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M24,176V153.13C24,95.65,70.15,48.2,127.63,48A104,104,0,0,1,232,152v24a8,8,0,0,1-8,8H32A8,8,0,0,1,24,176Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="128" y1="48" x2="128" y2="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="104" y1="184" x2="168" y2="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="136" x2="230.78" y2="136" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="25.39" y1="136" x2="56" y2="136" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

Before

Width:  |  Height:  |  Size: 896 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="112 40 48 40 48 216 112 216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="128" x2="224" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="184 88 224 128 184 168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

Before

Width:  |  Height:  |  Size: 543 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="88" y1="64" x2="216" y2="64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="192" x2="216" y2="192" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="44" cy="64" r="12"/><circle cx="44" cy="128" r="12"/><circle cx="44" cy="192" r="12"/></svg>

Before

Width:  |  Height:  |  Size: 631 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="64 80 64 40 192 40 192 80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><rect x="64" y="152" width="128" height="64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M64,176H24V96c0-8.84,7.76-16,17.33-16H214.67C224.24,80,232,87.16,232,96v80H192" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="188" cy="116" r="12"/></svg>

Before

Width:  |  Height:  |  Size: 625 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M200,224H56a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h96l56,56V216A8,8,0,0,1,200,224Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="152 32 152 88 208 88" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="96" y1="136" x2="160" y2="136" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="96" y1="168" x2="160" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

Before

Width:  |  Height:  |  Size: 719 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="128" y1="64" x2="128" y2="192" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="104 40 128 64 152 40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="104 216 128 192 152 216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="72.57" y1="96" x2="183.43" y2="160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="40 104 72.57 96 64 64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="192 192 183.43 160 216 152" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="72.57" y1="160" x2="183.43" y2="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="64 192 72.57 160 40 152" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="216 104 183.43 96 192 64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><rect x="48" y="48" width="64" height="64" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><rect x="48" y="144" width="64" height="64" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><rect x="144" y="48" width="64" height="64" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="144" y1="144" x2="144" y2="176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="144 208 176 208 176 144" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="176" y1="160" x2="208" y2="160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="208" y1="192" x2="208" y2="208" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="120" y1="160" x2="120" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="216" y1="48" x2="216" y2="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="185.57" y1="70.11" x2="216" y2="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="197.19" y1="105.89" x2="216" y2="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="234.81" y1="105.89" x2="216" y2="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="246.43" y1="70.11" x2="216" y2="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="120" cy="184" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M88,48a32,32,0,0,1,64,0v90a56,56,0,1,1-64,0Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="120" y1="160" x2="120" y2="48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="120" cy="184" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M88,48a32,32,0,0,1,64,0v90a56,56,0,1,1-64,0Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M184,76.4c22.91-14.92,33.09,14.12,56-.8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M184,108.4c22.91-14.92,33.09,14.12,56-.8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

Before

Width:  |  Height:  |  Size: 842 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="212" cy="84" r="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="120" y1="160" x2="120" y2="88" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="120" cy="184" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M88,48a32,32,0,0,1,64,0v90a56,56,0,1,1-64,0Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

Before

Width:  |  Height:  |  Size: 673 B

View file

@ -1,9 +0,0 @@
<svg width="520" height="100" viewBox="0 0 520 100" xmlns="http://www.w3.org/2000/svg">
<!-- Icon: Blaues Quadrat mit Wassertropfen -->
<rect x="0" y="8" width="84" height="84" rx="18" fill="#1B4FBF"/>
<path d="M42 22 C42 22 23 44 23 58 C23 69.5 31.6 78 42 78 C52.4 78 61 69.5 61 58 C61 44 42 22 42 22Z" fill="white"/>
<circle cx="35" cy="53" r="5" fill="#1B4FBF" opacity="0.4"/>
<!-- Firmenname -->
<text x="100" y="50" font-family="Arial, Helvetica, sans-serif" font-size="30" font-weight="700" fill="#1B4FBF">Schmidt</text>
<text x="100" y="78" font-family="Arial, Helvetica, sans-serif" font-size="22" font-weight="400" fill="#4a5568">Hygiene GmbH</text>
</svg>

Before

Width:  |  Height:  |  Size: 680 B

View file

@ -1,173 +1,21 @@
<script lang="ts">
import logo from '$lib/assets/checkflo-logo.png';
import hafnerLogo from '$lib/assets/hafner-logo.png';
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
const PUBLIC_TURNSTILE_SITE_KEY = '0x4AAAAAADRLILtAf9XMk-g0';
const DEMO_TENANT = 'mengbzc3ajxpccz';
const STATUS_LABEL: Record<string, string> = { ok: 'OK', abweichung: 'Abweichung', kritisch: 'Kritisch' };
const BADGE_STYLE: Record<string, string> = {
ok: 'background:#dcfce7;color:#16a34a',
abweichung: 'background:#ffedd5;color:#ea580c',
kritisch: 'background:#fee2e2;color:#dc2626'
};
import iconThermCold from '$lib/assets/icons/thermometer-cold.svg';
import iconSnowflake from '$lib/assets/icons/snowflake.svg';
import iconThermHot from '$lib/assets/icons/thermometer-hot.svg';
import iconBroom from '$lib/assets/icons/broom.svg';
import iconTherm from '$lib/assets/icons/thermometer.svg';
const TYPE_ICON: Record<string, string> = {
kuehlschrank: iconThermCold,
tiefkuehl: iconSnowflake,
warmhalte: iconThermHot,
hygiene: iconBroom,
sonstiges: iconTherm
};
let activeView = $state<'dashboard' | 'protokoll' | 'stationen'>('dashboard');
// Kontaktformular
let showModal = $state(false);
let modalPlan = $state('');
let formName = $state('');
let formCompany = $state('');
let formEmail = $state('');
let formPhone = $state('');
let formMsg = $state('');
let formSending = $state(false);
let formDone = $state(false);
let formError = $state('');
let tsWidgetId = $state<string | null>(null);
function openModal(plan = '') {
modalPlan = plan;
formDone = false;
formError = '';
tsWidgetId = null;
showModal = true;
setTimeout(() => {
const ts = (window as any).turnstile;
if (!ts) return;
const el = document.querySelector('.cf-turnstile');
if (el) {
el.innerHTML = '';
tsWidgetId = ts.render(el, {
sitekey: PUBLIC_TURNSTILE_SITE_KEY,
theme: 'light'
});
}
}, 100);
}
function closeModal() {
const ts = (window as any).turnstile;
if (ts && tsWidgetId !== null) ts.remove(tsWidgetId);
tsWidgetId = null;
showModal = false;
}
async function submitForm() {
if (!formName.trim() || !formEmail.trim()) { formError = 'Bitte Name und E-Mail ausfüllen.'; return; }
formSending = true; formError = '';
try {
const ts = (window as any).turnstile;
const token = tsWidgetId !== null
? (ts?.getResponse(tsWidgetId) ?? '')
: (ts?.getResponse() ?? '');
console.log('Turnstile token length:', token?.length);
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token, name: formName.trim(), email: formEmail.trim(),
company: formCompany.trim(), phone: formPhone.trim(),
message: formMsg.trim(), plan: modalPlan
})
});
if (!res.ok) {
const d = await res.json();
formError = d.error ?? 'Fehler beim Senden.';
(window as any).turnstile?.reset();
return;
}
formDone = true;
formName = formCompany = formEmail = formPhone = formMsg = '';
} catch {
formError = 'Fehler beim Senden. Bitte direkt an hallo@checkflo.de schreiben.';
} finally {
formSending = false;
}
}
let liveLogs = $state<any[]>([]);
let liveStats = $state({ total: 0, ok: 0, warn: 0, crit: 0 });
let lastUpdate = $state('');
let liveStations = $state<any[]>([]);
async function fetchLiveData() {
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dateStr = today.toISOString().slice(0, 19).replace('T', ' ');
const [logsResult, stationsResult] = await Promise.all([
pb.collection('check_logs').getList(1, 6, {
filter: `tenant = '${DEMO_TENANT}' && created >= '${dateStr}'`,
expand: 'station',
sort: '-created'
}),
pb.collection('stations').getFullList({
filter: `tenant = '${DEMO_TENANT}' && active = true`,
sort: 'name'
})
]);
liveLogs = logsResult.items;
liveStats.total = logsResult.totalItems;
liveStats.ok = logsResult.items.filter((l: any) => l.status === 'ok').length;
liveStats.warn = logsResult.items.filter((l: any) => l.status === 'abweichung').length;
liveStats.crit = logsResult.items.filter((l: any) => l.status === 'kritisch').length;
lastUpdate = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
liveStations = stationsResult;
} catch { }
}
onMount(() => {
if (!browser) return;
fetchLiveData();
// Polling alle 15s
const iv = setInterval(fetchLiveData, 15_000);
return () => clearInterval(iv);
});
function formatTime(iso: string) {
if (!iso) return '';
const d = new Date(iso);
return isNaN(d.getTime()) ? '' : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
const features = [
{
icon: '🏷️',
title: 'Ihr Name, Ihr Brand',
text: 'Ihre Kunden sehen nur Ihr Logo, Ihre Farben, Ihre Domain. checkflo bleibt unsichtbar im Hintergrund.'
icon: '📱',
title: 'Kein App-Store',
text: 'QR-Code scannen — fertig. Die App öffnet direkt im Browser, ohne Installation.'
},
{
icon: '📶',
title: 'Keine Installation nötig',
text: 'Ihre Kunden scannen den QR-Code und füllen die Checkliste direkt im Browser aus — kein App-Store, kein MDM-Problem.'
title: 'Offline-fähig',
text: 'Kein Empfang im Keller? Kein Problem. Daten werden lokal gespeichert und automatisch synchronisiert.'
},
{
icon: '📄',
title: 'Protokolle auf Knopfdruck',
text: 'Monatsprotokolle als PDF für Lebensmittelkontrollen — automatisch generiert, rechtssicher, ohne Aufwand für Sie.'
title: 'PDF auf Knopfdruck',
text: 'Monatsprotokolle für Lebensmittelkontrolleure automatisch generieren — rechtssicher und vollständig.'
}
];
</script>
@ -183,133 +31,15 @@
<!-- HERO -->
<section class="hero">
<div class="hero-inner">
<p class="eyebrow">White-Label-Software für Prüf- und Wartungsbetriebe</p>
<h1>Ihre eigene<br />HACCP-App —<br />unter Ihrem Namen.</h1>
<p class="eyebrow">HACCP-Dokumentation für die Gastronomie</p>
<h1>Prüfprotokolle.<br />Ohne Papier.<br />Ohne App-Store.</h1>
<p class="subtitle">
Bieten Sie Ihren Kunden digitale Prüfprotokolle als gebrandete App an.
Kein App-Store. Keine Installation. Läuft offline.
Und Ihre Kunden sehen nur Ihr Logo.
Kühlschranktemperaturen, Hygienekontrollen und Wartungsnachweise —
digital erfasst, automatisch protokolliert, jederzeit abrufbar.
</p>
<div class="hero-actions">
<button class="btn-primary" onclick={() => openModal()}>Kostenlose Demo vereinbaren</button>
<a href="#funktionen" class="btn-ghost">So funktioniert es ↓</a>
</div>
</div>
</section>
<!-- MOCKUPS -->
<section class="mockups">
<div class="container">
<p class="eyebrow-dark">So sieht Ihre gebrandete App aus</p>
<h2>Dashboard für Sie — Checkliste für Ihre Kunden</h2>
<div class="devices">
<!-- Desktop: CSS Dashboard Schmidt Hygiene -->
<div class="device-browser">
<div class="browser-bar">
<div class="browser-dots">
<span></span><span></span><span></span>
</div>
<div class="browser-url">app.hafner-pruefdienste.de/admin</div>
</div>
<div class="browser-screen">
<div class="dash-sidebar">
<img src={hafnerLogo} alt="Hafner Prüftechnik" class="dash-brand-logo" />
<button class="dash-nav-item {activeView === 'dashboard' ? 'active schmidt' : ''}" onclick={() => activeView = 'dashboard'}>Dashboard</button>
<button class="dash-nav-item {activeView === 'protokoll' ? 'active schmidt' : ''}" onclick={() => activeView = 'protokoll'}>Protokoll</button>
<button class="dash-nav-item {activeView === 'stationen' ? 'active schmidt' : ''}" onclick={() => activeView = 'stationen'}>Stationen</button>
</div>
<!-- DASHBOARD -->
{#if activeView === 'dashboard'}
<div class="dash-main">
<div class="dash-title">Dashboard</div>
<div class="dash-date">
{new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })}
{#if lastUpdate}<span class="dash-refresh">· {lastUpdate}</span>{/if}
</div>
<div class="dash-stats">
<div class="dash-stat"><div class="dash-num">{liveStats.total}</div><div class="dash-label">Heute</div></div>
<div class="dash-stat ok"><div class="dash-num">{liveStats.ok}</div><div class="dash-label">OK</div></div>
<div class="dash-stat warn"><div class="dash-num">{liveStats.warn}</div><div class="dash-label">Abw.</div></div>
<div class="dash-stat crit"><div class="dash-num">{liveStats.crit}</div><div class="dash-label">Kritisch</div></div>
</div>
<div class="dash-entries-title">Letzte Einträge</div>
{#each liveLogs.slice(0,4) as log}
<div class="dash-entry">
<span class="entry-badge" style={BADGE_STYLE[log.status] ?? BADGE_STYLE.ok}>{STATUS_LABEL[log.status] ?? log.status}</span>
<span class="entry-station">{log.expand?.station?.name ?? '—'}</span>
<span class="entry-temp">{log.temperature ? log.temperature + ' °C' : ''}</span>
<span class="entry-who">{log.checked_by} · {formatTime(log.created)}</span>
</div>
{:else}
<div class="dash-empty">Noch keine Einträge heute</div>
{/each}
</div>
{/if}
<!-- PROTOKOLL -->
{#if activeView === 'protokoll'}
<div class="dash-main">
<div class="dash-title">Protokoll</div>
<div class="dash-date">{new Date().toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}</div>
<table class="proto-table">
<thead><tr><th>Station</th><th>Status</th><th>Temp.</th><th>Zeit</th></tr></thead>
<tbody>
{#each liveLogs as log}
<tr>
<td class="proto-station">{log.expand?.station?.name ?? '—'}</td>
<td><span class="entry-badge" style={BADGE_STYLE[log.status] ?? BADGE_STYLE.ok}>{STATUS_LABEL[log.status] ?? log.status}</span></td>
<td>{log.temperature ? log.temperature + '°' : '—'}</td>
<td class="proto-time">{formatTime(log.created)}</td>
</tr>
{:else}
<tr><td colspan="4" class="dash-empty">Keine Einträge</td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- STATIONEN -->
{#if activeView === 'stationen'}
<div class="dash-main">
<div class="dash-title">Stationen</div>
<div class="dash-date">{liveStations.length} aktive Stationen</div>
<div class="stations-grid">
{#each liveStations as s}
<div class="station-card">
<img src={TYPE_ICON[s.type] ?? iconTherm} alt="" class="station-card-icon" />
<div class="station-card-name">{s.name}</div>
{#if s.target_temp_min || s.target_temp_max}
<div class="station-card-temp">{s.target_temp_min}° {s.target_temp_max}°C</div>
{/if}
</div>
{:else}
<div class="dash-empty">Keine Stationen</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<!-- Mobile: Live-Iframe Schmidt Hygiene (blau) -->
<div class="device-phone">
<div class="phone-frame">
<div class="phone-notch"></div>
<div class="phone-screen">
<iframe
src="https://checkflo.de/s/b6b30daf-8bfe-4648-ba2b-6c42916d2264"
title="Musterküche Demo"
></iframe>
</div>
<div class="phone-home"></div>
</div>
<p class="phone-caption">Ihr Kunde erfasst — Sie sehen es sofort</p>
</div>
<a href="mailto:hallo@checkflo.de" class="btn-primary">Kostenlose Demo anfragen</a>
<a href="#funktionen" class="btn-ghost">Wie es funktioniert ↓</a>
</div>
</div>
</section>
@ -317,22 +47,22 @@
<!-- HOW IT WORKS -->
<section class="steps" id="funktionen">
<div class="container">
<h2>In 3 Schritten zur eigenen HACCP-App</h2>
<h2>In 3 Schritten zum digitalen Protokoll</h2>
<div class="steps-grid">
<div class="step">
<div class="step-num">1</div>
<h3>Lizenz buchen & branden</h3>
<p>Sie erhalten Ihre App unter Ihrem Logo, Ihren Farben und Ihrer Domain — fertig in 48 Stunden.</p>
<h3>QR-Code aufkleben</h3>
<p>Robuste NFC/QR-Tags direkt am Kühlschrank, Warmhaltebehälter oder an der Hygiene-Station.</p>
</div>
<div class="step">
<div class="step-num">2</div>
<h3>Kunden einrichten</h3>
<p>Stationen anlegen, QR-Codes ausdrucken, beim Kunden aufkleben. Ihr Kunde braucht nichts installieren.</p>
<h3>Scannen & ausfüllen</h3>
<p>Techniker scannt den Code, trägt Temperatur ein, macht bei Abweichung ein Foto — fertig.</p>
</div>
<div class="step">
<div class="step-num">3</div>
<h3>Protokolle abrufen</h3>
<p>Ihre Kunden erfassen täglich — Sie sehen alles in Echtzeit und exportieren Protokolle per Knopfdruck als PDF.</p>
<h3>Protokoll abrufen</h3>
<p>Vollständiges Monatsprotokoll als PDF — für Lebensmittelkontrolle, Franchise-Geber oder eigene Dokumentation.</p>
</div>
</div>
</div>
@ -341,7 +71,7 @@
<!-- FEATURES -->
<section class="features">
<div class="container">
<h2>Was checkflo für Sie leistet</h2>
<h2>Warum checkflo?</h2>
<div class="features-grid">
{#each features as f}
<div class="feature-card">
@ -354,134 +84,15 @@
</div>
</section>
<!-- PRICING -->
<section class="pricing">
<div class="container">
<div class="pricing-intro">
<h2>White-Label-Lizenz für Prüf- und Wartungsbetriebe</h2>
<p class="pricing-sub">
Sie treten gegenüber Ihren Kunden mit einer <strong>eigenen gebrandeten App</strong> auf —
checkflo läuft im Hintergrund. Ihre Kunden sehen nur Ihr Logo, Ihre Farben, Ihre Domain.
</p>
<div class="pricing-math">
<span class="math-example">10 Kunden × 30 €/Monat</span>
<span class="math-arrow"></span>
<span class="math-result">300 € MRR bei 249 € Lizenz</span>
</div>
</div>
<div class="pricing-grid">
<div class="plan">
<div class="plan-name">Basic White-Label</div>
<div class="plan-price">249 €<span>/Monat</span></div>
<p class="plan-desc">Für den Einstieg — bis zu 10 Kundenbetriebe</p>
<ul class="plan-features">
<li>✓ Ihr Logo & CI-Farben in der App</li>
<li>✓ Subdomain (app.ihr-betrieb.de)</li>
<li>✓ Bis zu 500 Assets / QR-Tags gesamt</li>
<li>✓ HACCP-Protokoll-Templates</li>
<li>✓ PDF-Export für Ihre Kunden</li>
<li>✓ Offline-fähige PWA</li>
</ul>
<button class="btn-plan" onclick={() => openModal('Basic White-Label')}>Demo vereinbaren</button>
</div>
<div class="plan plan-featured">
<div class="plan-badge">Empfohlen</div>
<div class="plan-name">Pro White-Label</div>
<div class="plan-price">499 €<span>/Monat</span></div>
<p class="plan-desc">Für wachsende Betriebe ohne Asset-Limit</p>
<ul class="plan-features">
<li>✓ Eigene Custom Domain Ihrer Kunden</li>
<li>✓ Unlimitierte Assets & Kundenbetriebe</li>
<li>✓ Individueller Formular-Generator</li>
<li>✓ API-Zugang (ERP, Excel-Export)</li>
<li>✓ Dynamisches Branding je Kunde</li>
<li>✓ Prioritäts-Support</li>
</ul>
<button class="btn-plan btn-plan-featured" onclick={() => openModal('Pro White-Label')}>Demo vereinbaren</button>
</div>
<div class="plan">
<div class="plan-name">Enterprise</div>
<div class="plan-price">ab 1.500 €<span>/Monat</span></div>
<p class="plan-desc">Für Ketten, Verbände & große Facility-Manager</p>
<ul class="plan-features">
<li>✓ Dedizierte Server-Infrastruktur</li>
<li>✓ SAP / MS Dynamics Anbindung</li>
<li>✓ On-Premise Installation möglich</li>
<li>✓ SLA & dedizierter Ansprechpartner</li>
<li>✓ Individuelle Entwicklung</li>
<li>✓ Schulung & Onboarding</li>
</ul>
<button class="btn-plan" onclick={() => openModal('Enterprise')}>Angebot anfragen</button>
</div>
</div>
<p class="pricing-note">Keine Einrichtungsgebühr · Monatlich kündbar · Alle Pläne inkl. Updates</p>
</div>
</section>
<!-- CTA -->
<section class="cta">
<div class="container">
<h2>Bieten Sie Ihren Kunden eine eigene HACCP-App an.</h2>
<p>Für Prüfbetriebe, Ingenieurbüros und Facility-Management-Firmen.</p>
<button class="btn-primary btn-large" onclick={() => openModal()}>Kostenlose Demo vereinbaren</button>
<h2>Bereit für papierlose HACCP-Dokumentation?</h2>
<p>Für Gastronomie, Kantinen, Caterer und Lebensmittelverarbeiter.</p>
<a href="mailto:hallo@checkflo.de" class="btn-primary btn-large">Jetzt Demo vereinbaren</a>
</div>
</section>
<!-- KONTAKT-MODAL -->
{#if showModal}
<div class="modal-overlay" onclick={closeModal} onkeydown={(e) => e.key === 'Escape' && closeModal()} role="dialog" aria-modal="true" tabindex="-1">
<div class="modal" onclick={(e) => e.stopPropagation()}>
{#if formDone}
<div class="modal-success">
<div class="modal-success-icon"></div>
<h3>Vielen Dank!</h3>
<p>Wir melden uns innerhalb von 24 Stunden bei Ihnen.</p>
<button class="btn-primary" onclick={closeModal}>Schließen</button>
</div>
{:else}
<div class="modal-header">
<div>
<h3>Demo vereinbaren</h3>
{#if modalPlan}<p class="modal-plan">Plan: <strong>{modalPlan}</strong></p>{/if}
</div>
<button class="modal-close" onclick={closeModal}>✕</button>
</div>
<form onsubmit={(e) => { e.preventDefault(); submitForm(); }}>
<div class="modal-row">
<div class="modal-field">
<label for="m-name">Name *</label>
<input id="m-name" type="text" autocomplete="name" placeholder="Max Mustermann" bind:value={formName} />
</div>
<div class="modal-field">
<label for="m-company">Firma</label>
<input id="m-company" type="text" autocomplete="organization" placeholder="Muster Prüfdienste GmbH" bind:value={formCompany} />
</div>
</div>
<div class="modal-row">
<div class="modal-field">
<label for="m-email">E-Mail *</label>
<input id="m-email" type="email" autocomplete="email" placeholder="max@beispiel.de" bind:value={formEmail} />
</div>
<div class="modal-field">
<label for="m-phone">Telefon</label>
<input id="m-phone" type="tel" autocomplete="tel" placeholder="+49 123 456789" bind:value={formPhone} />
</div>
</div>
<div class="modal-field">
<label for="m-msg">Nachricht (optional)</label>
<textarea id="m-msg" rows="3" placeholder="Wie viele Kunden betreuen Sie? Welche Branche?" bind:value={formMsg}></textarea>
</div>
<div class="cf-turnstile"></div>
{#if formError}<p class="modal-error">{formError}</p>{/if}
<button type="submit" class="btn-primary" disabled={formSending}>
{formSending ? 'Wird gesendet…' : 'Anfrage senden'}
</button>
</form>
{/if}
</div>
</div>
{/if}
<!-- FOOTER -->
<footer>
<div class="container">
@ -519,224 +130,6 @@
}
.btn-nav:hover { background: #0B1023; color: #fff; }
/* MOCKUPS */
.mockups {
padding: 5rem 2rem;
background: #0B1023;
color: #fff;
overflow: hidden;
}
.mockups .eyebrow-dark {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #F97316;
text-align: center;
margin-bottom: 1rem;
}
.mockups h2 { color: #fff; text-align: center; margin-bottom: 3rem; }
.devices {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 3rem;
flex-wrap: wrap;
}
/* Browser Mockup */
.device-browser {
flex: 1;
min-width: 320px;
max-width: 580px;
background: #1a2035;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
border: 1px solid #2a3347;
}
.browser-bar {
background: #252d42;
padding: 0.6rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.browser-dots { display: flex; gap: 5px; }
.browser-dots span {
width: 10px; height: 10px;
border-radius: 50%;
background: #3a4055;
}
.browser-url {
flex: 1;
background: #1a2035;
border-radius: 4px;
padding: 0.2rem 0.6rem;
font-size: 0.7rem;
color: #6b7a9a;
font-family: monospace;
}
.browser-screen { display: flex; height: 420px; overflow: hidden; }
/* CSS Dashboard */
.dash-sidebar {
width: 120px;
background: #0B1023;
padding: 1rem 0.75rem;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dash-brand-logo { width: 100%; margin-bottom: 1rem; }
.dash-nav-item.schmidt { background: rgba(42,155,87,0.15); color: #2A9B57; }
.dash-nav-item {
padding: 0.4rem 0.5rem;
border-radius: 5px;
font-size: 0.65rem;
color: #6b7a9a;
background: none;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
transition: all 0.15s;
}
.dash-nav-item:hover { color: #fff; background: rgba(255,255,255,0.06); }
.dash-nav-item.active { background: rgba(249,115,22,0.15); color: #F97316; }
.dash-main {
flex: 1;
padding: 1rem;
background: #F5F7FA;
overflow: hidden;
}
.dash-title { font-size: 0.9rem; font-weight: 800; color: #0B1023; }
.dash-date { font-size: 0.65rem; color: #888; margin-bottom: 0.75rem; }
.dash-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.dash-stat {
background: #fff;
border-radius: 6px;
padding: 0.4rem;
border-left: 2.5px solid #ddd;
}
.dash-stat.ok { border-color: #16a34a; }
.dash-stat.warn { border-color: #ea580c; }
.dash-stat.crit { border-color: #dc2626; }
.dash-num { font-size: 1rem; font-weight: 800; color: #0B1023; line-height: 1; }
.dash-label { font-size: 0.5rem; color: #888; margin-top: 0.15rem; }
.dash-refresh { font-size: 0.55rem; color: #aaa; }
.dash-entries-title { font-size: 0.7rem; font-weight: 700; color: #0B1023; margin-bottom: 0.4rem; }
.dash-empty { font-size: 0.65rem; color: #aaa; padding: 0.5rem 0; }
/* Protokoll-Tabelle */
.proto-table { width: 100%; border-collapse: collapse; font-size: 0.72rem; margin-top: 0.5rem; }
.proto-table th { background: #F5F7FA; padding: 0.4rem 0.5rem; text-align: left; color: #666; font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.3px; }
.proto-table td { padding: 0.45rem 0.5rem; border-bottom: 1px solid #f0f0f0; color: #333; }
.proto-station { font-weight: 600; color: #0B1023; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.proto-time { color: #888; }
/* Stationen-Grid */
.stations-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; margin-top: 0.5rem; }
.station-card { background: #F5F7FA; border-radius: 6px; padding: 0.5rem; }
.station-card-icon { width: 16px; height: 16px; margin-bottom: 0.25rem; filter: invert(30%) sepia(20%) saturate(500%) hue-rotate(182deg); }
.station-card-name { font-size: 0.6rem; font-weight: 700; color: #0B1023; }
.station-card-temp { font-size: 0.55rem; color: #888; margin-top: 0.1rem; }
.dash-entry {
background: #fff;
border-radius: 6px;
padding: 0.4rem 0.5rem;
font-size: 0.6rem;
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.3rem;
}
.entry-badge {
padding: 0.1rem 0.35rem;
border-radius: 10px;
font-weight: 700;
font-size: 0.55rem;
white-space: nowrap;
}
.entry-station { flex: 1; font-weight: 600; color: #0B1023; }
.entry-temp { color: #555; }
.entry-who { color: #aaa; white-space: nowrap; }
/* Phone Mockup */
.device-phone {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.phone-frame {
width: 220px;
background: #1a1a2e;
border-radius: 36px;
padding: 12px;
box-shadow: 0 24px 60px rgba(0,0,0,0.6), inset 0 0 0 1px #2a2a4a;
}
.phone-notch {
width: 60px;
height: 20px;
background: #1a1a2e;
border-radius: 10px;
margin: 0 auto 8px;
border: 1.5px solid #2a2a4a;
}
.phone-screen {
background: #fff;
border-radius: 24px;
overflow: hidden;
height: 490px;
position: relative;
}
.phone-screen iframe {
position: absolute;
top: 0;
left: 0;
width: 390px;
height: 1200px;
border: none;
transform: scale(0.502);
transform-origin: top left;
pointer-events: auto;
}
.phone-home {
width: 60px;
height: 4px;
background: #3a3a5a;
border-radius: 2px;
margin: 10px auto 0;
}
.phone-caption {
font-size: 0.8rem;
color: #6b7a9a;
text-align: center;
}
@media (max-width: 768px) {
.devices {
flex-direction: column;
align-items: center;
}
.device-browser {
max-width: 100%;
min-width: unset;
}
.browser-screen { height: 220px; }
}
/* HERO */
.hero {
background: linear-gradient(135deg, #0B1023 0%, #16213e 100%);
@ -861,84 +254,6 @@
.feature-card h3 { font-size: 1.1rem; margin-bottom: 0.5rem; }
.feature-card p { color: #555; font-size: 0.95rem; }
/* PRICING */
.pricing { padding: 5rem 2rem; background: #F5F7FA; }
.pricing-intro { text-align: center; margin-bottom: 3rem; }
.pricing-sub { color: #555; margin-top: 1rem; margin-bottom: 1.5rem; font-size: 1.05rem; max-width: 640px; margin-left: auto; margin-right: auto; }
.pricing-math {
display: inline-flex;
align-items: center;
gap: 0.75rem;
background: #fff;
border: 1.5px solid #F97316;
border-radius: 10px;
padding: 0.6rem 1.25rem;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.math-example { color: #555; }
.math-arrow { color: #F97316; font-size: 1.2rem; }
.math-result { font-weight: 700; color: #0B1023; }
.plan-desc { font-size: 0.85rem; color: #888; margin-bottom: 1.25rem; margin-top: -0.75rem; }
.pricing-note { text-align: center; margin-top: 2rem; font-size: 0.85rem; color: #aaa; }
.pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
align-items: stretch;
}
.plan {
background: #fff;
border-radius: 16px;
padding: 2rem;
border: 1.5px solid #eee;
display: flex;
flex-direction: column;
position: relative;
}
.plan-featured {
border-color: #F97316;
box-shadow: 0 4px 24px rgba(249,115,22,0.12);
}
.plan-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: #F97316;
color: #fff;
padding: 0.2rem 0.9rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 700;
}
.plan-name { font-size: 0.85rem; font-weight: 700; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 0.75rem; }
.plan-price { font-size: 2rem; font-weight: 800; color: #0B1023; margin-bottom: 1.5rem; }
.plan-price span { font-size: 1rem; font-weight: 400; color: #888; }
.plan-features { list-style: none; margin: 0 0 2rem; padding: 0; display: flex; flex-direction: column; gap: 0.6rem; flex: 1; }
.plan-features li { font-size: 0.9rem; color: #444; }
.btn-plan {
display: block;
text-align: center;
padding: 0.75rem;
border-radius: 8px;
font-weight: 700;
font-size: 0.95rem;
border: 1.5px solid #0B1023;
color: #0B1023;
transition: all 0.15s;
}
.btn-plan:hover { background: #0B1023; color: #fff; }
.btn-plan-featured { background: #F97316; border-color: #F97316; color: #fff; }
.btn-plan-featured:hover { background: #ea6c10; border-color: #ea6c10; }
/* CTA */
.cta {
background: #0B1023;
@ -954,44 +269,6 @@
padding: 1.5rem 2rem;
border-top: 1px solid #f0f0f0;
}
/* MODAL */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(11,16,35,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 1rem;
backdrop-filter: blur(4px);
}
.modal {
background: #fff; border-radius: 16px;
padding: 2rem; width: 100%; max-width: 540px;
box-shadow: 0 24px 60px rgba(0,0,0,0.3);
}
.modal-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; }
.modal-header h3 { font-size: 1.3rem; font-weight: 800; color: #0B1023; }
.modal-plan { font-size: 0.85rem; color: #888; margin-top: 0.2rem; }
.modal-close { background: none; border: none; font-size: 1.2rem; color: #aaa; cursor: pointer; padding: 0.25rem; }
.modal-close:hover { color: #0B1023; }
.modal-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.modal-field { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 1rem; }
.modal-field label { font-size: 0.85rem; font-weight: 600; color: #444; }
.modal-field input, .modal-field textarea {
border: 1.5px solid #ddd; border-radius: 8px;
padding: 0.65rem 0.9rem; font-size: 0.95rem;
font-family: inherit; resize: vertical;
transition: border-color 0.15s;
}
.modal-field input:focus, .modal-field textarea:focus { outline: none; border-color: #F97316; }
.modal-error { color: #dc2626; font-size: 0.85rem; background: #fef2f2; padding: 0.6rem; border-radius: 8px; margin-bottom: 1rem; }
.modal-success { text-align: center; padding: 1rem 0; }
.modal-success-icon { width: 3.5rem; height: 3.5rem; background: #dcfce7; color: #16a34a; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; margin: 0 auto 1rem; }
.modal-success h3 { font-size: 1.3rem; font-weight: 800; color: #0B1023; margin-bottom: 0.5rem; }
.modal-success p { color: #666; margin-bottom: 1.5rem; }
@media (max-width: 480px) { .modal-row { grid-template-columns: 1fr; } }
footer .container {
display: flex;
justify-content: space-between;

View file

@ -3,11 +3,6 @@
import { goto } from '$app/navigation';
import { pb } from '$lib/pb';
import logo from '$lib/assets/checkflo-logo.png';
import iconDashboard from '$lib/assets/icons/dashboard.svg';
import iconLogs from '$lib/assets/icons/logs.svg';
import iconProtokoll from '$lib/assets/icons/protokoll.svg';
import iconStations from '$lib/assets/icons/stations.svg';
import iconLogout from '$lib/assets/icons/logout.svg';
let { children } = $props();
@ -19,10 +14,9 @@
}
const navItems = [
{ href: '/admin/dashboard', label: 'Dashboard', icon: iconDashboard },
{ href: '/admin/logs', label: 'Einträge', icon: iconLogs },
{ href: '/admin/protokoll', label: 'Protokoll', icon: iconProtokoll },
{ href: '/admin/stations', label: 'Stationen', icon: iconStations }
{ href: '/admin/dashboard', label: 'Dashboard', icon: '◈' },
{ href: '/admin/logs', label: 'Protokoll', icon: '≡' },
{ href: '/admin/stations', label: 'Stationen', icon: '⊞' }
];
</script>
@ -41,15 +35,12 @@
class="nav-item"
class:active={$page.url.pathname.startsWith(item.href)}
>
<img src={item.icon} alt="" class="nav-icon" />
<span class="nav-icon">{item.icon}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<button class="logout" onclick={logout}>
<img src={iconLogout} alt="" class="nav-icon" />
Abmelden
</button>
<button class="logout" onclick={logout}>Abmelden</button>
</aside>
<main>
@ -93,39 +84,20 @@
}
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
.nav-item.active { background: rgba(249,115,22,0.15); color: #F97316; }
.nav-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
filter: brightness(0) saturate(100%) invert(55%) sepia(14%) saturate(400%) hue-rotate(182deg);
}
.nav-item.active .nav-icon {
filter: brightness(0) saturate(100%) invert(56%) sepia(85%) saturate(600%) hue-rotate(359deg);
}
.nav-item:hover .nav-icon {
filter: brightness(0) invert(1);
}
.nav-icon { font-size: 1.1rem; }
.logout {
display: flex;
align-items: center;
gap: 0.75rem;
background: none;
border: 1px solid #2a3347;
color: #8892a4;
padding: 0.6rem 0.75rem;
padding: 0.6rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.15s;
margin-top: 1rem;
width: 100%;
}
.logout:hover { border-color: #F97316; color: #F97316; }
.logout:hover .nav-icon {
filter: brightness(0) saturate(100%) invert(56%) sepia(85%) saturate(600%) hue-rotate(359deg);
}
main {
flex: 1;
@ -133,12 +105,6 @@
padding: 2rem;
}
@media print {
aside { display: none !important; }
:global(.shell) { display: block !important; }
main { padding: 0 !important; overflow: visible !important; }
}
@media (max-width: 640px) {
.shell { flex-direction: column; }
aside {
@ -149,9 +115,8 @@
}
.logo-link { margin-bottom: 0; margin-right: auto; }
nav { flex-direction: row; flex: 0; gap: 0.5rem; }
.nav-item span { display: none; }
.logout { margin-top: 0; padding: 0.5rem 0.75rem; width: auto; }
.logout :global(span) { display: none; }
.nav-item span:last-child { display: none; }
.logout { margin-top: 0; padding: 0.5rem 0.75rem; }
main { padding: 1rem; }
}
</style>

View file

@ -24,9 +24,7 @@
const dateStr = today.toISOString().slice(0, 19).replace('T', ' ');
const result = await pb.collection('check_logs').getList(1, 50, {
filter: `tenant = '${tenantId}' && created >= '${dateStr}'`,
expand: 'station',
sort: '-created'
sort: '-id'
});
logs = result.items;
@ -42,10 +40,7 @@
});
function formatTime(iso: string) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
</script>

View file

@ -25,7 +25,7 @@
const result = await pb.collection('check_logs').getList(p, PER_PAGE, {
filter: `tenant = '${tenantId}'`,
expand: 'station',
sort: '-created'
sort: '-id'
});
logs = result.items;
page = p;
@ -38,10 +38,7 @@
}
function formatDate(iso: string) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleString('de-DE', {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
});

View file

@ -1,259 +0,0 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
import iconPrinter from '$lib/assets/icons/printer.svg';
const STATUS_LABEL: Record<string, string> = { ok: 'OK', abweichung: 'Abweichung', kritisch: 'Kritisch' };
const STATUS_COLOR: Record<string, string> = { ok: '#16a34a', abweichung: '#ea580c', kritisch: '#dc2626' };
const now = new Date();
let selectedMonth = $state(now.getMonth() + 1);
let selectedYear = $state(now.getFullYear());
let logs = $state<any[]>([]);
let tenantName = $state('');
let loading = $state(true);
const months = ['Januar','Februar','März','April','Mai','Juni',
'Juli','August','September','Oktober','November','Dezember'];
onMount(() => loadData());
async function loadData() {
loading = true;
try {
const user = await pb.collection('users').getOne(pb.authStore.record!.id);
const tenant = await pb.collection('tenants').getOne(user.tenant);
tenantName = tenant.name;
const from = `${selectedYear}-${String(selectedMonth).padStart(2,'0')}-01 00:00:00`;
const lastDay = new Date(selectedYear, selectedMonth, 0).getDate();
const to = `${selectedYear}-${String(selectedMonth).padStart(2,'0')}-${lastDay} 23:59:59`;
const result = await pb.collection('check_logs').getFullList({
filter: `tenant = '${user.tenant}' && created >= '${from}' && created <= '${to}'`,
expand: 'station',
sort: 'created'
});
logs = result;
} catch {
} finally {
loading = false;
}
}
function formatDate(iso: string) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' });
}
// Logs nach Station gruppieren
const byStation = $derived(() => {
const map: Record<string, any[]> = {};
for (const log of logs) {
const name = log.expand?.station?.name ?? 'Unbekannt';
(map[name] ??= []).push(log);
}
return map;
});
</script>
<svelte:head><title>Protokoll {months[selectedMonth-1]} {selectedYear} — checkflo</title></svelte:head>
<!-- Steuerung (nicht drucken) -->
<div class="controls no-print">
<div class="controls-left">
<h1>Monatsprotokoll</h1>
</div>
<div class="controls-right">
<select bind:value={selectedMonth} onchange={loadData}>
{#each months as m, i}
<option value={i+1}>{m}</option>
{/each}
</select>
<select bind:value={selectedYear} onchange={loadData}>
{#each [2025, 2026, 2027] as y}
<option value={y}>{y}</option>
{/each}
</select>
<button class="btn-print" onclick={() => window.print()}>
<img src={iconPrinter} alt="" class="btn-icon" /> Drucken / PDF
</button>
</div>
</div>
<!-- Protokoll (wird gedruckt) -->
<div class="protokoll">
<div class="protokoll-header">
<div>
<div class="protokoll-title">HACCP-Temperatur & Hygieneprotokoll</div>
<div class="protokoll-subtitle">{months[selectedMonth-1]} {selectedYear}</div>
</div>
<div class="protokoll-tenant">{tenantName}</div>
</div>
{#if loading}
<p class="hint no-print">Lädt…</p>
{:else if logs.length === 0}
<p class="hint no-print">Keine Einträge im gewählten Zeitraum.</p>
<p class="empty-print">Keine Einträge im {months[selectedMonth-1]} {selectedYear}.</p>
{:else}
{#each Object.entries(byStation()) as [stationName, stationLogs]}
<div class="station-block">
<div class="station-header">{stationName}</div>
<table>
<thead>
<tr>
<th>Datum / Zeit</th>
<th>Temp. (°C)</th>
<th>Status</th>
<th>Notiz</th>
<th>Geprüft von</th>
</tr>
</thead>
<tbody>
{#each stationLogs as log}
<tr>
<td>{formatDate(log.created)}</td>
<td>{log.temperature ? log.temperature + ' °C' : '—'}</td>
<td>
<span class="badge" style="color:{STATUS_COLOR[log.status]}">
{STATUS_LABEL[log.status] ?? log.status}
</span>
</td>
<td>{log.notes || '—'}</td>
<td>{log.checked_by}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
<div class="summary">
<strong>Gesamt:</strong> {logs.length} Einträge ·
{logs.filter(l=>l.status==='ok').length} OK ·
{logs.filter(l=>l.status==='abweichung').length} Abweichungen ·
{logs.filter(l=>l.status==='kritisch').length} Kritisch
</div>
{/if}
<div class="protokoll-footer">
<div>Erstellt: {new Date().toLocaleDateString('de-DE')} · checkflo.de</div>
<div class="signature-block">
<div class="signature-line">___________________________</div>
<div class="signature-label">Unterschrift Verantwortlicher</div>
</div>
</div>
</div>
<style>
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
h1 { font-size: 1.6rem; font-weight: 800; color: #0B1023; }
.controls-right { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
select {
padding: 0.5rem 0.75rem;
border: 1.5px solid #ddd;
border-radius: 8px;
font-size: 0.9rem;
background: #fff;
}
.btn-print {
padding: 0.6rem 1.25rem;
background: #0B1023;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.btn-print:hover { background: #1a2640; }
.btn-icon { width: 16px; height: 16px; vertical-align: middle; margin-right: 0.25rem; filter: brightness(0) invert(1); }
.hint { color: #aaa; }
.empty-print { display: none; }
/* Protokoll-Layout */
.protokoll {
background: #fff;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
max-width: 900px;
}
.protokoll-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid #0B1023;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
.protokoll-title { font-size: 1.1rem; font-weight: 800; color: #0B1023; }
.protokoll-subtitle { font-size: 0.9rem; color: #666; margin-top: 0.25rem; }
.protokoll-tenant { font-weight: 700; color: #0B1023; text-align: right; }
.station-block { margin-bottom: 2rem; }
.station-header {
background: #F5F7FA;
padding: 0.5rem 1rem;
font-weight: 700;
font-size: 0.95rem;
color: #0B1023;
border-left: 3px solid #F97316;
margin-bottom: 0.5rem;
border-radius: 0 4px 4px 0;
}
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { background: #F5F7FA; padding: 0.5rem 0.75rem; text-align: left; font-size: 0.75rem; color: #888; text-transform: uppercase; }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f0f0f0; }
tr:last-child td { border-bottom: none; }
.badge { font-weight: 700; }
.summary {
margin-top: 1.5rem;
padding: 0.75rem 1rem;
background: #F5F7FA;
border-radius: 8px;
font-size: 0.9rem;
color: #555;
}
.protokoll-footer {
margin-top: 3rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
font-size: 0.8rem;
color: #aaa;
}
.signature-block { text-align: center; }
.signature-line { border-top: 1px solid #999; width: 200px; margin-bottom: 0.25rem; }
.signature-label { font-size: 0.75rem; color: #888; }
@media print {
.no-print { display: none !important; }
.empty-print { display: block; }
.protokoll {
box-shadow: none;
padding: 0;
border-radius: 0;
}
table { font-size: 0.8rem; }
@page { margin: 15mm; size: A4; }
}
</style>

View file

@ -1,8 +1,6 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
import QRCode from 'qrcode';
import iconPrinter from '$lib/assets/icons/printer.svg';
const TYPE_LABEL: Record<string, string> = {
kuehlschrank: 'Kühlschrank',
@ -13,30 +11,20 @@
};
let stations = $state<any[]>([]);
let qrCodes = $state<Record<string, string>>({});
let loading = $state(true);
let tenantName = $state('');
let copied = $state<string | null>(null);
onMount(async () => {
try {
const user = await pb.collection('users').getOne(pb.authStore.record!.id);
const tenantId = user.tenant;
const tenant = await pb.collection('tenants').getOne(tenantId);
tenantName = tenant.name;
const result = await pb.collection('stations').getFullList({
filter: `tenant = '${tenantId}' && active = true`,
sort: 'name'
});
stations = result;
// QR-Codes mit Logo generieren
const codes: Record<string, string> = {};
for (const s of result) {
codes[s.qr_id] = await generateQRWithLogo(qrUrl(s.qr_id));
}
qrCodes = codes;
} catch {
// Keine Stationen
} finally {
loading = false;
}
@ -46,60 +34,18 @@
return `https://checkflo.de/s/${qrId}`;
}
async function generateQRWithLogo(url: string): Promise<string> {
const size = 400;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
await QRCode.toCanvas(canvas, url, {
width: size,
margin: 1,
errorCorrectionLevel: 'H',
color: { dark: '#0B1023', light: '#ffffff' }
});
const ctx = canvas.getContext('2d')!;
const logoSize = Math.round(size * 0.22);
const x = (size - logoSize) / 2;
const y = (size - logoSize) / 2;
const pad = 6;
const r = 10;
// Weißer abgerundeter Hintergrund
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.roundRect(x - pad, y - pad, logoSize + pad * 2, logoSize + pad * 2, r);
ctx.fill();
// Logo laden und zeichnen
const img = new Image();
img.src = '/favicon.svg';
await new Promise<void>(resolve => { img.onload = () => resolve(); });
ctx.drawImage(img, x, y, logoSize, logoSize);
return canvas.toDataURL('image/png');
}
function printAll() {
window.print();
async function copyUrl(qrId: string) {
await navigator.clipboard.writeText(qrUrl(qrId));
copied = qrId;
setTimeout(() => copied = null, 2000);
}
</script>
<svelte:head><title>Stationen — checkflo</title></svelte:head>
<div class="page no-print">
<div class="header-row">
<div>
<h1>Stationen</h1>
<p class="hint-text">QR-Codes ausdrucken und an den Stationen befestigen.</p>
</div>
{#if stations.length > 0}
<button class="btn-print" onclick={printAll}>
<img src={iconPrinter} alt="" class="btn-icon" /> Alle drucken
</button>
{/if}
</div>
<div class="page">
<h1>Stationen</h1>
<p class="hint-text">Jeden QR-Code ausdrucken und an der Station befestigen. Der Link öffnet die Checkliste direkt im Browser.</p>
{#if loading}
<p class="hint">Lädt…</p>
@ -107,19 +53,29 @@
<div class="list">
{#each stations as s}
<div class="card">
<div class="card-body">
<div class="card-info">
<div class="card-head">
<div>
<div class="station-name">{s.name}</div>
<div class="station-type">{TYPE_LABEL[s.type] ?? s.type}
{#if s.target_temp_min || s.target_temp_max}
· {s.target_temp_min}° bis {s.target_temp_max}°C
{/if}
</div>
<code class="qr-url">{qrUrl(s.qr_id)}</code>
</div>
{#if qrCodes[s.qr_id]}
<img src={qrCodes[s.qr_id]} alt="QR {s.name}" class="qr-img" />
{/if}
</div>
<div class="qr-row">
<code class="qr-url">{qrUrl(s.qr_id)}</code>
<button class="btn-copy" onclick={() => copyUrl(s.qr_id)}>
{copied === s.qr_id ? '✓ Kopiert' : 'Kopieren'}
</button>
<a
href="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encodeURIComponent(qrUrl(s.qr_id))}"
target="_blank"
class="btn-qr"
>
QR öffnen
</a>
</div>
</div>
{/each}
@ -127,105 +83,65 @@
{/if}
</div>
<!-- Druckansicht -->
{#if !loading}
<div class="print-sheet">
{#each stations as s}
<div class="print-card">
<div class="print-tenant">{tenantName}</div>
<div class="print-name">{s.name}</div>
<div class="print-type">{TYPE_LABEL[s.type] ?? s.type}
{#if s.target_temp_min || s.target_temp_max}
· Zielbereich {s.target_temp_min}° bis {s.target_temp_max}°C
{/if}
</div>
{#if qrCodes[s.qr_id]}
<img src={qrCodes[s.qr_id]} alt="QR" class="print-qr" />
{/if}
<div class="print-url">{qrUrl(s.qr_id)}</div>
<div class="print-footer">Scan → Checkliste ausfüllen → fertig</div>
</div>
{/each}
</div>
{/if}
<style>
.page { max-width: 800px; }
.header-row { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 2rem; }
h1 { font-size: 1.6rem; font-weight: 800; color: #0B1023; margin-bottom: 0.25rem; }
.hint-text { color: #666; font-size: 0.9rem; }
h1 { font-size: 1.6rem; font-weight: 800; color: #0B1023; margin-bottom: 0.5rem; }
.hint-text { color: #666; font-size: 0.9rem; margin-bottom: 2rem; }
.hint { color: #aaa; }
.btn-print {
padding: 0.6rem 1.25rem;
background: #0B1023;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.btn-print:hover { background: #1a2640; }
.btn-icon {
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 0.25rem;
filter: brightness(0) invert(1);
}
.list { display: flex; flex-direction: column; gap: 1rem; }
.card {
background: #fff;
border-radius: 12px;
padding: 1.25rem 1.5rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.card-body { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.card-info { flex: 1; }
.card-head { margin-bottom: 1rem; }
.station-name { font-weight: 700; font-size: 1.05rem; color: #0B1023; }
.station-type { font-size: 0.85rem; color: #888; margin: 0.2rem 0 0.75rem; }
.station-type { font-size: 0.85rem; color: #888; margin-top: 0.2rem; }
.qr-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.qr-url {
font-size: 0.75rem;
flex: 1;
font-size: 0.8rem;
background: #F5F7FA;
padding: 0.4rem 0.6rem;
border-radius: 5px;
padding: 0.5rem 0.75rem;
border-radius: 6px;
color: #555;
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
.qr-img { width: 100px; height: 100px; flex-shrink: 0; }
/* Druckansicht — nur beim Drucken sichtbar */
.print-sheet { display: none; }
@media print {
.no-print { display: none !important; }
.print-sheet {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
}
.print-card {
border: 1px solid #ddd;
padding: 1.5rem;
text-align: center;
page-break-inside: avoid;
break-inside: avoid;
}
.print-tenant { font-size: 0.7rem; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 0.5rem; }
.print-name { font-size: 1.2rem; font-weight: 800; color: #0B1023; margin-bottom: 0.25rem; }
.print-type { font-size: 0.8rem; color: #666; margin-bottom: 1rem; }
.print-qr { width: 160px; height: 160px; margin: 0 auto 0.75rem; display: block; }
.print-url { font-size: 0.65rem; color: #888; margin-bottom: 0.5rem; word-break: break-all; }
.print-footer { font-size: 0.7rem; color: #F97316; font-weight: 600; }
.btn-copy, .btn-qr {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.btn-copy {
background: #F5F7FA;
border: 1.5px solid #ddd;
color: #555;
}
.btn-copy:hover { border-color: #F97316; color: #F97316; }
.btn-qr {
background: #F97316;
color: #fff;
border: none;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.btn-qr:hover { background: #ea6c10; }
</style>

View file

@ -1,44 +0,0 @@
import { json } from '@sveltejs/kit';
export async function POST({ request }) {
const body = await request.json();
const { token, name, email, company, phone, message, plan } = body;
if (!name?.trim() || !email?.trim()) {
return json({ error: 'Name und E-Mail sind Pflichtfelder.' }, { status: 400 });
}
const secret = process.env.TURNSTILE_SECRET ?? '';
// Turnstile serverseitig verifizieren
const verification = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, response: token })
});
const result = await verification.json();
if (!result.success) {
return json({ error: 'Captcha-Verifizierung fehlgeschlagen.' }, { status: 400 });
}
// Anfrage in PocketBase speichern
const pb = await fetch('https://api.checkflo.de/api/collections/inquiries/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
email: email.trim(),
company: company?.trim() ?? '',
phone: phone?.trim() ?? '',
message: message?.trim() ?? '',
plan: plan ?? ''
})
});
if (!pb.ok) {
return json({ error: 'Fehler beim Speichern.' }, { status: 500 });
}
return json({ success: true });
}

View file

@ -85,7 +85,7 @@
<!-- HEADER -->
<header>
{#if tenant?.name}<div class="tenant-name">{tenant.name}</div>{/if}
<div class="tenant-name">{tenant?.name ?? 'checkflo'}</div>
<div class="station-name">{station.name}</div>
{#if station.target_temp_min || station.target_temp_max}
<div class="temp-range">
@ -176,7 +176,7 @@
{/if}
<footer>
{#if tenant?.name}<span>{tenant.name}</span>{/if}
<a href="/">checkflo</a>
</footer>
</div>
@ -187,7 +187,7 @@
flex-direction: column;
max-width: 480px;
margin: 0 auto;
padding: 0 0 0.5rem;
padding: 0 0 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@ -347,9 +347,10 @@
/* FOOTER */
footer {
text-align: center;
padding: 0.5rem;
font-size: 0.75rem;
color: #ccc;
margin-top: auto;
padding: 1rem;
font-size: 0.8rem;
color: #aaa;
}
footer a { color: #aaa; }
footer a:hover { color: var(--brand); }
</style>

View file

@ -8,10 +8,8 @@ services:
volumes:
- /volume1/docker/checkflo/pocketbase/data:/pb_data
- /volume1/docker/checkflo/pocketbase/storage:/pb_public
- /volume1/docker/checkflo/pocketbase/data/pb_hooks:/pb_hooks
environment:
- TZ=Europe/Berlin
- BREVO_KEY=${BREVO_KEY}
networks:
- default
- npm_bridge
@ -27,7 +25,6 @@ services:
- TZ=Europe/Berlin
- HOST=0.0.0.0
- PORT=3000
- TURNSTILE_SECRET=${TURNSTILE_SECRET}
networks:
- default
- npm_bridge

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

View file

@ -1,36 +0,0 @@
onRecordAfterCreateSuccess(function(e) {
if (!e.record) return;
var key = $os.getenv("BREVO_KEY");
if (!key) return;
var name = e.record.getString("name").replace(/"/g, "'");
var company = e.record.getString("company").replace(/"/g, "'");
var email = e.record.getString("email").replace(/"/g, "'");
var phone = e.record.getString("phone").replace(/"/g, "'");
var message = e.record.getString("message").replace(/"/g, "'");
var plan = e.record.getString("plan").replace(/"/g, "'");
var subject = "checkflo: Anfrage von " + name + (company ? " (" + company + ")" : "");
var text = "Von: " + name + (company ? " / " + company : "") +
"\nEmail: " + email +
(phone ? "\nTel: " + phone : "") +
(plan ? "\nPlan: " + plan : "") +
(message ? "\nNachricht: " + message : "");
var body = '{"sender":{"name":"checkflo","email":"hallo@checkflo.de"},' +
'"to":[{"email":"checkflo@motocamp.de"}],' +
'"subject":"' + subject + '",' +
'"textContent":"' + text.replace(/\n/g, "\\n") + '"}';
try {
$http.send({
url: "https://api.brevo.com/v3/smtp/email",
method: "POST",
headers: { "api-key": key, "Content-Type": "application/json" },
body: body
});
} catch(err) {
console.error("Brevo error: " + String(err));
}
}, "inquiries");

View file

@ -1,93 +0,0 @@
#!/bin/bash
# Löscht heutige Demo-Einträge und erstellt frische für die letzte Stunde.
# Cron: jede Stunde via DSM Task Scheduler oder crontab
set -euo pipefail
PB_URL="${PB_URL:-https://api.checkflo.de}"
if [ -z "${PB_EMAIL:-}" ] || [ -z "${PB_PASSWORD:-}" ]; then
echo "Aufruf: PB_EMAIL=... PB_PASSWORD=... ./demo-refresh.sh"; exit 1
fi
TOKEN=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
-H "Content-Type: application/json" \
-d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" | jq -r '.token')
TENANT_ID="mengbzc3ajxpccz"
NOW=$(date +"%Y-%m-%d %H:00:00")
TODAY=$(date +"%Y-%m-%d")
# Alle heutigen Demo-Einträge löschen
EXISTING=$(curl -sf \
"$PB_URL/api/collections/check_logs/records?filter=tenant%3D'$TENANT_ID'%26%26created%3E%3D'$TODAY%2000%3A00%3A00'&perPage=200" \
-H "Authorization: $TOKEN" | jq -r '.items[].id')
COUNT_DEL=0
for id in $EXISTING; do
curl -sf -X DELETE "$PB_URL/api/collections/check_logs/records/$id" \
-H "Authorization: $TOKEN" > /dev/null
COUNT_DEL=$((COUNT_DEL + 1))
done
echo "$COUNT_DEL alte Einträge gelöscht"
# Stationen laden
STATIONS=$(curl -sf "$PB_URL/api/collections/stations/records?filter=tenant%3D'$TENANT_ID'%26%26active%3Dtrue&perPage=50" \
-H "Authorization: $TOKEN")
STATION_COUNT=$(echo "$STATIONS" | jq '.items | length')
NAMES=("Maria S." "Klaus B." "Sandra M." "Tobias R." "Anna K." "Michael F.")
NOTES_ABW=("Tür stand leicht offen" "Dichtung prüfen" "Temperatur leicht erhöht")
NOTES_KRIT=("Tür war 2h offen" "Kompressor ausgefallen" "Sofortmaßnahme eingeleitet")
rand_between() { echo $(( $1 + RANDOM % ($2 - $1 + 1) )); }
create_log() {
curl -sf -X POST "$PB_URL/api/collections/check_logs/records" \
-H "Authorization: $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"station\": \"$1\",
\"tenant\": \"$TENANT_ID\",
\"temperature\": $2,
\"status\": \"$3\",
\"notes\": \"$4\",
\"checked_by\": \"$5\",
\"created\": \"$6\",
\"updated\": \"$6\"
}" > /dev/null
}
# Für jede vergangene Stunde seit 7:00 Einträge erstellen
CURRENT_HOUR=$(date +"%H")
COUNT_NEW=0
for HOUR in $(seq 7 $CURRENT_HOUR); do
for i in $(seq 0 $((STATION_COUNT - 1))); do
SID=$(echo "$STATIONS" | jq -r ".items[$i].id")
TYPE=$(echo "$STATIONS" | jq -r ".items[$i].type")
TMIN=$(echo "$STATIONS" | jq -r ".items[$i].target_temp_min // 0")
TMAX=$(echo "$STATIONS" | jq -r ".items[$i].target_temp_max // 0")
PERSON=${NAMES[$((RANDOM % ${#NAMES[@]}))]}
MIN=$(rand_between 0 59)
TIME="$TODAY $(printf "%02d" $HOUR):$(printf "%02d" $MIN):00"
if [ "$TYPE" = "hygiene" ] && [ "$HOUR" != "9" ]; then continue; fi
RAND=$((RANDOM % 100))
if [ $RAND -lt 88 ]; then
TEMP=$(rand_between $TMIN $TMAX)
create_log "$SID" "$TEMP" "ok" "" "$PERSON" "$TIME"
elif [ $RAND -lt 96 ]; then
TEMP=$((TMAX + 1 + RANDOM % 3))
NOTE=${NOTES_ABW[$((RANDOM % ${#NOTES_ABW[@]}))]}
create_log "$SID" "$TEMP" "abweichung" "$NOTE" "$PERSON" "$TIME"
else
TEMP=$((TMAX + 4 + RANDOM % 4))
NOTE=${NOTES_KRIT[$((RANDOM % ${#NOTES_KRIT[@]}))]}
create_log "$SID" "$TEMP" "kritisch" "$NOTE" "$PERSON" "$TIME"
fi
COUNT_NEW=$((COUNT_NEW + 1))
done
done
echo "$COUNT_NEW neue Demo-Einträge erstellt (bis $(printf "%02d" $CURRENT_HOUR):00 Uhr)"

View file

@ -1,53 +0,0 @@
#!/bin/bash
# Legt Demo-Tenant "Schmidt Hygiene GmbH" mit blauer CI an
set -euo pipefail
PB_URL="${PB_URL:-https://api.checkflo.de}"
if [ -z "${PB_EMAIL:-}" ] || [ -z "${PB_PASSWORD:-}" ]; then
echo "Aufruf: PB_EMAIL=... PB_PASSWORD=... ./seed-schmidt.sh"; exit 1
fi
TOKEN=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
-H "Content-Type: application/json" \
-d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" | jq -r '.token')
echo "✓ Eingeloggt"
# Tenant anlegen
TENANT=$(curl -sf -X POST "$PB_URL/api/collections/tenants/records" \
-H "Authorization: $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Schmidt Hygiene GmbH",
"slug": "schmidt-hygiene",
"primary_color": "#1B4FBF",
"plan": "pro",
"active": true
}')
TENANT_ID=$(echo "$TENANT" | jq -r '.id')
echo "✓ Tenant: Schmidt Hygiene GmbH ($TENANT_ID)"
# Station anlegen (Kühltheke Metzgerei)
QR_ID=$(python3 -c "import uuid; print(str(uuid.uuid4()))" 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen | tr '[:upper:]' '[:lower:]')
STATION=$(curl -sf -X POST "$PB_URL/api/collections/stations/records" \
-H "Authorization: $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"tenant\": \"$TENANT_ID\",
\"name\": \"Fleischkühltheke\",
\"type\": \"kuehlschrank\",
\"target_temp_min\": 0,
\"target_temp_max\": 4,
\"qr_id\": \"$QR_ID\",
\"active\": true
}")
STATION_ID=$(echo "$STATION" | jq -r '.id')
echo "✓ Station: Fleischkühltheke ($QR_ID)"
echo ""
echo " QR-Scan URL: https://checkflo.de/s/$QR_ID"
echo " Tenant-ID: $TENANT_ID"
echo ""
echo " → Diese QR-URL in +page.svelte für den iPhone-Mockup eintragen"

View file

@ -1,140 +0,0 @@
#!/bin/bash
# Generiert realistische HACCP-Testdaten für die letzten 30 Tage
set -euo pipefail
PB_URL="${PB_URL:-https://api.checkflo.de}"
if [ -z "${PB_EMAIL:-}" ] || [ -z "${PB_PASSWORD:-}" ]; then
echo "Aufruf: PB_EMAIL=... PB_PASSWORD=... ./seed-testdata.sh"
exit 1
fi
TOKEN=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
-H "Content-Type: application/json" \
-d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" | jq -r '.token')
echo "✓ Eingeloggt"
TENANT_ID="mengbzc3ajxpccz"
# Stationen als JSON laden
STATIONS_JSON=$(curl -sf "$PB_URL/api/collections/stations/records?filter=tenant%3D'$TENANT_ID'&perPage=50" \
-H "Authorization: $TOKEN")
STATION_COUNT=$(echo "$STATIONS_JSON" | jq '.items | length')
echo "$STATION_COUNT Stationen gefunden"
NAMES=("Maria S." "Klaus B." "Sandra M." "Tobias R." "Anna K." "Michael F." "Lisa W.")
NOTES_ABW=("Tür stand leicht offen" "Dichtung prüfen" "Kompressor läuft laut" "Temperatur leicht erhöht" "Nach Lieferung noch warm")
NOTES_KRIT=("Tür war 2h offen" "Kompressor ausgefallen" "Sofortmaßnahme eingeleitet" "Produkte aussortiert" "Techniker verständigt")
create_log() {
local station_id=$1
local date_str=$2
local status=$3
local temp=$4
local notes=$5
local person=$6
curl -sf -X POST "$PB_URL/api/collections/check_logs/records" \
-H "Authorization: $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"station\": \"$station_id\",
\"tenant\": \"$TENANT_ID\",
\"temperature\": $temp,
\"status\": \"$status\",
\"notes\": \"$notes\",
\"checked_by\": \"$person\",
\"created\": \"$date_str\",
\"updated\": \"$date_str\"
}" > /dev/null
}
rand_between() {
local min=$1
local max=$2
echo $(( min + RANDOM % (max - min + 1) ))
}
echo "→ Erstelle Einträge für die letzten 30 Tage..."
COUNT=0
for DAY in $(seq 29 -1 0); do
# Datum berechnen (macOS + Linux kompatibel)
if date -v -${DAY}d +"%Y-%m-%d" > /dev/null 2>&1; then
DATE=$(date -v -${DAY}d +"%Y-%m-%d")
DOW=$(date -v -${DAY}d +"%u")
else
DATE=$(date -d "-${DAY} days" +"%Y-%m-%d")
DOW=$(date -d "-${DAY} days" +"%u")
fi
for i in $(seq 0 $((STATION_COUNT - 1))); do
SID=$(echo "$STATIONS_JSON" | jq -r ".items[$i].id")
TYPE=$(echo "$STATIONS_JSON" | jq -r ".items[$i].type")
TMIN=$(echo "$STATIONS_JSON" | jq -r ".items[$i].target_temp_min // 0")
TMAX=$(echo "$STATIONS_JSON" | jq -r ".items[$i].target_temp_max // 0")
PERSON=${NAMES[$((RANDOM % ${#NAMES[@]}))]}
if [ "$TYPE" = "kuehlschrank" ] || [ "$TYPE" = "tiefkuehl" ]; then
# Morgencheck
HOUR=$(rand_between 7 8)
MIN_T=$(rand_between 0 5)
SEC_T=$(rand_between 0 59)
TIME="${DATE} 0${HOUR}:$(printf "%02d" $MIN_T):$(printf "%02d" $SEC_T)"
RAND=$((RANDOM % 100))
if [ $RAND -lt 90 ]; then
TEMP=$(rand_between $TMIN $TMAX)
create_log "$SID" "$TIME" "ok" "$TEMP" "" "$PERSON"
elif [ $RAND -lt 98 ]; then
TEMP=$(( TMAX + 1 + RANDOM % 3 ))
NOTE=${NOTES_ABW[$((RANDOM % ${#NOTES_ABW[@]}))]}
create_log "$SID" "$TIME" "abweichung" "$TEMP" "$NOTE" "$PERSON"
else
TEMP=$(( TMAX + 4 + RANDOM % 5 ))
NOTE=${NOTES_KRIT[$((RANDOM % ${#NOTES_KRIT[@]}))]}
create_log "$SID" "$TIME" "kritisch" "$TEMP" "$NOTE" "$PERSON"
fi
COUNT=$((COUNT + 1))
# Abendcheck (Mo-Fr)
if [ "$DOW" -lt 6 ]; then
HOUR=$(rand_between 17 19)
TIME="${DATE} ${HOUR}:$(printf "%02d" $(rand_between 0 59)):00"
TEMP=$(rand_between $TMIN $TMAX)
create_log "$SID" "$TIME" "ok" "$TEMP" "" "$PERSON"
COUNT=$((COUNT + 1))
fi
elif [ "$TYPE" = "warmhalte" ]; then
TIME="${DATE} 11:$(printf "%02d" $(rand_between 0 30)):00"
RAND=$((RANDOM % 100))
if [ $RAND -lt 85 ]; then
TEMP=$(rand_between $TMIN $TMAX)
create_log "$SID" "$TIME" "ok" "$TEMP" "" "$PERSON"
else
TEMP=$(( TMIN - 3 - RANDOM % 5 ))
NOTE=${NOTES_ABW[$((RANDOM % ${#NOTES_ABW[@]}))]}
create_log "$SID" "$TIME" "abweichung" "$TEMP" "$NOTE" "$PERSON"
fi
COUNT=$((COUNT + 1))
elif [ "$TYPE" = "hygiene" ]; then
if [ "$DOW" -eq 1 ]; then
TIME="${DATE} 09:$(printf "%02d" $(rand_between 0 30)):00"
create_log "$SID" "$TIME" "ok" "0" "" "$PERSON"
COUNT=$((COUNT + 1))
fi
fi
done
printf " Tag -%02d: %s (%s)..." "$DAY" "$DATE" "DOW $DOW"
echo " OK"
done
echo ""
echo "$COUNT Einträge erstellt"

View file

@ -1,36 +0,0 @@
#!/bin/bash
# Legt die inquiries-Collection für Kontaktanfragen an
set -euo pipefail
PB_URL="${PB_URL:-https://api.checkflo.de}"
if [ -z "${PB_EMAIL:-}" ] || [ -z "${PB_PASSWORD:-}" ]; then
echo "Aufruf: PB_EMAIL=... PB_PASSWORD=... ./setup-inquiries.sh"; exit 1
fi
TOKEN=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
-H "Content-Type: application/json" \
-d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" | jq -r '.token')
curl -sf -X POST "$PB_URL/api/collections" \
-H "Authorization: $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "inquiries",
"type": "base",
"fields": [
{"name": "name", "type": "text", "required": true},
{"name": "company", "type": "text"},
{"name": "email", "type": "email", "required": true},
{"name": "phone", "type": "text"},
{"name": "message", "type": "text"},
{"name": "plan", "type": "text"}
],
"listRule": null,
"viewRule": null,
"createRule": "",
"updateRule": null,
"deleteRule": null
}' | jq .name
echo "✓ inquiries Collection angelegt"