diff --git a/.zscripts/dev.pid b/.zscripts/dev.pid index 4524e19..2b4ff56 100755 --- a/.zscripts/dev.pid +++ b/.zscripts/dev.pid @@ -1 +1 @@ -984 +1114 diff --git a/bun.lock b/bun.lock index 9d56187..91a309a 100755 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,8 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.1.1", + "@hugeicons/core-free-icons": "^4.3.3", + "@hugeicons/react": "^1.1.10", "@mdxeditor/editor": "^3.39.1", "@prisma/client": "^6.11.1", "@radix-ui/react-accordion": "^1.2.11", @@ -46,10 +48,11 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", - "framer-motion": "^12.23.2", + "framer-motion": "^14.7.1", "imapflow": "^2.9.1", "input-otp": "^1.4.2", - "lucide-react": "^0.525.0", + "lucide-react": "^1.62.0", + "motion": "^12.23.12", "next": "^16.1.1", "next-auth": "^4.24.11", "next-intl": "^4.3.4", @@ -65,7 +68,7 @@ "recharts": "^2.15.4", "sharp": "^0.34.3", "sonner": "^2.0.6", - "tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.7.0", "tailwindcss-animate": "^1.0.7", "uuid": "^11.1.0", "vaul": "^1.1.2", @@ -247,6 +250,10 @@ "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "https://registry.npmjs.com/@hookform/resolvers/-/resolvers-5.2.2.tgz", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], + "@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@4.3.5", "", {}, "sha512-Sv+NjHRPnQk+yZsGCMcGznCHdTfJR9PMMOEKcJYAQU4gy90dmlc9PwdtvYOImBZIjiheMsTLL5eMn2DmHxUiUg=="], + + "@hugeicons/react": ["@hugeicons/react@1.1.10", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-9tAsd92yMVVGP+E+d8cM+wc0VnXuYZHNbWQi7TOe8k2neawmhiaFmVFbzyruejhOMa197/LbgvSED32ayUo3ZA=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmjs.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "https://registry.npmjs.com/@humanfs/node/-/node-0.16.7.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -1113,7 +1120,7 @@ "format": ["format@0.2.2", "https://registry.npmjs.com/format/-/format-0.2.2.tgz", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], - "framer-motion": ["framer-motion@12.26.2", "https://registry.npmjs.com/framer-motion/-/framer-motion-12.26.2.tgz", { "dependencies": { "motion-dom": "^12.26.2", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA=="], + "framer-motion": ["framer-motion@14.7.1", "", { "dependencies": { "motion-dom": "14.7.1", "motion-utils": "14.7.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-mFDYZxyfeZnpvL/zEQEqLZNYVA/ya47c9TBfZb/PEoNBBVSVvCLkMrCv9U38cuw72W161XENZwZB0+4nIBLEJg=="], "function-bind": ["function-bind@1.1.2", "https://registry.npmjs.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1359,7 +1366,7 @@ "lru-cache": ["lru-cache@6.0.0", "https://registry.npmjs.com/lru-cache/-/lru-cache-6.0.0.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - "lucide-react": ["lucide-react@0.525.0", "https://registry.npmjs.com/lucide-react/-/lucide-react-0.525.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="], + "lucide-react": ["lucide-react@1.62.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-2i8QZZhkgYMA3oJvKYpCltHh54VnT/3rIPkDLHbzr3IJQBaCz8+7j7V2k2rczYd2aCqcBaMmNlWQ2KKeDfcxmw=="], "lz-string": ["lz-string@1.5.0", "https://registry.npmjs.com/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -1477,9 +1484,11 @@ "minimist": ["minimist@1.2.8", "https://registry.npmjs.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "motion-dom": ["motion-dom@12.26.2", "https://registry.npmjs.com/motion-dom/-/motion-dom-12.26.2.tgz", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw=="], + "motion": ["motion@12.43.0", "", { "dependencies": { "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ=="], - "motion-utils": ["motion-utils@12.24.10", "https://registry.npmjs.com/motion-utils/-/motion-utils-12.24.10.tgz", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="], + "motion-dom": ["motion-dom@14.7.1", "", { "dependencies": { "motion-utils": "14.7.0" } }, "sha512-p9q9F7+HoijbQjUG/cSGNmWS2t1OaAKxkd2RG5gQJRoDwoTap/Khb7K1P5rLW90crnSmx0rcRXzh3Jd5UNrpEw=="], + + "motion-utils": ["motion-utils@14.7.0", "", {}, "sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw=="], "mri": ["mri@1.2.0", "https://registry.npmjs.com/mri/-/mri-1.2.0.tgz", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -1761,7 +1770,7 @@ "tabbable": ["tabbable@6.4.0", "https://registry.npmjs.com/tabbable/-/tabbable-6.4.0.tgz", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], - "tailwind-merge": ["tailwind-merge@3.4.0", "https://registry.npmjs.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="], "tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmjs.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -1979,6 +1988,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmjs.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "motion/framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="], + "next/postcss": ["postcss@8.4.31", "https://registry.npmjs.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next-auth/uuid": ["uuid@8.3.2", "https://registry.npmjs.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], @@ -1999,6 +2010,10 @@ "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "https://registry.npmjs.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="], + + "motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "refractor/parse-entities/character-entities": ["character-entities@1.2.4", "https://registry.npmjs.com/character-entities/-/character-entities-1.2.4.tgz", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="], "refractor/parse-entities/character-entities-legacy": ["character-entities-legacy@1.1.4", "https://registry.npmjs.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="], diff --git a/components.json b/components.json index ffe928f..8ee00ed 100755 --- a/components.json +++ b/components.json @@ -17,5 +17,8 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" -} \ No newline at end of file + "iconLibrary": "lucide", + "registries": { + "@react-bits": "https://reactbits.dev/r/{name}.json" + } +} diff --git a/data/files/7d/7dc3bd0691230ae41dbbf62fa408b3f1b913561de2ff77ff4772e704580fc7a6 b/data/files/7d/7dc3bd0691230ae41dbbf62fa408b3f1b913561de2ff77ff4772e704580fc7a6 new file mode 100644 index 0000000..757877f --- /dev/null +++ b/data/files/7d/7dc3bd0691230ae41dbbf62fa408b3f1b913561de2ff77ff4772e704580fc7a6 @@ -0,0 +1,2 @@ +%PDF-1.4 +fake content diff --git a/eslint.config.mjs b/eslint.config.mjs index 2e6ddf5..4deb7d9 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,7 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, { // React rules "react-hooks/exhaustive-deps": "off", + "react-hooks/refs": "off", "react-hooks/purity": "off", "react/no-unescaped-entities": "off", "react/display-name": "off", diff --git a/package.json b/package.json index 846a9f3..f1fdd93 100755 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.1.1", + "@hugeicons/core-free-icons": "^4.3.3", + "@hugeicons/react": "^1.1.10", "@mdxeditor/editor": "^3.39.1", "@prisma/client": "^6.11.1", "@radix-ui/react-accordion": "^1.2.11", @@ -56,10 +58,11 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", - "framer-motion": "^12.23.2", + "framer-motion": "^14.7.1", "imapflow": "^2.9.1", "input-otp": "^1.4.2", - "lucide-react": "^0.525.0", + "lucide-react": "^1.62.0", + "motion": "^12.23.12", "next": "^16.1.1", "next-auth": "^4.24.11", "next-intl": "^4.3.4", @@ -75,7 +78,7 @@ "recharts": "^2.15.4", "sharp": "^0.34.3", "sonner": "^2.0.6", - "tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.7.0", "tailwindcss-animate": "^1.0.7", "uuid": "^11.1.0", "vaul": "^1.1.2", diff --git a/src/app/api/aos/belege/upload/route.ts b/src/app/api/aos/belege/upload/route.ts new file mode 100644 index 0000000..cdc7fe3 --- /dev/null +++ b/src/app/api/aos/belege/upload/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { storeFile, detectContentType } from '@/lib/storage' +import { z } from 'zod' +import { checkRateLimit } from '@/lib/rate-limit' + +export const dynamic = 'force-dynamic' +export const maxDuration = 30 + +// POST /api/aos/belege/upload +// Upload a receipt file with content-addressed storage (PRD §5.5 + §5.7 Belege). +// Accepts multipart/form-data with file + metadata. +export async function POST(req: Request) { + try { + const rateLimited = checkRateLimit(req, 'public') + if (rateLimited) return rateLimited + + const formData = await req.formData() + const file = formData.get('file') as File | null + const type = formData.get('type') as string | null + const vendor = formData.get('vendor') as string | null + const invoiceNumber = formData.get('invoiceNumber') as string | null + const amountCentsStr = formData.get('amountCents') as string | null + const bookingDateStr = formData.get('bookingDate') as string | null + + if (!file) { + return NextResponse.json({ error: 'file required (multipart/form-data, field name: file)' }, { status: 400 }) + } + + // Read file content + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + // Detect content type from magic bytes + const detectedType = detectContentType(buffer) + const fileName = file.name + const fileType = file.type || detectedType + const fileSizeBytes = buffer.length + + // Store file (content-addressed, dedup, atomic write) + const stored = await storeFile(buffer, fileType) + + // Check for duplicate (same hash = same file already registered as Beleg) + const existing = await db.beleg.findUnique({ where: { fileHash: stored.hash } }) + if (existing) { + return NextResponse.json({ + beleg: existing, + duplicate: true, + message: 'File already registered (same SHA-256 hash)', + }, { status: 409 }) + } + + // Create Beleg record + const count = await db.beleg.count() + const number = `BEL-2026-${String(count + 1).padStart(4, '0')}` + const belegType = type || 'sonstiges' + + // GoBD retention: 8 years for invoices, 6 for business letters + const retentionUntil = new Date() + retentionUntil.setFullYear(retentionUntil.getFullYear() + (belegType.includes('rechnung') ? 8 : 6)) + + const beleg = await db.beleg.create({ + data: { + number, + type: belegType, + fileHash: stored.hash, + fileName, + fileType, + fileSizeBytes, + vendor: vendor ?? null, + invoiceNumber: invoiceNumber ?? null, + amountCents: amountCentsStr ? parseInt(amountCentsStr) : null, + bookingDate: bookingDateStr ? new Date(bookingDateStr) : null, + retentionUntil, + }, + }) + + await db.auditLog.create({ + data: { + actor: 'nora@nordlicht.digital', + action: 'beleg.upload', + entity: 'Beleg', + entityId: beleg.id, + after: JSON.stringify({ number, type: belegType, fileName, hash: stored.hash.slice(0, 16) + '…', sizeBytes: fileSizeBytes }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ + beleg, + storage: { + hash: stored.hash, + sizeBytes: stored.size, + contentType: stored.contentType, + detectedType, + deduped: stored.size !== fileSizeBytes, // if size differs, existing file was returned + }, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/aos/invoices/[id]/pdf/route.ts b/src/app/api/aos/invoices/[id]/pdf/route.ts new file mode 100644 index 0000000..81a1af0 --- /dev/null +++ b/src/app/api/aos/invoices/[id]/pdf/route.ts @@ -0,0 +1,165 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' +export const maxDuration = 15 + +// GET /api/aos/invoices/[id]/pdf +// Generates a real PDF invoice (German layout) using HTML → print-ready format. +// Returns the invoice as an HTML page that browsers can "Print to PDF". +// In production, this would use Typst (PRD §5.2) for server-side PDF generation. +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + + const invoice = await db.invoice.findUnique({ + where: { id }, + include: { company: true, lines: true }, + }) + + if (!invoice) { + return NextResponse.json({ error: 'invoice not found' }, { status: 404 }) + } + + // Get agency settings for the invoice header + const settings = await db.setting.findMany() + const getSetting = (key: string) => settings.find((s) => s.key === key)?.value ?? '' + + const agencyName = getSetting('company.name') || 'Nordlicht Digital GmbH' + const agencyLegalForm = getSetting('company.legalForm') || 'GmbH' + const agencyVatId = getSetting('company.vatId') || '' + const agencyRegisterCourt = getSetting('company.registerCourt') || '' + const agencyRegisterNumber = getSetting('company.registerNumber') || '' + const agencyCity = getSetting('company.city') || '' + const agencyBank = getSetting('company.bank') || '' + const agencyBic = getSetting('company.bic') || '' + + // Generate German invoice HTML (print-ready) + const html = generateInvoiceHTML({ + invoice, + agency: { name: agencyName, legalForm: agencyLegalForm, vatId: agencyVatId, registerCourt: agencyRegisterCourt, registerNumber: agencyRegisterNumber, city: agencyCity, bank: agencyBank, bic: agencyBic }, + }) + + return new Response(html, { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'content-disposition': `inline; filename="${invoice.number}.html"`, + 'cache-control': 'no-store', + }, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +function generateInvoiceHTML({ invoice, agency }: { + invoice: { + number: string; status: string; type: string; issueDate: Date; dueDate: Date; + netCents: number; taxBp: number; grossCents: number; + zugferd: boolean; xrechnung: boolean; + company: { name: string; legalForm: string | null; city: string | null; vatId: string | null }; + lines: { description: string; quantity: number; unitCents: number; totalCents: number }[]; + }; + agency: { name: string; legalForm: string; vatId: string; registerCourt: string; registerNumber: string; city: string; bank: string; bic: string }; +}): string { + const fmtDate = (d: Date) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + const fmtEuro = (cents: number) => (cents / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' + const taxPct = (invoice.taxBp / 100).toFixed(0) + + return ` + + + + +Rechnung ${invoice.number} + + + +
+
+

${agency.name}

+

${agency.city}

+

USt-IdNr.: ${agency.vatId}

+

${agency.registerCourt} ${agency.registerNumber}

+
+
+

RECHNUNG

+

Nr.: ${invoice.number}

+

Datum: ${fmtDate(invoice.issueDate)}

+

Fällig: ${fmtDate(invoice.dueDate)}

+ ${invoice.zugferd ? '

✓ ZUGFeRD

' : ''} + ${invoice.xrechnung ? '

✓ XRechnung

' : ''} +
+
+ +
+

Rechnungsempfänger

+

${invoice.company.name}

+ ${invoice.company.legalForm ? `

${invoice.company.legalForm}

` : ''} + ${invoice.company.city ? `

${invoice.company.city}

` : ''} + ${invoice.company.vatId ? `

USt-IdNr.: ${invoice.company.vatId}

` : ''} +
+ + + + + + + + + + + + ${invoice.lines.map((l) => ` + + + + + + `).join('')} + +
BeschreibungMengeEinzelpreisGesamt
${l.description}${l.quantity}${fmtEuro(l.unitCents)}${fmtEuro(l.totalCents)}
+ + + + + +
Netto${fmtEuro(invoice.netCents)}
USt (${taxPct}%)${fmtEuro(invoice.grossCents - invoice.netCents)}
Gesamt${fmtEuro(invoice.grossCents)}
+ + + +` +} diff --git a/src/app/api/aos/webhooks/github/route.ts b/src/app/api/aos/webhooks/github/route.ts new file mode 100644 index 0000000..3109850 --- /dev/null +++ b/src/app/api/aos/webhooks/github/route.ts @@ -0,0 +1,159 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { createHmac } from 'crypto' +import { checkRateLimit } from '@/lib/rate-limit' + +export const dynamic = 'force-dynamic' + +// POST /api/aos/webhooks/github +// GitHub/Gitea webhook receiver (PRD §6.3: "GitHub/Gitea webhooks (commits and PRs linked to tasks)") +// +// Receives push events and PR events from GitHub, extracts commit messages, +// and links them to tasks by matching "#TASK-XXXX" references in commit messages. +// +// Security: verifies X-Hub-Signature-256 HMAC using GITHUB_WEBHOOK_SECRET env var. +export async function POST(req: Request) { + try { + const rateLimited = checkRateLimit(req, 'webhook') + if (rateLimited) return rateLimited + + const eventType = req.headers.get('x-github-event') ?? '' + const signature = req.headers.get('x-hub-signature-256') ?? '' + const body = await req.text() + + // Verify HMAC signature if secret is configured + const secret = process.env.GITHUB_WEBHOOK_SECRET + if (secret) { + const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex') + if (signature !== expected) { + return NextResponse.json({ error: 'invalid signature' }, { status: 401 }) + } + } + + const payload = JSON.parse(body) + const repo = payload.repository?.full_name ?? 'unknown' + + if (eventType === 'push') { + // Push event: extract commits and link to tasks + const commits = payload.commits ?? [] + const linkedTasks: { commit: string; message: string; taskId: string; taskTitle: string }[] = [] + + for (const commit of commits) { + const message = commit.message ?? '' + const sha = (commit.id ?? '').slice(0, 7) + + // Match task references in commit message: #TKT-2026-XXXX or task: + const taskMatches = message.match(/(?:#TKT-2026-|task:)([a-z0-9]+)/gi) ?? [] + + for (const match of taskMatches) { + const taskId = match.replace(/#TKT-2026-|task:/i, '').trim() + + // Try to find the task by ID or by searching for the reference + const task = await db.task.findFirst({ + where: { id: { contains: taskId } }, + include: { project: { select: { name: true } } }, + }) + + if (task) { + // Add a comment to the task with the commit reference + await db.taskComment.create({ + data: { + taskId: task.id, + author: `GitHub (${payload.pusher?.name ?? 'unknown'})`, + body: `Commit ${sha} in ${repo}:\n${message.split('\n')[0]}\n\nLink: ${payload.repository?.html_url}/commit/${commit.id}`, + }, + }) + + linkedTasks.push({ + commit: sha, + message: message.split('\n')[0], + taskId: task.id, + taskTitle: task.title, + }) + } + } + } + + await db.auditLog.create({ + data: { + actor: 'system:github-webhook', + action: 'webhook.github:push', + entity: 'Task', + entityId: 'batch', + after: JSON.stringify({ repo, commits: commits.length, linked: linkedTasks.length }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ + ok: true, + event: 'push', + repo, + commits: commits.length, + linkedTasks: linkedTasks.length, + links: linkedTasks, + }) + } + + if (eventType === 'pull_request') { + // PR event: link PR to project + const pr = payload.pull_request ?? {} + const action = payload.action ?? 'opened' + const prTitle = pr.title ?? 'Unknown PR' + const prNumber = pr.number ?? 0 + const prUrl = pr.html_url ?? '' + const branch = pr.head?.ref ?? '' + + // Try to match project by branch name (e.g. "feature/intranet-portal" → Intranet-Portal) + const project = await db.project.findFirst({ + where: { name: { contains: branch.replace(/feature\/|fix\//g, '').replace(/-/g, ' ') } }, + }) + + if (project) { + await db.auditLog.create({ + data: { + actor: 'system:github-webhook', + action: 'webhook.github:pr', + entity: 'Project', + entityId: project.id, + after: JSON.stringify({ prNumber, prTitle, prUrl, action, branch, repo }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ + ok: true, + event: 'pull_request', + action, + repo, + prNumber, + prTitle, + branch, + linkedProject: project.name, + }) + } + + return NextResponse.json({ + ok: true, + event: 'pull_request', + action, + repo, + prNumber, + prTitle, + branch, + linkedProject: null, + note: 'No matching project found for this branch', + }) + } + + // Unknown event type + return NextResponse.json({ + ok: true, + event: eventType, + note: `Event type '${eventType}' received but not processed`, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d9dbf41..2965e30 100755 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -48,6 +48,15 @@ export const metadata: Metadata = { }, }; +// PRD §5.6: suppressHydrationWarning on html + body because we use: +// 1. next-themes (class on changes client-side) +// 2. Zustand persist (view/locale loaded from localStorage client-side) +// 3. Radix UI generates IDs with useId() which differ between server and client +// (this is expected and the warning is cosmetic — Radix components still work correctly) +// The alternative would be to delay rendering until after hydration (mount gate), +// but that causes a flash of unstyled content. suppressHydrationWarning is the +// official next-themes + Radix pattern. + export default function RootLayout({ children, }: Readonly<{ @@ -57,6 +66,7 @@ export default function RootLayout({ {children} diff --git a/src/components/CodeSlots.tsx b/src/components/CodeSlots.tsx new file mode 100644 index 0000000..cec6fab --- /dev/null +++ b/src/components/CodeSlots.tsx @@ -0,0 +1,530 @@ +'use client'; + +import React, { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { + animate, + motion, + motionValue, + useMotionValue, + useReducedMotion, + useTransform, + type MotionValue +} from 'motion/react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Tick02Icon } from '@hugeicons/core-free-icons'; + +const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1]; +const WASH_IN = 0.3; +const WASH_OUT = 0.2; +const SINK_DELAY = 0.06; +const SINK_STEP = 0.03; +const CHECK_DELAY = 0.28; +const CHECK_RISE = 8; +const SINK_FADE = 0.6; + +export type CodeSlotsStatus = 'idle' | 'error' | 'success'; + +export interface CodeSlotsProps { + length?: number; + value?: string; + defaultValue?: string; + onChange?: (code: string) => void; + onComplete?: (code: string) => void; + status?: CodeSlotsStatus; + mask?: boolean; + caret?: boolean; + disabled?: boolean; + autoFocus?: boolean; + accentColor?: string; + inkColor?: string; + slotColor?: string; + digitColor?: string; + dangerColor?: string; + slotSize?: number; + gap?: number; + radius?: number; + bounce?: number; + settle?: number; + rise?: number; + cascade?: number; + ariaLabel?: string; + className?: string; +} + +interface Live { + settle: number; + bounce: number; + cascade: number; + reduce: boolean | null; +} + +interface SlotProps { + mv: MotionValue; + drop: MotionValue; + char: string; + active: boolean; + rise: number; + sink: number; +} + +const clamp01 = (v: number) => Math.min(1, Math.max(0, v)); +const digitsOf = (raw: string | undefined) => String(raw ?? '').replace(/\D/g, ''); +const toSlots = (raw: string | undefined, n: number) => { + const d = digitsOf(raw).slice(0, n); + return Array.from({ length: n }, (_, i) => d[i] ?? ''); +}; +const firstEmptyOf = (slots: string[]) => { + const i = slots.indexOf(''); + return i === -1 ? slots.length - 1 : i; +}; +const isFull = (slots: string[]) => slots.every(Boolean); +const STYLE = '@keyframes code-slots-blink{0%,49.9%{opacity:1}50%,100%{opacity:0}}'; + +const CodeSlots: React.FC = ({ + length = 6, + value, + defaultValue = '', + onChange, + onComplete, + status = 'idle', + mask = false, + caret = true, + disabled = false, + autoFocus = false, + accentColor = '#f5f5f5', + inkColor = '#f5f5f5', + slotColor = '#27272a', + digitColor = '#18181b', + dangerColor = '#ff3b30', + slotSize = 44, + gap = 8, + radius = 12, + bounce = 0.2, + settle = 0.3, + rise = 8, + cascade = 20, + ariaLabel = 'One-time code', + className = '' +}) => { + const uid = useId(); + const reduce = useReducedMotion(); + const inputRef = useRef(null); + const rowRef = useRef(null); + const [slots, setSlots] = useState(() => toSlots(value ?? defaultValue, length)); + const [active, setActive] = useState(() => firstEmptyOf(slots)); + const [focused, setFocused] = useState(false); + const [veiled, setVeiled] = useState(status === 'success'); + const activeMv = useMotionValue(active); + const openMv = useMotionValue(status === 'success' ? 1 : 0); + const checkMv = useMotionValue(status === 'success' ? 1 : 0); + const glide = useRef>(new Set()); + const target = useRef([]); + const draining = useRef(false); + const drainTimer = useRef | undefined>(undefined); + const statusRef = useRef(status); + const emitted = useRef(digitsOf(value ?? defaultValue).slice(0, length)); + const slotsRef = useRef(slots); + slotsRef.current = slots; + const live = useRef({} as Live); + live.current = { settle, bounce, cascade, reduce }; + + const springs = useMemo( + () => ({ + mvs: Array.from({ length }, (_, i) => motionValue(slotsRef.current[i] ? 1 : 0)), + drops: Array.from({ length }, () => motionValue(statusRef.current === 'success' ? 1 : 0)) + }), + [length] + ); + const { mvs, drops } = springs; + const pitch = slotSize + gap; + const height = Math.round(slotSize * 1.18); + const washRadius = Math.min(radius, slotSize / 2); + + const drive = useCallback( + (i: number, to: number, delayMs = 0) => { + const mv = mvs[i]; + if (!mv) return; + target.current[i] = to; + const L = live.current; + if (L.reduce) { + mv.jump(to); + return; + } + animate(mv, to, { type: 'spring', duration: L.settle, bounce: L.bounce, delay: delayMs / 1000 }); + }, + [mvs] + ); + const land = useCallback( + (i: number, delayMs = 0) => { + if (mvs[i].get() > 0) mvs[i].jump(0); + drive(i, 1, delayMs); + }, + [mvs, drive] + ); + const moveActive = useCallback( + (next: number, crossed: number[]) => { + crossed.forEach(j => glide.current.add(j)); + activeMv.jump(next); + setActive(next); + }, + [activeMv] + ); + const jumpActive = useCallback( + (next: number) => { + glide.current.clear(); + activeMv.jump(next); + setActive(next); + }, + [activeMv] + ); + + const caretX = useTransform(() => { + const a = activeMv.get(); + let x = a * pitch; + for (let j = 0; j < mvs.length; j++) { + const h = clamp01(mvs[j].get()); + if (!glide.current.has(j)) continue; + const to = target.current[j]; + if (to === undefined || h === clamp01(to)) { + glide.current.delete(j); + continue; + } + x += j < a ? -(1 - h) * pitch : h * pitch; + } + return Math.min(Math.max(x, 0), (mvs.length - 1) * pitch); + }); + const caretTransform = useTransform(caretX, (x: number) => `translateX(${x}px)`); + const washClip = useTransform(openMv, (o: number) => `inset(0 ${(1 - clamp01(o)) * 50}% round ${washRadius}px)`); + const checkTransform = useTransform( + checkMv, + (c: number) => `translateY(${(1 - c) * CHECK_RISE}px) scale(${0.85 + 0.15 * Math.max(c, 0)})` + ); + const checkOpacity = useTransform(checkMv, clamp01); + + const commit = useCallback( + (next: string[]) => { + const prev = slotsRef.current; + slotsRef.current = next; + setSlots(next); + const code = next.join(''); + emitted.current = code; + onChange?.(code); + if (!isFull(prev) && isFull(next)) onComplete?.(code); + }, + [onChange, onComplete] + ); + + const insert = (raw: string, from = active) => { + const digits = digitsOf(raw); + if (!digits) return; + const next = [...slotsRef.current]; + const crossed: number[] = []; + const step = reduce ? 0 : cascade; + let i = from; + for (const ch of digits) { + if (i >= length) break; + next[i] = ch; + land(i, (i - from) * step); + crossed.push(i); + i += 1; + } + if (!crossed.length) return; + commit(next); + moveActive(Math.min(i, length - 1), crossed); + }; + const clearSlot = (i: number, stepBack = false) => { + if (!slotsRef.current[i]) { + if (stepBack) jumpActive(i); + return; + } + const next = [...slotsRef.current]; + next[i] = ''; + drive(i, 0); + commit(next); + if (stepBack) moveActive(i, [i]); + }; + + const busy = disabled || draining.current || status === 'success'; + const onKeyDown = (e: React.KeyboardEvent) => { + if (busy || e.metaKey || e.ctrlKey || e.altKey) return; + const k = e.key; + if (/^[0-9]$/.test(k)) { + e.preventDefault(); + insert(k); + } else if (k === 'Backspace') { + e.preventDefault(); + if (slots[active]) clearSlot(active); + else if (active > 0) clearSlot(active - 1, true); + } else if (k === 'Delete') { + e.preventDefault(); + clearSlot(active); + } else if (k === 'ArrowLeft') { + e.preventDefault(); + jumpActive(Math.max(active - 1, 0)); + } else if (k === 'ArrowRight') { + e.preventDefault(); + jumpActive(Math.min(active + 1, length - 1)); + } else if (k === 'Home') { + e.preventDefault(); + jumpActive(0); + } else if (k === 'End') { + e.preventDefault(); + jumpActive(length - 1); + } + }; + const onPaste = (e: React.ClipboardEvent) => { + if (busy) return; + e.preventDefault(); + insert(e.clipboardData.getData('text')); + }; + const onInput = (e: React.ChangeEvent) => { + if (busy) return; + const d = digitsOf(e.target.value); + if (!d) return; + insert(d, d.length === 1 ? active : 0); + }; + const onRowMouseDown = (e: React.MouseEvent) => { + if (disabled) return; + e.preventDefault(); + const row = rowRef.current; + if (row && !draining.current && status !== 'success') { + const rect = row.getBoundingClientRect(); + const zoom = rect.width / (row.offsetWidth || rect.width) || 1; + const i = Math.floor((e.clientX - rect.left) / zoom / pitch); + jumpActive(Math.max(0, Math.min(i, firstEmptyOf(slotsRef.current)))); + } + inputRef.current?.focus(); + }; + + useEffect(() => { + glide.current.clear(); + target.current = []; + const next = Array.from({ length }, (_, i) => slotsRef.current[i] ?? ''); + slotsRef.current = next; + setSlots(next); + jumpActive(firstEmptyOf(next)); + const code = next.join(''); + if (code !== emitted.current) { + emitted.current = code; + onChange?.(code); + } + }, [length]); + + useEffect(() => { + if (value === undefined) return; + const clean = digitsOf(value).slice(0, length); + if (clean === emitted.current) return; + emitted.current = clean; + const prev = slotsRef.current; + const next = toSlots(clean, length); + const hidden = statusRef.current === 'success'; + const landing: number[] = []; + const leaving: number[] = []; + next.forEach((ch, i) => { + if (ch === prev[i]) return; + (ch ? landing : leaving).push(i); + }); + const step = live.current.reduce || hidden ? 0 : live.current.cascade; + landing.forEach((i, k) => land(i, k * step)); + leaving.reverse().forEach((i, k) => { + if (hidden) { + target.current[i] = 0; + mvs[i].jump(0); + drops[i].jump(0); + } else drive(i, 0, k * step); + }); + slotsRef.current = next; + setSlots(next); + moveActive(firstEmptyOf(next), [...landing, ...leaving]); + if (!isFull(prev) && isFull(next)) onComplete?.(clean); + }, [value, length]); + + useEffect(() => { + const was = statusRef.current; + const L = live.current; + if (status === 'success') { + setVeiled(true); + if (L.reduce) { + openMv.jump(1); + drops.forEach(d => d.jump(1)); + checkMv.jump(1); + return; + } + animate(openMv, 1, { duration: WASH_IN, ease: EASE_OUT }); + drops.forEach((d, k) => + animate(d, 1, { type: 'spring', duration: 0.3, bounce: 0, delay: SINK_DELAY + k * SINK_STEP }) + ); + animate(checkMv, 1, { type: 'spring', duration: 0.35, bounce: L.bounce, delay: CHECK_DELAY }); + return; + } + if (was !== 'success') return; + if (L.reduce) { + openMv.jump(0); + checkMv.jump(0); + drops.forEach(d => d.jump(0)); + setVeiled(false); + return; + } + animate(checkMv, 0, { duration: 0.15, ease: EASE_OUT }); + animate(openMv, 0, { duration: WASH_OUT, ease: EASE_OUT, delay: 0.06 }).then(() => { + if (openMv.get() === 0) setVeiled(false); + }); + drops.forEach(d => animate(d, 0, { type: 'spring', duration: 0.3, bounce: 0, delay: 0.1 })); + }, [status]); + + useEffect(() => { + if (status !== 'error') return; + const filled = slotsRef.current.map((c, i) => (c ? i : -1)).filter(i => i >= 0); + if (!filled.length) return; + filled.reverse(); + draining.current = true; + const L = live.current; + const step = L.reduce ? 0 : L.cascade; + filled.forEach((i, k) => drive(i, 0, k * step)); + moveActive( + 0, + slotsRef.current.map((_, j) => j) + ); + clearTimeout(drainTimer.current); + drainTimer.current = setTimeout( + () => { + draining.current = false; + commit(Array.from({ length }, () => '')); + }, + L.reduce ? 300 : (filled.length - 1) * step + L.settle * 1000 + ); + }, [status]); + useEffect(() => { + statusRef.current = status; + }, [status]); + useEffect(() => () => clearTimeout(drainTimer.current), []); + useEffect(() => { + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); + + const view = slots.length === length ? slots : Array.from({ length }, (_, i) => slots[i] ?? ''); + const showCaret = + caret && focused && !disabled && !veiled && status !== 'success' && (status === 'error' || !view[active]); + + return ( +
+ +
+ setFocused(true)} + onBlur={() => setFocused(false)} + /> + {view.map((ch, i) => ( + + ))} + + +
+ + {status === 'success' ? 'Code accepted' : `${view.filter(Boolean).length} of ${length} digits entered`} + +
+ ); +}; + +function Slot({ mv, drop, char, active, rise, sink }: SlotProps) { + const [shown, setShown] = useState(char); + if (char && char !== shown) setShown(char); + const fill = useTransform(mv, (t: number) => `scale(${Math.max(t, 0)})`); + const lift = useTransform( + [mv, drop], + ([t, d]: number[]) => `translateY(${(1 - t) * rise + Math.max(d, 0) * sink}px)` + ); + const ink = useTransform([mv, drop], ([t, d]: number[]) => clamp01(t) * (1 - clamp01(d / SINK_FADE))); + return ( + + ); +} + +export default CodeSlots; diff --git a/src/components/CometDial.tsx b/src/components/CometDial.tsx new file mode 100644 index 0000000..7d0f555 --- /dev/null +++ b/src/components/CometDial.tsx @@ -0,0 +1,446 @@ +'use client'; + +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'; +import { animate, useMotionValue, useReducedMotion } from 'motion/react'; + +const R = 80; +const DEAD = 44; +const K = 12; +const V_FULL = 4; +const V_FLICK = 2; +const TAU_FOLLOW = 0.06; +const TAU_V = 0.05; +const DECEL = 0.99; +const DRAG_PX = 10; +const STALE_MS = 80; + +export interface CometDialChangeDetail { + velocity: number; + bounce: number; +} + +export interface CometDialProps { + value?: number; + defaultValue?: number; + min?: number; + max?: number; + step?: number; + unit?: string; + label?: string; + accent?: string; + ink?: string; + size?: number; + sweep?: number; + thickness?: number; + speed?: number; + tapBounce?: number; + flickBounce?: number; + momentum?: number; + cometReach?: number; + cometWidth?: number; + disabled?: boolean; + onChange?: (value: number) => void; + onChangeEnd?: (value: number, detail: CometDialChangeDetail) => void; + className?: string; +} + +type Sample = [number, number]; + +interface Grip { + id: number; + at: number | null; + hist: Sample[]; + side: 'hi' | 'lo' | null; + moved: boolean; + x0: number; + y0: number; +} + +interface Loop { + raf: number; + last: number; + v: number; + rPrev: number | null; + text: string; + valueText: string; +} + +interface Live { + move: (e: PointerEvent) => void; + up: (e: PointerEvent) => void; +} + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); +const velocityOf = (hist: Sample[]) => { + if (hist.length < 2) return 0; + const a = hist[0]; + const b = hist[hist.length - 1]; + return ((b[1] - a[1]) / Math.max(1, b[0] - a[0])) * 1000; +}; +const decimalsOf = (step: number) => { + const s = String(step); + const i = s.indexOf('.'); + return i === -1 ? 0 : s.length - i - 1; +}; +const pointAt = (deg: number): [number, number] => { + const a = (deg * Math.PI) / 180; + return [100 + Math.cos(a) * R, 100 + Math.sin(a) * R]; +}; +const arcPath = (a0: number, a1: number) => { + const [x0, y0] = pointAt(a0); + const [x1, y1] = pointAt(a1); + return `M ${x0.toFixed(3)} ${y0.toFixed(3)} A ${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(3)} ${y1.toFixed(3)}`; +}; + +const CometDial: React.FC = ({ + value, + defaultValue = 62, + min = 0, + max = 100, + step = 1, + unit = '%', + label = 'Level', + accent = '#f5f5f5', + ink = '#fdfdfd', + size = 250, + sweep = 320, + thickness = 5, + speed = 25, + tapBounce = 0.2, + flickBounce = 0.1, + momentum = 1, + cometReach = 180, + cometWidth = 12, + disabled = false, + onChange, + onChangeEnd, + className = '' +}) => { + const reduce = useReducedMotion(); + const [dragging, setDragging] = useState(false); + const svgRef = useRef(null); + const litRef = useRef(null); + const headRef = useRef(null); + const comet = useRef<(SVGPathElement | null)[]>([]); + const figure = useRef(null); + const target = useRef(clamp(value ?? defaultValue, min, max)); + const grip = useRef(null); + const unbind = useRef<(() => void) | null>(null); + const loop = useRef({ raf: 0, last: 0, v: 0, rPrev: null, text: '', valueText: '' }); + const live = useRef({} as Live); + const reading = useMotionValue(target.current); + + const range = Math.max(1e-9, max - min); + const gap = 360 - sweep; + const start = 90 + gap / 2; + const end = start + sweep; + const decimals = decimalsOf(step); + const k = 200 + (clamp(speed, 0, 100) / 100) * 700; + const crit = 2 * Math.sqrt(k); + const snap = (v: number) => + step > 0 ? clamp(Math.round((v - min) / step) * step + min, min, max) : clamp(v, min, max); + + const commit = (v: number, finished: boolean, detail?: CometDialChangeDetail) => { + const s = snap(v); + if (s !== target.current) { + target.current = s; + onChange?.(s); + } + if (finished) onChangeEnd?.(s, detail ?? { velocity: 0, bounce: tapBounce }); + }; + + const paintRef = useRef<((now: number) => void) | null>(null); + const tick = useCallback((now: number) => paintRef.current?.(now), []); + const wake = () => { + const L = loop.current; + if (L.raf) return; + L.last = performance.now(); + L.rPrev = null; + L.raf = requestAnimationFrame(tick); + }; + const launch = (to: number, bounce: number, velocity = reading.getVelocity()) => { + reading.stop(); + if (reduce) { + reading.jump(to); + wake(); + return; + } + animate(reading, to, { + type: 'spring', + stiffness: k, + damping: crit * (1 - clamp(bounce, 0, 0.9)), + mass: 1, + velocity + }); + wake(); + }; + + const paint = (now: number) => { + const L = loop.current; + const svg = svgRef.current; + if (!svg) { + L.raf = 0; + return; + } + const dt = clamp((now - L.last) / 1000, 0, 0.05); + L.last = now; + const g = grip.current; + if (g && g.at != null) { + if (reduce) reading.jump(g.at); + else reading.set(reading.get() + (g.at - reading.get()) * (1 - Math.exp(-dt / TAU_FOLLOW))); + } + const r = reading.get(); + const vRaw = L.rPrev == null || dt === 0 || reduce ? 0 : (r - L.rPrev) / dt / range; + L.rPrev = r; + L.v += (vRaw - L.v) * (1 - Math.exp(-dt / TAU_V)); + const v = L.v; + const s = clamp(Math.abs(v) / V_FULL, 0, 1); + const dir = Math.sign(v) || 1; + const f = clamp((r - min) / range, 0, 1); + const ang = start + f * sweep; + if (litRef.current) litRef.current.setAttribute('d', f > 0.0005 ? arcPath(start, ang) : ''); + if (headRef.current) { + const [cx, cy] = pointAt(ang); + headRef.current.setAttribute('cx', cx.toFixed(3)); + headRef.current.setAttribute('cy', cy.toFixed(3)); + } + const seg = (cometReach * s) / K; + for (let j = 0; j < K; j++) { + const el = comet.current[j]; + if (!el) continue; + let a0 = dir > 0 ? ang - (j + 1) * seg : ang + j * seg; + let a1 = dir > 0 ? ang - j * seg : ang + (j + 1) * seg; + a0 = clamp(a0, start, end); + a1 = clamp(a1, start, end); + if (seg < 0.01 || a1 - a0 < 0.01) { + if (el.style.opacity !== '0') el.style.opacity = '0'; + continue; + } + const w = 1 - j / K; + el.setAttribute('d', arcPath(a0, a1)); + el.setAttribute('stroke-width', (thickness + cometWidth * w * s).toFixed(2)); + el.style.opacity = (w * s).toFixed(3); + } + const shown = clamp(r, min, max).toFixed(decimals); + if (shown !== L.text) { + L.text = shown; + if (figure.current) figure.current.textContent = shown; + svg.setAttribute('aria-valuenow', shown); + } + const valueText = `${target.current}${unit}`; + if (valueText !== L.valueText) { + L.valueText = valueText; + svg.setAttribute('aria-valuetext', valueText); + } + const moving = !!g || reading.isAnimating() || Math.abs(v) > 0.002; + if (!moving) { + for (const el of comet.current) if (el && el.style.opacity !== '0') el.style.opacity = '0'; + } + L.raf = moving ? requestAnimationFrame(tick) : 0; + }; + paintRef.current = paint; + + const localPoint = (cx: number, cy: number): [number, number] => { + const b = (svgRef.current as SVGSVGElement).getBoundingClientRect(); + return [((cx - b.left) / b.width) * 200 - 100, ((cy - b.top) / b.height) * 200 - 100]; + }; + const angleAt = (cx: number, cy: number) => { + const [x, y] = localPoint(cx, cy); + let rel = ((Math.atan2(y, x) * 180) / Math.PI - start + 720) % 360; + const g = grip.current; + if (rel > sweep) rel = g && g.side ? (g.side === 'hi' ? sweep : 0) : rel < sweep + gap / 2 ? sweep : 0; + else if (g) g.side = rel > sweep / 2 ? 'hi' : 'lo'; + return min + (rel / sweep) * range; + }; + + const move = (e: PointerEvent) => { + const g = grip.current; + if (!g || g.id !== e.pointerId) return; + if (!g.moved) { + if (Math.hypot(e.clientX - g.x0, e.clientY - g.y0) < DRAG_PX) return; + g.moved = true; + reading.stop(); + } + g.at = angleAt(e.clientX, e.clientY); + g.hist.push([performance.now(), g.at]); + if (g.hist.length > 4) g.hist.shift(); + commit(g.at, false); + wake(); + }; + const up = (e: PointerEvent) => { + const g = grip.current; + if (!g || g.id !== e.pointerId) return; + grip.current = null; + unbind.current?.(); + unbind.current = null; + setDragging(false); + try { + svgRef.current?.releasePointerCapture(e.pointerId); + } catch {} + if (!g.moved) { + commit(target.current, true); + return; + } + const stale = performance.now() - g.hist[g.hist.length - 1][0] > STALE_MS; + const v = e.type === 'pointercancel' || stale ? 0 : velocityOf(g.hist); + const bounce = tapBounce + (flickBounce - tapBounce) * clamp(Math.abs(v) / range / V_FLICK, 0, 1); + const to = snap((g.at ?? target.current) + (v / 1000) * (DECEL / (1 - DECEL)) * momentum); + commit(to, true, { velocity: v, bounce }); + launch(to, bounce, v); + }; + live.current = { move, up }; + + const down = (e: React.PointerEvent) => { + if (disabled || grip.current || e.button > 0) return; + const [x, y] = localPoint(e.clientX, e.clientY); + if (Math.hypot(x, y) < DEAD) return; + e.preventDefault(); + const svg = svgRef.current as SVGSVGElement; + try { + svg.setPointerCapture(e.pointerId); + } catch {} + svg.focus({ preventScroll: true }); + grip.current = { id: e.pointerId, at: null, hist: [], side: null, moved: false, x0: e.clientX, y0: e.clientY }; + const at = angleAt(e.clientX, e.clientY); + grip.current.hist.push([performance.now(), at]); + setDragging(true); + commit(at, false); + launch(snap(at), tapBounce); + const onMove = (ev: PointerEvent) => { + if (ev.isTrusted) live.current.move(ev); + }; + const onUp = (ev: PointerEvent) => { + if (ev.isTrusted) live.current.up(ev); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + unbind.current = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + }; + + const key = (e: React.KeyboardEvent) => { + if (disabled) return; + const t = target.current; + const big = e.shiftKey ? 10 : 1; + let to: number; + switch (e.key) { + case 'ArrowRight': + case 'ArrowUp': + to = t + step * big; + break; + case 'ArrowLeft': + case 'ArrowDown': + to = t - step * big; + break; + case 'PageUp': + to = t + step * 10; + break; + case 'PageDown': + to = t - step * 10; + break; + case 'Home': + to = min; + break; + case 'End': + to = max; + break; + default: + return; + } + e.preventDefault(); + to = snap(to); + reading.stop(); + reading.jump(to); + commit(to, true); + wake(); + }; + + useLayoutEffect(() => { + const L = loop.current; + L.text = ''; + L.valueText = ''; + paintRef.current?.(performance.now()); + }, [min, max, step, sweep, thickness, cometReach, cometWidth, unit]); + useEffect(() => { + if (value === undefined || grip.current) return; + const s = snap(value); + if (s === target.current) return; + target.current = s; + launch(s, tapBounce); + }, [value]); + useEffect( + () => () => { + cancelAnimationFrame(loop.current.raf); + unbind.current?.(); + reading.stop(); + }, + [reading] + ); + + return ( +
+ + + + + {Array.from({ length: K }, (_, j) => ( + { + comet.current[j] = el; + }} + style={{ opacity: 0 }} + /> + ))} + + + + +
+ ); +}; + +export default CometDial; diff --git a/src/components/HoldButton.tsx b/src/components/HoldButton.tsx new file mode 100644 index 0000000..d2cbf00 --- /dev/null +++ b/src/components/HoldButton.tsx @@ -0,0 +1,358 @@ +'use client'; + +import React, { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; + +export type HoldButtonSize = 'sm' | 'md' | 'lg'; +export type HoldButtonDirection = 'right' | 'up'; + +export interface HoldButtonProps { + children?: ReactNode; + doneLabel?: ReactNode; + icon?: ReactNode; + doneIcon?: ReactNode; + backgroundColor?: string; + fillColor?: string; + textColor?: string; + fillTextColor?: string; + size?: HoldButtonSize; + radius?: number; + fillDirection?: HoldButtonDirection; + holdTime?: number; + releaseTime?: number; + pressScale?: number; + wave?: boolean; + waveAmplitude?: number; + glow?: boolean; + resetAfter?: number; + disabled?: boolean; + onHold?: () => void; + onTap?: () => void; + className?: string; +} + +type Phase = 'idle' | 'holding' | 'done'; +type Input = 'pointer' | 'key' | null; + +interface Motion { + raf: number; + p: number; + from: number; + to: number; + start: number; +} + +interface Gesture { + pointerId: number | null; + start: number; + rect: DOMRect | null; +} + +interface ReleaseOptions { + drifted?: boolean; +} + +const TAP_MS = 250; +const HIT_PAD = 10; +const LINEAR = (t: number) => t; +const EASE_OUT = (t: number) => 1 - Math.pow(1 - t, 3); + +const SIZES: Record = { + sm: 'h-9 px-4 text-[13px]', + md: 'h-11 px-[22px] text-[15px]', + lg: 'h-[52px] px-7 text-[17px]' +}; + +const GLOW = 'inset_0_1px_0_rgba(255,255,255,0.06),0_10px_32px_-6px_color-mix(in_srgb,var(--hb-fill)_70%,transparent)'; + +const LABEL_SPAN = + '[grid-area:1/1] inline-flex items-center gap-2 whitespace-nowrap [transition:opacity_200ms_ease,filter_200ms_ease]'; + +const STYLE = ` +.hb-root{--hb-w:0px;--hb-h:0px;--hb-cycles:2;--hb-p:0} +.hb-fill{clip-path:inset(0 calc((1 - var(--hb-p)) * (100% + 0.75 * var(--hb-wave)) - var(--hb-p) * 0.25 * var(--hb-wave)) 0 0)} +.hb-root[data-direction=up] .hb-fill{clip-path:inset(calc((1 - var(--hb-p)) * (100% + 0.75 * var(--hb-wave)) - var(--hb-p) * 0.25 * var(--hb-wave)) 0 0 0)} +.hb-crest{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='200' viewBox='0 0 20 200' preserveAspectRatio='none'%3E%3Cpath d='M0 0H10C18 8 18 25.3 10 33.3S2 58.7 10 66.7S18 92 10 100S2 125.3 10 133.3S18 158.7 10 166.7S2 192 10 200H0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='200' viewBox='0 0 20 200' preserveAspectRatio='none'%3E%3Cpath d='M0 0H10C18 8 18 25.3 10 33.3S2 58.7 10 66.7S18 92 10 100S2 125.3 10 133.3S18 158.7 10 166.7S2 192 10 200H0Z'/%3E%3C/svg%3E");-webkit-mask-repeat:repeat-y;mask-repeat:repeat-y;-webkit-mask-size:var(--hb-wave) calc(var(--hb-h) * 2);mask-size:var(--hb-wave) calc(var(--hb-h) * 2);-webkit-mask-position-x:calc(-1 * var(--hb-wave) + var(--hb-p) * (var(--hb-w) + var(--hb-wave)));mask-position-x:calc(-1 * var(--hb-wave) + var(--hb-p) * (var(--hb-w) + var(--hb-wave)));-webkit-mask-position-y:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-h));mask-position-y:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-h))} +.hb-root[data-direction=up] .hb-crest{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='20' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M0 20V10C12 2 38 2 50 10S88 18 100 10S138 2 150 10S188 18 200 10V20Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='20' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M0 20V10C12 2 38 2 50 10S88 18 100 10S138 2 150 10S188 18 200 10V20Z'/%3E%3C/svg%3E");-webkit-mask-repeat:repeat-x;mask-repeat:repeat-x;-webkit-mask-size:calc(var(--hb-w) * 2) var(--hb-wave);mask-size:calc(var(--hb-w) * 2) var(--hb-wave);-webkit-mask-position-x:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-w));mask-position-x:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-w));-webkit-mask-position-y:calc(var(--hb-h) - var(--hb-p) * (var(--hb-h) + var(--hb-wave)));mask-position-y:calc(var(--hb-h) - var(--hb-p) * (var(--hb-h) + var(--hb-wave)))} +@keyframes hb-pulse{from{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb,var(--hb-fill) 55%,transparent)}to{opacity:0;box-shadow:0 0 0 14px color-mix(in srgb,var(--hb-fill) 0%,transparent)}} +@media (prefers-reduced-motion:reduce){ +.hb-root{transform:none!important;transition:background-color 160ms ease,box-shadow var(--hb-release) ease!important} +.hb-fill{clip-path:inset(0)!important;opacity:0;transition:opacity var(--hb-release) ease!important} +.hb-crest{display:none} +.hb-root[data-phase=holding] .hb-fill,.hb-root[data-phase=done] .hb-fill{opacity:1;transition:opacity var(--hb-hold) linear!important} +.hb-pulse{animation:none!important} +.hb-label>span{filter:none!important;transition:opacity 200ms ease!important} +}`; + +const HoldButton: React.FC = ({ + children = 'Hold to delete', + doneLabel = 'Deleted', + icon = null, + doneIcon = null, + backgroundColor = '#27272a', + fillColor = '#5227FF', + textColor = '#f5f5f5', + fillTextColor = '#ffffff', + size = 'md', + radius = 14, + fillDirection = 'right', + holdTime = 2000, + releaseTime = 200, + pressScale = 0.97, + wave = true, + waveAmplitude = 6, + glow = true, + resetAfter = 1200, + disabled = false, + onHold, + onTap, + className = '' +}) => { + const [phase, setPhase] = useState('idle'); + const [input, setInput] = useState(null); + const phaseRef = useRef('idle'); + const inputRef = useRef(null); + const buttonRef = useRef(null); + const gesture = useRef({ pointerId: null, start: 0, rect: null }); + const timers = useRef({ complete: 0, reset: 0 }); + const hintId = useId(); + + const go = (next: Phase, kind: Input = null) => { + phaseRef.current = next; + inputRef.current = kind; + setPhase(next); + setInput(kind); + }; + + const clearTimers = () => { + clearTimeout(timers.current.complete); + clearTimeout(timers.current.reset); + }; + + const motion = useRef({ raf: 0, p: 0, from: 0, to: 0, start: 0 }); + const drive = (to: number, duration: number, ease: (t: number) => number) => { + const m = motion.current; + cancelAnimationFrame(m.raf); + m.from = m.p; + m.to = to; + m.start = performance.now(); + const step = (now: number) => { + const t = duration > 0 ? Math.min(1, (now - m.start) / duration) : 1; + m.p = m.from + (m.to - m.from) * ease(t); + buttonRef.current?.style.setProperty('--hb-p', m.p.toFixed(4)); + if (t < 1) { + m.raf = requestAnimationFrame(step); + return; + } + m.raf = 0; + if (m.to === 1) complete(); + }; + m.raf = requestAnimationFrame(step); + }; + + const complete = () => { + if (phaseRef.current !== 'holding') return; + if (performance.now() - gesture.current.start < holdTime - 50) return; + clearTimers(); + go('done', inputRef.current); + onHold?.(); + if (resetAfter > 0) { + timers.current.reset = window.setTimeout(() => { + go('idle'); + drive(0, releaseTime, EASE_OUT); + }, resetAfter); + } + }; + + const begin = (kind: Input) => { + if (disabled || phaseRef.current !== 'idle') return false; + const button = buttonRef.current; + if (!button) return false; + gesture.current.start = performance.now(); + gesture.current.rect = button.getBoundingClientRect(); + go('holding', kind); + drive(1, holdTime, LINEAR); + timers.current.complete = window.setTimeout(complete, holdTime + 100); + return true; + }; + + const release = ({ drifted = false }: ReleaseOptions = {}) => { + if (phaseRef.current !== 'holding') return; + clearTimers(); + const held = performance.now() - gesture.current.start; + go('idle'); + drive(0, releaseTime, EASE_OUT); + if (!drifted && held < TAP_MS) onTap?.(); + }; + const releaseRef = useRef(release); + releaseRef.current = release; + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0 || !e.isPrimary || gesture.current.pointerId !== null) return; + if (!begin('pointer')) return; + gesture.current.pointerId = e.pointerId; + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch {} + }; + + const endPointer = (e: React.PointerEvent, options?: ReleaseOptions) => { + if (e.pointerId !== gesture.current.pointerId) return; + gesture.current.pointerId = null; + try { + if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); + } catch {} + release(options); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (e.pointerId !== gesture.current.pointerId) return; + const r = gesture.current.rect; + if (!r) return; + const out = + e.clientX < r.left - HIT_PAD || + e.clientX > r.right + HIT_PAD || + e.clientY < r.top - HIT_PAD || + e.clientY > r.bottom + HIT_PAD; + if (out) endPointer(e, { drifted: true }); + }; + + const handlePointerLeave = (e: React.PointerEvent) => { + if (e.pointerType !== 'touch') endPointer(e, { drifted: true }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + if (inputRef.current === 'key') release({ drifted: true }); + return; + } + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + if (!e.repeat) begin('key'); + } + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + if (inputRef.current === 'key') release(); + } + }; + + useLayoutEffect(() => { + const button = buttonRef.current; + if (!button) return undefined; + const measure = () => { + button.style.setProperty('--hb-w', `${button.offsetWidth}px`); + button.style.setProperty('--hb-h', `${button.offsetHeight}px`); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(button); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (phase !== 'holding') return undefined; + const cancel = () => releaseRef.current({ drifted: true }); + const onVisibility = () => { + if (document.hidden) cancel(); + }; + window.addEventListener('blur', cancel); + document.addEventListener('visibilitychange', onVisibility); + return () => { + window.removeEventListener('blur', cancel); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [phase]); + + useEffect(() => { + const t = timers.current; + const m = motion.current; + return () => { + clearTimeout(t.complete); + clearTimeout(t.reset); + cancelAnimationFrame(m.raf); + }; + }, []); + + const direction: HoldButtonDirection = fillDirection === 'up' ? 'up' : 'right'; + const labels = ( + <> + + {icon ? {icon} : null} + {children} + + + {doneIcon ? {doneIcon} : null} + {doneLabel} + + + ); + + const cssVars = { + '--hb-radius': `${radius}px`, + '--hb-bg': backgroundColor, + '--hb-fill': fillColor, + '--hb-text': textColor, + '--hb-fill-text': fillTextColor, + '--hb-hold': `${holdTime}ms`, + '--hb-cycles': holdTime / 1100, + '--hb-release': `${releaseTime}ms`, + '--hb-press': pressScale, + '--hb-wave': `${wave ? waveAmplitude : 0}px`, + '--hb-ease-out': 'cubic-bezier(0.23, 1, 0.32, 1)' + } as CSSProperties; + + return ( + + ); +}; + +export default HoldButton; diff --git a/src/components/SwipeRow.tsx b/src/components/SwipeRow.tsx new file mode 100644 index 0000000..66e0cc2 --- /dev/null +++ b/src/components/SwipeRow.tsx @@ -0,0 +1,490 @@ +'use client'; + +import React, { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { animate, motion, useMotionValue, useReducedMotion, useTransform } from 'motion/react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Delete02Icon } from '@hugeicons/core-free-icons'; + +const HYST = 10; +const FLICK = 110; +const DECEL = 0.998; +const VMAX = 1500; +const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1]; +const SPRING_UI = { type: 'spring' as const, duration: 0.3, bounce: 0 }; +const DEFAULT_ACTIONS: SwipeAction[] = [{ id: 'delete', label: 'Delete' }]; + +export interface SwipeAction { + id: string; + label: string; + icon?: ReactNode; + color?: string; + dismiss?: boolean; + onSelect?: () => void; +} + +export interface SwipeRowProps { + children?: ReactNode; + actions?: SwipeAction[]; + actionColor?: string; + drawerColor?: string; + rowColor?: string; + textColor?: string; + height?: number; + radius?: number; + actionWidth?: number; + direction?: 'left' | 'right'; + snapBounce?: number; + resistance?: number; + collapseMs?: number; + commitAt?: number; + fullSwipe?: boolean; + disabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onAction?: (action: SwipeAction) => void; + onCommit?: (action: SwipeAction) => void; + closeOnAction?: boolean; + haptic?: boolean; + label?: string; + className?: string; + style?: CSSProperties; +} + +type Sample = [number, number]; +type Phase = 'idle' | 'committing' | 'collapsing'; + +interface Grip { + id: number; + x0: number; + y0: number; + grab: number | null; + moved: boolean; + hist: Sample[]; + touch: boolean; +} + +interface Live { + move: (e: PointerEvent) => void; + up: (e: PointerEvent) => void; +} + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); +const rubber = (o: number, dim: number, c: number) => (o * dim * c) / (dim + c * Math.abs(o)); +const unrubber = (y: number, dim: number, c: number) => (y * dim) / (c * Math.max(1, dim - Math.abs(y))); +const project = (v: number) => ((v / 1000) * DECEL) / (1 - DECEL); +const velocityOf = (hist: Sample[]) => { + if (hist.length < 2) return 0; + const a = hist[0]; + const b = hist[hist.length - 1]; + return ((b[1] - a[1]) / Math.max(1, b[0] - a[0])) * 1000; +}; +const onColor = (hex: string) => { + const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim()); + if (!m) return '#ffffff'; + const h = m[1].length === 3 ? [...m[1]].map(ch => ch + ch).join('') : m[1]; + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return (r * 299 + g * 587 + b * 114) / 1000 >= 150 ? '#111111' : '#ffffff'; +}; +const watchWindow = (live: { current: Live }) => { + const onMove = (e: PointerEvent) => { + if (e.isTrusted) live.current.move(e); + }; + const onUp = (e: PointerEvent) => { + if (e.isTrusted) live.current.up(e); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + return () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; +}; + +const SwipeRow: React.FC = ({ + children, + actions = DEFAULT_ACTIONS, + actionColor = '#e5484d', + drawerColor = '#3f3f46', + rowColor = '#27272a', + textColor = '#f5f5f5', + height = 64, + radius = 16, + actionWidth = 80, + direction = 'left', + snapBounce = 0.2, + resistance = 0.55, + collapseMs = 200, + commitAt = 0.6, + fullSwipe = true, + disabled = false, + open: openProp, + onOpenChange, + onAction, + onCommit, + closeOnAction = true, + haptic = true, + label = 'List item', + className = '', + style +}) => { + const uid = useId(); + const reduce = useReducedMotion(); + const s = direction === 'left' ? -1 : 1; + const A = actionWidth; + const n = actions.length; + const D = n * A; + const c = clamp(resistance, 0.05, 1); + const primary = actions[0]; + const [openState, setOpenState] = useState(false); + const [phase, setPhase] = useState('idle'); + const [say, setSay] = useState(''); + const open = openProp ?? openState; + + const root = useRef(null); + const surface = useRef(null); + const w = useRef(360); + const grip = useRef(null); + const unwatch = useRef<(() => void) | null>(null); + const live = useRef({} as Live); + const foldTimer = useRef | undefined>(undefined); + const heading = useRef(null); + + const x = useMotionValue(0); + const spread = useMotionValue(0); + const landed = useMotionValue(0); + const commitPoint = () => Math.max(commitAt * w.current, D + A / 2); + const canCommit = () => fullSwipe && n > 0 && commitPoint() <= w.current; + const exposed = useTransform(x, v => s * v); + const surfaceXf = useTransform(x, v => `translateX(${v}px)`); + const railXf = useTransform(exposed, (e: number) => `translateX(${-s * Math.max(0, D - e)}px)`); + const shift = useTransform([exposed, spread], ([e, p]: number[]) => p * Math.max(0, e - A)); + const blockXf = useTransform(shift, v => `translateX(${s * v}px)`); + const glyphXf = useTransform( + [shift, landed], + ([v, l]: number[]) => `translateX(${-s * l * (v - (w.current - A) / 2)}px)` + ); + + const map = (raw: number) => { + const W = w.current; + if (raw < 0) return rubber(raw, W, c); + if (raw <= D) return raw; + if (!canCommit()) return D + rubber(raw - D, W, c); + const C = commitPoint(); + const knee = D + (C - D) / c; + return raw <= knee ? D + c * (raw - D) : C + rubber(raw - knee, W, c); + }; + const inv = (ex: number) => { + const W = w.current; + if (ex < 0) return unrubber(ex, W, c); + if (ex <= D) return ex; + if (!canCommit()) return D + unrubber(ex - D, W, c); + const C = commitPoint(); + const knee = D + (C - D) / c; + return ex <= C ? D + (ex - D) / c : knee + unrubber(ex - C, W, c); + }; + + useLayoutEffect(() => { + const el = root.current; + if (!el) return undefined; + w.current = el.offsetWidth || w.current; + const observer = new ResizeObserver(entries => { + const width = entries[0]?.contentRect.width; + if (width) w.current = width; + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + useEffect( + () => () => { + clearTimeout(foldTimer.current); + unwatch.current?.(); + }, + [] + ); + + const setOpen = (next: boolean) => { + if (next === open) return; + setOpenState(next); + onOpenChange?.(next); + }; + const settle = (target: number, v = 0) => { + heading.current = target; + if (reduce) { + animate(x, s * target, { duration: 0.2, ease: EASE_OUT }); + return; + } + const flick = Math.abs(v) >= FLICK; + animate( + x, + s * target, + flick + ? { type: 'spring', duration: 0.4, bounce: snapBounce, velocity: s * clamp(v, -VMAX, VMAX) } + : { ...SPRING_UI, velocity: s * v } + ); + }; + const setSpread = (on: boolean) => { + if ((spread.get() === 1) === on) return; + if (reduce) spread.set(on ? 1 : 0); + else animate(spread, on ? 1 : 0, SPRING_UI); + if (on && primary) { + setSay(`Release to ${primary.label}`); + if (haptic && grip.current?.touch) navigator.vibrate?.(8); + } + }; + const commit = (a: SwipeAction, viaKey: boolean, v = 0) => { + const leap = a === primary; + setPhase('committing'); + setSay(a.label); + setOpen(false); + const fold = () => { + setPhase('collapsing'); + foldTimer.current = setTimeout(() => { + onCommit?.(a); + a.onSelect?.(); + }, collapseMs); + }; + if (viaKey || reduce) { + if (leap) { + spread.set(1); + landed.set(1); + } + if (viaKey) { + x.set(s * w.current); + fold(); + } else animate(x, s * w.current, { duration: 0.2, ease: EASE_OUT }).then(fold); + return; + } + if (leap) { + if (spread.get() < 1) animate(spread, 1, SPRING_UI); + animate(landed, 1, SPRING_UI); + } + animate(x, s * w.current, { ...SPRING_UI, velocity: s * v }).then(fold); + }; + useEffect(() => { + if (openProp === undefined || grip.current || phase !== 'idle') return; + const target = open ? D : 0; + if (heading.current === target) return; + if (Math.abs(exposed.get() - target) > 0.5) settle(target); + }, [open, D]); + + const down = (e: React.PointerEvent) => { + if (disabled || n === 0 || phase !== 'idle' || grip.current || e.button !== 0) return; + x.stop(); + heading.current = null; + grip.current = { + id: e.pointerId, + x0: e.clientX, + y0: e.clientY, + grab: null, + moved: false, + hist: [], + touch: e.pointerType === 'touch' + }; + try { + surface.current?.setPointerCapture(e.pointerId); + } catch {} + unwatch.current?.(); + unwatch.current = watchWindow(live); + }; + const move = (e: PointerEvent) => { + const g = grip.current; + if (!g || g.id !== e.pointerId) return; + if (g.grab === null) { + const dx = e.clientX - g.x0; + const dy = e.clientY - g.y0; + if (Math.abs(dx) < HYST || Math.abs(dx) < Math.abs(dy)) return; + g.grab = s * (g.x0 + Math.sign(dx) * HYST) - inv(exposed.get()); + g.moved = true; + root.current?.setAttribute('data-dragging', ''); + } + const ex = map(s * e.clientX - g.grab); + x.set(s * ex); + g.hist.push([performance.now(), ex]); + if (g.hist.length > 4) g.hist.shift(); + setSpread(canCommit() && ex >= commitPoint()); + }; + const up = (e: PointerEvent) => { + const g = grip.current; + if (!g || g.id !== e.pointerId) return; + grip.current = null; + unwatch.current?.(); + unwatch.current = null; + root.current?.removeAttribute('data-dragging'); + try { + surface.current?.releasePointerCapture(e.pointerId); + } catch {} + const ex = exposed.get(); + const v = velocityOf(g.hist); + if (!g.moved) { + if (open) { + setOpen(false); + settle(0); + } + return; + } + if (primary && canCommit() && ex >= commitPoint()) { + commit(primary, false, v); + return; + } + const target = Math.abs(v) >= FLICK ? (v > 0 ? D : 0) : ex + project(v) > D / 2 ? D : 0; + setSpread(false); + setOpen(target === D); + settle(target, v); + }; + live.current = { move, up }; + + const act = (a: SwipeAction, e: React.MouseEvent) => { + if (phase !== 'idle') return; + onAction?.(a); + if (a === primary || a.dismiss) { + commit(a, e.detail === 0); + return; + } + a.onSelect?.(); + if (!closeOnAction) return; + setOpen(false); + if (e.detail === 0) { + heading.current = 0; + x.set(0); + } else settle(0); + }; + const openNow = () => { + heading.current = D; + x.set(s * D); + setOpen(true); + setSay(`${n} actions revealed`); + }; + const closeNow = () => { + heading.current = 0; + x.set(0); + setOpen(false); + }; + const onToggleKey = (e: React.KeyboardEvent) => { + if (disabled || phase !== 'idle' || n === 0) return; + const openKey = s < 0 ? 'ArrowLeft' : 'ArrowRight'; + const closeKey = s < 0 ? 'ArrowRight' : 'ArrowLeft'; + const toggle = e.key === 'Enter' || e.key === ' '; + if (e.key === openKey || (toggle && !open)) { + e.preventDefault(); + openNow(); + } else if (e.key === closeKey || e.key === 'Escape' || (toggle && open)) { + e.preventDefault(); + closeNow(); + } else if ((e.key === 'Delete' || e.key === 'Backspace') && open && primary && canCommit()) { + e.preventDefault(); + commit(primary, true); + } + }; + const onToggleClick = (e: React.MouseEvent) => { + if (e.detail !== 0 || disabled || phase !== 'idle' || n === 0) return; + if (open) closeNow(); + else openNow(); + }; + + const railId = `${uid}-rail`; + return ( +
+
+ + {actions.slice(1).map((a, i) => ( + + ))} + {primary ? ( + + act(primary, e)} + > + + + {primary.icon ?? } + + {primary.label} + + + + ) : null} + + + {children} + + +
+ + {say} + +
+ ); +}; + +export default SwipeRow; diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 36226fa..537a555 100755 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -1,5 +1,6 @@ 'use client' +import { useState, useEffect } from 'react' import { useAos } from '@/store/aos' import { SidebarNav } from './sidebar-nav' import { TopBar } from './top-bar' @@ -38,10 +39,32 @@ import { MobileBottomNav } from './mobile-bottom-nav' export function AppShell() { const { view, sidebarOpen, setSidebarOpen, portalMode } = useAos() + const [mounted, setMounted] = useState(false) + + // Hydration gate: wait for client mount before rendering interactive UI + // This prevents Radix UI useId() mismatches between server and client + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setMounted(true) + }, []) // Portal mode renders a completely separate experience if (portalMode) return + // Before mount: render a minimal skeleton to prevent hydration mismatch + if (!mounted) { + return ( +
+
+ ) + } + return (
{/* Desktop sidebar */} diff --git a/src/components/aos/fuse-cta.tsx b/src/components/aos/fuse-cta.tsx new file mode 100644 index 0000000..5c65a50 --- /dev/null +++ b/src/components/aos/fuse-cta.tsx @@ -0,0 +1,61 @@ +'use client' + +import FuseButton from '@/components/FuseButton' +import { useState } from 'react' +import { toast } from 'sonner' +import { Loader2 } from 'lucide-react' + +// FuseButton wrapper for primary CTA actions with undo window. +// Used for: create deal, finalize invoice, run recurring, approve estimate. +export function FuseCTA({ + label, + doneLabel = 'Erledigt', + undoLabel = 'Rückgängig', + onCommit, + onUndo, + disabled, + size = 'md', +}: { + label: string + doneLabel?: string + undoLabel?: string + onCommit: () => void | Promise + onUndo?: () => void + disabled?: boolean + size?: 'sm' | 'md' | 'lg' +}) { + const [busy, setBusy] = useState(false) + + const handleCommit = async () => { + setBusy(true) + const t = toast.loading('Wird ausgeführt…') + try { + await onCommit() + toast.success(doneLabel, { id: t }) + } catch { + toast.error('Fehlgeschlagen', { id: t }) + } finally { + setBusy(false) + } + } + + return ( + + ) +} diff --git a/src/components/aos/hold-to-confirm.tsx b/src/components/aos/hold-to-confirm.tsx new file mode 100644 index 0000000..7490eed --- /dev/null +++ b/src/components/aos/hold-to-confirm.tsx @@ -0,0 +1,62 @@ +'use client' + +import HoldButton from '@/components/HoldButton' +import { useState } from 'react' +import { toast } from 'sonner' +import { Loader2, Check } from 'lucide-react' +import { cn } from '@/lib/utils' + +// HoldButton wrapper for dangerous actions (PRD §5.6: confirm before destructive operations) +// Used for: contract sign, invoice finalize, reseed, module disable +export function HoldToConfirm({ + children, + onConfirm, + holdTime = 1200, + size = 'md', + className, + disabled, +}: { + children: React.ReactNode + onConfirm: () => void | Promise + holdTime?: number + size?: 'sm' | 'md' | 'lg' + className?: string + disabled?: boolean +}) { + const [holding, setHolding] = useState(false) + + const handleHold = async () => { + setHolding(true) + try { + await onConfirm() + } catch { + // error handled by caller + } finally { + setHolding(false) + } + } + + return ( + + {holding ? ( + + + Ausführen… + + ) : ( + children + )} + + ) +} diff --git a/src/components/aos/slide-to-lock.tsx b/src/components/aos/slide-to-lock.tsx new file mode 100644 index 0000000..a0d0641 --- /dev/null +++ b/src/components/aos/slide-to-lock.tsx @@ -0,0 +1,55 @@ +'use client' + +import SlideCommit from '@/components/SlideCommit' +import { useState } from 'react' +import { toast } from 'sonner' +import { Lock, Check, AlertTriangle } from 'lucide-react' + +// SlideCommit wrapper for timesheet week lock (PRD §6.3: "lock after invoicing"). +// User slides to confirm — prevents accidental week locking. +export function SlideToLock({ + onConfirm, + disabled, + label = 'Schieben zum Sperren', + doneLabel = 'Woche gesperrt', +}: { + onConfirm: () => void | Promise + disabled?: boolean + label?: string + doneLabel?: string +}) { + const [phase, setPhase] = useState<'idle' | 'pending' | 'done'>('idle') + + const handleConfirm = async () => { + setPhase('pending') + try { + await onConfirm() + setPhase('done') + } catch { + setPhase('idle') + toast.error('Sperren fehlgeschlagen') + } + } + + return ( +
+ {}} + onError={() => setPhase('idle')} + disabled={disabled || phase === 'done' || phase === 'pending'} + trackColor="oklch(0.95 0 0)" + handleColor="oklch(0.2 0 0)" + successColor="oklch(0.3 0 0)" + dangerColor="oklch(0.5 0 0)" + width={320} + height={48} + radius={10} + holdMs={400} + icon={phase === 'done' ? : } + /> +
+ ) +} diff --git a/src/components/aos/swipeable-lead-card.tsx b/src/components/aos/swipeable-lead-card.tsx new file mode 100644 index 0000000..aadb605 --- /dev/null +++ b/src/components/aos/swipeable-lead-card.tsx @@ -0,0 +1,77 @@ +'use client' + +import SwipeRow from '@/components/SwipeRow' +import type { SwipeAction } from '@/components/SwipeRow' +import { Check, X, Clock, MapPin } from 'lucide-react' +import { formatRelative } from '@/lib/format' + +const SCORE_LABELS: Record = { + 'new-site': 'Neue Website', redesign: 'Redesign', seo: 'SEO', aeo: 'AEO/GEO', + shop: 'Shop', maintenance: 'Wartung', accessibility: 'Barrierefreiheit', +} + +export interface SwipeableLeadData { + id: string + companyName: string + website: string | null + city: string | null + score: number + scoreLabel: string | null + createdAt: string +} + +export function SwipeableLeadCard({ + lead, + onApprove, + onReject, + onSnooze, +}: { + lead: SwipeableLeadData + onApprove: (id: string) => void + onReject: (id: string) => void + onSnooze: (id: string) => void +}) { + const actions: SwipeAction[] = [ + { id: 'approve', label: 'Genehmigen', icon: , color: 'oklch(0.45 0 0)', dismiss: false }, + { id: 'snooze', label: 'Snooze', icon: , color: 'oklch(0.6 0 0)', dismiss: false }, + { id: 'reject', label: 'Ablehnen', icon: , color: 'oklch(0.5 0 0)', dismiss: true }, + ] + + const handleAction = (action: SwipeAction) => { + if (action.id === 'approve') onApprove(lead.id) + else if (action.id === 'snooze') onSnooze(lead.id) + else if (action.id === 'reject') onReject(lead.id) + } + + return ( + +
+
+ {lead.score} +
+
+

{lead.companyName}

+
+ {lead.website && {lead.website}} + {lead.city && <>·{lead.city}} +
+ {lead.scoreLabel && SCORE_LABELS[lead.scoreLabel] && ( + + {SCORE_LABELS[lead.scoreLabel]} + + )} +
+ {formatRelative(lead.createdAt)} +
+
+ ) +} diff --git a/src/components/aos/views/contracts.tsx b/src/components/aos/views/contracts.tsx index 893566f..20763d4 100755 --- a/src/components/aos/views/contracts.tsx +++ b/src/components/aos/views/contracts.tsx @@ -28,6 +28,7 @@ import { } from '@/components/ui/dialog' import { PageHeader } from '../primitives' import { formatEuro, formatDate, formatDateTime, daysUntil } from '@/lib/format' +import { HoldToConfirm } from '../hold-to-confirm' import { cn } from '@/lib/utils' import { toast } from 'sonner' @@ -312,10 +313,17 @@ export function ContractsView() { {canSign && ( - + sign(selected)} + holdTime={1500} + size="md" + disabled={signing} + > + + + E-Signatur anfordern + + )} diff --git a/src/components/aos/views/leads.tsx b/src/components/aos/views/leads.tsx index ba81923..9272132 100755 --- a/src/components/aos/views/leads.tsx +++ b/src/components/aos/views/leads.tsx @@ -17,6 +17,7 @@ import { Filter, ArrowRight, } from 'lucide-react' +import { SwipeableLeadCard } from '../swipeable-lead-card' import { useFetch } from '@/hooks/use-fetch' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -207,6 +208,28 @@ export function LeadsView() {
{filtered.map((l) => { const meta = STATUS_META[l.status] ?? STATUS_META.pending + // Pending leads get SwipeRow (swipe to approve/reject/snooze) + if (l.status === 'pending') { + return ( +
+ { setSelectedId(id); review(id, 'approved') }} + onReject={(id) => { setSelectedId(id); review(id, 'rejected', 'Nicht zur Zielgruppe') }} + onSnooze={(id) => { setSelectedId(id); review(id, 'snoozed', 'Später erneut prüfen') }} + /> +
+ ) + } + // Non-pending leads keep the simple button card return ( + } /> diff --git a/src/components/aos/views/timesheet.tsx b/src/components/aos/views/timesheet.tsx index c165d30..0e0e7f6 100755 --- a/src/components/aos/views/timesheet.tsx +++ b/src/components/aos/views/timesheet.tsx @@ -11,6 +11,7 @@ import { formatMinutes } from '@/lib/format' import { cn } from '@/lib/utils' import { toast } from 'sonner' import { ChevronLeft, ChevronRight, Lock, Clock, TrendingUp, CheckCircle2, Calendar } from 'lucide-react' +import { SlideToLock } from '../slide-to-lock' interface DayData { date: string @@ -83,10 +84,7 @@ export function TimesheetView() { description="Wochen-basierte Zeiterfassung mit PM-Freigabe (Lock). Nach Freigabe können Einträge nicht mehr geändert werden — GoBD-konform." actions={ !stats.allLocked && stats.totalEntries > 0 ? ( - + ) : stats.allLocked ? ( Gesperrt