From c7927f288354d71c2ed6a80c2cd5883c2f01d3f0 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 17 Aug 2026 14:30:28 +0200 Subject: [PATCH] feat: Wabenhain Honig-Shop Anwendung Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests. --- .env.example | 82 + README.md | 268 +- e2e/auth.spec.ts | 56 + e2e/cart.spec.ts | 26 + e2e/checkout.spec.ts | 33 + e2e/homepage.spec.ts | 17 + e2e/shop.spec.ts | 22 + next.config.ts | 44 +- package.json | 51 +- playwright.config.ts | 22 + pnpm-lock.yaml | 3200 ++++++++++++++++- pnpm-workspace.yaml | 10 +- prisma.config.ts | 15 + .../20260808043638_init/migration.sql | 322 ++ .../migration.sql | 10 + .../20260808150810_admin_role/migration.sql | 5 + .../migration.sql | 24 + .../migration.sql | 24 + .../migration.sql | 3 + .../migration.sql | 12 + .../migration.sql | 54 + .../migration.sql | 57 + .../migration.sql | 5 + .../migration.sql | 17 + .../migration.sql | 3 + .../migration.sql | 21 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 633 ++++ prisma/seed.ts | 265 ++ public/.well-known/security.txt | 4 + scripts/generate-prisma-clients.mjs | 47 + scripts/prisma-provider.mjs | 40 + src/app/admin/actions.ts | 14 + src/app/admin/actions/_shared.ts | 55 + src/app/admin/actions/content.ts | 59 + src/app/admin/actions/customers.ts | 194 + src/app/admin/actions/discount-codes.ts | 96 + src/app/admin/actions/harvests-batches.ts | 96 + src/app/admin/actions/inquiries.ts | 27 + src/app/admin/actions/orders.ts | 164 + src/app/admin/actions/products.ts | 125 + src/app/admin/actions/reviews.ts | 47 + src/app/admin/actions/shipping.ts | 86 + src/app/admin/anfragen/page.tsx | 117 + src/app/admin/audit-log/page.tsx | 52 + src/app/admin/benutzer/[id]/page.tsx | 162 + src/app/admin/benutzer/neu/page.tsx | 68 + src/app/admin/benutzer/page.tsx | 158 + src/app/admin/bestellungen/[id]/page.tsx | 227 ++ src/app/admin/bestellungen/page.tsx | 147 + src/app/admin/bewertungen/page.tsx | 130 + src/app/admin/chargen/[id]/page.tsx | 73 + src/app/admin/chargen/page.tsx | 110 + src/app/admin/ernten/[id]/page.tsx | 77 + src/app/admin/ernten/page.tsx | 107 + src/app/admin/error.tsx | 36 + src/app/admin/gutscheine/[id]/page.tsx | 123 + src/app/admin/gutscheine/neu/page.tsx | 81 + src/app/admin/gutscheine/page.tsx | 91 + src/app/admin/inhalte/page.tsx | 145 + src/app/admin/layout.tsx | 55 + src/app/admin/loading.tsx | 31 + src/app/admin/page.tsx | 357 ++ src/app/admin/produkte/[id]/page.tsx | 155 + src/app/admin/produkte/neu/page.tsx | 156 + src/app/admin/produkte/page.tsx | 69 + src/app/admin/retouren/actions.ts | 106 + src/app/admin/retouren/page.tsx | 128 + src/app/admin/versand/[id]/page.tsx | 82 + src/app/admin/versand/neu/page.tsx | 59 + src/app/admin/versand/page.tsx | 73 + src/app/agb/page.tsx | 20 + src/app/api/business-inquiry/route.ts | 41 + src/app/api/checkout/route.ts | 293 ++ src/app/api/checkout/webhook/route.ts | 209 ++ src/app/api/konto/datenexport/route.ts | 92 + src/app/api/konto/email-bestaetigen/route.ts | 40 + src/app/api/newsletter/confirm/route.ts | 26 + src/app/api/newsletter/route.ts | 54 + src/app/api/newsletter/unsubscribe/route.ts | 24 + src/app/api/shop/search-suggestions/route.ts | 37 + src/app/bestellung-verfolgen/page.tsx | 162 + src/app/charge/[code]/page.tsx | 96 + src/app/checkout/cancel/page.tsx | 28 + src/app/checkout/checkout-form.test.tsx | 58 + src/app/checkout/checkout-form.tsx | 279 ++ src/app/checkout/loading.tsx | 23 + src/app/checkout/page.tsx | 49 + src/app/checkout/success/layout.tsx | 12 + src/app/checkout/success/page.tsx | 52 + src/app/cookie-richtlinie/page.tsx | 27 + src/app/datenschutz/page.tsx | 32 + src/app/ernten/[year]/[type]/page.tsx | 81 + src/app/ernten/page.tsx | 63 + src/app/error.tsx | 37 + src/app/global-error.tsx | 57 + src/app/globals.css | 65 +- src/app/herkunft/page.tsx | 46 + src/app/honig/[slug]/loading.tsx | 42 + src/app/honig/[slug]/opengraph-image.tsx | 90 + src/app/honig/[slug]/page.tsx | 369 ++ src/app/honig/actions.ts | 150 + src/app/icon.svg | 21 + src/app/imkerei/page.tsx | 37 + src/app/impressum/page.tsx | 37 + src/app/kontakt/page.tsx | 47 + src/app/konto/actions.ts | 11 + src/app/konto/actions/_shared.ts | 12 + src/app/konto/actions/account.ts | 65 + src/app/konto/actions/addresses.ts | 55 + src/app/konto/actions/login.ts | 147 + src/app/konto/actions/newsletter.ts | 31 + src/app/konto/actions/password-reset.ts | 138 + src/app/konto/actions/registration.ts | 93 + src/app/konto/actions/two-factor.ts | 125 + src/app/konto/adressen/page.tsx | 131 + .../konto/bestellungen/[id]/retoure/page.tsx | 83 + src/app/konto/bestellungen/actions.ts | 60 + src/app/konto/bestellungen/loading.tsx | 38 + src/app/konto/bestellungen/page.tsx | 179 + src/app/konto/page.tsx | 257 ++ src/app/konto/passwort-vergessen/page.tsx | 51 + src/app/konto/passwort-zuruecksetzen/page.tsx | 52 + src/app/konto/wunschliste/page.tsx | 55 + src/app/konto/zwei-faktor-code/page.tsx | 52 + .../konto/zwei-faktor/backup-codes/page.tsx | 47 + src/app/konto/zwei-faktor/einrichten/page.tsx | 67 + src/app/konto/zwei-faktor/page.tsx | 74 + src/app/layout.tsx | 116 +- src/app/manifest.ts | 34 + src/app/newsletter-bestaetigt/page.tsx | 57 + src/app/not-found.tsx | 25 + src/app/opengraph-image.tsx | 64 + src/app/page.tsx | 125 +- src/app/rechnung/[invoiceNumber]/page.tsx | 113 + src/app/rechnung/[invoiceNumber]/pdf/route.ts | 40 + src/app/robots.ts | 15 + src/app/shop/actions.ts | 21 + src/app/shop/loading.tsx | 40 + src/app/shop/page.tsx | 179 + src/app/sitemap.ts | 59 + src/app/unternehmen/page.tsx | 46 + src/app/versand-zahlung/page.tsx | 47 + src/app/warenkorb/loading.tsx | 32 + src/app/warenkorb/page.tsx | 35 + src/app/warenkorb/warenkorb-content.test.tsx | 65 + src/app/warenkorb/warenkorb-content.tsx | 151 + src/app/widerrufsbelehrung/page.tsx | 20 + src/app/wissen/[slug]/opengraph-image.tsx | 66 + src/app/wissen/[slug]/page.tsx | 106 + src/app/wissen/page.tsx | 37 + src/components/account-ui.tsx | 222 ++ src/components/admin-shell.tsx | 171 + src/components/back-link.tsx | 20 + src/components/business-inquiry-form.tsx | 132 + src/components/cart-item-thumbnail.tsx | 16 + src/components/confirm-form.tsx | 32 + src/components/cookie-consent.tsx | 83 + src/components/hero.tsx | 89 + src/components/home-sections-a.tsx | 186 + src/components/home-sections-b.tsx | 192 + src/components/language-switcher.tsx | 50 + src/components/legal-page.tsx | 44 + src/components/logomark.tsx | 37 + src/components/newsletter-signup.tsx | 88 + src/components/password-field.tsx | 131 + src/components/placeholder-image.tsx | 47 + src/components/product-card.tsx | 240 ++ src/components/product-gallery.tsx | 68 + src/components/quantity-stepper.tsx | 44 + src/components/recently-viewed-section.tsx | 47 + src/components/record-product-view.tsx | 17 + src/components/reorder-button.tsx | 51 + src/components/scroll-reveal.tsx | 35 + src/components/site-chrome.tsx | 600 ++++ src/components/star-rating.tsx | 40 + src/components/trust-badges.tsx | 75 + src/components/turnstile-widget.tsx | 60 + src/components/ui.tsx | 107 + src/i18n/actions.ts | 22 + src/i18n/config.ts | 21 + src/i18n/dictionaries/de.ts | 1391 +++++++ src/i18n/dictionaries/en.ts | 1390 +++++++ src/i18n/dictionary-type.ts | 1107 ++++++ src/i18n/get-dictionary.ts | 13 + src/i18n/i18n-provider.tsx | 34 + src/i18n/locale.ts | 9 + src/lib/account-data.ts | 69 + src/lib/audit-log.ts | 35 + src/lib/auth.ts | 185 + src/lib/bulk-discount-calc.test.ts | 48 + src/lib/bulk-discount-calc.ts | 33 + src/lib/bulk-discount.ts | 44 + src/lib/company-info.ts | 44 + src/lib/content-parsers.test.ts | 91 + src/lib/content-parsers.ts | 50 + src/lib/content-registry.ts | 80 + src/lib/content.ts | 50 + src/lib/data.ts | 132 + src/lib/db-adapter.ts | 22 + src/lib/discount-calc.test.ts | 90 + src/lib/discount-calc.ts | 40 + src/lib/email-strings.ts | 309 ++ src/lib/email-templates.ts | 371 ++ src/lib/email-verification.ts | 33 + src/lib/format.test.ts | 41 + src/lib/format.ts | 41 + src/lib/google-reviews.ts | 74 + src/lib/inventory.ts | 19 + src/lib/invoice-pdf.ts | 130 + src/lib/json-array.test.ts | 32 + src/lib/json-array.ts | 12 + src/lib/mail.ts | 74 + src/lib/newsletter-discount.ts | 25 + src/lib/password-breach-check.ts | 39 + src/lib/password-reset.ts | 34 + src/lib/prisma-search.test.ts | 49 + src/lib/prisma-search.ts | 16 + src/lib/prisma.ts | 37 + src/lib/rate-limit.ts | 57 + src/lib/reviews.ts | 73 + src/lib/session.ts | 176 + src/lib/shipment-tracking.ts | 15 + src/lib/shipping-calc.test.ts | 71 + src/lib/shipping-calc.ts | 50 + src/lib/shipping.ts | 13 + src/lib/site-config.ts | 93 + src/lib/stock-notifications.ts | 37 + src/lib/stripe.ts | 32 + src/lib/turnstile.ts | 40 + src/lib/two-factor.ts | 67 + src/lib/url.ts | 8 + src/lib/validation.test.ts | 139 + src/lib/validation.ts | 195 + src/lib/wishlist.ts | 29 + src/proxy.ts | 96 + src/store/cart-store.ts | 77 + src/store/recently-viewed-store.ts | 32 + vitest.config.ts | 21 + vitest.setup.ts | 7 + 240 files changed, 26096 insertions(+), 137 deletions(-) create mode 100644 .env.example create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/cart.spec.ts create mode 100644 e2e/checkout.spec.ts create mode 100644 e2e/homepage.spec.ts create mode 100644 e2e/shop.spec.ts create mode 100644 playwright.config.ts create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20260808043638_init/migration.sql create mode 100644 prisma/migrations/20260808051645_product_categories/migration.sql create mode 100644 prisma/migrations/20260808150810_admin_role/migration.sql create mode 100644 prisma/migrations/20260808185522_mail_and_password_reset/migration.sql create mode 100644 prisma/migrations/20260810011736_two_factor_and_lockout/migration.sql create mode 100644 prisma/migrations/20260810015710_customer_blocked_at/migration.sql create mode 100644 prisma/migrations/20260810095348_shipping_tiers/migration.sql create mode 100644 prisma/migrations/20260811182136_reviews_discounts/migration.sql create mode 100644 prisma/migrations/20260811210328_wishlist_audit_email_verification/migration.sql create mode 100644 prisma/migrations/20260811210752_backfill_existing_customers_verified/migration.sql create mode 100644 prisma/migrations/20260812212850_stock_notifications/migration.sql create mode 100644 prisma/migrations/20260812222911_inquiry_status_abgelehnt/migration.sql create mode 100644 prisma/migrations/20260812223624_return_requests/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 public/.well-known/security.txt create mode 100644 scripts/generate-prisma-clients.mjs create mode 100644 scripts/prisma-provider.mjs create mode 100644 src/app/admin/actions.ts create mode 100644 src/app/admin/actions/_shared.ts create mode 100644 src/app/admin/actions/content.ts create mode 100644 src/app/admin/actions/customers.ts create mode 100644 src/app/admin/actions/discount-codes.ts create mode 100644 src/app/admin/actions/harvests-batches.ts create mode 100644 src/app/admin/actions/inquiries.ts create mode 100644 src/app/admin/actions/orders.ts create mode 100644 src/app/admin/actions/products.ts create mode 100644 src/app/admin/actions/reviews.ts create mode 100644 src/app/admin/actions/shipping.ts create mode 100644 src/app/admin/anfragen/page.tsx create mode 100644 src/app/admin/audit-log/page.tsx create mode 100644 src/app/admin/benutzer/[id]/page.tsx create mode 100644 src/app/admin/benutzer/neu/page.tsx create mode 100644 src/app/admin/benutzer/page.tsx create mode 100644 src/app/admin/bestellungen/[id]/page.tsx create mode 100644 src/app/admin/bestellungen/page.tsx create mode 100644 src/app/admin/bewertungen/page.tsx create mode 100644 src/app/admin/chargen/[id]/page.tsx create mode 100644 src/app/admin/chargen/page.tsx create mode 100644 src/app/admin/ernten/[id]/page.tsx create mode 100644 src/app/admin/ernten/page.tsx create mode 100644 src/app/admin/error.tsx create mode 100644 src/app/admin/gutscheine/[id]/page.tsx create mode 100644 src/app/admin/gutscheine/neu/page.tsx create mode 100644 src/app/admin/gutscheine/page.tsx create mode 100644 src/app/admin/inhalte/page.tsx create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/loading.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/produkte/[id]/page.tsx create mode 100644 src/app/admin/produkte/neu/page.tsx create mode 100644 src/app/admin/produkte/page.tsx create mode 100644 src/app/admin/retouren/actions.ts create mode 100644 src/app/admin/retouren/page.tsx create mode 100644 src/app/admin/versand/[id]/page.tsx create mode 100644 src/app/admin/versand/neu/page.tsx create mode 100644 src/app/admin/versand/page.tsx create mode 100644 src/app/agb/page.tsx create mode 100644 src/app/api/business-inquiry/route.ts create mode 100644 src/app/api/checkout/route.ts create mode 100644 src/app/api/checkout/webhook/route.ts create mode 100644 src/app/api/konto/datenexport/route.ts create mode 100644 src/app/api/konto/email-bestaetigen/route.ts create mode 100644 src/app/api/newsletter/confirm/route.ts create mode 100644 src/app/api/newsletter/route.ts create mode 100644 src/app/api/newsletter/unsubscribe/route.ts create mode 100644 src/app/api/shop/search-suggestions/route.ts create mode 100644 src/app/bestellung-verfolgen/page.tsx create mode 100644 src/app/charge/[code]/page.tsx create mode 100644 src/app/checkout/cancel/page.tsx create mode 100644 src/app/checkout/checkout-form.test.tsx create mode 100644 src/app/checkout/checkout-form.tsx create mode 100644 src/app/checkout/loading.tsx create mode 100644 src/app/checkout/page.tsx create mode 100644 src/app/checkout/success/layout.tsx create mode 100644 src/app/checkout/success/page.tsx create mode 100644 src/app/cookie-richtlinie/page.tsx create mode 100644 src/app/datenschutz/page.tsx create mode 100644 src/app/ernten/[year]/[type]/page.tsx create mode 100644 src/app/ernten/page.tsx create mode 100644 src/app/error.tsx create mode 100644 src/app/global-error.tsx create mode 100644 src/app/herkunft/page.tsx create mode 100644 src/app/honig/[slug]/loading.tsx create mode 100644 src/app/honig/[slug]/opengraph-image.tsx create mode 100644 src/app/honig/[slug]/page.tsx create mode 100644 src/app/honig/actions.ts create mode 100644 src/app/icon.svg create mode 100644 src/app/imkerei/page.tsx create mode 100644 src/app/impressum/page.tsx create mode 100644 src/app/kontakt/page.tsx create mode 100644 src/app/konto/actions.ts create mode 100644 src/app/konto/actions/_shared.ts create mode 100644 src/app/konto/actions/account.ts create mode 100644 src/app/konto/actions/addresses.ts create mode 100644 src/app/konto/actions/login.ts create mode 100644 src/app/konto/actions/newsletter.ts create mode 100644 src/app/konto/actions/password-reset.ts create mode 100644 src/app/konto/actions/registration.ts create mode 100644 src/app/konto/actions/two-factor.ts create mode 100644 src/app/konto/adressen/page.tsx create mode 100644 src/app/konto/bestellungen/[id]/retoure/page.tsx create mode 100644 src/app/konto/bestellungen/actions.ts create mode 100644 src/app/konto/bestellungen/loading.tsx create mode 100644 src/app/konto/bestellungen/page.tsx create mode 100644 src/app/konto/page.tsx create mode 100644 src/app/konto/passwort-vergessen/page.tsx create mode 100644 src/app/konto/passwort-zuruecksetzen/page.tsx create mode 100644 src/app/konto/wunschliste/page.tsx create mode 100644 src/app/konto/zwei-faktor-code/page.tsx create mode 100644 src/app/konto/zwei-faktor/backup-codes/page.tsx create mode 100644 src/app/konto/zwei-faktor/einrichten/page.tsx create mode 100644 src/app/konto/zwei-faktor/page.tsx create mode 100644 src/app/manifest.ts create mode 100644 src/app/newsletter-bestaetigt/page.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/app/opengraph-image.tsx create mode 100644 src/app/rechnung/[invoiceNumber]/page.tsx create mode 100644 src/app/rechnung/[invoiceNumber]/pdf/route.ts create mode 100644 src/app/robots.ts create mode 100644 src/app/shop/actions.ts create mode 100644 src/app/shop/loading.tsx create mode 100644 src/app/shop/page.tsx create mode 100644 src/app/sitemap.ts create mode 100644 src/app/unternehmen/page.tsx create mode 100644 src/app/versand-zahlung/page.tsx create mode 100644 src/app/warenkorb/loading.tsx create mode 100644 src/app/warenkorb/page.tsx create mode 100644 src/app/warenkorb/warenkorb-content.test.tsx create mode 100644 src/app/warenkorb/warenkorb-content.tsx create mode 100644 src/app/widerrufsbelehrung/page.tsx create mode 100644 src/app/wissen/[slug]/opengraph-image.tsx create mode 100644 src/app/wissen/[slug]/page.tsx create mode 100644 src/app/wissen/page.tsx create mode 100644 src/components/account-ui.tsx create mode 100644 src/components/admin-shell.tsx create mode 100644 src/components/back-link.tsx create mode 100644 src/components/business-inquiry-form.tsx create mode 100644 src/components/cart-item-thumbnail.tsx create mode 100644 src/components/confirm-form.tsx create mode 100644 src/components/cookie-consent.tsx create mode 100644 src/components/hero.tsx create mode 100644 src/components/home-sections-a.tsx create mode 100644 src/components/home-sections-b.tsx create mode 100644 src/components/language-switcher.tsx create mode 100644 src/components/legal-page.tsx create mode 100644 src/components/logomark.tsx create mode 100644 src/components/newsletter-signup.tsx create mode 100644 src/components/password-field.tsx create mode 100644 src/components/placeholder-image.tsx create mode 100644 src/components/product-card.tsx create mode 100644 src/components/product-gallery.tsx create mode 100644 src/components/quantity-stepper.tsx create mode 100644 src/components/recently-viewed-section.tsx create mode 100644 src/components/record-product-view.tsx create mode 100644 src/components/reorder-button.tsx create mode 100644 src/components/scroll-reveal.tsx create mode 100644 src/components/site-chrome.tsx create mode 100644 src/components/star-rating.tsx create mode 100644 src/components/trust-badges.tsx create mode 100644 src/components/turnstile-widget.tsx create mode 100644 src/components/ui.tsx create mode 100644 src/i18n/actions.ts create mode 100644 src/i18n/config.ts create mode 100644 src/i18n/dictionaries/de.ts create mode 100644 src/i18n/dictionaries/en.ts create mode 100644 src/i18n/dictionary-type.ts create mode 100644 src/i18n/get-dictionary.ts create mode 100644 src/i18n/i18n-provider.tsx create mode 100644 src/i18n/locale.ts create mode 100644 src/lib/account-data.ts create mode 100644 src/lib/audit-log.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/bulk-discount-calc.test.ts create mode 100644 src/lib/bulk-discount-calc.ts create mode 100644 src/lib/bulk-discount.ts create mode 100644 src/lib/company-info.ts create mode 100644 src/lib/content-parsers.test.ts create mode 100644 src/lib/content-parsers.ts create mode 100644 src/lib/content-registry.ts create mode 100644 src/lib/content.ts create mode 100644 src/lib/data.ts create mode 100644 src/lib/db-adapter.ts create mode 100644 src/lib/discount-calc.test.ts create mode 100644 src/lib/discount-calc.ts create mode 100644 src/lib/email-strings.ts create mode 100644 src/lib/email-templates.ts create mode 100644 src/lib/email-verification.ts create mode 100644 src/lib/format.test.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/google-reviews.ts create mode 100644 src/lib/inventory.ts create mode 100644 src/lib/invoice-pdf.ts create mode 100644 src/lib/json-array.test.ts create mode 100644 src/lib/json-array.ts create mode 100644 src/lib/mail.ts create mode 100644 src/lib/newsletter-discount.ts create mode 100644 src/lib/password-breach-check.ts create mode 100644 src/lib/password-reset.ts create mode 100644 src/lib/prisma-search.test.ts create mode 100644 src/lib/prisma-search.ts create mode 100644 src/lib/prisma.ts create mode 100644 src/lib/rate-limit.ts create mode 100644 src/lib/reviews.ts create mode 100644 src/lib/session.ts create mode 100644 src/lib/shipment-tracking.ts create mode 100644 src/lib/shipping-calc.test.ts create mode 100644 src/lib/shipping-calc.ts create mode 100644 src/lib/shipping.ts create mode 100644 src/lib/site-config.ts create mode 100644 src/lib/stock-notifications.ts create mode 100644 src/lib/stripe.ts create mode 100644 src/lib/turnstile.ts create mode 100644 src/lib/two-factor.ts create mode 100644 src/lib/url.ts create mode 100644 src/lib/validation.test.ts create mode 100644 src/lib/validation.ts create mode 100644 src/lib/wishlist.ts create mode 100644 src/proxy.ts create mode 100644 src/store/cart-store.ts create mode 100644 src/store/recently-viewed-store.ts create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2aa126c --- /dev/null +++ b/.env.example @@ -0,0 +1,82 @@ +# Datenbank: "postgresql" (Standard) oder "mysql". Nach einer Aenderung +# einmal `pnpm db:push` ausfuehren (schreibt prisma/schema.prisma neu und +# generiert den passenden Treiber-Adapter-Client) — siehe README, Abschnitt +# "Datenbank-Provider wechseln". +DATABASE_PROVIDER=postgresql + +# PostgreSQL (Standard). Fuer docker compose siehe POSTGRES_* unten. +DATABASE_URL="postgresql://wabenhain:wabenhain@localhost:5432/wabenhain?schema=public" +# MySQL/MariaDB stattdessen: +# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain" + +# Postgres-Zugangsdaten fuer docker-compose.yml (Datenbank-Container) +POSTGRES_USER=wabenhain +POSTGRES_PASSWORD=change-me +POSTGRES_DB=wabenhain + +# Nur noetig, wenn DATABASE_PROVIDER=mysql: Zugangsdaten fuer den +# db-mysql-Container ("docker compose --profile mysql up -d db-mysql") +MYSQL_USER=wabenhain +MYSQL_PASSWORD=change-me +MYSQL_DATABASE=wabenhain + +# Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys) +STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx +STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx +# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint +STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx + +# Admin-Zugang: Der interne /admin-Bereich wird NICHT per Basic-Auth +# geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die +# Rolle "ADMIN" am Konto (siehe README, Abschnitt "Admin-Zugang"). Diese +# beiden Werte legt `prisma db seed` als initiales Admin-Konto an, falls es +# noch nicht existiert. Eine UNVORHERSEHBARE E-Mail waehlen (nicht +# "admin@..." o.ae.) — sonst koennte sich vor dem ersten Seed-Lauf jemand +# genau diese Adresse selbst registrieren; prisma/seed.ts befoerdert ein +# bereits bestehendes Nicht-Admin-Konto zwar nicht mehr automatisch, ein +# unvorhersehbarer Wert ist trotzdem die robustere erste Verteidigungslinie. +ADMIN_EMAIL=change-me@example.com +ADMIN_PASSWORD=change-me + +# Geheimer Schluessel zum Signieren der Kundenkonto-JWTs (HS256). +# In Produktion MINDESTENS 32 Zeichen (wird beim Start erzwungen). +# Erzeugen z.B. mit: openssl rand -base64 32 +SESSION_SECRET=change-me-to-a-long-random-string + +# Basis-URL der Seite (fuer absolute Links, z.B. in E-Mails/Sitemaps). +# Lokale Entwicklung (pnpm dev): http://localhost:3000 (Standard hier). +# Vollstaendiger Docker-Betrieb: http://localhost (Caddy laeuft auf Port 80, +# siehe SITE_ADDRESS/Caddyfile unten) bzw. die echte Domain in Produktion. +NEXT_PUBLIC_SITE_URL=http://localhost:3000 + +# Nur fuer den vollstaendigen Docker-Betrieb (docker-compose.yml, Caddyfile): +# die oeffentliche Domain, unter der die Seite erreichbar sein soll. Leer +# gelassen laeuft Caddy lokal per HTTP auf Port 80 ohne TLS — fuer eine +# echte Domain mit automatischem HTTPS z.B. "wabenhain.com" eintragen. +SITE_ADDRESS= + +# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite +# automatisch die statischen Beispiel-Stimmen als Fallback. +# API-Key: Google-Cloud-Projekt mit aktivierter "Places API". +# Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id +GOOGLE_PLACES_API_KEY= +GOOGLE_PLACE_ID= + +# Cloudflare Turnstile (optional). Ohne diese beiden Werte wird auf +# Registrierung, Newsletter und Firmenanfrage kein CAPTCHA angezeigt und die +# serverseitige Pruefung uebersprungen. Site-Key ist oeffentlich (NEXT_PUBLIC_), +# Secret-Key bleibt privat. Einrichtung: https://dash.cloudflare.com/?to=/:account/turnstile +TURNSTILE_SECRET_KEY= +NEXT_PUBLIC_TURNSTILE_SITE_KEY= + +# SMTP fuer Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in, +# Passwort-Reset). Ohne diese drei Werte werden E-Mails nur geloggt statt +# versendet — praktisch fuer lokale Entwicklung ohne Mail-Setup. +# Funktioniert mit jedem SMTP-Anbieter (z.B. das E-Mail-Postfach eures +# Domain-Hosters wie IONOS/Strato, oder ein Dienst wie Resend/Postmark im +# SMTP-Modus). +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM="Wabenhain " diff --git a/README.md b/README.md index e215bc4..f007b00 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,260 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Wabenhain -## Getting Started +Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe. -First, run the development server: +## Tech-Stack -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +| Bereich | Technologie | +|---|---| +| Sprache | TypeScript | +| Framework | Next.js 16 (App Router) + React 19 | +| Styling | Tailwind CSS v4 | +| Scroll-/Hero-Animationen | GSAP + ScrollTrigger | +| UI-Animationen | Motion for React (Menü, Warenkorb, Karten) | +| Datenbank | PostgreSQL (Standard) oder MySQL/MariaDB, per `DATABASE_PROVIDER` wählbar — siehe „Datenbank-Provider wechseln" | +| ORM | Prisma 7 (`@prisma/adapter-pg` bzw. `@prisma/adapter-mariadb`) | +| Payment | Stripe Checkout | +| Hosting | Docker / Node.js | + +## Was ist enthalten + +- Startseite mit allen Sections aus dem Briefing (Hero, aktuelle Ernte, Herkunft, Philosophie, Produktbereich, Prozess-Timeline, Regionale Imkerei, Nachhaltigkeit, Firmenkunden-Teaser, Bewertungen, Newsletter) — die Bewertungen-Section zeigt echte Google-Rezensionen, sobald konfiguriert (siehe „Google-Rezensionen" unten), sonst statische Beispiel-Stimmen als Fallback +- Shop-Übersicht mit Suche (`?q=`, mehrwortig und case-insensitiv über Name, Beschreibung und Geschmacks-Tags), Sortierung (Neu eingetroffen/Preis auf-/absteigend/Verfügbarkeit), „Nur verfügbare"-Filter und `BreadcrumbList`-JSON-LD + Produktdetailseite (Geschmacksskala, Charge, strukturierte Daten/JSON-LD, Lagerbestands-Hinweis, „Das könnte dir auch gefallen" mit verwandten Produkten derselben Kategorie) +- Warenkorb (Drawer + Seite) mit Produktbild-Platzhaltern je Position und „Das könnte dir auch gefallen"-Cross-Sell auf der Warenkorb-Seite (verfügbare Produkte, die noch nicht im Warenkorb liegen) +- **Zuletzt angesehen**: rein lokal im Browser (localStorage, kein Tracking-Cookie) gemerkte zuletzt besuchte Produkte, als Sektion auf Produktdetail- und Shop-Seite +- **Echte Kaufsignale statt künstlicher Dringlichkeit**: Sternebewertungen (aus echten, freigeschalteten Bewertungen) jetzt auch direkt auf den Produktkarten der Shop-Übersicht, nicht nur auf der Detailseite — bewusst keine erfundenen Countdown-Timer oder Live-Besucherzahlen +- **Echte Produktbewertungen**: eingeloggte Kund:innen können ein Produkt nur bewerten, wenn sie es tatsächlich gekauft haben (verifizierter Kauf, eine Bewertung pro Konto und Produkt); neue Bewertungen erscheinen erst nach admin-Freischaltung (`/admin/bewertungen`) öffentlich, inkl. Sternebewertung, optionalem Kommentar und `AggregateRating`-JSON-LD auf der Produktseite +- **Rabattcodes/Gutscheine**: prozentual oder als fester Betrag, mit optionalem Mindestbestellwert, Gültigkeitszeitraum und Nutzungslimit, verwaltet unter `/admin/gutscheine`. Der Code wird beim Checkout eingelöst und als einmaliger Stripe-Coupon auf die Checkout-Session angewendet (Rabatt gilt nur auf den Warenwert, nicht auf den Versand); die Nutzung wird erst bei bestätigter Zahlung gezählt, nicht schon bei einem abgebrochenen Checkout +- **Lagerbestands-Hinweise**: „Ausverkauft"-/„Nur noch N"-Badges auf Produktkarte und Produktdetailseite (ab 5 Stück oder weniger), Bestellbutton wird bei Bestand 0 deaktiviert +- **Benachrichtigung bei Wiederverfügbarkeit**: bei ausverkauften Produkten kann eine E-Mail-Adresse hinterlegt werden (`StockNotification`-Modell); sobald ein Admin den Lagerbestand im Admin-Bereich wieder über 0 setzt, werden alle wartenden Adressen automatisch per E-Mail benachrichtigt (`lib/stock-notifications.ts`) +- **Retouren**: Kund:innen können im Konto (`/konto/bestellungen`) für bezahlte, nicht stornierte Bestellungen eine Rücksendung mit Begründung anfragen (`ReturnRequest`-Modell). Admin sieht alle Anfragen unter `/admin/retouren`, kann sie annehmen oder ablehnen und eine angenommene Retoure per Klick über die Stripe Refunds API erstatten — dabei wird automatisch der Lagerbestand zurückgebucht und `Payment.status` auf `REFUNDED` gesetzt. Jeder Schritt (Anfrage, Annahme/Ablehnung, Erstattung) löst eine E-Mail an die Kundschaft aus. +- **Schnell-Hinzufügen**: Produktkarten in der Shop-Übersicht haben einen direkten „In den Warenkorb"-Button für verfügbare Produkte, ohne die Produktseite öffnen zu müssen +- **Mengenauswahl**: auf der Produktseite kann die Menge vor dem Hinzufügen zum Warenkorb direkt per Mengen-Steller gewählt werden (begrenzt auf den verfügbaren Lagerbestand) +- **Wunschliste** (`/konto/wunschliste`): eingeloggte Kund:innen können Produkte auf der Produktdetailseite merken und später in ihrem Konto wiederfinden oder entfernen +- Warenkorb (Zustand-Store, persistiert im Browser) + Cart-Drawer +- Checkout mit Gastbestellung oder eingeloggtem Konto (bei Login werden Name/E-Mail/Standardadresse automatisch vorausgefüllt), Versand/Abholung, Stripe Checkout Session mit Karte, **SEPA-Lastschrift** und **Klarna** (siehe „Stripe" unten), admin-konfigurierbare **Versandkosten-Staffel** nach Gesamtmenge (siehe „Versandkosten" unten). Die Checkout-Seite zeigt Versandkosten und Gesamtsumme live an (reagiert auf Versand/Abholung-Auswahl) statt sie erst nach der Weiterleitung zu Stripe sichtbar zu machen. +- Chargen-Seite (`/charge/[code]`) für den QR-Code auf jedem Glas +- Ernten-Übersicht + verschachtelte Ernten-Detailseite (`/ernten/[jahr]/[typ]`) +- Herkunft, Imkerei, Kontakt, Unternehmen (B2B-Formular) +- Honigwissen mit allen 9 im Briefing genannten Artikeln +- Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Versand und Zahlung (**Platzhaltertexte, rechtlich prüfen lassen**) +- **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In +- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten) +- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" unten +- `sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo +- **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite. +- **Performance**: GSAP (inkl. ScrollTrigger-Plugin) wurde als Animationsbibliothek entfernt und durch `motion/react` ersetzt, das ohnehin schon für andere UI-Animationen geladen wird — spart ein komplettes zweites Animations-Framework (~45 KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten. +- **Chargen-Seite (`/charge/[code]`, QR-Code auf jedem Glas) aufgewertet**: statt einer reinen Datenliste jetzt mit "Verifizierte Charge"-Kennzeichnung, einer Ernte-→-Abfüllung-→-MHD-Zeitleiste und einem Verweis auf das zugehörige Produkt — der Moment, in dem jemand mit dem Glas in der Hand die Herkunft nachprüft, bekommt entsprechend mehr Gewicht. Alle gezeigten Angaben sind echte, bereits vorhandene Chargendaten. +- **Lieferzeit im Checkout sichtbar**: bei Versand zeigt die Lieferart-Auswahl direkt die bereits auf „Versand und Zahlung" dokumentierte übliche Lieferzeit (2–4 Werktage nach Zahlungseingang) an, statt dass Kund:innen dafür extra die Versandseite aufsuchen müssen. +- **Live-Suchvorschläge**: die Kopfzeilen-Suche zeigt beim Tippen (ab 2 Zeichen, debounced) bis zu 5 passende Produkte mit Preis direkt zum Anklicken — `/api/shop/search-suggestions`, rate-limitiert wie jeder andere öffentliche Endpunkt, nutzt dieselbe Suchlogik wie die Shop-Übersicht. +- **Ernten-Detailseiten aufgewertet**: Zurück-Link, Chargen als klickbare Karten statt Fließtext-Links, Produkte über die neue geteilte `ProductTeaserLink`-Komponente (auch auf der Chargen-Seite verwendet — kein doppelter Code für dasselbe Muster). +- **Admin-2FA-Hinweis**: Admin-Konten ohne aktivierte Zwei-Faktor-Authentifizierung sehen einen dauerhaften, nicht blockierenden Hinweis mit Link zur Einrichtung — relevant, da Admin-Zugriff Rückerstattungen und Kundendaten einschließt. +- **`.well-known/security.txt`** (RFC 9116): dokumentierter Kontaktweg für verantwortungsvolle Schwachstellenmeldungen. +- **Escape schließt Overlays + vollständiges Fokus-Management**: Cart-Drawer, mobiles Menü und die Kopfzeilen-Suche lassen sich jetzt auch per Escape-Taste schließen, nicht nur per Maus-Klick. Beim Öffnen wandert der Tastatur-Fokus aktiv in das Overlay hinein (statt auf dem jetzt verdeckten Ausloeser-Button stehen zu bleiben), Tab/Shift+Tab bleiben innerhalb des Overlays gefangen (kein Durchtabben zu unsichtbaren Links dahinter), und beim Schließen springt der Fokus zurück auf den auslösenden Button. +- **Mobile-Layout-Fixes**: die Warenkorb-Zeile (Menge/Preis/Entfernen wurde auf schmalen Viewports komplett aus dem sichtbaren Bereich gedrängt) und Platzhalterbild-Beschriftungen (hartes Abschneiden statt Kürzung mit „…") behoben — gefunden durch echte Viewport-Emulation, nicht nur Desktop-Tests. +- **Kundenkonto-Design**: der Konto-Bereich (Übersicht, Bestellungen, Adressen, Wunschliste, Zwei-Faktor) hat jetzt dieselbe optische Sorgfalt wie Shop/Checkout — Icon-Navigationskarten mit Hover-Anhebung statt reiner Text-Boxen, farbcodierte Bestellstatus-Badges statt Fließtext, und gestaltete Leer-Zustände mit Icon und Weiterleitung zum Shop statt einzelner Hinweissätze. Zusätzlich behoben: der Warenkorb-Button im Header hatte einen `aria-label`, der die sichtbare Artikelanzahl nicht einschloss (gleiches Muster wie bei der Bildergalerie). +- **Admin-Design**: alle 10 Tabellen im Admin-Bereich (Bestellungen, Produkte, Chargen, Ernten, Benutzer, Gutscheine, Versand, Bewertungen, Audit-Log) haben jetzt einen Hover-Zustand auf Zeilenebene statt komplett statischer Reihen. Das Dashboard zeigt jede Kennzahl mit einem passenden Icon, und der wichtigste Wert („Umsatz heute") ist optisch hervorgehoben statt gleichwertig neben sieben anderen Zahlen zu stehen. Die Bestellliste zeigt den Status jetzt zusätzlich als farbcodiertes Badge (wiederverwendet aus dem Kundenkonto) neben dem bestehenden Bearbeiten-Dropdown. + +### Bewusst vereinfacht + +- **Bilder**: Alle Foto-Flächen sind hochwertige, markenkonforme Platzhalter (Farbverläufe) statt echter Produkt-/Naturfotografie. Ich kann keine echten Fotos erzeugen oder beschaffen — hierfür werden eure tatsächlichen Aufnahmen benötigt. +- Rechtstexte sind Platzhalter und **müssen** vor Live-Gang anwaltlich geprüft werden. + +## Admin-Zugang + +Es gibt **kein separates Admin-Login** und keine Basic-Auth mehr. Der `/admin`-Bereich wird über dasselbe Login-Formular wie das Kundenkonto (`/konto`) erreicht — ob ein Konto zusätzlich Admin-Rechte hat, entscheidet einzig das Feld `role` (`KUNDE` | `ADMIN`) am `Customer`-Datensatz: + +- Beim Login/bei der Registrierung wird die Rolle des Kontos in die Session (JWT) geschrieben. +- `src/proxy.ts` prüft bei jedem Zugriff auf `/admin/*`, ob die Session die Rolle `ADMIN` trägt, und leitet sonst mit einer erklärenden Meldung zu `/konto` um. +- Jede Admin-Server-Action (`src/app/admin/actions.ts`) und das Admin-Layout prüfen die Rolle zusätzlich **direkt gegen die Datenbank** (`getCurrentAdmin()`) — der Proxy ist nur der schnelle Vorfilter, nicht die einzige Kontrollinstanz. +- Neue Konten über das öffentliche Registrierungsformular erhalten immer die Rolle `KUNDE`; es gibt keinen Weg, sich selbst zum Admin zu machen. + +**Erstes Admin-Konto anlegen:** `ADMIN_EMAIL` (unvorhersehbar wählen, nicht „admin@...") und `ADMIN_PASSWORD` in `.env` setzen und `pnpm db:seed` (bzw. `docker compose exec app pnpm prisma db seed`) **vor** dem ersten öffentlichen Start ausführen — legt das Konto neu an, oder erneuert bei einem bereits bestehenden Admin-Konto mit dieser E-Mail-Adresse nur dessen Passwort. Existiert unter dieser E-Mail-Adresse bereits ein **Nicht**-Admin-Konto (z. B. weil sich jemand die Adresse vorregistriert hat), befördert der Seed-Lauf es bewusst **nicht** automatisch — stattdessen erscheint eine Warnung im Log, und die Beförderung muss manuell über die Datenbank erfolgen (siehe unten). So lässt sich ein Admin-Konto nicht durch Vorregistrieren der erwarteten E-Mail-Adresse kapern. Login danach ganz normal über `/konto`. + +**Benutzerverwaltung:** Unter `/admin/benutzer` können Admins alle Konten einsehen, anlegen, bearbeiten (inkl. Rolle `KUNDE`/`ADMIN`), sperren/entsperren und löschen. Beim Anlegen wird **kein Passwort vom Admin vergeben** — das neue Konto erhält sofort eine E-Mail mit einem Link, über den die Person ihr eigenes Passwort festlegt (derselbe Mechanismus wie „Passwort vergessen"). Ein Passwort-Reset für bestehende Konten läuft genauso: der Admin-Button löst nur den Versand des Reset-Links aus, ein Admin bekommt nie ein Kunden-Passwort im Klartext zu Gesicht. + +Eingebaute Selbstschutz-Regeln verhindern, dass sich ein Admin versehentlich aussperrt: +- Ein Admin kann seine eigene Rolle nicht ändern und sich selbst nicht sperren oder löschen. +- Der letzte verbleibende `ADMIN`-Account kann nicht herabgestuft, gesperrt oder gelöscht werden. + +Alternativ weiterhin direkt über die Datenbank möglich: + +```sql +UPDATE customers SET role = 'ADMIN' WHERE email = 'person@example.com'; ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +oder per Prisma Studio (`pnpm db:studio`) am Datensatz das Feld `role` ändern. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Voraussetzungen -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +- Node.js 20.9+ (getestet mit 24.x) +- pnpm (`corepack enable` oder `npm i -g pnpm`) +- Docker + Docker Compose (für PostgreSQL lokal und für den Produktions-Build) -## Learn More +## Lokale Entwicklung -To learn more about Next.js, take a look at the following resources: +```bash +cp .env.example .env +# .env anpassen (mindestens SESSION_SECRET, ADMIN_EMAIL/ADMIN_PASSWORD; Stripe-Keys optional für UI-Arbeit) -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +pnpm install -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +# Nur die Datenbank per Docker starten: +docker compose up -d db -## Deploy on Vercel +pnpm db:migrate # erstellt Tabellen (Prisma Migrate) +pnpm db:seed # legt 2 Beispiel-Ernten/Chargen/Produkte + das Admin-Konto (ADMIN_EMAIL/ADMIN_PASSWORD) an -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +pnpm dev # http://localhost:3000 +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Admin-Bereich: `http://localhost:3000/admin` — Login mit `ADMIN_EMAIL`/`ADMIN_PASSWORD` über das normale Formular unter `/konto` (siehe „Admin-Zugang" unten). + +## Datenbank-Provider wechseln + +Die App läuft wahlweise mit PostgreSQL (Standard) oder MySQL/MariaDB — steuerbar über zwei Variablen in `.env`: + +```bash +DATABASE_PROVIDER=postgresql # oder: mysql +DATABASE_URL="postgresql://wabenhain:wabenhain@localhost:5432/wabenhain?schema=public" +# bei mysql stattdessen z.B.: +# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain" +``` + +Nach einer Änderung von `DATABASE_PROVIDER` (z.B. lokal): + +```bash +# Nur die MySQL-Datenbank per Docker starten (statt "docker compose up -d db"): +docker compose --profile mysql up -d db-mysql + +pnpm db:push # legt Tabellen beim gewaehlten Provider an (schreibt kurzzeitig die passende provider-Zeile in schema.prisma, siehe unten) +pnpm db:seed # Beispieldaten + Admin-Konto +pnpm dev +``` + +**Ein Build/Image reicht für beide Provider gleichzeitig**: `scripts/generate-prisma-clients.mjs` generiert bei jedem `dev`/`build`/`start`/`postinstall` automatisch BEIDE Prisma-Client-Varianten (Postgres und MySQL) — `src/lib/prisma.ts`/`prisma/seed.ts` wählen zur Laufzeit rein anhand von `DATABASE_PROVIDER`, welche davon tatsächlich verwendet wird (technisch nötig, weil ein einzelner generierter Prisma-Client fest an einen Provider gebunden ist). Ein bereits gebautes Docker-Image lässt sich also allein durch Ändern von `DATABASE_PROVIDER`/`DATABASE_URL` und einen Neustart umschalten, **ohne Neubau** — praktisch für Kubernetes, siehe „Kubernetes / Gitea CI" unten. + +Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich gegen EINE konkrete Datenbank Schema-Änderungen anwenden) schreiben vorher kurzzeitig die passende `provider`-Zeile in `prisma/schema.prisma` (Prisma erlaubt dort kein `env()`, siehe `scripts/prisma-provider.mjs`) — das betrifft nur diese Befehle, nicht die beiden generierten Clients selbst. + +**Technischer Hintergrund**: `Harvest.images`, `Product.tasteTags` und `Product.images` sind als `Json` statt `String[]` modelliert, weil MySQL keine nativen Skalar-Array-Spalten kennt (Prisma-Einschränkung). Zur Laufzeit werden diese Felder in `lib/data.ts`/`lib/json-array.ts` transparent zu normalen String-Arrays normalisiert — der Rest der App merkt vom Provider-Wechsel nichts. + +## Stripe + +1. Test-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`). +2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen. +3. Ohne konfigurierte Stripe-Keys bleibt der Checkout funktionsfähig: Die Bestellung wird angelegt, der Nutzer landet mit Hinweis auf der Erfolgsseite, die Bestellung kann im Admin-Bereich manuell nachbearbeitet werden. + +### Zahlungsmethoden (Karte, SEPA-Lastschrift, Klarna) + +Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig. + +SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das: + +- `checkout.session.completed` markiert die Bestellung nur dann als bezahlt, wenn `payment_status` nicht `unpaid` ist (bei Kartenzahlung sofort der Fall). +- `checkout.session.async_payment_succeeded` markiert die Bestellung nachträglich als bezahlt, sobald die verzögerte Zahlung tatsächlich eingegangen ist (löst Bestätigungs-E-Mail und Rechnung aus). +- `checkout.session.async_payment_failed` storniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt. +- `checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung genauso wie einen fehlgeschlagenen Zahlungseinzug — storniert die Bestellung und bucht den beim Checkout reservierten Bestand zurück, statt ihn dauerhaft zu blockieren. + +Für einen produktiven Webhook-Endpoint müssen alle vier Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter. + +## Versandkosten + +Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag), sondern nach einer im Admin-Bereich unter `/admin/versand` gepflegten Mengenstaffel — die Gesamtmenge aller Positionen im Warenkorb wird dabei zusammengezählt, mehrere verschiedene Produkte also gemeinsam bewertet statt einzeln berechnet. Ab Werk sind drei Staffeln angelegt (0–3 Stück: 7,00 €, 4–9 Stück: 8,50 €, 10–19 Stück: 11,00 €), die jederzeit im Admin-Bereich angepasst, gelöscht oder um weitere Stufen ergänzt werden können — praktisch, wenn sich die Kosten der Versanddienstleister ändern. Eine Staffel ohne "Bis"-Wert gilt als offene, oberste Stufe ("ab X Stück"). Sind gar keine Staffeln gepflegt, greift ein fester Sicherheits-Fallback von 4,90 €, statt den Checkout zu blockieren. Abholung in Altenmünster ist immer versandkostenfrei. Änderungen wirken sich sofort auf Warenkorb, Checkout und die öffentliche Seite `/versand-zahlung` aus. + +## Google-Rezensionen + +Die „Bewertungen"-Section auf der Startseite zeigt echte Google-Rezensionen des Google-Maps-Eintrags, sobald zwei Umgebungsvariablen gesetzt sind — ohne sie greift automatisch der statische Beispieltext-Fallback, die Seite bleibt also auch ohne Setup voll funktionsfähig: + +1. In der [Google Cloud Console](https://console.cloud.google.com/) ein Projekt anlegen (oder ein bestehendes verwenden) und darin die **„Places API"** aktivieren. +2. Einen API-Key unter „APIs & Dienste" → „Anmeldedaten" erstellen und als `GOOGLE_PLACES_API_KEY` in `.env` eintragen. Empfehlenswert: den Key auf die Places API einschränken. +3. Die Place-ID des Wabenhain-Google-Maps-Eintrags über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id) ermitteln und als `GOOGLE_PLACE_ID` eintragen. + +Die Rezensionen werden serverseitig eine Stunde gecacht (`src/lib/google-reviews.ts`), um API-Kosten gering zu halten. + +## E-Mail-Versand + +Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset werden über SMTP versendet (`src/lib/mail.ts`, `nodemailer`). Ohne `SMTP_HOST`/`SMTP_USER`/`SMTP_PASSWORD` in `.env` wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt konfigurieren. + +Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Hosters (IONOS, Strato, …) oder Diensten wie Resend/Postmark im SMTP-Modus. `SMTP_FROM` sollte zur `SMTP_USER`-Domain passen, sonst landen Mails leicht im Spam. + +## CAPTCHA (Cloudflare Turnstile) + +Registrierung, Newsletter-Anmeldung und Firmenanfrage sind optional durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting. Ohne konfigurierte Keys wird kein Widget angezeigt und die serverseitige Prüfung übersprungen, die Formulare bleiben also auch ohne Setup voll nutzbar: + +1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig). +2. Den **Site Key** als `NEXT_PUBLIC_TURNSTILE_SITE_KEY` und den **Secret Key** als `TURNSTILE_SECRET_KEY` in `.env` eintragen. + +Die serverseitige Prüfung läuft über `src/lib/turnstile.ts` gegen die Cloudflare-Siteverify-API; das Widget selbst ist `src/components/turnstile-widget.tsx`. + +## Vollständiger Docker-Betrieb + +```bash +cp .env.example .env +# .env ausfuellen + +docker compose up -d --build +docker compose exec app pnpm db:deploy +docker compose exec app pnpm db:seed # Beispieldaten + legt/befördert das Admin-Konto (ADMIN_EMAIL/ADMIN_PASSWORD) +``` + +Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://localhost`, PostgreSQL unter Port `5432`. `app` selbst ist **nicht** direkt vom Host aus erreichbar (kein `ports:`-Mapping mehr, nur `expose`) — Caddy ist bewusst der einzige nach außen offene Dienst, weil er die tatsächliche Client-Verbindung terminiert und `X-Forwarded-For` dabei neu setzt statt einen vom Client selbst mitgeschickten Wert zu übernehmen. Ohne diesen Proxy davor ließe sich jedes IP-basierte Rate-Limit (Login, 2FA-Code, Checkout, Registrierung, siehe `lib/rate-limit.ts`) durch einen gefälschten Header umgehen. Für eine echte Domain mit automatischem HTTPS `SITE_ADDRESS` in `.env` setzen und Port `443` in `docker-compose.yml` freigeben (siehe Kommentar dort und in der `Caddyfile`). + +## Kubernetes / Gitea CI + +`.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` automatisch zwei Docker-Image-Tags (schlankes Laufzeit-Image + ein `:migrate-*`-Tag mit Prisma-CLI für Migrationen) und veröffentlicht sie in der Gitea-eigenen Container-Registry. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, Migrations-Job, App-Deployment, Ingress) für den Betrieb dieser Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md). + +Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe „Datenbank-Provider wechseln" oben) — welcher tatsächlich läuft, entscheidet allein `DATABASE_PROVIDER`/`DATABASE_URL` zur Laufzeit, kein separater Image-Build nötig. + +## Sicherheit + +- **Sessions als JWT (HS256, `jose`)**: httpOnly-Cookie, `SameSite=Lax`, `Secure` in Produktion, trägt Kunden-ID, Rolle und `sessionVersion`. Gültigkeit 30 Tage für Kundenkonten, **7 Tage für Admin-Konten** (kürzeres Zeitfenster für ein gestohlenes/vergessenes Cookie, da Admin-Zugriff Rückerstattungen und Kundendaten einschließt). `SESSION_SECRET` muss in Produktion mindestens 32 Zeichen lang sein (wird beim Start erzwungen). +- **Session-Widerruf**: `Customer.sessionVersion` wird bei Passwort-Reset, 2FA-Ein-/Ausschalten und "Alle Geräte abmelden" hochgezählt; `getCurrentCustomer()` vergleicht diesen Wert gegen das JWT-Claim und behandelt eine Abweichung als abgelaufen — ein technisch noch gültiges Token verliert so sofort seine Wirkung, ohne eine serverseitige Session-Tabelle für jeden Request führen zu müssen. +- **Zwei-Faktor-Authentifizierung (TOTP)**: optional pro Konto unter `/konto/zwei-faktor`, kompatibel mit gängigen Authenticator-Apps (RFC 6238, `otpauth`). Bei Aktivierung werden 8 Einmal-Backup-Codes generiert (nur als SHA-256-Hash gespeichert, Klartext einmalig direkt nach der Einrichtung angezeigt). Login mit aktivem 2FA läuft zweistufig: Passwort korrekt → kurzlebiger Zwischenschritt-Cookie (5 Minuten, eigener Token-Typ, niemals mit einer vollständigen Session verwechselbar) → Code- oder Backup-Code-Eingabe auf `/konto/zwei-faktor-code`. +- **Passwörter**: bcrypt mit Cost-Faktor 12; Limit 72 Zeichen (bcrypt-Grenze) wird validiert statt still abgeschnitten. +- **Login-Härtung**: Vergleich gegen Dummy-Hash bei unbekannter E-Mail (kein User-Enumeration über Antwortzeiten); E-Mails werden normalisiert (trim/lowercase); Konto-Sperre für 15 Minuten nach 5 aufeinanderfolgenden Fehlversuchen (`Customer.failedLoginAttempts`/`lockedUntil`, überlebt im Gegensatz zum IP-Rate-Limit einen Server-Neustart). +- **Admin-Sperre** (`Customer.blockedAt`, unter `/admin/benutzer`): getrennt von der automatischen Fehlversuchs-Sperre — ein Admin kann ein Konto gezielt und dauerhaft sperren. Der Login wird dabei noch vor der Passwortprüfung mit einer eigenen Meldung abgelehnt, `getCurrentCustomer()` beendet zusätzlich jede bereits bestehende Session sofort (nicht erst beim natürlichen Cookie-Ablauf), und ein Passwort-Reset kann die Sperre nicht mehr eigenständig umgehen. +- **Rate-Limiting** (In-Memory): Login 10/15 min pro IP **und** 8/15 min pro E-Mail-Adresse (schützt auch gegen verteilte Versuche über viele IPs auf ein einzelnes Konto), Registrierung 5/h, Passwort-Reset-Anfrage 5/h, 2FA-Code-Prüfung 8/10 min, Newsletter & Firmenanfragen 5/h, Checkout 10/h. Die Client-IP wird aus dem **letzten** `X-Forwarded-For`-Eintrag gelesen (vom eigenen Reverse-Proxy angehängt), nicht dem ersten — der wäre vom Client frei wählbar und würde jedes Rate-Limit hier aushebeln. Bei horizontaler Skalierung auf einen gemeinsamen Store (z.B. Redis) umstellen. +- **CAPTCHA** (Cloudflare Turnstile, optional): siehe Abschnitt „CAPTCHA" oben — zusätzlicher Bot-Schutz auf Registrierung, Newsletter und Firmenanfrage. +- **E-Mail-Verifizierung**: neu registrierte Konten erhalten einen Bestätigungslink (`EmailVerificationToken`, SHA-256-Hash wie beim Passwort-Reset, 24 Stunden gültig) und müssen ihre E-Mail-Adresse bestätigen, bevor sie mit ihrem Konto bestellen können (`/api/checkout` prüft `Customer.emailVerifiedAt`) — Gastbestellungen sind davon nicht betroffen. Ein erfolgreich über einen zugesandten Link gesetztes Passwort (Reset oder admin-angelegtes Konto) gilt ebenfalls als Bestätigung, ohne eine weitere E-Mail zu benötigen. +- **Admin-Audit-Log** (`/admin/audit-log`, `AuditLog`-Modell): protokolliert jede sicherheitsrelevante Admin-Mutation (Produkte, Bestellstatus, Gutscheine, Versandkosten, Benutzerrollen, Bewertungsmoderation, …) mit Zeitstempel, Admin-E-Mail (als Snapshot, bleibt auch nach Kontolöschung lesbar), Aktion und Details. +- **Abhängigkeits-Sicherheitsscan**: `pnpm audit` vor jedem Release ausführen, um bekannte Schwachstellen in Paketen frühzeitig zu erkennen (siehe „Nützliche Befehle" unten). Aktuell 0 bekannte Schwachstellen (zuletzt geprüft: eine transitive `nanoid`-DoS-Lücke über `pnpm-workspace.yaml`s `overrides` gepatcht). +- **Security-Header**: CSP (keine externen Skript-Hosts außer dem optionalen Turnstile-Widget), `X-Frame-Options: DENY`, `nosniff`, Referrer-Policy, Permissions-Policy, HSTS. +- **Formularfelder**: korrekte `autocomplete`-Attribute (`email`, `current-password`, `new-password`, `one-time-code`) auf allen Auth-Formularen für Passwort-Manager und Autofill. +- **TOTP-Replay-Schutz** (`Customer.lastTotpStep`): ein beim Login akzeptierter 2FA-Code kann nicht ein zweites Mal eingelöst werden, selbst innerhalb seines eigentlichen 30-Sekunden-Gültigkeitsfensters. +- **Passwort-Reset respektiert 2FA**: ein erfolgreicher Reset loggt bei aktivem 2FA nicht direkt ein, sondern verlangt wie beim normalen Login den zweiten Faktor — sonst würde ein Reset-Link den zweiten Faktor genau in dem Szenario aushebeln, für das er gedacht ist. +- **Registrierung ohne E-Mail-Enumeration**: eine bereits vergebene E-Mail-Adresse erzeugt dieselbe generische Antwort (Text und Zeitaufwand) wie eine erfolgreiche Neuanmeldung, statt "Konto existiert bereits" zu bestätigen. +- **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen. +- **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren). +- **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen. +- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und behandelt eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) wie einen fehlgeschlagenen Zahlungseinzug. +- **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank. +- **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestell-/Rechnungsnummern kommen aus `crypto.randomBytes`. +- **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`. +- Server Actions prüfen Besitzrechte (Adressen/Bestellungen/Rechnungen nur für das eigene Konto); Next.js validiert bei Actions zusätzlich den Origin-Header (CSRF). + +## Datenmodell + +Siehe [`prisma/schema.prisma`](prisma/schema.prisma) für das vollständige Schema: +`Customer`, `Address`, `PasswordResetToken`, `EmailVerificationToken`, `TwoFactorBackupCode`, `Harvest`, `Batch`, `Product`, `Inventory`, `Review`, `WishlistItem`, `StockNotification`, `Order`, `OrderItem`, `Payment`, `Shipment`, `Pickup`, `Invoice`, `ReturnRequest`, `ShippingTier`, `DiscountCode`, `AuditLog`, `NewsletterSubscriber`, `BusinessInquiry`. + +Ein `Product` ist optional einer `Harvest` und einer `Batch` zugeordnet (Pflicht bei Honig, leer bei Kerzen/Propolis); jede `OrderItem` speichert die `Batch`, aus der das jeweilige Glas stammt, sodass sich jede Honig-Bestellung bis zur Charge zurückverfolgen lässt. `Customer.role` (`KUNDE` | `ADMIN`) steuert den Zugriff auf `/admin`, `Customer.blockedAt` die admin-gesperrten Konten, siehe „Admin-Zugang" oben. + +## Tests + +- **Unit-/Integrationstests** (`src/**/*.test.ts`, [Vitest](https://vitest.dev/)): decken die geldrelevante und sicherheitsrelevante Kernlogik ab, die absichtlich als reine Funktionen ohne Datenbankzugriff ausgelagert ist — `lib/shipping-calc.ts`, `lib/discount-calc.ts`, `lib/format.ts` sowie die Zod-Schemas in `lib/validation.ts`. `pnpm test` ausführen. +- **E2E-Tests** (`e2e/*.spec.ts`, [Playwright](https://playwright.dev/)): Smoke-Tests für die kritischsten Nutzerpfade (Startseite, Shop-Übersicht, Warenkorb inkl. Mengenänderung/Entfernen, Checkout-Preisanzeige inkl. Versand/Abholung-Umschaltung, Login-/Registrierungsvalidierung) gegen eine laufende Instanz. `pnpm test:e2e` ausführen — startet bei Bedarf automatisch `pnpm dev`, nutzt aber einen bereits laufenden Dev-Server, falls vorhanden. + +Dies ist eine bewusst schlanke Startabdeckung der riskantesten Logik (Geldberechnung, Auth-Validierung, Kern-Flows), kein vollständiger Coverage-Anspruch — die Infrastruktur ist eingerichtet, weitere Tests lassen sich unkompliziert ergänzen. + +Beim Ausbau der Warenkorb-/Checkout-Tests kam ein echter, vorher unentdeckter Bug ans Licht: der Cart-Store persistierte versehentlich auch den rein transienten „ist das Drawer offen"-Zustand in `localStorage` (`persist()` ohne `partialize`). Dadurch konnte sich das Cart-Drawer nach einem Seiten-Reload ungewollt wieder öffnen und die Seite dahinter blockieren, wenn es beim letzten Verlassen offen war. Behoben in `src/store/cart-store.ts`, indem nur noch `items` persistiert wird. + +## Nützliche Befehle + +```bash +pnpm dev # Entwicklungsserver +pnpm build # Produktions-Build +pnpm lint # ESLint +pnpm db:studio # Prisma Studio (DB-GUI) +pnpm db:migrate # neue Migration erstellen/anwenden (Dev) +pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen +``` diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..7a55228 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from "@playwright/test"; + +test("login with wrong credentials shows an error message", async ({ page }) => { + await page.goto("/konto"); + + await page.getByLabel("E-Mail", { exact: true }).first().fill("unbekannt@example.de"); + await page.getByLabel("Passwort", { exact: true }).first().fill("falschespasswort"); + await page.getByRole("button", { name: "Anmelden" }).click(); + + await expect(page.getByText(/E-Mail-Adresse oder Passwort ist falsch/i)).toBeVisible(); +}); + +test("registration rejects a password shorter than 8 characters", async ({ page }) => { + await page.goto("/konto"); + + await page.getByLabel("Vorname").fill("Test"); + await page.getByLabel("Nachname").fill("Person"); + await page.getByLabel("E-Mail", { exact: true }).nth(1).fill(`e2e-${Date.now()}@example.de`); + await page.getByLabel(/Passwort \(mind\. 8 Zeichen\)/).fill("kurz"); + await page.getByRole("button", { name: "Konto erstellen" }).click(); + + await expect(page.getByText(/mindestens 8 Zeichen/i)).toBeVisible(); +}); + +test("registering, logging out, and logging back in reaches the logged-in account page", async ({ page }) => { + const email = `e2e-login-${Date.now()}@example.de`; + // Zufaellig statt eines festen Werts wie "Passwort123!" — der HIBP- + // Breach-Check (isPasswordBreached) lehnt bekannte Leak-Passwoerter ab, + // ein per Zeitstempel eindeutiges Passwort landet nicht in solchen Listen. + const password = `Xk9-qLp2-Zt${Date.now()}`; + + await page.goto("/konto"); + await page.getByLabel("Vorname").fill("Test"); + await page.getByLabel("Nachname").fill("Person"); + await page.getByLabel("E-Mail", { exact: true }).nth(1).fill(email); + await page.getByLabel(/Passwort \(mind\. 8 Zeichen\)/).fill(password); + await page.getByLabel("Passwort bestätigen").fill(password); + await page.getByRole("button", { name: "Konto erstellen" }).click(); + + // Registrierung meldet automatisch an — erst abmelden, um den separaten + // Login-Pfad (loginAction statt der impliziten Session bei der + // Registrierung) tatsaechlich zu pruefen. Zwei Buttons heissen "Abmelden" + // (auch "alle Geraete abmelden") — .last() ist der einfache Logout-Button, + // der tatsaechlich zur ausgeloggten Ansicht zurueckfuehrt. + await expect(page.getByRole("button", { name: "Abmelden" }).first()).toBeVisible(); + await page.getByRole("button", { name: "Abmelden" }).last().click(); + + await expect(page.getByRole("heading", { name: "Anmelden oder Konto erstellen" })).toBeVisible(); + + await page.getByLabel("E-Mail", { exact: true }).first().fill(email); + await page.getByLabel("Passwort", { exact: true }).first().fill(password); + await page.getByRole("button", { name: "Anmelden" }).click(); + + await expect(page.getByRole("button", { name: "Abmelden" }).first()).toBeVisible(); + await expect(page.getByRole("heading", { name: "Anmelden oder Konto erstellen" })).not.toBeVisible(); +}); diff --git a/e2e/cart.spec.ts b/e2e/cart.spec.ts new file mode 100644 index 0000000..9a471bd --- /dev/null +++ b/e2e/cart.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from "@playwright/test"; + +test("cart page: increasing quantity updates the line total", async ({ page }) => { + await page.goto("/honig/fruehtracht-2026"); + await page.getByRole("button", { name: "In den Warenkorb" }).first().click(); + + await page.goto("/warenkorb"); + + const lineTotalBefore = await page.getByText(/^\d+,\d{2}\s€$/).first().textContent(); + + await page.getByRole("button", { name: "Menge erhöhen" }).click(); + + await expect(page.getByText(/^\d+,\d{2}\s€$/).first()).not.toHaveText(lineTotalBefore ?? ""); +}); + +test("cart page: removing the only item shows the empty-cart state", async ({ page }) => { + await page.goto("/honig/fruehtracht-2026"); + await page.getByRole("button", { name: "In den Warenkorb" }).first().click(); + + await page.goto("/warenkorb"); + await expect(page.getByText("Zwischensumme")).toBeVisible(); + + await page.getByRole("button", { name: "Entfernen" }).click(); + + await expect(page.getByText("Dein Warenkorb ist noch leer.")).toBeVisible(); +}); diff --git a/e2e/checkout.spec.ts b/e2e/checkout.spec.ts new file mode 100644 index 0000000..0e1927c --- /dev/null +++ b/e2e/checkout.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; + +test("checkout shows live shipping cost for Versand and switches to free for Abholung", async ({ page }) => { + await page.goto("/honig/fruehtracht-2026"); + await page.getByRole("button", { name: "In den Warenkorb" }).first().click(); + + await page.goto("/checkout"); + + await expect(page.getByText("Zwischensumme")).toBeVisible(); + await expect(page.getByText("Gesamt")).toBeVisible(); + + // Versand ist per Default aktiv und zeigt eine Versandstaffel-Kosten + // (nicht "kostenlos"), solange der Warenkorb unter der Freigrenze liegt. + const summary = page.locator("aside"); + await expect(summary.getByText(/Versand/)).toBeVisible(); + + await page.getByRole("button", { name: "Abholung in Altenmünster" }).click(); + + await expect(summary.getByText("kostenlos")).toBeVisible(); +}); + +test("checkout requires street address only when Versand is selected", async ({ page }) => { + await page.goto("/honig/fruehtracht-2026"); + await page.getByRole("button", { name: "In den Warenkorb" }).first().click(); + + await page.goto("/checkout"); + + await expect(page.getByLabel("Straße und Hausnummer")).toBeVisible(); + + await page.getByRole("button", { name: "Abholung in Altenmünster" }).click(); + + await expect(page.getByLabel("Straße und Hausnummer")).toHaveCount(0); +}); diff --git a/e2e/homepage.spec.ts b/e2e/homepage.spec.ts new file mode 100644 index 0000000..cf86e46 --- /dev/null +++ b/e2e/homepage.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from "@playwright/test"; + +test("homepage shows the hero heading and primary navigation", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByRole("heading", { name: /Honig aus den Westlichen Wäldern/i })).toBeVisible(); + await expect(page.getByRole("navigation").getByRole("link", { name: "Shop", exact: true })).toBeVisible(); +}); + +test("homepage newsletter form is present and accepts an email", async ({ page }) => { + await page.goto("/"); + + const emailField = page.getByPlaceholder("deine@email.de"); + await expect(emailField).toBeVisible(); + await emailField.fill("besucher@example.de"); + await expect(emailField).toHaveValue("besucher@example.de"); +}); diff --git a/e2e/shop.spec.ts b/e2e/shop.spec.ts new file mode 100644 index 0000000..36cd1fd --- /dev/null +++ b/e2e/shop.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from "@playwright/test"; + +test("shop overview lists products", async ({ page }) => { + await page.goto("/shop"); + + await expect(page.getByRole("heading", { name: "Unser Sortiment" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Zum Honig|Zum Produkt/i }).first()).toBeVisible(); +}); + +test("adding a product to the cart updates the cart count", async ({ page }) => { + await page.goto("/honig/fruehtracht-2026"); + + // "In den Warenkorb" erscheint mehrfach auf der Seite (Haupt-CTA oben, + // weitere in der "Das koennte dir auch gefallen"-Produktkarten-Sektion + // weiter unten) — .first() greift gezielt den Haupt-CTA des Produkts. + const cartButton = page.getByRole("button", { name: /Warenkorb öffnen/ }); + const initialLabel = await cartButton.getAttribute("aria-label"); + + await page.getByRole("button", { name: "In den Warenkorb" }).first().click(); + + await expect(cartButton).not.toHaveAttribute("aria-label", initialLabel ?? ""); +}); diff --git a/next.config.ts b/next.config.ts index e9ffa30..3031d14 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,49 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // Verhindert den "X-Powered-By: Next.js"-Response-Header — verrimt keinen + // funktionalen Vorteil, gibt Angreifern aber unnoetig Framework-Fingerprinting. + poweredByHeader: false, + + // "standalone" buendelt eine minimale Node-Laufzeit inkl. Dependencies, + // wodurch das Docker-Image (siehe Dockerfile) deutlich kleiner bleibt. + output: "standalone", + + // pdfkit laedt seine Schriftart-Metadaten (.afm-Dateien) zur Laufzeit ueber + // einen eigenen __dirname-relativen Pfad. Wird das Paket vom Bundler + // (Turbopack/Webpack) mitgebuendelt, geht dieser Pfad verloren (ENOENT). + // "serverExternalPackages" laesst Next.js es stattdessen ganz normal per + // require() aus node_modules laden. + serverExternalPackages: ["pdfkit"], + + // Turbopacks Dateisystem-Caches (.next/dev/cache & .next/cache/turbopack) + // kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis + // wiederholt mit der Cloud-Synchronisierung ("Failed to open database" / + // mmap-Timeout bei next dev, "Failed to compact database" beim + // next-build-Shutdown). Beide Caches werden daher deaktiviert. + experimental: { + turbopackFileSystemCacheForDev: false, + turbopackFileSystemCacheForBuild: false, + }, + + async headers() { + // Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht + // eine pro Request neue Nonce (siehe proxy.ts), was ein statischer + // headers()-Eintrag hier nicht kann. + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(self)" }, + // Wirkt nur ueber HTTPS; im lokalen HTTP-Betrieb ignoriert der Browser den Header. + { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/package.json b/package.json index 56049ae..2fe5760 100644 --- a/package.json +++ b/package.json @@ -3,25 +3,62 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint" + "dev": "node scripts/generate-prisma-clients.mjs && next dev", + "build": "node scripts/generate-prisma-clients.mjs && next build", + "start": "node scripts/generate-prisma-clients.mjs && next start", + "lint": "eslint", + "postinstall": "node scripts/generate-prisma-clients.mjs", + "db:push": "node scripts/prisma-provider.mjs && prisma db push", + "db:migrate": "node scripts/prisma-provider.mjs && prisma migrate dev", + "db:deploy": "node scripts/prisma-provider.mjs && prisma migrate deploy", + "db:seed": "prisma db seed", + "db:studio": "node scripts/prisma-provider.mjs && prisma studio", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test" }, "dependencies": { + "@prisma/adapter-mariadb": "^7.9.1", + "@prisma/adapter-pg": "^7.9.1", + "@prisma/client": "^7.9.1", + "bcryptjs": "^3.0.3", + "jose": "^6.2.8", + "mariadb": "^3.5.3", + "motion": "^13.0.0", "next": "16.3.0", + "nodemailer": "^9.0.5", + "otpauth": "^9.5.1", + "pdfkit": "^0.19.1", + "prisma": "^7.9.1", + "qrcode": "^1.5.4", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "sharp": "^0.35.3", + "stripe": "^22.4.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "^4", - "@types/node": "^20", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.4", + "@types/bcryptjs": "^3.0.0", + "@types/node": "^20.19.43", + "@types/nodemailer": "^8.0.1", + "@types/pdfkit": "^0.17.6", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.5", "eslint": "^9", "eslint-config-next": "16.3.0", + "jsdom": "^30.0.1", "tailwindcss": "^4", - "typescript": "^5" + "tsx": "^4", + "typescript": "^5", + "vitest": "^4.1.10" }, "packageManager": "pnpm@11.20.0" } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e88f060 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: "list", + use: { + baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000", + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + // Startet den Dev-Server automatisch, falls er nicht schon laeuft (z.B. lokal + // waehrend der Entwicklung) — CI-Umgebungen ohne laufenden Server profitieren + // davon am meisten. + webServer: { + command: "pnpm dev", + url: "http://localhost:3000", + reuseExistingServer: true, + timeout: 60_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63e119e..b08216e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,51 +4,149 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + nanoid: '>=3.3.18' + importers: .: dependencies: + '@prisma/adapter-mariadb': + specifier: ^7.9.1 + version: 7.9.1 + '@prisma/adapter-pg': + specifier: ^7.9.1 + version: 7.9.1 + '@prisma/client': + specifier: ^7.9.1 + version: 7.9.1(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 + jose: + specifier: ^6.2.8 + version: 6.2.8 + mariadb: + specifier: ^3.5.3 + version: 3.5.3 + motion: + specifier: ^13.0.0 + version: 13.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next: specifier: 16.3.0 - version: 16.3.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + nodemailer: + specifier: ^9.0.5 + version: 9.0.5 + otpauth: + specifier: ^9.5.1 + version: 9.5.1 + pdfkit: + specifier: ^0.19.1 + version: 0.19.1 + prisma: + specifier: ^7.9.1 + version: 7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: 19.2.8 version: 19.2.8 react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@20.19.43) + stripe: + specifier: ^22.4.0 + version: 22.4.0(@types/node@20.19.43) + zod: + specifier: ^4.4.3 + version: 4.4.3 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.18)(react@19.2.8) devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 '@tailwindcss/postcss': specifier: ^4 version: 4.3.3 + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@20.19.43)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9))) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.4 + version: 14.6.4(@testing-library/dom@10.4.1) + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 '@types/node': - specifier: ^20 + specifier: ^20.19.43 version: 20.19.43 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 + '@types/pdfkit': + specifier: ^0.17.6 + version: 0.17.6 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19 version: 19.2.18 '@types/react-dom': specifier: ^19 version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.0.5 + version: 6.0.5(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9)) eslint: specifier: ^9 version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) eslint-config-next: specifier: 16.3.0 version: 16.3.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + jsdom: + specifier: ^30.0.1 + version: 30.0.1(@noble/hashes@2.2.0) tailwindcss: specifier: ^4 version: 4.3.3 + tsx: + specifier: ^4 + version: 4.23.9 typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@20.19.43)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -104,6 +202,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -116,6 +218,60 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.0': + resolution: {integrity: sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.8': + resolution: {integrity: sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@electric-sql/pglite-socket@0.1.3': + resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3': + resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==} + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': + resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -128,6 +284,162 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -172,6 +484,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -435,6 +756,18 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -451,9 +784,253 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@prisma/adapter-mariadb@7.9.1': + resolution: {integrity: sha512-4aZUbIg2av7XFSQyM9cQeAp9YP9HiPr8H7ehe2rDo375IZXILEzQrNXZ/zLetgRQ2ojlaUl/brArFdOVS3n1xA==} + + '@prisma/adapter-pg@7.9.1': + resolution: {integrity: sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==} + + '@prisma/client-runtime-utils@7.9.1': + resolution: {integrity: sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg==} + + '@prisma/client@7.9.1': + resolution: {integrity: sha512-+xgrh2EhJVF79wC0yX5G4PI1Rdcm7Qn/nekNQ+t/O153wtNggruHal+fXHSa0QE+Tp/Cw5wvxeCEhZZ59xGm8Q==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.9.1': + resolution: {integrity: sha512-4znKhxTmXmuPye9Z6pbIyYb5VZlkZ05qG1L6Dr4g+7oTwc6V50Bs9XirFBDdjWt+H/AabMn9aUnxBcvj8z05aA==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.9.1': + resolution: {integrity: sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==} + + '@prisma/dev@0.24.17': + resolution: {integrity: sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==} + + '@prisma/driver-adapter-utils@7.9.1': + resolution: {integrity: sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==} + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': + resolution: {integrity: sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==} + + '@prisma/engines@7.9.1': + resolution: {integrity: sha512-UprXSMNXx2NF5ow4pqaQtE8OuBz6K78B0wc0tn2L28G5r933iWp1DR9Do2qWrsNvvFIP3x6mpEWnQtckMO0Uhg==} + + '@prisma/fetch-engine@7.9.1': + resolution: {integrity: sha512-9DwxrNTeT25Orbu9CWh0CZvVlyY1lmscpbaeLZcOnuR7zcuFrt91YSmmOfIm7zJ08YOZ6mVzURKwLoMwEBcK8w==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.9.1': + resolution: {integrity: sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.11': + resolution: {integrity: sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==} + engines: {bun: '>=1.2.0', node: '>=22.0.0'} + + '@prisma/studio-core@0.33.0': + resolution: {integrity: sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rolldown/binding-android-arm64@1.2.3': + resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.3': + resolution: {integrity: sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.3': + resolution: {integrity: sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.3': + resolution: {integrity: sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + resolution: {integrity: sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + resolution: {integrity: sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.3': + resolution: {integrity: sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + resolution: {integrity: sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + resolution: {integrity: sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.3': + resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.3': + resolution: {integrity: sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.3': + resolution: {integrity: sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + resolution: {integrity: sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.3': + resolution: {integrity: sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -549,21 +1126,123 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.4': + resolution: {integrity: sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.0.3': + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + + '@types/d3-color@3.1.0': + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + + '@types/d3-delaunay@6.0.1': + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + + '@types/d3-format@3.0.1': + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-interpolate@3.0.1': + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.2': + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time-format@2.1.0': + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + + '@types/d3-time@3.0.0': + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + + '@types/pdfkit@0.17.6': + resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==} + + '@types/pg@8.20.4': + resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==} + + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.4': resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: @@ -751,6 +1430,83 @@ packages: cpu: [x64] os: [win32] + '@visx/curve@4.0.1-alpha.0': + resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} + + '@visx/event@4.0.1-alpha.0': + resolution: {integrity: sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==} + + '@visx/grid@4.0.1-alpha.0': + resolution: {integrity: sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/group@4.0.1-alpha.0': + resolution: {integrity: sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/point@4.0.1-alpha.0': + resolution: {integrity: sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==} + + '@visx/responsive@4.0.1-alpha.0': + resolution: {integrity: sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/scale@4.0.1-alpha.0': + resolution: {integrity: sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==} + + '@visx/shape@4.0.1-alpha.0': + resolution: {integrity: sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/vendor@4.0.0-alpha.0': + resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + + '@vitejs/plugin-react@6.0.5': + resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -764,13 +1520,27 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -807,6 +1577,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -818,6 +1592,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + axe-core@4.13.0: resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} @@ -833,11 +1611,28 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.11.12: resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + + better-result@2.10.0: + resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -849,11 +1644,25 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + browserslist@4.28.7: resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -870,16 +1679,38 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001807: resolution: {integrity: sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -890,6 +1721,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -897,12 +1731,71 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -932,9 +1825,20 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -943,28 +1847,82 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + electron-to-chromium@1.5.402: resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + enhanced-resolve@5.24.5: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + es-abstract-get@1.0.0: resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} engines: {node: '>= 0.4'} @@ -985,6 +1943,9 @@ packages: resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1001,6 +1962,11 @@ packages: resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1125,10 +2091,27 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1142,6 +2125,12 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1162,6 +2151,14 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} + engines: {node: '>=20'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1173,10 +2170,38 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + framer-motion@13.0.0: + resolution: {integrity: sha512-nQGZXlsiigN48nzvE7AL1GLekql+etcphp8v+PQ0X04oxO20yVmV9rU9XQ25c136RdeIYoaWlKT9oAwvJza3mg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1187,6 +2212,9 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -1195,10 +2223,17 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1210,6 +2245,10 @@ packages: get-tsconfig@4.14.1: resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1237,6 +2276,12 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammex@3.1.13: + resolution: {integrity: sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -1270,6 +2315,18 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1286,10 +2343,18 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1337,6 +2402,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -1361,6 +2430,12 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1411,6 +2486,12 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1418,6 +2499,15 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1429,6 +2519,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1465,30 +2558,60 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} @@ -1496,6 +2619,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} @@ -1503,6 +2633,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -1510,6 +2647,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} @@ -1517,22 +2661,52 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1540,20 +2714,52 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mariadb@3.4.5: + resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==} + engines: {node: '>= 14'} + + mariadb@3.5.3: + resolution: {integrity: sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==} + engines: {node: '>= 20.0.0'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1562,6 +2768,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -1572,12 +2782,37 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + motion-dom@13.0.0: + resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==} + + motion-utils@13.0.0: + resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==} + + motion@13.0.0: + resolution: {integrity: sha512-RYZjEpgHUCKjBNqjmUQzW93VJWLvEZGTPdKRFSqOdOl07IIMvz+WrsZmD1XXAkoFLruW/8bO1L7UF+ViQTxtLg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@6.0.1: + resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} + engines: {node: ^22 || ^24 || >=26} hasBin: true napi-postinstall@0.3.4: @@ -1617,6 +2852,10 @@ packages: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} + nodemailer@9.0.5: + resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} + engines: {node: '>=6.0.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1649,26 +2888,57 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + otpauth@9.5.1: + resolution: {integrity: sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==} + own-keys@1.0.2: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1680,6 +2950,49 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pdfkit@0.19.1: + resolution: {integrity: sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1691,6 +3004,26 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + png-js@1.1.0: + resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==} + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1703,20 +3036,75 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + prisma@7.9.1: + resolution: {integrity: sha512-aPqePoZIqwlAchbgbFDO/wHqGB+7H1nj9gaM+OsL9h77S5S3TnLd9BgD3LnoeDikULo7cl2HSUrEyQ55Z7DYbg==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -1725,10 +3113,21 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -1737,6 +3136,20 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1749,10 +3162,29 @@ packages: engines: {node: '>= 0.4'} hasBin: true + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rolldown@1.2.3: + resolution: {integrity: sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1768,6 +3200,17 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1780,6 +3223,12 @@ packages: engines: {node: '>=10'} hasBin: true + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -1825,17 +3274,48 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -1859,14 +3339,31 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@22.4.0: + resolution: {integrity: sha512-LVJ+tcSYeqOSnXr3i+Kz2tZ7y0crLLdP2uwD/4wccrmEVyn0g/Heo0pF7as7rxS/sOjJzrb1lxgZY0Y0Dx1pSA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -1888,6 +3385,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1895,14 +3395,43 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1915,6 +3444,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.9: + resolution: {integrity: sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1954,6 +3488,19 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} @@ -1966,6 +3513,118 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1978,6 +3637,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.22: resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} @@ -1987,17 +3649,51 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -2007,10 +3703,45 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2088,6 +3819,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -2111,6 +3844,44 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.8(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2132,6 +3903,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))': dependencies: eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) @@ -2183,6 +4032,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2199,8 +4052,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.3': optionalDependencies: @@ -2365,6 +4217,12 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.0': optional: true + '@noble/ciphers@1.3.0': {} + + '@noble/hashes@1.8.0': {} + + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2379,8 +4237,227 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.143.0': {} + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@prisma/adapter-mariadb@7.9.1': + dependencies: + '@prisma/driver-adapter-utils': 7.9.1 + mariadb: 3.4.5 + + '@prisma/adapter-pg@7.9.1': + dependencies: + '@prisma/driver-adapter-utils': 7.9.1 + '@types/pg': 8.20.4 + pg: 8.22.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.9.1': {} + + '@prisma/client@7.9.1(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.9.1 + optionalDependencies: + prisma: 7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.9.1': + dependencies: + c12: 3.3.4 + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.9.1': {} + + '@prisma/dev@0.24.17(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) + '@electric-sql/pglite-tools': 0.3.3(@electric-sql/pglite@0.4.3) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.11 + find-my-way: 9.7.0 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.4.2(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': {} + + '@prisma/engines@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/fetch-engine': 7.9.1 + '@prisma/get-platform': 7.9.1 + + '@prisma/fetch-engine@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/get-platform': 7.9.1 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.9.1': + dependencies: + '@prisma/debug': 7.9.1 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.11': + dependencies: + ajv: 8.20.0 + better-result: 2.10.0 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.33.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/event': 4.0.1-alpha.0 + '@visx/grid': 4.0.1-alpha.0(react@19.2.8) + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/responsive': 4.0.1-alpha.0(react@19.2.8) + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + d3-array: 3.2.4 + d3-shape: 3.2.0 + elkjs: 0.11.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react-dom' + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@rolldown/binding-android-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-x64@1.2.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.3': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -2454,21 +4531,127 @@ snapshots: postcss: 8.5.26 tailwindcss: 4.3.3 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@20.19.43)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9)))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 4.1.10(@types/node@20.19.43)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9)) + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.0.3': {} + + '@types/d3-color@3.1.0': {} + + '@types/d3-delaunay@6.0.1': {} + + '@types/d3-format@3.0.1': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-interpolate@3.0.1': + dependencies: + '@types/d3-color': 3.1.0 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.2': + dependencies: + '@types/d3-time': 3.0.0 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@2.1.0': {} + + '@types/d3-time@3.0.0': {} + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/lodash@4.17.25': {} + '@types/node@20.19.43': dependencies: undici-types: 6.21.0 + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 20.19.43 + + '@types/pdfkit@0.17.6': + dependencies: + '@types/node': 20.19.43 + + '@types/pg@8.20.4': + dependencies: + '@types/node': 20.19.43 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 20.19.43 + '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -2638,6 +4821,129 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@visx/curve@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/event@4.0.1-alpha.0': + dependencies: + '@types/react': 19.2.18 + '@visx/point': 4.0.1-alpha.0 + + '@visx/grid@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/point': 4.0.1-alpha.0 + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + classnames: 2.5.1 + react: 19.2.8 + + '@visx/group@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/react': 19.2.18 + classnames: 2.5.1 + react: 19.2.8 + + '@visx/point@4.0.1-alpha.0': {} + + '@visx/responsive@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.2.18 + lodash: 4.18.1 + react: 19.2.8 + + '@visx/scale@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/shape@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/scale': 4.0.1-alpha.0 + '@visx/vendor': 4.0.0-alpha.0 + classnames: 2.5.1 + lodash: 4.18.1 + react: 19.2.8 + + '@visx/vendor@4.0.0-alpha.0': + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.1.1 + '@types/d3-scale': 4.0.2 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + + '@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 @@ -2651,12 +4957,27 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -2726,6 +5047,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -2734,6 +5057,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-ssl-profiles@1.1.2: {} + axe-core@4.13.0: {} axobject-query@4.1.0: {} @@ -2742,8 +5067,20 @@ snapshots: balanced-match@4.0.4: {} + base64-js@0.0.8: {} + + base64-js@1.5.1: {} + baseline-browser-mapping@2.11.12: {} + bcryptjs@3.0.3: {} + + better-result@2.10.0: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -2757,6 +5094,14 @@ snapshots: dependencies: fill-range: 7.1.1 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + browserslist@4.28.7: dependencies: baseline-browser-mapping: 2.11.12 @@ -2765,6 +5110,21 @@ snapshots: node-releases: 2.0.53 update-browserslist-db: 1.3.0(browserslist@4.28.7) + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2784,15 +5144,33 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001807: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + classnames@2.5.1: {} + client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + clone@2.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2801,6 +5179,8 @@ snapshots: concat-map@0.0.1: {} + confbox@0.2.4: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -2809,10 +5189,70 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} + d3-array@3.2.1: + dependencies: + internmap: 2.0.3 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-delaunay@6.0.2: + dependencies: + delaunator: 5.1.0 + + d3-format@3.1.0: {} + + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + damerau-levenshtein@1.0.8: {} + data-urls@7.0.0(@noble/hashes@2.2.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -2843,8 +5283,14 @@ snapshots: optionalDependencies: supports-color: 7.2.0 + decamelize@1.2.0: {} + + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -2857,27 +5303,64 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.7: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + denque@2.1.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + detect-libc@2.1.2: {} + dfa@1.2.0: {} + + dijkstrajs@1.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dotenv@17.4.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.402: {} + elkjs@0.11.1: {} + + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + empathic@2.0.0: {} + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + + env-paths@3.0.0: {} + es-abstract-get@1.0.0: dependencies: es-errors: 1.3.0 @@ -2965,6 +5448,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -2989,6 +5474,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -3196,8 +5710,22 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + + exsolve@1.1.1: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -3212,6 +5740,12 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.5: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3228,6 +5762,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@9.7.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3240,10 +5785,42 @@ snapshots: flatted@3.4.4: {} + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.15 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + for-each@0.3.5: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + framer-motion@13.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + motion-dom: 13.0.0 + motion-utils: 13.0.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.2.0: @@ -3260,10 +5837,16 @@ snapshots: functions-have-names@1.2.3: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3277,6 +5860,8 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-port-please@3.2.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -3292,6 +5877,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + giget@3.3.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3313,6 +5900,10 @@ snapshots: graceful-fs@4.2.11: {} + grammex@3.1.13: {} + + graphmatch@1.1.1: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -3341,6 +5932,20 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.6: {} @@ -3352,12 +5957,16 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 hasown: 2.0.4 side-channel: 1.1.1 + internmap@2.0.3: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -3412,6 +6021,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -3435,6 +6046,10 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + + is-property@1.0.2: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -3489,18 +6104,50 @@ snapshots: jiti@2.7.0: {} + jose@6.2.8: {} + + js-md5@0.8.3: {} + js-tokens@4.0.0: {} js-yaml@4.3.1: dependencies: argparse: 2.0.1 + jsdom@30.0.1(@noble/hashes@2.2.0): + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.8(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.2.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0(@noble/hashes@2.2.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -3534,36 +6181,69 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -3580,26 +6260,81 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash.merge@4.6.2: {} + lodash@4.18.1: {} + + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lru.min@1.1.4: {} + + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mariadb@3.4.5: + dependencies: + '@types/geojson': 7946.0.16 + '@types/node': 24.13.3 + denque: 2.1.0 + iconv-lite: 0.6.3 + lru-cache: 10.4.3 + + mariadb@3.5.3: + dependencies: + '@types/geojson': 7946.0.16 + '@types/node': 20.19.43 + denque: 2.1.0 + iconv-lite: 0.7.3 + lru-cache: 11.5.2 + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -3607,6 +6342,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + min-indent@1.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -3617,15 +6354,45 @@ snapshots: minimist@1.2.8: {} + motion-dom@13.0.0: + dependencies: + motion-utils: 13.0.0 + + motion-utils@13.0.0: {} + + motion@13.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + framer-motion: 13.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + ms@2.1.3: {} - nanoid@3.3.17: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + nanoid@6.0.1: {} napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} - next@16.3.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.3.0 '@swc/helpers': 0.5.15 @@ -3644,6 +6411,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.3.0 '@next/swc-win32-arm64-msvc': 16.3.0 '@next/swc-win32-x64-msvc': 16.3.0 + '@playwright/test': 1.62.1 sharp: 0.35.3(@types/node@20.19.43) transitivePeerDependencies: - '@babel/core' @@ -3659,6 +6427,8 @@ snapshots: node-releases@2.0.53: {} + nodemailer@9.0.5: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -3701,6 +6471,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + obug@2.1.4: {} + + ohash@2.0.11: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3710,6 +6484,10 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + otpauth@9.5.1: + dependencies: + '@noble/hashes': 2.2.0 + own-keys@1.0.2: dependencies: call-bound: 1.0.4 @@ -3717,56 +6495,198 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + + pako@0.2.9: {} + + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} path-parse@1.0.7: {} + pathe@2.0.3: {} + + pdfkit@0.19.1: + dependencies: + '@noble/ciphers': 1.3.0 + '@noble/hashes': 1.8.0 + fontkit: 2.0.4 + js-md5: 0.8.3 + linebreak: 1.1.0 + png-js: 1.1.0 + + perfect-debounce@2.1.0: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} picomatch@4.0.5: {} + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + png-js@1.1.0: + dependencies: + browserify-zlib: 0.2.0 + + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.23: dependencies: - nanoid: 3.3.17 + nanoid: 6.0.1 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.26: dependencies: - nanoid: 3.3.17 + nanoid: 6.0.1 picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.9.1 + '@prisma/dev': 0.24.17(typescript@5.9.3) + '@prisma/engines': 7.9.1 + '@prisma/studio-core': 0.33.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + punycode@2.3.1: {} + pure-rand@6.1.0: {} + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + queue-microtask@1.2.3: {} + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 @@ -3774,8 +6694,17 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react@19.2.8: {} + readdirp@5.1.1: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -3796,6 +6725,14 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + remeda@2.33.4: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -3809,8 +6746,36 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restructure@3.0.2: {} + + ret@0.5.0: {} + + retry@0.12.0: {} + reusify@1.1.0: {} + robust-predicates@3.0.3: {} + + rolldown@1.2.3: + dependencies: + '@oxc-project/types': 0.143.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.3 + '@rolldown/binding-darwin-arm64': 1.2.3 + '@rolldown/binding-darwin-x64': 1.2.3 + '@rolldown/binding-freebsd-x64': 1.2.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.3 + '@rolldown/binding-linux-arm64-gnu': 1.2.3 + '@rolldown/binding-linux-arm64-musl': 1.2.3 + '@rolldown/binding-linux-ppc64-gnu': 1.2.3 + '@rolldown/binding-linux-s390x-gnu': 1.2.3 + '@rolldown/binding-linux-x64-gnu': 1.2.3 + '@rolldown/binding-linux-x64-musl': 1.2.3 + '@rolldown/binding-openharmony-arm64': 1.2.3 + '@rolldown/binding-win32-arm64-msvc': 1.2.3 + '@rolldown/binding-win32-x64-msvc': 1.2.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3834,12 +6799,26 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} semver@7.8.5: {} + seq-queue@0.0.5: {} + + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -3894,7 +6873,6 @@ snapshots: '@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-x64': 0.35.3 '@types/node': 20.19.43 - optional: true shebang-command@2.0.0: dependencies: @@ -3930,15 +6908,37 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} + split2@4.2.0: {} + + sqlstring@2.3.3: {} + stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + + std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -3990,10 +6990,22 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-bom@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} + stripe@22.4.0(@types/node@20.19.43): + optionalDependencies: + '@types/node': 20.19.43 + styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.8): dependencies: client-only: 0.0.1 @@ -4007,19 +7019,43 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} + tiny-inflate@1.0.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.1: {} + + tldts-core@7.4.10: {} + + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4033,6 +7069,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.9: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -4092,6 +7134,20 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.18.2: {} + + undici@8.10.0: {} + + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 @@ -4129,6 +7185,76 @@ snapshots: dependencies: punycode: 2.3.1 + valibot@1.4.2(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.9 + + vitest@4.1.10(@types/node@20.19.43)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.9) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + jsdom: 30.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4160,6 +7286,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7 @@ -4174,14 +7302,62 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y18n@4.0.3: {} + yallist@3.1.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.13 + graphmatch: 1.1.1 + zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 zod@4.4.3: {} + + zustand@5.0.14(@types/react@19.2.18)(react@19.2.8): + optionalDependencies: + '@types/react': 19.2.18 + react: 19.2.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c2da90e..a0cb9b9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,11 @@ allowBuilds: - sharp: false + '@prisma/engines': true + esbuild: true + prisma: true + sharp: true unrs-resolver: false + +overrides: + # GHSA-2v37-7h3g-55p8: DoS bei size=0 in aelteren nanoid-Versionen, ueber + # postcss/vite transitiv eingezogen (next, @tailwindcss/postcss, vitest). + nanoid: '>=3.3.18' diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..846b38d --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,15 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + seed: "tsx prisma/seed.ts", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/prisma/migrations/20260808043638_init/migration.sql b/prisma/migrations/20260808043638_init/migration.sql new file mode 100644 index 0000000..b9a1c02 --- /dev/null +++ b/prisma/migrations/20260808043638_init/migration.sql @@ -0,0 +1,322 @@ +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('NEU', 'BEZAHLT', 'IN_VORBEREITUNG', 'VERSANDBEREIT', 'VERSENDET', 'ABHOLBEREIT', 'ABGEHOLT', 'STORNIERT'); + +-- CreateEnum +CREATE TYPE "FulfillmentMethod" AS ENUM ('VERSAND', 'ABHOLUNG'); + +-- CreateEnum +CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'SUCCEEDED', 'FAILED', 'REFUNDED'); + +-- CreateEnum +CREATE TYPE "BusinessPackage" AS ENUM ('KLASSIK', 'GESCHENK', 'INDIVIDUELL'); + +-- CreateEnum +CREATE TYPE "InquiryStatus" AS ENUM ('NEU', 'IN_BEARBEITUNG', 'ABGESCHLOSSEN'); + +-- CreateTable +CREATE TABLE "customers" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "phone" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "customers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "addresses" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "label" TEXT NOT NULL DEFAULT 'Zuhause', + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "street" TEXT NOT NULL, + "zip" TEXT NOT NULL, + "city" TEXT NOT NULL, + "country" TEXT NOT NULL DEFAULT 'DE', + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "addresses_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "harvests" ( + "id" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "type" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "harvestDate" TIMESTAMP(3) NOT NULL, + "location" TEXT NOT NULL DEFAULT 'Altenmuenster', + "description" TEXT NOT NULL, + "images" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "harvests_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "batches" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "harvestId" TEXT NOT NULL, + "fillDate" TIMESTAMP(3) NOT NULL, + "bestBeforeDate" TIMESTAMP(3) NOT NULL, + "glassCount" INTEGER NOT NULL, + "remainingGlasses" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "batches_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "products" ( + "id" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "harvestId" TEXT NOT NULL, + "batchId" TEXT NOT NULL, + "description" TEXT NOT NULL, + "tasteTags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "intensityMild" INTEGER NOT NULL DEFAULT 3, + "intensityFloral" INTEGER NOT NULL DEFAULT 3, + "intensityKraeftig" INTEGER NOT NULL DEFAULT 3, + "consistency" TEXT NOT NULL DEFAULT 'feincremig', + "weightGrams" INTEGER NOT NULL, + "priceCents" INTEGER NOT NULL, + "pricePerKgCents" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'EUR', + "images" TEXT[] DEFAULT ARRAY[]::TEXT[], + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "products_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "inventory" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "inventory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "orders" ( + "id" TEXT NOT NULL, + "orderNumber" TEXT NOT NULL, + "customerId" TEXT, + "status" "OrderStatus" NOT NULL DEFAULT 'NEU', + "fulfillmentMethod" "FulfillmentMethod" NOT NULL DEFAULT 'VERSAND', + "email" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "phone" TEXT, + "street" TEXT NOT NULL, + "zip" TEXT NOT NULL, + "city" TEXT NOT NULL, + "country" TEXT NOT NULL DEFAULT 'DE', + "subtotalCents" INTEGER NOT NULL, + "shippingCents" INTEGER NOT NULL DEFAULT 0, + "totalCents" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'EUR', + "stripeCheckoutSessionId" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "orders_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "order_items" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "batchId" TEXT NOT NULL, + "productName" TEXT NOT NULL, + "unitPriceCents" INTEGER NOT NULL, + "quantity" INTEGER NOT NULL, + "weightGrams" INTEGER NOT NULL, + "totalCents" INTEGER NOT NULL, + + CONSTRAINT "order_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "payments" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "provider" TEXT NOT NULL DEFAULT 'stripe', + "stripePaymentIntentId" TEXT, + "status" "PaymentStatus" NOT NULL DEFAULT 'PENDING', + "amountCents" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'EUR', + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "payments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "shipments" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "carrier" TEXT, + "trackingNumber" TEXT, + "shippedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "shipments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "pickups" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "location" TEXT NOT NULL DEFAULT 'Altenmuenster', + "readyAt" TIMESTAMP(3), + "pickedUpAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "pickups_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "invoices" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "invoiceNumber" TEXT NOT NULL, + "issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "totalCents" INTEGER NOT NULL, + "pdfUrl" TEXT, + + CONSTRAINT "invoices_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "newsletter_subscribers" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "customerId" TEXT, + "confirmed" BOOLEAN NOT NULL DEFAULT false, + "subscribedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "unsubscribedAt" TIMESTAMP(3), + + CONSTRAINT "newsletter_subscribers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "business_inquiries" ( + "id" TEXT NOT NULL, + "companyName" TEXT NOT NULL, + "contactPerson" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT, + "desiredQuantity" INTEGER NOT NULL, + "desiredDeliveryDate" TIMESTAMP(3), + "package" "BusinessPackage" NOT NULL DEFAULT 'KLASSIK', + "message" TEXT, + "status" "InquiryStatus" NOT NULL DEFAULT 'NEU', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "business_inquiries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "customers_email_key" ON "customers"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "harvests_slug_key" ON "harvests"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "harvests_year_type_key" ON "harvests"("year", "type"); + +-- CreateIndex +CREATE UNIQUE INDEX "batches_code_key" ON "batches"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "products_slug_key" ON "products"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "inventory_productId_key" ON "inventory"("productId"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_orderNumber_key" ON "orders"("orderNumber"); + +-- CreateIndex +CREATE UNIQUE INDEX "payments_orderId_key" ON "payments"("orderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "shipments_orderId_key" ON "shipments"("orderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "pickups_orderId_key" ON "pickups"("orderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "invoices_orderId_key" ON "invoices"("orderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "invoices_invoiceNumber_key" ON "invoices"("invoiceNumber"); + +-- CreateIndex +CREATE UNIQUE INDEX "newsletter_subscribers_email_key" ON "newsletter_subscribers"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "newsletter_subscribers_customerId_key" ON "newsletter_subscribers"("customerId"); + +-- AddForeignKey +ALTER TABLE "addresses" ADD CONSTRAINT "addresses_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "batches" ADD CONSTRAINT "batches_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "harvests"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "products" ADD CONSTRAINT "products_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "harvests"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "products" ADD CONSTRAINT "products_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory" ADD CONSTRAINT "inventory_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_items" ADD CONSTRAINT "order_items_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_items" ADD CONSTRAINT "order_items_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_items" ADD CONSTRAINT "order_items_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "payments" ADD CONSTRAINT "payments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "shipments" ADD CONSTRAINT "shipments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "pickups" ADD CONSTRAINT "pickups_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invoices" ADD CONSTRAINT "invoices_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "newsletter_subscribers" ADD CONSTRAINT "newsletter_subscribers_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260808051645_product_categories/migration.sql b/prisma/migrations/20260808051645_product_categories/migration.sql new file mode 100644 index 0000000..aa769a1 --- /dev/null +++ b/prisma/migrations/20260808051645_product_categories/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "ProductCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS'); + +-- AlterTable +ALTER TABLE "order_items" ALTER COLUMN "batchId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "products" ADD COLUMN "category" "ProductCategory" NOT NULL DEFAULT 'HONIG', +ALTER COLUMN "harvestId" DROP NOT NULL, +ALTER COLUMN "batchId" DROP NOT NULL; diff --git a/prisma/migrations/20260808150810_admin_role/migration.sql b/prisma/migrations/20260808150810_admin_role/migration.sql new file mode 100644 index 0000000..c61dd02 --- /dev/null +++ b/prisma/migrations/20260808150810_admin_role/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "CustomerRole" AS ENUM ('KUNDE', 'ADMIN'); + +-- AlterTable +ALTER TABLE "customers" ADD COLUMN "role" "CustomerRole" NOT NULL DEFAULT 'KUNDE'; diff --git a/prisma/migrations/20260808185522_mail_and_password_reset/migration.sql b/prisma/migrations/20260808185522_mail_and_password_reset/migration.sql new file mode 100644 index 0000000..0638ac3 --- /dev/null +++ b/prisma/migrations/20260808185522_mail_and_password_reset/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "newsletter_subscribers" ADD COLUMN "confirmationToken" TEXT; + +-- CreateTable +CREATE TABLE "password_reset_tokens" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "password_reset_tokens_tokenHash_key" ON "password_reset_tokens"("tokenHash"); + +-- CreateIndex +CREATE UNIQUE INDEX "newsletter_subscribers_confirmationToken_key" ON "newsletter_subscribers"("confirmationToken"); + +-- AddForeignKey +ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260810011736_two_factor_and_lockout/migration.sql b/prisma/migrations/20260810011736_two_factor_and_lockout/migration.sql new file mode 100644 index 0000000..507dfee --- /dev/null +++ b/prisma/migrations/20260810011736_two_factor_and_lockout/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "customers" ADD COLUMN "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "lockedUntil" TIMESTAMP(3), +ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "twoFactorSecret" TEXT; + +-- CreateTable +CREATE TABLE "two_factor_backup_codes" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "two_factor_backup_codes_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "two_factor_backup_codes_codeHash_key" ON "two_factor_backup_codes"("codeHash"); + +-- AddForeignKey +ALTER TABLE "two_factor_backup_codes" ADD CONSTRAINT "two_factor_backup_codes_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260810015710_customer_blocked_at/migration.sql b/prisma/migrations/20260810015710_customer_blocked_at/migration.sql new file mode 100644 index 0000000..5ff1bd3 --- /dev/null +++ b/prisma/migrations/20260810015710_customer_blocked_at/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "customers" ADD COLUMN "blockedAt" TIMESTAMP(3); + diff --git a/prisma/migrations/20260810095348_shipping_tiers/migration.sql b/prisma/migrations/20260810095348_shipping_tiers/migration.sql new file mode 100644 index 0000000..fbaa25c --- /dev/null +++ b/prisma/migrations/20260810095348_shipping_tiers/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "shipping_tiers" ( + "id" TEXT NOT NULL, + "minQuantity" INTEGER NOT NULL, + "maxQuantity" INTEGER, + "priceCents" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "shipping_tiers_pkey" PRIMARY KEY ("id") +); + diff --git a/prisma/migrations/20260811182136_reviews_discounts/migration.sql b/prisma/migrations/20260811182136_reviews_discounts/migration.sql new file mode 100644 index 0000000..72033aa --- /dev/null +++ b/prisma/migrations/20260811182136_reviews_discounts/migration.sql @@ -0,0 +1,54 @@ +-- CreateEnum +CREATE TYPE "DiscountType" AS ENUM ('PERCENT', 'FIXED'); + +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "discountCents" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "discountCodeId" TEXT; + +-- CreateTable +CREATE TABLE "reviews" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "rating" INTEGER NOT NULL, + "comment" TEXT, + "isApproved" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "reviews_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "discount_codes" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "type" "DiscountType" NOT NULL, + "value" INTEGER NOT NULL, + "minOrderCents" INTEGER NOT NULL DEFAULT 0, + "maxUses" INTEGER, + "usedCount" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "validFrom" TIMESTAMP(3), + "validUntil" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "discount_codes_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "reviews_productId_customerId_key" ON "reviews"("productId", "customerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "discount_codes_code_key" ON "discount_codes"("code"); + +-- AddForeignKey +ALTER TABLE "reviews" ADD CONSTRAINT "reviews_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reviews" ADD CONSTRAINT "reviews_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "discount_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260811210328_wishlist_audit_email_verification/migration.sql b/prisma/migrations/20260811210328_wishlist_audit_email_verification/migration.sql new file mode 100644 index 0000000..bb11127 --- /dev/null +++ b/prisma/migrations/20260811210328_wishlist_audit_email_verification/migration.sql @@ -0,0 +1,57 @@ +-- AlterTable +ALTER TABLE "customers" ADD COLUMN "emailVerifiedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "email_verification_tokens" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "wishlist_items" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "wishlist_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" TEXT NOT NULL, + "adminId" TEXT, + "adminEmail" TEXT NOT NULL, + "action" TEXT NOT NULL, + "entityType" TEXT NOT NULL, + "entityId" TEXT, + "detail" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "email_verification_tokens_tokenHash_key" ON "email_verification_tokens"("tokenHash"); + +-- CreateIndex +CREATE UNIQUE INDEX "wishlist_items_customerId_productId_key" ON "wishlist_items"("customerId", "productId"); + +-- AddForeignKey +ALTER TABLE "email_verification_tokens" ADD CONSTRAINT "email_verification_tokens_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260811210752_backfill_existing_customers_verified/migration.sql b/prisma/migrations/20260811210752_backfill_existing_customers_verified/migration.sql new file mode 100644 index 0000000..42b90f5 --- /dev/null +++ b/prisma/migrations/20260811210752_backfill_existing_customers_verified/migration.sql @@ -0,0 +1,5 @@ +-- Bestandskonten, die vor Einfuehrung der E-Mail-Verifizierung registriert +-- wurden, gelten rueckwirkend als bestaetigt (createdAt als Zeitstempel) -- +-- sonst waeren sie ploetzlich vom Checkout ausgesperrt, obwohl sie sich nie +-- verifizieren mussten. +UPDATE "customers" SET "emailVerifiedAt" = "createdAt" WHERE "emailVerifiedAt" IS NULL; diff --git a/prisma/migrations/20260812212850_stock_notifications/migration.sql b/prisma/migrations/20260812212850_stock_notifications/migration.sql new file mode 100644 index 0000000..7be9cc5 --- /dev/null +++ b/prisma/migrations/20260812212850_stock_notifications/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "stock_notifications" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "notifiedAt" TIMESTAMP(3), + + CONSTRAINT "stock_notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "stock_notifications_productId_email_key" ON "stock_notifications"("productId", "email"); + +-- AddForeignKey +ALTER TABLE "stock_notifications" ADD CONSTRAINT "stock_notifications_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260812222911_inquiry_status_abgelehnt/migration.sql b/prisma/migrations/20260812222911_inquiry_status_abgelehnt/migration.sql new file mode 100644 index 0000000..6766e93 --- /dev/null +++ b/prisma/migrations/20260812222911_inquiry_status_abgelehnt/migration.sql @@ -0,0 +1,3 @@ +-- AlterEnum +ALTER TYPE "InquiryStatus" ADD VALUE 'ABGELEHNT'; + diff --git a/prisma/migrations/20260812223624_return_requests/migration.sql b/prisma/migrations/20260812223624_return_requests/migration.sql new file mode 100644 index 0000000..be1ef59 --- /dev/null +++ b/prisma/migrations/20260812223624_return_requests/migration.sql @@ -0,0 +1,21 @@ +-- CreateEnum +CREATE TYPE "ReturnStatus" AS ENUM ('ANGEFRAGT', 'ANGENOMMEN', 'ABGELEHNT', 'ERSTATTET'); + +-- CreateTable +CREATE TABLE "return_requests" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "status" "ReturnStatus" NOT NULL DEFAULT 'ANGEFRAGT', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "return_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "return_requests_orderId_key" ON "return_requests"("orderId"); + +-- AddForeignKey +ALTER TABLE "return_requests" ADD CONSTRAINT "return_requests_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..e745af0 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,633 @@ +// Wabenhain — Datenmodell +// Alle Geldbetraege werden als Cent-Integer gespeichert, um Rundungsfehler zu vermeiden. + +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +// "provider" wird NICHT hier per Hand gepflegt: Prisma erlaubt kein env() an +// dieser Stelle, daher schreibt scripts/prisma-provider.mjs diese Zeile vor +// jedem generate/db push/migrate anhand von DATABASE_PROVIDER ("postgresql" +// [Standard] oder "mysql") neu. Siehe README, Abschnitt "Datenbank-Provider +// wechseln". +datasource db { + provider = "postgresql" +} + +// --------------------------------------------------------------------------- +// Kunden +// --------------------------------------------------------------------------- + +enum CustomerRole { + KUNDE + ADMIN +} + +model Customer { + id String @id @default(cuid()) + email String @unique + passwordHash String + firstName String + lastName String + phone String? + role CustomerRole @default(KUNDE) + + /// Wird bei sicherheitsrelevanten Ereignissen (Passwort-Reset, "alle + /// Geraete abmelden", 2FA an/aus) hochgezaehlt. Im JWT eingebettet — bei + /// Abweichung von diesem Wert gilt das Token als widerrufen, obwohl es + /// technisch noch nicht abgelaufen ist. + sessionVersion Int @default(0) + /// Fuer die Konto-Sperre nach wiederholten Fehlversuchen (zusaetzlich zum + /// IP-basierten Rate-Limit — persistent und ueberlebt einen Neustart/ + /// mehrere Server-Instanzen, anders als der In-Memory-Rate-Limiter). + failedLoginAttempts Int @default(0) + lockedUntil DateTime? + + /// Base32-TOTP-Secret, erst gesetzt wenn 2FA aktiv bestaetigt wurde. + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + /// Zeitschritt (Unix-Zeit / 30s) des zuletzt beim Login akzeptierten + /// TOTP-Codes — verhindert, dass ein abgefangener/mitgelesener Code im + /// selben oder einem frueheren Zeitfenster ein zweites Mal funktioniert. + lastTotpStep Int? + + /// Von einem Admin gesperrtes Konto (Benutzerverwaltung) — unabhaengig von + /// der automatischen Fehlversuchs-Sperre in lockedUntil. Verhindert den + /// Login bis ein Admin das Konto wieder entsperrt. + blockedAt DateTime? + + /// Gesetzt, sobald die E-Mail-Adresse per Bestaetigungslink oder durch + /// erfolgreiches Setzen eines Passworts ueber einen zugesandten Reset-Link + /// nachweislich unter der Kontrolle des Kontoinhabers ist. Registrierte + /// Kund:innen benoetigen dies fuer den Checkout (siehe /api/checkout). + emailVerifiedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + orders Order[] + addresses Address[] + newsletterSubscriber NewsletterSubscriber? + passwordResetTokens PasswordResetToken[] + emailVerificationTokens EmailVerificationToken[] + twoFactorBackupCodes TwoFactorBackupCode[] + reviews Review[] + wishlistItems WishlistItem[] + auditLogs AuditLog[] + + @@map("customers") +} + +model EmailVerificationToken { + id String @id @default(cuid()) + customerId String + /// SHA-256-Hash des per E-Mail zugesandten Tokens — wie beim + /// Passwort-Reset wird nie der Klartext gespeichert. + tokenHash String @unique + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) + + @@map("email_verification_tokens") +} + +model TwoFactorBackupCode { + id String @id @default(cuid()) + customerId String + /// SHA-256-Hash des Codes — der Klartext wird dem Kunden nur einmalig bei + /// der Einrichtung angezeigt und nie wieder gespeichert. + codeHash String @unique + usedAt DateTime? + createdAt DateTime @default(now()) + + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) + + @@map("two_factor_backup_codes") +} + +model Address { + id String @id @default(cuid()) + customerId String + label String @default("Zuhause") + firstName String + lastName String + street String + zip String + city String + country String @default("DE") + isDefault Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) + + @@map("addresses") +} + +// --------------------------------------------------------------------------- +// Ernten, Chargen, Lagerbestand, Produkte +// --------------------------------------------------------------------------- + +model Harvest { + id String @id @default(cuid()) + year Int + /// z.B. "Fruehtracht", "Sommertracht" + type String + slug String @unique + harvestDate DateTime + location String @default("Altenmuenster") + description String + /// Liste von Bild-URLs als JSON-Array (String[] ist nicht Provider-uebergreifend: + /// MySQL kennt keine nativen Skalar-Arrays) — siehe lib/json-array.ts fuer den + /// Normalisierungs-Helfer beim Lesen. + images Json @default("[]") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + batches Batch[] + products Product[] + + @@unique([year, type]) + @@map("harvests") +} + +model Batch { + id String @id @default(cuid()) + /// z.B. FH-26-01 + code String @unique + harvestId String + fillDate DateTime + bestBeforeDate DateTime + glassCount Int + remainingGlasses Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + harvest Harvest @relation(fields: [harvestId], references: [id], onDelete: Restrict) + products Product[] + orderItems OrderItem[] + + @@map("batches") +} + +enum ProductCategory { + HONIG + KERZEN + PROPOLIS +} + +model Product { + id String @id @default(cuid()) + slug String @unique + name String + category ProductCategory @default(HONIG) + /// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet) + harvestId String? + batchId String? + description String + /// Geschmacksprofil-Tags als JSON-Array, z.B. ["mild", "floral", "feincremig"] + /// — String[] ist nicht Provider-uebergreifend (MySQL kennt keine nativen + /// Skalar-Arrays), siehe lib/json-array.ts fuer den Normalisierungs-Helfer. + tasteTags Json @default("[]") + /// Intensitaet 1-5 fuer die visuelle Geschmacksskala + intensityMild Int @default(3) + intensityFloral Int @default(3) + intensityKraeftig Int @default(3) + consistency String @default("feincremig") + weightGrams Int + priceCents Int + pricePerKgCents Int + currency String @default("EUR") + /// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben. + images Json @default("[]") + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + harvest Harvest? @relation(fields: [harvestId], references: [id], onDelete: Restrict) + batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict) + inventory Inventory? + orderItems OrderItem[] + reviews Review[] + wishlistItems WishlistItem[] + stockNotifications StockNotification[] + + @@map("products") +} + +model StockNotification { + id String @id @default(cuid()) + productId String + email String + /// Spracheinstellung bei der Anmeldung — die Benachrichtigungs-E-Mail wird + /// spaeter asynchron (beim naechsten Bestandsupdate) versendet, ohne + /// Cookie-Kontext der urspruenglichen Anfrage. + locale String @default("de") + createdAt DateTime @default(now()) + /// Gesetzt, sobald die Benachrichtigungs-E-Mail versendet wurde — verhindert + /// Mehrfachversand, falls der Bestand mehrfach zwischen 0 und >0 wechselt, + /// ohne dass sich die Kundin/der Kunde neu angemeldet hat. + notifiedAt DateTime? + + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + /// Eine Anmeldung pro E-Mail-Adresse und Produkt. + @@unique([productId, email]) + @@map("stock_notifications") +} + +model WishlistItem { + id String @id @default(cuid()) + customerId String + productId String + createdAt DateTime @default(now()) + + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + /// Ein Produkt kann pro Kunde nur einmal auf der Wunschliste stehen. + @@unique([customerId, productId]) + @@map("wishlist_items") +} + +model Review { + id String @id @default(cuid()) + productId String + customerId String + /// 1-5 + rating Int + comment String? + /// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt — + /// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung. + isApproved Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) + + /// Ein Kunde kann ein Produkt nur einmal bewerten. + @@unique([productId, customerId]) + @@map("reviews") +} + +model Inventory { + id String @id @default(cuid()) + productId String @unique + quantity Int @default(0) + updatedAt DateTime @updatedAt + + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@map("inventory") +} + +// --------------------------------------------------------------------------- +// Bestellungen +// --------------------------------------------------------------------------- + +enum OrderStatus { + NEU + BEZAHLT + IN_VORBEREITUNG + VERSANDBEREIT + VERSENDET + ABHOLBEREIT + ABGEHOLT + STORNIERT +} + +enum FulfillmentMethod { + VERSAND + ABHOLUNG +} + +model Order { + id String @id @default(cuid()) + orderNumber String @unique + customerId String? + status OrderStatus @default(NEU) + fulfillmentMethod FulfillmentMethod @default(VERSAND) + /// Spracheinstellung beim Checkout ("de"/"en") — wird fuer spaeter + /// asynchron versendete E-Mails (Stripe-Webhook) benoetigt, da dort kein + /// Cookie-/Request-Kontext mehr vorhanden ist, um die Sprache erneut zu bestimmen. + locale String @default("de") + + // Kontakt- und Versanddaten werden am Bestellzeitpunkt gespeichert, + // damit Gastbestellungen unabhaengig von einem Customer-Datensatz funktionieren. + email String + firstName String + lastName String + phone String? + street String + zip String + city String + country String @default("DE") + + subtotalCents Int + shippingCents Int @default(0) + discountCents Int @default(0) + totalCents Int + currency String @default("EUR") + + discountCodeId String? + + stripeCheckoutSessionId String? + notes String? + /// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim + /// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail). + giftMessage String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) + discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull) + items OrderItem[] + payment Payment? + shipment Shipment? + pickup Pickup? + invoice Invoice? + returnRequest ReturnRequest? + + @@map("orders") +} + +enum ReturnStatus { + ANGEFRAGT + ANGENOMMEN + ABGELEHNT + ERSTATTET +} + +/// Eine Rueckgabeanfrage pro Bestellung (orderId @unique) — deckt den Fall +/// "gesamte Bestellung zurueckschicken" ab, keine positionsweise Teil-Retoure. +model ReturnRequest { + id String @id @default(cuid()) + orderId String @unique + reason String + status ReturnStatus @default(ANGEFRAGT) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + + @@map("return_requests") +} + +model OrderItem { + id String @id @default(cuid()) + orderId String + productId String + /// Nur gesetzt, wenn das Produkt einer Charge zugeordnet ist (Honig) + batchId String? + + // Snapshot der Produktdaten zum Bestellzeitpunkt (Preisaenderungen duerfen + // bestehende Bestellungen nicht rueckwirkend veraendern). + productName String + unitPriceCents Int + quantity Int + weightGrams Int + totalCents Int + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Restrict) + batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict) + + @@map("order_items") +} + +enum PaymentStatus { + PENDING + SUCCEEDED + FAILED + REFUNDED +} + +model Payment { + id String @id @default(cuid()) + orderId String @unique + provider String @default("stripe") + stripePaymentIntentId String? + status PaymentStatus @default(PENDING) + amountCents Int + currency String @default("EUR") + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + + @@map("payments") +} + +model Shipment { + id String @id @default(cuid()) + orderId String @unique + carrier String? + trackingNumber String? + shippedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + + @@map("shipments") +} + +model Pickup { + id String @id @default(cuid()) + orderId String @unique + location String @default("Altenmuenster") + readyAt DateTime? + pickedUpAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + + @@map("pickups") +} + +model Invoice { + id String @id @default(cuid()) + orderId String @unique + invoiceNumber String @unique + issuedAt DateTime @default(now()) + totalCents Int + pdfUrl String? + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + + @@map("invoices") +} + +// --------------------------------------------------------------------------- +// Marketing / Kontakt +// --------------------------------------------------------------------------- + +model NewsletterSubscriber { + id String @id @default(cuid()) + email String @unique + customerId String? @unique + confirmed Boolean @default(false) + /// Dient sowohl der Bestaetigung (Double-Opt-In) als auch als dauerhafter + /// Abmelde-Link fuer Abonnenten ohne Kundenkonto. + confirmationToken String? @unique + subscribedAt DateTime @default(now()) + unsubscribedAt DateTime? + + customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) + + @@map("newsletter_subscribers") +} + +model PasswordResetToken { + id String @id @default(cuid()) + customerId String + /// SHA-256-Hash des dem Kunden per E-Mail zugesandten Tokens — wie bei + /// Passwoertern wird nie der Klartext gespeichert. + tokenHash String @unique + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) + + @@map("password_reset_tokens") +} + +enum BusinessPackage { + KLASSIK + GESCHENK + INDIVIDUELL +} + +enum InquiryStatus { + NEU + IN_BEARBEITUNG + ABGESCHLOSSEN + ABGELEHNT +} + +// --------------------------------------------------------------------------- +// Versand +// --------------------------------------------------------------------------- + +model ShippingTier { + id String @id @default(cuid()) + /// Menge (Stueck ueber alle Artikel im Warenkorb zusammengezaehlt) ab der + /// diese Staffel gilt. + minQuantity Int + /// Bis einschliesslich dieser Menge — null bedeutet "und mehr" (offene, + /// oberste Staffel). + maxQuantity Int? + priceCents Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("shipping_tiers") +} + +// --------------------------------------------------------------------------- +// Rabattcodes +// --------------------------------------------------------------------------- + +enum DiscountType { + PERCENT + FIXED +} + +model DiscountCode { + id String @id @default(cuid()) + code String @unique + type DiscountType + /// PERCENT: Prozentpunkte (1-100). FIXED: Cent-Betrag. + value Int + + /// Mindestbestellwert (Cent) fuer die Anwendung, 0 = keine Mindestsumme. + minOrderCents Int @default(0) + /// Mindestmenge (Summe aller Artikel im Warenkorb) fuer die Anwendung — + /// null = keine Mengenschwelle. Gesetzt macht aus dem Code einen + /// Mengenrabatt: Checkout wendet ihn automatisch an, sobald die Schwelle + /// erreicht ist (siehe api/checkout/route.ts), ohne dass der Code + /// eingegeben werden muss. + minQuantity Int? + /// Nutzungslimit insgesamt ueber alle Kunden — null = unbegrenzt. + maxUses Int? + usedCount Int @default(0) + isActive Boolean @default(true) + validFrom DateTime? + validUntil DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + orders Order[] + + @@map("discount_codes") +} + +// --------------------------------------------------------------------------- +// Admin-Audit-Log +// --------------------------------------------------------------------------- + +/// Protokolliert sicherheitsrelevante Admin-Mutationen (wer hat wann was +/// geaendert) — wichtig bei mehreren Admin-Konten, um Aenderungen +/// nachvollziehen zu koennen. adminEmail ist ein Snapshot, damit der +/// Eintrag auch nach Loeschung des Admin-Kontos lesbar bleibt. +model AuditLog { + id String @id @default(cuid()) + adminId String? + adminEmail String + /// z.B. "product.update", "order.status_change", "discount_code.delete" + action String + entityType String + entityId String? + detail String? + createdAt DateTime @default(now()) + + admin Customer? @relation(fields: [adminId], references: [id], onDelete: SetNull) + + @@map("audit_logs") +} + +/// Admin-editierbare Website-Inhalte (Firmendaten, Homepage-Texte, +/// Rechtstexte) als generisches Key-Value-Overlay ueber den statischen +/// i18n-Dictionaries — siehe lib/content-registry.ts fuer die Liste aller +/// editierbaren Schluessel und deren Fallback-Werte. Fehlt eine Zeile (kein +/// Admin-Override gepflegt), liefert getContentValue() den urspruenglichen +/// Dictionary-/site-config-Text — die Seite bricht also nie, auch bevor die +/// erste Bearbeitung stattgefunden hat. +model SiteContent { + /// z.B. "company.email", "home.philosophy.items", "legal.impressum" + key String @id + /// Bei nicht lokalisierten Feldern (Firmendaten) einziger Speicherort; + /// bei lokalisierten Feldern (Homepage/Rechtstexte) die deutsche Fassung. + valueDe String @db.Text + /// Nur bei lokalisierten Feldern gesetzt — null bei Firmendaten. + valueEn String? @db.Text + updatedAt DateTime @updatedAt + + @@map("site_content") +} + +model BusinessInquiry { + id String @id @default(cuid()) + companyName String + contactPerson String + email String + phone String? + desiredQuantity Int + desiredDeliveryDate DateTime? + package BusinessPackage @default(KLASSIK) + message String? + status InquiryStatus @default(NEU) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("business_inquiries") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..9b2eb85 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,265 @@ +import bcrypt from "bcryptjs"; +import { PrismaClient } from "../src/generated/prisma/client"; +import { PrismaClient as MySqlPrismaClient } from "../src/generated/prisma-mysql/client"; +import { createDbAdapter } from "../src/lib/db-adapter"; +import { isPasswordBreached } from "../src/lib/password-breach-check"; + +// Bewusst kein Import aus "@/lib/auth": das Modul zieht next/headers (cookies()) +// nach sich, das ausserhalb eines Next.js-Request-Kontexts (hier: Standalone- +// Skript via tsx) fehlschlaegt. Der Hash-Rundenwert 12 ist mit auth.ts identisch. +const BCRYPT_ROUNDS = 12; + +const adapter = createDbAdapter(); +// Gleiches Prinzip wie lib/prisma.ts: zwei generierte Clients im Image, +// Auswahl zur Laufzeit ueber DATABASE_PROVIDER. +const prisma: PrismaClient = + (process.env.DATABASE_PROVIDER ?? "postgresql").trim() === "mysql" + ? (new MySqlPrismaClient({ adapter }) as unknown as PrismaClient) + : new PrismaClient({ adapter }); + +async function main() { + const fruehtracht = await prisma.harvest.upsert({ + where: { slug: "fruehtracht-2026" }, + update: {}, + create: { + year: 2026, + type: "Frühtracht", + slug: "fruehtracht-2026", + harvestDate: new Date("2026-05-10"), + location: "Altenmünster", + description: + "Die erste Ernte des Jahres entsteht aus dem Nektar von Obstblüte, Raps und Löwenzahn rund um Altenmünster. Mild, floral und feincremig — der Auftakt jeder Wabenhain-Saison.", + images: [], + }, + }); + + const sommertracht = await prisma.harvest.upsert({ + where: { slug: "sommertracht-2026" }, + update: {}, + create: { + year: 2026, + type: "Sommertracht", + slug: "sommertracht-2026", + harvestDate: new Date("2026-07-18"), + location: "Altenmünster", + description: + "Im Hochsommer sammeln unsere Völker Nektar von Linde, Wiesenblumen und Wald. Das Ergebnis ist kräftiger, aromatischer und charaktervoller als die Frühtracht.", + images: [], + }, + }); + + const fhBatch = await prisma.batch.upsert({ + where: { code: "FH-26-01" }, + update: {}, + create: { + code: "FH-26-01", + harvestId: fruehtracht.id, + fillDate: new Date("2026-06-02"), + bestBeforeDate: new Date("2028-06-02"), + glassCount: 200, + remainingGlasses: 167, + }, + }); + + const stBatch = await prisma.batch.upsert({ + where: { code: "ST-26-01" }, + update: {}, + create: { + code: "ST-26-01", + harvestId: sommertracht.id, + fillDate: new Date("2026-08-05"), + bestBeforeDate: new Date("2028-08-05"), + glassCount: 150, + remainingGlasses: 150, + }, + }); + + const fhProduct = await prisma.product.upsert({ + where: { slug: "fruehtracht-2026" }, + update: { tasteTags: ["mild", "floral", "feincremig"] }, + create: { + slug: "fruehtracht-2026", + name: "Frühtracht 2026", + harvestId: fruehtracht.id, + batchId: fhBatch.id, + description: + "Mild, floral und feincremig. Unsere erste Ernte des Jahres aus dem Raum Altenmünster.", + tasteTags: ["mild", "floral", "feincremig"], + intensityMild: 4, + intensityFloral: 4, + intensityKraeftig: 2, + consistency: "feincremig", + weightGrams: 500, + priceCents: 990, + pricePerKgCents: 1980, + images: [], + isActive: true, + }, + }); + + const stProduct = await prisma.product.upsert({ + where: { slug: "sommertracht-2026" }, + update: { tasteTags: ["kräftig", "aromatisch", "charaktervoll"] }, + create: { + slug: "sommertracht-2026", + name: "Sommertracht 2026", + harvestId: sommertracht.id, + batchId: stBatch.id, + description: "Kräftiger, aromatischer und charaktervoller Sommerhonig aus Altenmünster.", + tasteTags: ["kräftig", "aromatisch", "charaktervoll"], + intensityMild: 2, + intensityFloral: 2, + intensityKraeftig: 5, + consistency: "cremig", + weightGrams: 500, + priceCents: 1090, + pricePerKgCents: 2180, + images: [], + isActive: true, + }, + }); + + const candleProduct = await prisma.product.upsert({ + where: { slug: "bienenwachskerze-rund" }, + update: { tasteTags: ["handgegossen", "reines Bienenwachs", "rußarm"] }, + create: { + slug: "bienenwachskerze-rund", + name: "Bienenwachskerze, rund", + category: "KERZEN", + description: + "Handgegossen aus reinem Bienenwachs unserer eigenen Völker. Brennt gleichmäßig, rußarm und verströmt einen dezenten Honigduft.", + tasteTags: ["handgegossen", "reines Bienenwachs", "rußarm"], + consistency: "fest", + weightGrams: 150, + priceCents: 1290, + pricePerKgCents: 8600, + images: [], + isActive: true, + }, + }); + + const propolisProduct = await prisma.product.upsert({ + where: { slug: "propolis-tinktur" }, + update: { tasteTags: ["schonend verarbeitet", "ohne Zusätze"] }, + create: { + slug: "propolis-tinktur", + name: "Propolis-Tinktur", + category: "PROPOLIS", + description: + "Schonend angesetzte Propolis-Tinktur aus dem Harz unserer Bienenstöcke rund um Altenmünster. Ohne synthetische Zusätze.", + tasteTags: ["schonend verarbeitet", "ohne Zusätze"], + consistency: "flüssig", + weightGrams: 30, + priceCents: 1490, + pricePerKgCents: 49666, + images: [], + isActive: true, + }, + }); + + await prisma.inventory.upsert({ + where: { productId: fhProduct.id }, + update: { quantity: 167 }, + create: { productId: fhProduct.id, quantity: 167 }, + }); + + await prisma.inventory.upsert({ + where: { productId: stProduct.id }, + update: { quantity: 150 }, + create: { productId: stProduct.id, quantity: 150 }, + }); + + await prisma.inventory.upsert({ + where: { productId: candleProduct.id }, + update: { quantity: 60 }, + create: { productId: candleProduct.id, quantity: 60 }, + }); + + await prisma.inventory.upsert({ + where: { productId: propolisProduct.id }, + update: { quantity: 40 }, + create: { productId: propolisProduct.id, quantity: 40 }, + }); + + const existingShippingTiers = await prisma.shippingTier.count(); + if (existingShippingTiers === 0) { + await prisma.shippingTier.createMany({ + data: [ + { minQuantity: 0, maxQuantity: 3, priceCents: 700 }, + { minQuantity: 4, maxQuantity: 9, priceCents: 850 }, + { minQuantity: 10, maxQuantity: 19, priceCents: 1100 }, + ], + }); + console.log("Versandstaffeln angelegt: 0-3=7,00€, 4-9=8,50€, 10-19=11,00€."); + } + + const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase(); + const adminPassword = process.env.ADMIN_PASSWORD; + + if (adminEmail && adminPassword) { + // Dieselben Mindestanforderungen wie bei der normalen Kundenregistrierung + // (registration.ts) — ein Admin-Konto hat Zugriff auf Bestellungen, + // Rueckerstattungen und Kundendaten und verdient keine schwaecheren + // Passwort-Regeln nur weil es per Seed statt per Formular angelegt wird. + if (adminPassword.length < 8) { + throw new Error("ADMIN_PASSWORD muss mindestens 8 Zeichen lang sein."); + } + if (await isPasswordBreached(adminPassword)) { + throw new Error( + "ADMIN_PASSWORD wurde in bekannten Datenlecks gefunden (Have I Been Pwned). Bitte ein anderes Passwort waehlen." + ); + } + + const existingAtEmail = await prisma.customer.findUnique({ where: { email: adminEmail } }); + + if (!existingAtEmail) { + await prisma.customer.create({ + data: { + email: adminEmail, + passwordHash: await bcrypt.hash(adminPassword, BCRYPT_ROUNDS), + firstName: "Admin", + lastName: "Wabenhain", + role: "ADMIN", + }, + }); + console.log(`Admin-Konto angelegt: ${adminEmail} (Passwort aus ADMIN_PASSWORD).`); + } else if (existingAtEmail.role === "ADMIN") { + // Bereits Admin: Passwort-Rotation ueber ein geaendertes ADMIN_PASSWORD + // weiterhin unterstuetzen (dokumentierter Weg, ein Admin-Passwort zu + // erneuern) — unproblematisch, da das Konto schon Admin ist. + await prisma.customer.update({ + where: { id: existingAtEmail.id }, + data: { passwordHash: await bcrypt.hash(adminPassword, BCRYPT_ROUNDS) }, + }); + console.log(`Admin-Passwort synchronisiert: ${adminEmail}.`); + } else { + // Sicherheitsrelevant: seed befoerdert NIE automatisch ein bestehendes + // Nicht-Admin-Konto zu ADMIN, nur weil dessen E-Mail mit ADMIN_EMAIL + // uebereinstimmt — sonst koennte sich jemand die Admin-Mail vor dem + // ersten Deploy-Seed-Lauf selbst registrieren und beim naechsten + // "pnpm prisma db seed" automatisch Admin werden, mit selbstgewaehltem + // Passwort. Promotion eines bestehenden Kontos daher nur manuell ueber + // die Datenbank, siehe README "Admin-Zugang". + console.warn( + `WARNUNG: ${adminEmail} existiert bereits als Kundenkonto (nicht Admin) und wird NICHT automatisch befoerdert. ` + + 'Siehe README, Abschnitt "Admin-Zugang", fuer die manuelle Befoerderung.' + ); + } + } else { + console.log( + "Kein Admin-Konto angelegt: ADMIN_EMAIL/ADMIN_PASSWORD sind nicht gesetzt. " + + "Siehe README, wie ein bestehendes Konto nachtraeglich zu ADMIN befoerdert wird." + ); + } + + console.log("Seed abgeschlossen: 2 Ernten, 2 Chargen, 4 Produkte (Honig, Kerzen, Propolis)."); +} + +main() + .catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000..e319e71 --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,4 @@ +Contact: mailto:hallo@wabenhain.com +Expires: 2027-08-14T00:00:00.000Z +Preferred-Languages: de, en +Canonical: https://wabenhain.com/.well-known/security.txt diff --git a/scripts/generate-prisma-clients.mjs b/scripts/generate-prisma-clients.mjs new file mode 100644 index 0000000..15b5152 --- /dev/null +++ b/scripts/generate-prisma-clients.mjs @@ -0,0 +1,47 @@ +// Generiert BEIDE Prisma-Client-Varianten (Postgres + MySQL) aus derselben +// prisma/schema.prisma, damit ein einzelnes Docker-Image zur Laufzeit per +// DATABASE_PROVIDER zwischen beiden waehlen kann (lib/prisma.ts, +// prisma/seed.ts) — ein einzelner generierter Client ist fest an EINEN +// Provider gebunden (Prisma unterstuetzt keinen Laufzeit-Wechsel desselben +// generierten Clients zwischen Providern). Anders als +// scripts/prisma-provider.mjs (schreibt genau EINEN Provider fuer +// db push/migrate/studio, die gegen eine konkrete Ziel-DB laufen) ist dieses +// Skript fuer dev/build/start/postinstall gedacht. +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; +import path from "node:path"; + +const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const schemaPath = path.join(projectRoot, "prisma", "schema.prisma"); + +const VARIANTS = [ + { provider: "postgresql", output: "../src/generated/prisma" }, + { provider: "mysql", output: "../src/generated/prisma-mysql" }, +]; + +const originalSchema = readFileSync(schemaPath, "utf8"); + +try { + for (const { provider, output } of VARIANTS) { + let schema = originalSchema.replace( + /(datasource db \{[^}]*provider\s*=\s*")[a-z]+(")/, + `$1${provider}$2` + ); + schema = schema.replace( + /(generator client \{[^}]*output\s*=\s*")[^"]*(")/, + `$1${output}$2` + ); + writeFileSync(schemaPath, schema); + console.log(`Generiere Prisma Client (${provider}) -> ${output} ...`); + // execSync (statt execFileSync) laeuft ueber die Shell — auf Windows ist + // "npx" ein .cmd-Batch-Skript, das execFileSync ohne shell:true nicht + // aufloest. + execSync("npx prisma generate", { stdio: "inherit", cwd: projectRoot }); + } +} finally { + // schema.prisma unveraendert lassen: der eigentliche Provider fuer + // db push/migrate/studio wird separat von scripts/prisma-provider.mjs + // gesetzt, nicht von diesem Skript. + writeFileSync(schemaPath, originalSchema); +} diff --git a/scripts/prisma-provider.mjs b/scripts/prisma-provider.mjs new file mode 100644 index 0000000..674da03 --- /dev/null +++ b/scripts/prisma-provider.mjs @@ -0,0 +1,40 @@ +// Prisma erlaubt kein env() fuer datasource.provider (nur fuer url) — dieses +// Skript schreibt die provider-Zeile in prisma/schema.prisma daher vor jedem +// generate/db push/migrate anhand von DATABASE_PROVIDER neu. Wird von den +// package.json-Skripten aufgerufen, nicht direkt. +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const SUPPORTED_PROVIDERS = ["postgresql", "mysql"]; +const DEFAULT_PROVIDER = "postgresql"; + +const schemaPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "prisma", + "schema.prisma" +); + +const provider = (process.env.DATABASE_PROVIDER ?? DEFAULT_PROVIDER).trim(); + +if (!SUPPORTED_PROVIDERS.includes(provider)) { + console.error( + `DATABASE_PROVIDER="${provider}" wird nicht unterstuetzt. Erlaubt: ${SUPPORTED_PROVIDERS.join(", ")}.` + ); + process.exit(1); +} + +const schema = readFileSync(schemaPath, "utf8"); +const updated = schema.replace( + /(datasource db \{[^}]*provider\s*=\s*")([a-z]+)(")/, + `$1${provider}$3` +); + +if (updated === schema && !schema.includes(`provider = "${provider}"`)) { + console.error("datasource-Block in prisma/schema.prisma nicht gefunden — provider konnte nicht gesetzt werden."); + process.exit(1); +} + +writeFileSync(schemaPath, updated); +console.log(`prisma/schema.prisma: datasource provider = "${provider}"`); diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts new file mode 100644 index 0000000..f1fbf64 --- /dev/null +++ b/src/app/admin/actions.ts @@ -0,0 +1,14 @@ +// Barrel-Datei: die eigentlichen Server Actions leben domainweise unter +// ./actions/* (waren zuvor alle in dieser einen 800+-Zeilen-Datei). Bestehende +// Importe ("@/app/admin/actions" bzw. "../actions" aus den Admin-Seiten) +// bleiben dadurch unveraendert funktionsfaehig. +export { requireAdmin } from "./actions/_shared"; +export * from "./actions/orders"; +export * from "./actions/harvests-batches"; +export * from "./actions/products"; +export * from "./actions/inquiries"; +export * from "./actions/customers"; +export * from "./actions/shipping"; +export * from "./actions/reviews"; +export * from "./actions/discount-codes"; +export * from "./actions/content"; diff --git a/src/app/admin/actions/_shared.ts b/src/app/admin/actions/_shared.ts new file mode 100644 index 0000000..f31c848 --- /dev/null +++ b/src/app/admin/actions/_shared.ts @@ -0,0 +1,55 @@ +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { getCurrentAdmin } from "@/lib/auth"; +import type { CustomerRole } from "@/generated/prisma/client"; + +/** + * Autoritative Rollenpruefung fuer jede Admin-Mutation. Der Proxy + * (src/proxy.ts) blockt fremde Requests bereits auf Netzwerkebene, aber + * Server Actions sind eigene POST-Endpunkte und muessen laut Next.js- + * Sicherheitsleitfaden auch fuer sich genommen autorisieren — Render-Zeit- + * Gating (Button wird nur im Admin-UI angezeigt) ist keine Sicherheitsgrenze. + * Gibt das Admin-Konto zurueck, damit Aufrufer es ohne Zweitabfrage fuers + * Audit-Log verwenden koennen. + */ +export async function requireAdmin(): Promise<{ id: string; email: string; role: CustomerRole }> { + const admin = await getCurrentAdmin(); + if (!admin) { + throw new Error("Nicht autorisiert."); + } + // Admin-Konten mit Zugriff auf Bestellungen, Rueckerstattungen und + // Kundendaten muessen Zwei-Faktor-Authentifizierung aktiviert haben — das + // Admin-Layout leitet dafuer bereits weiter, diese Pruefung faengt zusaetzlich + // eine direkt aufgerufene Server Action ab, die das Layout umgeht. + if (!admin.twoFactorEnabled) { + throw new Error("Zwei-Faktor-Authentifizierung ist für Admin-Konten erforderlich."); + } + return admin; +} + +export function fail(path: string, message: string): never { + redirect(`${path}?error=${encodeURIComponent(message)}`); +} + +/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */ +export async function isLastAdmin(customerId: string): Promise { + const customer = await prisma.customer.findUnique({ where: { id: customerId } }); + if (!customer || customer.role !== "ADMIN") return false; + const adminCount = await prisma.customer.count({ where: { role: "ADMIN" } }); + return adminCount <= 1; +} + +export function str(formData: FormData, key: string): string { + return String(formData.get(key) ?? "").trim(); +} + +export function num(formData: FormData, key: string): number { + const value = Number(formData.get(key) ?? 0); + // NaN/Infinity (z.B. leeres oder manipuliertes Feld) nicht an Prisma durchreichen + return Number.isFinite(value) ? value : 0; +} + +export function strOrNull(formData: FormData, key: string): string | null { + const value = str(formData, key); + return value === "" ? null : value; +} diff --git a/src/app/admin/actions/content.ts b/src/app/admin/actions/content.ts new file mode 100644 index 0000000..f4c3ed7 --- /dev/null +++ b/src/app/admin/actions/content.ts @@ -0,0 +1,59 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import { CONTENT_REGISTRY } from "@/lib/content-registry"; +import { requireAdmin } from "./_shared"; + +/** + * Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte) + * — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action + * verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht + * lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" / + * "en::{key}" (siehe admin/inhalte/page.tsx). + */ +export async function updateSiteContentAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const group = String(formData.get("group") ?? ""); + + const updates: { key: string; valueDe: string; valueEn: string | null }[] = []; + + for (const def of CONTENT_REGISTRY) { + if (def.localized) { + const de = formData.get(`de::${def.key}`); + const en = formData.get(`en::${def.key}`); + if (de === null && en === null) continue; + updates.push({ + key: def.key, + valueDe: String(de ?? ""), + valueEn: String(en ?? ""), + }); + } else { + const value = formData.get(def.key); + if (value === null) continue; + updates.push({ key: def.key, valueDe: String(value), valueEn: null }); + } + } + + await prisma.$transaction( + updates.map((u) => + prisma.siteContent.upsert({ + where: { key: u.key }, + create: { key: u.key, valueDe: u.valueDe, valueEn: u.valueEn }, + update: { valueDe: u.valueDe, valueEn: u.valueEn }, + }) + ) + ); + + await logAdminAction(admin, { + action: "site_content.update", + entityType: "SiteContent", + entityId: group || undefined, + detail: `${updates.length} Feld(er)`, + }); + + revalidatePath("/", "layout"); + redirect("/admin/inhalte?saved=1"); +} diff --git a/src/app/admin/actions/customers.ts b/src/app/admin/actions/customers.ts new file mode 100644 index 0000000..09fd02e --- /dev/null +++ b/src/app/admin/actions/customers.ts @@ -0,0 +1,194 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { randomBytes } from "node:crypto"; +import { prisma } from "@/lib/prisma"; +import { hashPassword } from "@/lib/auth"; +import { createAndSendPasswordResetEmail } from "@/lib/password-reset"; +import { createAndSendVerificationEmail } from "@/lib/email-verification"; +import { logAdminAction } from "@/lib/audit-log"; +import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation"; +import { requireAdmin, fail, isLastAdmin, str } from "./_shared"; + +export async function createCustomerAction(formData: FormData): Promise { + const admin = await requireAdmin(); + + const parsed = adminCreateCustomerSchema.safeParse({ + firstName: str(formData, "firstName"), + lastName: str(formData, "lastName"), + email: str(formData, "email"), + role: str(formData, "role") || "KUNDE", + }); + if (!parsed.success) { + fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); + } + + const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } }); + if (existing) { + fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto."); + } + + // Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen, + // niemandem bekannten Hash. Der Nutzer vergibt sein eigenes Passwort ueber + // den Reset-Link, der sofort per E-Mail verschickt wird. + const unusablePassword = randomBytes(32).toString("hex"); + const customer = await prisma.customer.create({ + data: { + firstName: parsed.data.firstName, + lastName: parsed.data.lastName, + email: parsed.data.email, + role: parsed.data.role, + passwordHash: await hashPassword(unusablePassword), + }, + }); + + await createAndSendPasswordResetEmail(customer); + await logAdminAction(admin, { + action: "customer.create", + entityType: "Customer", + entityId: customer.id, + detail: `${customer.email} (${parsed.data.role})`, + }); + + revalidatePath("/admin/benutzer"); + redirect("/admin/benutzer"); +} + +export async function updateCustomerAction(formData: FormData): Promise { + const admin = await requireAdmin(); + + const id = str(formData, "id"); + const parsed = adminUpdateCustomerSchema.safeParse({ + firstName: str(formData, "firstName"), + lastName: str(formData, "lastName"), + email: str(formData, "email"), + phone: str(formData, "phone"), + role: str(formData, "role") || "KUNDE", + }); + if (!parsed.success) { + fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); + } + + if (id === admin.id && parsed.data.role !== admin.role) { + fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern."); + } + + const target = await prisma.customer.findUnique({ where: { id } }); + if (!target) fail("/admin/benutzer", "Konto nicht gefunden."); + + if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) { + fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden."); + } + + const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } }); + if (emailOwner && emailOwner.id !== id) { + fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto."); + } + + const emailChanged = parsed.data.email !== target.email; + + const updated = await prisma.customer.update({ + where: { id }, + data: { + firstName: parsed.data.firstName, + lastName: parsed.data.lastName, + email: parsed.data.email, + phone: parsed.data.phone || null, + role: parsed.data.role, + // Eine geaenderte Adresse ist nicht automatisch bestaetigt — sonst + // koennte eine falsch eingetragene (oder von einem Admin-Account aus + // manipulierte) Adresse als "verifiziert" gelten, ohne dass deren + // tatsaechliche Inhaberin/Inhaber je einen Bestaetigungslink geoeffnet hat. + ...(emailChanged ? { emailVerifiedAt: null } : {}), + }, + }); + + if (emailChanged) { + await createAndSendVerificationEmail(updated); + } + + await logAdminAction(admin, { + action: "customer.update", + entityType: "Customer", + entityId: id, + detail: target.role !== parsed.data.role ? `Rolle: ${target.role} → ${parsed.data.role}` : parsed.data.email, + }); + + revalidatePath("/admin/benutzer"); + redirect("/admin/benutzer"); +} + +export async function blockCustomerAction(formData: FormData): Promise { + const admin = await requireAdmin(); + + const id = str(formData, "id"); + if (id === admin.id) { + fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren."); + } + if (await isLastAdmin(id)) { + fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden."); + } + + // sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der + // gesperrten Kundschaft sofort ungueltig wird statt erst beim naechsten + // Login (getCurrentCustomer prueft ohnehin zusaetzlich blockedAt direkt). + const blocked = await prisma.customer.update({ + where: { id }, + data: { blockedAt: new Date(), sessionVersion: { increment: 1 } }, + }); + await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email }); + revalidatePath("/admin/benutzer"); +} + +export async function unblockCustomerAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const unblocked = await prisma.customer.update({ + where: { id }, + data: { blockedAt: null, failedLoginAttempts: 0, lockedUntil: null }, + }); + await logAdminAction(admin, { + action: "customer.unblock", + entityType: "Customer", + entityId: id, + detail: unblocked.email, + }); + revalidatePath("/admin/benutzer"); +} + +export async function deleteCustomerAction(formData: FormData): Promise { + const admin = await requireAdmin(); + + const id = str(formData, "id"); + if (id === admin.id) { + fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen."); + } + if (await isLastAdmin(id)) { + fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden."); + } + + const deleted = await prisma.customer.delete({ where: { id } }); + await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email }); + revalidatePath("/admin/benutzer"); + redirect("/admin/benutzer"); +} + +/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */ +export async function sendCustomerPasswordResetAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const customer = await prisma.customer.findUnique({ where: { id } }); + if (!customer) fail("/admin/benutzer", "Konto nicht gefunden."); + + await createAndSendPasswordResetEmail(customer); + await logAdminAction(admin, { + action: "customer.password_reset_sent", + entityType: "Customer", + entityId: id, + detail: customer.email, + }); + redirect(`/admin/benutzer/${id}?message=` + encodeURIComponent("Passwort-Reset-E-Mail wurde verschickt.")); +} diff --git a/src/app/admin/actions/discount-codes.ts b/src/app/admin/actions/discount-codes.ts new file mode 100644 index 0000000..ec78c20 --- /dev/null +++ b/src/app/admin/actions/discount-codes.ts @@ -0,0 +1,96 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import { discountCodeSchema } from "@/lib/validation"; +import { requireAdmin, fail, str } from "./_shared"; + +function parseDiscountCodeForm(formData: FormData) { + const rawMax = str(formData, "maxUses"); + const rawMinQuantity = str(formData, "minQuantity"); + const rawFrom = str(formData, "validFrom"); + const rawUntil = str(formData, "validUntil"); + return discountCodeSchema.safeParse({ + code: str(formData, "code"), + type: str(formData, "type"), + value: str(formData, "value"), + minOrderCents: str(formData, "minOrderCents") || "0", + minQuantity: rawMinQuantity === "" ? undefined : rawMinQuantity, + maxUses: rawMax === "" ? undefined : rawMax, + isActive: formData.get("isActive") === "on", + validFrom: rawFrom === "" ? undefined : rawFrom, + validUntil: rawUntil === "" ? undefined : rawUntil, + }); +} + +export async function createDiscountCodeAction(formData: FormData): Promise { + const admin = await requireAdmin(); + + const parsed = parseDiscountCodeForm(formData); + if (!parsed.success) { + fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); + } + + const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } }); + if (existing) { + fail("/admin/gutscheine/neu", "Dieser Code existiert bereits."); + } + + const code = await prisma.discountCode.create({ data: parsed.data }); + + await logAdminAction(admin, { + action: "discount_code.create", + entityType: "DiscountCode", + entityId: code.id, + detail: code.code, + }); + + revalidatePath("/admin/gutscheine"); + redirect("/admin/gutscheine"); +} + +export async function updateDiscountCodeAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const parsed = parseDiscountCodeForm(formData); + if (!parsed.success) { + fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); + } + + const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } }); + if (existing && existing.id !== id) { + fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet."); + } + + await prisma.discountCode.update({ where: { id }, data: parsed.data }); + + await logAdminAction(admin, { + action: "discount_code.update", + entityType: "DiscountCode", + entityId: id, + detail: parsed.data.code, + }); + + revalidatePath("/admin/gutscheine"); + redirect("/admin/gutscheine"); +} + +export async function deleteDiscountCodeAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const deleted = await prisma.discountCode.delete({ where: { id } }); + + await logAdminAction(admin, { + action: "discount_code.delete", + entityType: "DiscountCode", + entityId: id, + detail: deleted.code, + }); + + revalidatePath("/admin/gutscheine"); + redirect("/admin/gutscheine"); +} diff --git a/src/app/admin/actions/harvests-batches.ts b/src/app/admin/actions/harvests-batches.ts new file mode 100644 index 0000000..4ab4d61 --- /dev/null +++ b/src/app/admin/actions/harvests-batches.ts @@ -0,0 +1,96 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import { requireAdmin, str, num } from "./_shared"; + +export async function createHarvestAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const year = num(formData, "year"); + const type = str(formData, "type"); + const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`; + + const harvest = await prisma.harvest.create({ + data: { + year, + type, + slug, + harvestDate: new Date(str(formData, "harvestDate")), + location: str(formData, "location") || "Altenmünster", + description: str(formData, "description"), + }, + }); + + await logAdminAction(admin, { + action: "harvest.create", + entityType: "Harvest", + entityId: harvest.id, + detail: `${type} ${year}`, + }); + + revalidatePath("/admin/ernten"); + redirect("/admin/ernten"); +} + +export async function updateHarvestAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + await prisma.harvest.update({ + where: { id }, + data: { + year: num(formData, "year"), + type: str(formData, "type"), + harvestDate: new Date(str(formData, "harvestDate")), + location: str(formData, "location") || "Altenmünster", + description: str(formData, "description"), + }, + }); + + await logAdminAction(admin, { action: "harvest.update", entityType: "Harvest", entityId: id }); + + revalidatePath("/admin/ernten"); + redirect("/admin/ernten"); +} + +export async function createBatchAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const code = str(formData, "code"); + const batch = await prisma.batch.create({ + data: { + code, + harvestId: str(formData, "harvestId"), + fillDate: new Date(str(formData, "fillDate")), + bestBeforeDate: new Date(str(formData, "bestBeforeDate")), + glassCount: num(formData, "glassCount"), + remainingGlasses: num(formData, "remainingGlasses"), + }, + }); + + await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code }); + + revalidatePath("/admin/chargen"); + redirect("/admin/chargen"); +} + +export async function updateBatchAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + await prisma.batch.update({ + where: { id }, + data: { + fillDate: new Date(str(formData, "fillDate")), + bestBeforeDate: new Date(str(formData, "bestBeforeDate")), + glassCount: num(formData, "glassCount"), + remainingGlasses: num(formData, "remainingGlasses"), + }, + }); + + await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id }); + + revalidatePath("/admin/chargen"); + redirect("/admin/chargen"); +} diff --git a/src/app/admin/actions/inquiries.ts b/src/app/admin/actions/inquiries.ts new file mode 100644 index 0000000..0a41ce6 --- /dev/null +++ b/src/app/admin/actions/inquiries.ts @@ -0,0 +1,27 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import { requireAdmin, str } from "./_shared"; + +export async function updateBusinessInquiryStatusAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + const status = str(formData, "status"); + if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return; + + await prisma.businessInquiry.update({ + where: { id }, + data: { status: status as "NEU" | "IN_BEARBEITUNG" | "ABGESCHLOSSEN" | "ABGELEHNT" }, + }); + + await logAdminAction(admin, { + action: "business_inquiry.status_change", + entityType: "BusinessInquiry", + entityId: id, + detail: status, + }); + + revalidatePath("/admin/anfragen"); +} diff --git a/src/app/admin/actions/orders.ts b/src/app/admin/actions/orders.ts new file mode 100644 index 0000000..bc22679 --- /dev/null +++ b/src/app/admin/actions/orders.ts @@ -0,0 +1,164 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import { sendMail } from "@/lib/mail"; +import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates"; +import { getCarrierTrackingUrl } from "@/lib/shipment-tracking"; +import { restockOrderItems } from "@/lib/inventory"; +import { isLocale, defaultLocale } from "@/i18n/config"; +import { requireAdmin, str, strOrNull } from "./_shared"; + +const ORDER_STATUSES = [ + "NEU", + "BEZAHLT", + "IN_VORBEREITUNG", + "VERSANDBEREIT", + "VERSENDET", + "ABHOLBEREIT", + "ABGEHOLT", + "STORNIERT", +] as const; + +export async function updateOrderStatusAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const orderId = str(formData, "orderId"); + const status = str(formData, "status") as (typeof ORDER_STATUSES)[number]; + if (!ORDER_STATUSES.includes(status)) return; + + const result = await prisma.$transaction(async (tx) => { + const previous = await tx.order.findUnique({ + where: { id: orderId }, + select: { + status: true, + orderNumber: true, + email: true, + firstName: true, + locale: true, + items: { select: { productId: true, quantity: true } }, + }, + }); + if (!previous) return null; + + await tx.order.update({ where: { id: orderId }, data: { status } }); + + // Beim Checkout reservierter Bestand wird bei Stornierung zurueckgebucht + // (genau einmal — nur beim Wechsel VON einem nicht-stornierten Status). + if (status === "STORNIERT" && previous.status !== "STORNIERT") { + await restockOrderItems(tx, previous.items); + } + + let shipment: { carrier: string | null; trackingNumber: string | null } | null = null; + if (status === "VERSENDET") { + shipment = await tx.shipment.upsert({ + where: { orderId }, + create: { orderId, shippedAt: new Date() }, + update: { shippedAt: new Date() }, + select: { carrier: true, trackingNumber: true }, + }); + } + if (status === "ABHOLBEREIT") { + await tx.pickup.upsert({ + where: { orderId }, + create: { orderId, readyAt: new Date() }, + update: { readyAt: new Date() }, + }); + } + if (status === "ABGEHOLT") { + await tx.pickup.upsert({ + where: { orderId }, + create: { orderId, pickedUpAt: new Date() }, + update: { pickedUpAt: new Date() }, + }); + } + + return { ...previous, shipment }; + }); + + if (result) { + await logAdminAction(admin, { + action: "order.status_change", + entityType: "Order", + entityId: orderId, + detail: `${result.orderNumber}: ${result.status} → ${status}`, + }); + + // E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach + // der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht + // rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts). + const locale = isLocale(result.locale) ? result.locale : defaultLocale; + if (status === "VERSENDET" && result.status !== "VERSENDET") { + const carrier = result.shipment?.carrier ?? null; + const trackingNumber = result.shipment?.trackingNumber ?? null; + const { subject, html, text } = await shipmentEmail({ + locale, + orderNumber: result.orderNumber, + firstName: result.firstName, + carrier, + trackingNumber, + trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber), + }); + await sendMail({ to: result.email, subject, html, text }); + } + if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") { + const { subject, html, text } = await pickupReadyEmail({ + locale, + orderNumber: result.orderNumber, + firstName: result.firstName, + }); + await sendMail({ to: result.email, subject, html, text }); + } + } + + revalidatePath("/admin/bestellungen"); +} + +/** + * Erfasst Versanddienstleister + Sendungsnummer fuer eine Bestellung — + * unabhaengig vom Status setzbar, da die Nummer oft schon vor dem + * eigentlichen Versandscan vom Versanddienstleister vergeben wird. Ist die + * Bestellung bereits als VERSENDET markiert, wird die Sendungsverfolgung + * per separater E-Mail nachgereicht statt nur in der ersten Versand-Mail zu + * stehen (die zu diesem Zeitpunkt schon verschickt war). + */ +export async function updateShipmentTrackingAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const orderId = str(formData, "orderId"); + const carrier = strOrNull(formData, "carrier"); + const trackingNumber = strOrNull(formData, "trackingNumber"); + + const order = await prisma.order.findUnique({ + where: { id: orderId }, + select: { orderNumber: true, email: true, firstName: true, status: true, locale: true }, + }); + if (!order) return; + + await prisma.shipment.upsert({ + where: { orderId }, + create: { orderId, carrier, trackingNumber }, + update: { carrier, trackingNumber }, + }); + + await logAdminAction(admin, { + action: "order.tracking_update", + entityType: "Order", + entityId: orderId, + detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(), + }); + + if (order.status === "VERSENDET" && carrier && trackingNumber) { + const { subject, html, text } = await shipmentEmail({ + locale: isLocale(order.locale) ? order.locale : defaultLocale, + orderNumber: order.orderNumber, + firstName: order.firstName, + carrier, + trackingNumber, + trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber), + }); + await sendMail({ to: order.email, subject, html, text }); + } + + revalidatePath("/admin/bestellungen"); + revalidatePath(`/admin/bestellungen/${orderId}`); +} diff --git a/src/app/admin/actions/products.ts b/src/app/admin/actions/products.ts new file mode 100644 index 0000000..4040233 --- /dev/null +++ b/src/app/admin/actions/products.ts @@ -0,0 +1,125 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import { notifyStockAvailable } from "@/lib/stock-notifications"; +import { adminProductNumbersSchema } from "@/lib/validation"; +import { requireAdmin, fail, str, strOrNull } from "./_shared"; + +export async function createProductAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-"); + + const parsedNumbers = adminProductNumbersSchema.safeParse({ + intensityMild: str(formData, "intensityMild"), + intensityFloral: str(formData, "intensityFloral"), + intensityKraeftig: str(formData, "intensityKraeftig"), + weightGrams: str(formData, "weightGrams"), + priceCents: str(formData, "priceCents"), + pricePerKgCents: str(formData, "pricePerKgCents"), + quantity: str(formData, "quantity"), + }); + if (!parsedNumbers.success) { + fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); + } + const numbers = parsedNumbers.data; + + const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS"; + // Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind + // davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien + // ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen. + const isHoney = category === "HONIG"; + + const product = await prisma.product.create({ + data: { + slug, + name: str(formData, "name"), + category, + harvestId: isHoney ? strOrNull(formData, "harvestId") : null, + batchId: isHoney ? strOrNull(formData, "batchId") : null, + description: str(formData, "description"), + tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean), + intensityMild: numbers.intensityMild, + intensityFloral: numbers.intensityFloral, + intensityKraeftig: numbers.intensityKraeftig, + consistency: str(formData, "consistency") || "feincremig", + weightGrams: numbers.weightGrams, + priceCents: numbers.priceCents, + pricePerKgCents: numbers.pricePerKgCents, + isActive: formData.get("isActive") === "on", + inventory: { create: { quantity: numbers.quantity } }, + }, + }); + + await logAdminAction(admin, { + action: "product.create", + entityType: "Product", + entityId: product.id, + detail: product.name, + }); + + revalidatePath("/admin/produkte"); + redirect("/admin/produkte"); +} + +export async function updateProductAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const parsedNumbers = adminProductNumbersSchema.safeParse({ + intensityMild: str(formData, "intensityMild"), + intensityFloral: str(formData, "intensityFloral"), + intensityKraeftig: str(formData, "intensityKraeftig"), + weightGrams: str(formData, "weightGrams"), + priceCents: str(formData, "priceCents"), + pricePerKgCents: str(formData, "pricePerKgCents"), + quantity: str(formData, "quantity"), + }); + if (!parsedNumbers.success) { + fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); + } + const numbers = parsedNumbers.data; + + const previousInventory = await prisma.inventory.findUnique({ + where: { productId: id }, + select: { quantity: true }, + }); + + const product = await prisma.product.update({ + where: { id }, + data: { + name: str(formData, "name"), + description: str(formData, "description"), + tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean), + intensityMild: numbers.intensityMild, + intensityFloral: numbers.intensityFloral, + intensityKraeftig: numbers.intensityKraeftig, + consistency: str(formData, "consistency") || "feincremig", + weightGrams: numbers.weightGrams, + priceCents: numbers.priceCents, + pricePerKgCents: numbers.pricePerKgCents, + isActive: formData.get("isActive") === "on", + inventory: { + upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } }, + }, + }, + }); + + await logAdminAction(admin, { + action: "product.update", + entityType: "Product", + entityId: id, + detail: product.name, + }); + + // Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und + // ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden. + if ((previousInventory?.quantity ?? 0) === 0 && numbers.quantity > 0) { + await notifyStockAvailable(id); + } + + revalidatePath("/admin/produkte"); + redirect("/admin/produkte"); +} diff --git a/src/app/admin/actions/reviews.ts b/src/app/admin/actions/reviews.ts new file mode 100644 index 0000000..49f7921 --- /dev/null +++ b/src/app/admin/actions/reviews.ts @@ -0,0 +1,47 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import { requireAdmin, str } from "./_shared"; + +export async function approveReviewAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const review = await prisma.review.update({ + where: { id }, + data: { isApproved: true }, + select: { product: { select: { slug: true, name: true } } }, + }); + + await logAdminAction(admin, { + action: "review.approve", + entityType: "Review", + entityId: id, + detail: review.product.name, + }); + + revalidatePath("/admin/bewertungen"); + revalidatePath(`/honig/${review.product.slug}`); +} + +export async function deleteReviewAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const review = await prisma.review.delete({ + where: { id }, + select: { product: { select: { slug: true, name: true } } }, + }); + + await logAdminAction(admin, { + action: "review.delete", + entityType: "Review", + entityId: id, + detail: review.product.name, + }); + + revalidatePath("/admin/bewertungen"); + revalidatePath(`/honig/${review.product.slug}`); +} diff --git a/src/app/admin/actions/shipping.ts b/src/app/admin/actions/shipping.ts new file mode 100644 index 0000000..c62ce6e --- /dev/null +++ b/src/app/admin/actions/shipping.ts @@ -0,0 +1,86 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import { shippingTierSchema } from "@/lib/validation"; +import { requireAdmin, fail, str } from "./_shared"; + +function parseShippingTierForm(formData: FormData) { + const rawMax = str(formData, "maxQuantity"); + return shippingTierSchema.safeParse({ + minQuantity: str(formData, "minQuantity"), + maxQuantity: rawMax === "" ? undefined : rawMax, + priceCents: str(formData, "priceCents"), + }); +} + +export async function createShippingTierAction(formData: FormData): Promise { + const admin = await requireAdmin(); + + const parsed = parseShippingTierForm(formData); + if (!parsed.success) { + fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); + } + + const tier = await prisma.shippingTier.create({ + data: { + minQuantity: parsed.data.minQuantity, + maxQuantity: parsed.data.maxQuantity ?? null, + priceCents: parsed.data.priceCents, + }, + }); + + await logAdminAction(admin, { + action: "shipping_tier.create", + entityType: "ShippingTier", + entityId: tier.id, + detail: `ab ${tier.minQuantity} Stück: ${tier.priceCents} Cent`, + }); + + revalidatePath("/admin/versand"); + revalidatePath("/warenkorb"); + revalidatePath("/versand-zahlung"); + redirect("/admin/versand"); +} + +export async function updateShippingTierAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + const parsed = parseShippingTierForm(formData); + if (!parsed.success) { + fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); + } + + await prisma.shippingTier.update({ + where: { id }, + data: { + minQuantity: parsed.data.minQuantity, + maxQuantity: parsed.data.maxQuantity ?? null, + priceCents: parsed.data.priceCents, + }, + }); + + await logAdminAction(admin, { action: "shipping_tier.update", entityType: "ShippingTier", entityId: id }); + + revalidatePath("/admin/versand"); + revalidatePath("/warenkorb"); + revalidatePath("/versand-zahlung"); + redirect("/admin/versand"); +} + +export async function deleteShippingTierAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = str(formData, "id"); + + await prisma.shippingTier.delete({ where: { id } }); + + await logAdminAction(admin, { action: "shipping_tier.delete", entityType: "ShippingTier", entityId: id }); + + revalidatePath("/admin/versand"); + revalidatePath("/warenkorb"); + revalidatePath("/versand-zahlung"); + redirect("/admin/versand"); +} diff --git a/src/app/admin/anfragen/page.tsx b/src/app/admin/anfragen/page.tsx new file mode 100644 index 0000000..4774a6b --- /dev/null +++ b/src/app/admin/anfragen/page.tsx @@ -0,0 +1,117 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import { insensitiveContains } from "@/lib/prisma-search"; +import { BackLink } from "@/components/back-link"; +import { formatDate } from "@/lib/format"; +import { updateBusinessInquiryStatusAction } from "../actions"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) { + const t = getDictionary(await getLocale()); + const statusLabels: Record = { + NEU: t.adminInquiriesPage.statusNew, + IN_BEARBEITUNG: t.adminInquiriesPage.statusInProgress, + ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted, + ABGELEHNT: t.adminInquiriesPage.statusRejected, + }; + const { q } = await props.searchParams; + const query = typeof q === "string" ? q.trim() : ""; + + const inquiries = await prisma.businessInquiry.findMany({ + where: query + ? { + OR: [ + { companyName: insensitiveContains(query) }, + { contactPerson: insensitiveContains(query) }, + { email: insensitiveContains(query) }, + ], + } + : undefined, + orderBy: { createdAt: "desc" }, + take: 100, + }); + + return ( +
+ +

{t.adminInquiriesPage.title}

+ +
+ + + {query ? ( + + {t.adminInquiriesPage.resetFilter} + + ) : null} +
+
+ {inquiries.map((inquiry) => ( +
+
+
+

{inquiry.companyName}

+

+ {inquiry.contactPerson} · {inquiry.email} + {inquiry.phone ? ` · ${inquiry.phone}` : ""} +

+

{formatDate(inquiry.createdAt)}

+
+
+ + + +
+
+
+
+
{t.adminInquiriesPage.packageLabel}
+
{inquiry.package}
+
+
+
{t.adminInquiriesPage.quantityLabel}
+
{t.adminInquiriesPage.unitsSuffix(inquiry.desiredQuantity)}
+
+
+
{t.adminInquiriesPage.deliveryDateLabel}
+
{inquiry.desiredDeliveryDate ? formatDate(inquiry.desiredDeliveryDate) : "—"}
+
+
+ {inquiry.message ? ( +

{inquiry.message}

+ ) : null} +
+ ))} + {inquiries.length === 0 ? ( +

+ {query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet} +

+ ) : null} +
+
+ ); +} diff --git a/src/app/admin/audit-log/page.tsx b/src/app/admin/audit-log/page.tsx new file mode 100644 index 0000000..4235eee --- /dev/null +++ b/src/app/admin/audit-log/page.tsx @@ -0,0 +1,52 @@ +import { prisma } from "@/lib/prisma"; +import { BackLink } from "@/components/back-link"; +import { formatDateTime } from "@/lib/format"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +const RECENT_LIMIT = 200; + +export default async function AdminAuditLogPage() { + const t = getDictionary(await getLocale()); + const entries = await prisma.auditLog.findMany({ + orderBy: { createdAt: "desc" }, + take: RECENT_LIMIT, + }); + + return ( +
+ +

{t.adminAuditLogPage.title}

+

{t.adminAuditLogPage.intro(RECENT_LIMIT)}

+ +
+ + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + ))} + +
{t.adminAuditLogPage.colTimestamp}{t.adminAuditLogPage.colAdmin}{t.adminAuditLogPage.colAction}{t.adminAuditLogPage.colObject}{t.adminAuditLogPage.colDetails}
{formatDateTime(entry.createdAt)}{entry.adminEmail}{entry.action} + {entry.entityType} + {entry.entityId ? #{entry.entityId.slice(-6)} : null} + {entry.detail ?? "—"}
+ {entries.length === 0 ?

{t.adminAuditLogPage.noEntriesYet}

: null} +
+
+ ); +} diff --git a/src/app/admin/benutzer/[id]/page.tsx b/src/app/admin/benutzer/[id]/page.tsx new file mode 100644 index 0000000..356c575 --- /dev/null +++ b/src/app/admin/benutzer/[id]/page.tsx @@ -0,0 +1,162 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { BackLink } from "@/components/back-link"; +import { ConfirmForm } from "@/components/confirm-form"; +import { getCurrentAdmin } from "@/lib/auth"; +import { formatDate } from "@/lib/format"; +import { + updateCustomerAction, + blockCustomerAction, + unblockCustomerAction, + deleteCustomerAction, + sendCustomerPasswordResetAction, +} from "../../actions"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) { + const t = getDictionary(await getLocale()); + const { id } = await props.params; + const { error, message } = await props.searchParams; + + const [admin, customer] = await Promise.all([ + getCurrentAdmin(), + prisma.customer.findUnique({ where: { id } }), + ]); + if (!customer) notFound(); + + const isSelf = customer.id === admin?.id; + const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date()); + + return ( +
+ +

+ {customer.firstName} {customer.lastName} +

+

{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}

+ + {error ? ( +

{error}

+ ) : null} + {message ? ( +

{message}

+ ) : null} + +
+ +
+ + +
+ + + + + + + +
+

{t.adminUserEditPage.securityHeading}

+
+
+
{t.adminUserEditPage.twoFactorLabel}
+
{customer.twoFactorEnabled ? t.adminUserEditPage.twoFactorActive : t.adminUserEditPage.twoFactorInactive}
+
+
+
{t.adminUserEditPage.statusLabel}
+
+ {customer.blockedAt ? ( + + {t.adminUserEditPage.blockedSince(formatDate(customer.blockedAt))} + + ) : isTemporarilyLocked ? ( + {t.adminUserEditPage.temporarilyLockedFailedAttempts} + ) : ( + {t.adminUserEditPage.statusActive} + )} +
+
+
+ +
+ + +
+ + {!isSelf ? ( +
+ {customer.blockedAt ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + + + + +
+ ) : null} +
+
+ ); +} + +function Field({ + name, + label, + type = "text", + defaultValue, + required = false, +}: { + name: string; + label: string; + type?: string; + defaultValue?: string; + required?: boolean; +}) { + return ( + + ); +} diff --git a/src/app/admin/benutzer/neu/page.tsx b/src/app/admin/benutzer/neu/page.tsx new file mode 100644 index 0000000..7de0b5e --- /dev/null +++ b/src/app/admin/benutzer/neu/page.tsx @@ -0,0 +1,68 @@ +import { BackLink } from "@/components/back-link"; +import { createCustomerAction } from "../../actions"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benutzer/neu">) { + const t = getDictionary(await getLocale()); + const { error } = await props.searchParams; + + return ( +
+ +

{t.adminUserNewPage.title}

+

{t.adminUserNewPage.intro}

+ + {error ? ( +

{error}

+ ) : null} + +
+
+ + +
+ + + + + +
+ ); +} + +function Field({ + name, + label, + type = "text", + required = false, +}: { + name: string; + label: string; + type?: string; + required?: boolean; +}) { + return ( + + ); +} diff --git a/src/app/admin/benutzer/page.tsx b/src/app/admin/benutzer/page.tsx new file mode 100644 index 0000000..0f58765 --- /dev/null +++ b/src/app/admin/benutzer/page.tsx @@ -0,0 +1,158 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import { insensitiveContains } from "@/lib/prisma-search"; +import { BackLink } from "@/components/back-link"; +import { ConfirmForm } from "@/components/confirm-form"; +import { getCurrentAdmin } from "@/lib/auth"; +import { formatDate } from "@/lib/format"; +import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) { + const t = getDictionary(await getLocale()); + const roleLabels: Record = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin }; + const { error, message, q } = await props.searchParams; + const query = typeof q === "string" ? q.trim() : ""; + + const [admin, customers] = await Promise.all([ + getCurrentAdmin(), + prisma.customer.findMany({ + where: query + ? { + OR: [ + { firstName: insensitiveContains(query) }, + { lastName: insensitiveContains(query) }, + { email: insensitiveContains(query) }, + ], + } + : undefined, + orderBy: { createdAt: "desc" }, + take: 100, + }), + ]); + + return ( +
+ +
+

{t.adminUsersPage.title}

+ + {t.adminUsersPage.newUser} + +
+ + {error ? ( +

{error}

+ ) : null} + {message ? ( +

{message}

+ ) : null} + +
+ + + {query ? ( + + {t.adminUsersPage.resetFilter} + + ) : null} +
+ +
+ + + + + + + + + + + + + {customers.map((customer) => { + const isSelf = customer.id === admin?.id; + const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date()); + + return ( + + + + + + + + + + ); + })} + +
{t.adminUsersPage.colName}{t.adminUsersPage.colEmail}{t.adminUsersPage.colRole}{t.adminUsersPage.colStatus}{t.adminUsersPage.col2fa}{t.adminUsersPage.colCreated} +
+ {customer.firstName} {customer.lastName} + {isSelf ? {t.adminUsersPage.youSuffix} : null} + {customer.email}{roleLabels[customer.role] ?? customer.role} + {customer.blockedAt ? ( + {t.adminUsersPage.statusBlocked} + ) : isTemporarilyLocked ? ( + {t.adminUsersPage.statusTemporarilyLocked} + ) : ( + {t.adminUsersPage.statusActive} + )} + {customer.twoFactorEnabled ? t.adminUsersPage.statusActive : "—"}{formatDate(customer.createdAt)} +
+ + {t.adminUsersPage.edit} + + {!isSelf ? ( + customer.blockedAt ? ( +
+ + +
+ ) : ( +
+ + +
+ ) + ) : null} + {!isSelf ? ( + + + + + ) : null} +
+
+ {customers.length === 0 ? ( +

+ {query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet} +

+ ) : null} +
+
+ ); +} diff --git a/src/app/admin/bestellungen/[id]/page.tsx b/src/app/admin/bestellungen/[id]/page.tsx new file mode 100644 index 0000000..1df5582 --- /dev/null +++ b/src/app/admin/bestellungen/[id]/page.tsx @@ -0,0 +1,227 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import { BackLink } from "@/components/back-link"; +import { formatPrice, formatDate } from "@/lib/format"; +import { updateOrderStatusAction, updateShipmentTrackingAction } from "../../actions"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) { + const t = getDictionary(await getLocale()); + const statusLabels = t.orderStatusBadge; + const { id } = await props.params; + const order = await prisma.order.findUnique({ + where: { id }, + include: { + items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } }, + payment: true, + shipment: true, + pickup: true, + invoice: true, + customer: { select: { email: true, firstName: true, lastName: true } }, + discountCode: { select: { code: true } }, + }, + }); + if (!order) notFound(); + + return ( +
+ + +
+
+

{order.orderNumber}

+

{formatDate(order.createdAt)}

+
+
+ + + +
+
+ + {order.giftMessage ? ( +
+

{t.adminOrderDetailPage.giftHeading}

+

{order.giftMessage}

+
+ ) : null} + +
+
+

{t.adminOrderDetailPage.customerHeading}

+

{order.firstName} {order.lastName}

+

{order.email}

+ {order.phone ?

{order.phone}

: null} + {order.customer ? ( +

{t.adminOrderDetailPage.registeredAccount}

+ ) : ( +

{t.adminOrderDetailPage.guestOrder}

+ )} +
+ +
+

+ {order.fulfillmentMethod === "VERSAND" + ? t.adminOrderDetailPage.shippingAddressHeading + : t.adminOrderDetailPage.pickupHeading} +

+ {order.fulfillmentMethod === "VERSAND" ? ( + <> +

{order.street}

+

{order.zip} {order.city}

+

+ {order.shipment?.shippedAt + ? t.adminOrderDetailPage.shippedOn(formatDate(order.shipment.shippedAt)) + : t.adminOrderDetailPage.notShippedYet} +

+ + ) : ( +

+ {order.pickup?.pickedUpAt + ? t.adminOrderDetailPage.pickedUpOn(formatDate(order.pickup.pickedUpAt)) + : order.pickup?.readyAt + ? t.adminOrderDetailPage.readySince(formatDate(order.pickup.readyAt)) + : t.adminOrderDetailPage.notPickedUpYet} +

+ )} +
+
+ + {order.fulfillmentMethod === "VERSAND" ? ( +
+

{t.adminOrderDetailPage.trackingHeading}

+
+ + + + +
+
+ ) : null} + +
+

{t.adminOrderDetailPage.itemsHeading}

+ + + + + + + + + + + {order.items.map((item) => ( + + + + + + + ))} + +
{t.adminOrderDetailPage.colProduct}{t.adminOrderDetailPage.colBatch}{t.adminOrderDetailPage.colQuantity}{t.adminOrderDetailPage.colTotal}
+ + {item.productName} + {" "} + ({item.weightGrams} g) + {item.batch?.code ?? "—"}{item.quantity}{formatPrice(item.totalCents, order.currency)}
+
+
+
+ {t.adminOrderDetailPage.subtotalLabel} + {formatPrice(order.subtotalCents, order.currency)} +
+
+ {t.adminOrderDetailPage.shippingLabel} + + {order.shippingCents === 0 + ? t.adminOrderDetailPage.freeShipping + : formatPrice(order.shippingCents, order.currency)} + +
+ {order.discountCents > 0 ? ( +
+ + {t.adminOrderDetailPage.discountLabel}{order.discountCode ? ` (${order.discountCode.code})` : ""} + + −{formatPrice(order.discountCents, order.currency)} +
+ ) : null} +
+ {t.adminOrderDetailPage.totalLabel} + {formatPrice(order.totalCents, order.currency)} +
+
+
+
+ +
+
+

{t.adminOrderDetailPage.paymentHeading}

+ {order.payment ? ( + <> +

{order.payment.status}

+

{formatPrice(order.payment.amountCents, order.payment.currency)}

+ {order.payment.paidAt ? ( +

+ {t.adminOrderDetailPage.paidOn(formatDate(order.payment.paidAt))} +

+ ) : null} + + ) : ( +

{t.adminOrderDetailPage.noPaymentYet}

+ )} +
+ +
+

{t.adminOrderDetailPage.invoiceHeading}

+ {order.invoice ? ( +
+ {order.invoice.invoiceNumber} + + {t.adminOrderDetailPage.downloadPdf} + +
+ ) : ( +

{t.adminOrderDetailPage.noInvoiceYet}

+ )} +
+
+
+ ); +} diff --git a/src/app/admin/bestellungen/page.tsx b/src/app/admin/bestellungen/page.tsx new file mode 100644 index 0000000..2662a4e --- /dev/null +++ b/src/app/admin/bestellungen/page.tsx @@ -0,0 +1,147 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import { BackLink } from "@/components/back-link"; +import { formatPrice, formatDate } from "@/lib/format"; +import { OrderStatusBadge } from "@/components/account-ui"; +import { updateOrderStatusAction } from "../actions"; +import type { OrderStatus } from "@/generated/prisma/client"; +import { insensitiveContains } from "@/lib/prisma-search"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) { + const t = getDictionary(await getLocale()); + const statusLabels = t.orderStatusBadge; + const { q, status } = await props.searchParams; + const query = typeof q === "string" ? q.trim() : ""; + const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined; + + const orders = await prisma.order.findMany({ + where: { + ...(statusFilter ? { status: statusFilter } : {}), + ...(query + ? { + OR: [ + { orderNumber: insensitiveContains(query) }, + { email: insensitiveContains(query) }, + { lastName: insensitiveContains(query) }, + ], + } + : {}), + }, + orderBy: { createdAt: "desc" }, + take: 100, + include: { items: true }, + }); + + return ( +
+ +

{t.adminOrdersPage.title}

+ +
+ + + + {query || statusFilter ? ( + + {t.adminOrdersPage.resetFilter} + + ) : null} +
+ +
+ + + + + + + + + + + + + + {orders.map((order) => ( + + + + + + + + + + ))} + +
{t.adminOrdersPage.colNumber}{t.adminOrdersPage.colCustomer}{t.adminOrdersPage.colDate}{t.adminOrdersPage.colItems}{t.adminOrdersPage.colTotal}{t.adminOrdersPage.colStatus}{t.adminOrdersPage.colFulfillment}
+ + {order.orderNumber} + + {order.giftMessage ? ( + + 🎁 + + ) : null} + + {order.firstName} {order.lastName} +
+ {order.email} +
{formatDate(order.createdAt)}{order.items.length}{formatPrice(order.totalCents, order.currency)} + +
+ + + +
+
+ {order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup} +
+ {orders.length === 0 ? ( +

+ {query || statusFilter ? t.adminOrdersPage.noResultsFiltered : t.adminOrdersPage.noOrdersYet} +

+ ) : null} +
+
+ ); +} diff --git a/src/app/admin/bewertungen/page.tsx b/src/app/admin/bewertungen/page.tsx new file mode 100644 index 0000000..442acef --- /dev/null +++ b/src/app/admin/bewertungen/page.tsx @@ -0,0 +1,130 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import { BackLink } from "@/components/back-link"; +import { ConfirmForm } from "@/components/confirm-form"; +import { formatDate } from "@/lib/format"; +import { insensitiveContains } from "@/lib/prisma-search"; +import { approveReviewAction, deleteReviewAction } from "../actions"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) { + const t = getDictionary(await getLocale()); + const { q } = await props.searchParams; + const query = typeof q === "string" ? q.trim() : ""; + + const reviews = await prisma.review.findMany({ + where: query + ? { + OR: [ + { product: { name: insensitiveContains(query) } }, + { customer: { firstName: insensitiveContains(query) } }, + { customer: { lastName: insensitiveContains(query) } }, + ], + } + : undefined, + include: { + product: { select: { name: true, slug: true } }, + customer: { select: { firstName: true, lastName: true } }, + }, + orderBy: [{ isApproved: "asc" }, { createdAt: "desc" }], + take: 100, + }); + + return ( +
+ +

{t.adminReviewsPage.title}

+ +
+ + + {query ? ( + + {t.adminReviewsPage.resetFilter} + + ) : null} +
+ +
+ + + + + + + + + + + + + {reviews.map((review) => ( + + + + + + + + + + ))} + +
{t.adminReviewsPage.colProduct}{t.adminReviewsPage.colCustomer}{t.adminReviewsPage.colRating}{t.adminReviewsPage.colComment}{t.adminReviewsPage.colDate}{t.adminReviewsPage.colStatus} +
+ + {review.product.name} + + + {review.customer.firstName} {review.customer.lastName} + + {"★".repeat(review.rating)} + {"☆".repeat(5 - review.rating)} + {review.comment ?? "—"}{formatDate(review.createdAt)} + {review.isApproved ? ( + {t.adminReviewsPage.statusPublished} + ) : ( + {t.adminReviewsPage.statusPending} + )} + +
+ {!review.isApproved ? ( +
+ + +
+ ) : null} + + + + +
+
+ {reviews.length === 0 ? ( +

+ {query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet} +

+ ) : null} +
+
+ ); +} diff --git a/src/app/admin/chargen/[id]/page.tsx b/src/app/admin/chargen/[id]/page.tsx new file mode 100644 index 0000000..4b5f38e --- /dev/null +++ b/src/app/admin/chargen/[id]/page.tsx @@ -0,0 +1,73 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { BackLink } from "@/components/back-link"; +import { updateBatchAction } from "../../actions"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export default async function AdminBatchEditPage(props: PageProps<"/admin/chargen/[id]">) { + const t = getDictionary(await getLocale()); + const { id } = await props.params; + const batch = await prisma.batch.findUnique({ where: { id }, include: { harvest: true } }); + if (!batch) notFound(); + + return ( +
+ +

{t.adminBatchEditPage.title}

+

+ {batch.code} · {batch.harvest.type} {batch.harvest.year} +

+
+ +
+ + +
+
+ + +
+ +
+
+ ); +} diff --git a/src/app/admin/chargen/page.tsx b/src/app/admin/chargen/page.tsx new file mode 100644 index 0000000..f689402 --- /dev/null +++ b/src/app/admin/chargen/page.tsx @@ -0,0 +1,110 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import { BackLink } from "@/components/back-link"; +import { formatDate } from "@/lib/format"; +import { createBatchAction } from "../actions"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export default async function AdminBatchesPage() { + const t = getDictionary(await getLocale()); + const [batches, harvests] = await Promise.all([ + prisma.batch.findMany({ + orderBy: { fillDate: "desc" }, + include: { harvest: true }, + }), + prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }), + ]); + + return ( +
+ +

{t.adminBatchesPage.title}

+
+ + + + + + + + + + + + + {batches.map((batch) => ( + + + + + + + + + + ))} + +
{t.adminBatchesPage.colBatch}{t.adminBatchesPage.colHarvest}{t.adminBatchesPage.colFillDate}{t.adminBatchesPage.colBestBefore}{t.adminBatchesPage.colTotalGlasses}{t.adminBatchesPage.colRemaining} +
{batch.code} + {batch.harvest.type} {batch.harvest.year} + {formatDate(batch.fillDate)}{formatDate(batch.bestBeforeDate)}{batch.glassCount}{batch.remainingGlasses} + + {t.adminBatchesPage.edit} + +
+ {batches.length === 0 ?

{t.adminBatchesPage.noBatchesYet}

: null} +
+ +
+

{t.adminBatchesPage.newBatchHeading}

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ ); +} diff --git a/src/app/admin/ernten/[id]/page.tsx b/src/app/admin/ernten/[id]/page.tsx new file mode 100644 index 0000000..0923305 --- /dev/null +++ b/src/app/admin/ernten/[id]/page.tsx @@ -0,0 +1,77 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { BackLink } from "@/components/back-link"; +import { updateHarvestAction } from "../../actions"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) { + const t = getDictionary(await getLocale()); + const { id } = await props.params; + const harvest = await prisma.harvest.findUnique({ where: { id } }); + if (!harvest) notFound(); + + const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10); + + return ( +
+ +

{t.adminHarvestEditPage.title}

+
+ +
+ + +
+ + +