From de6573b15eae19904ca07b8c16b6448469230084 Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 01:24:48 +0000 Subject: [PATCH 1/6] feat: Invoice PDF + Beleg file upload with content-addressed storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2 new features: 1. Invoice PDF (GET /api/aos/invoices/[id]/pdf) - Generates German invoice as print-ready HTML (browser → Print to PDF) - Real agency header (name, legal form, USt-IdNr, register, bank, BIC from settings) - Customer block, line items table, net/tax/gross totals - ZUGFeRD/XRechnung badges if applicable - German number/date formatting (1.234,56 € / 27.10.2026) - GoBD: no-store cache header - 3.2KB per invoice, contains RECHNUNG + Gesamt (Production: Typst for server-side PDF generation per PRD §5.2) 2. Beleg file upload (POST /api/aos/belege/upload) - Multipart/form-data file upload - Content-addressed storage (SHA-256 hash, atomic write, dedup) - Magic number content type detection (PDF, PNG, JPEG, etc.) - Duplicate detection (same hash → 409) - GoBD retention (8 years for invoices, 6 for others) - Audit-logged - Tested: upload OK, duplicate → 409 0 TS errors, ESLint clean. --- .zscripts/dev.pid | 2 +- aos-shots/01-dashboard.png | Bin aos-shots/02-crm.png | Bin aos-shots/03-leads.png | Bin aos-shots/04-projects.png | Bin aos-shots/05-licenses.png | Bin aos-shots/06-finance.png | Bin aos-shots/07-settings.png | Bin aos-shots/08-lead-explain.png | Bin aos-shots/09-mobile-dashboard.png | Bin aos-shots/10-mobile-crm.png | Bin aos-shots/v10-estimate-detail.png | Bin aos-shots/v10-estimates.png | Bin aos-shots/v10-support-summarized.png | Bin aos-shots/v10-support.png | Bin aos-shots/v11-reporting-mrr.png | Bin aos-shots/v11-reporting-utilization.png | Bin aos-shots/v11-reporting.png | Bin aos-shots/v11-wizard-step1.png | Bin aos-shots/v11-wizard-step3-modules.png | Bin aos-shots/v11-wizard-step4-mail.png | Bin aos-shots/v12-wizard.png | Bin aos-shots/v13-accounting-bank.png | Bin aos-shots/v13-accounting-dunning.png | Bin aos-shots/v13-accounting-eur.png | Bin aos-shots/v13-accounting-vat.png | Bin aos-shots/v14-mail-real-test.png | Bin aos-shots/v14-vat-real-xml.png | Bin aos-shots/v15-api-docs.png | Bin aos-shots/v15-api-tokens.png | Bin aos-shots/v16-portal-approval-dialog.png | Bin aos-shots/v16-portal-approval-done.png | Bin aos-shots/v16-portal-approvals.png | Bin aos-shots/v16-portal-invoices.png | Bin aos-shots/v16-portal-login.png | Bin aos-shots/v16-portal-overview.png | Bin aos-shots/v17-de.png | Bin aos-shots/v17-debug1.png | Bin aos-shots/v17-dropdown-open.png | Bin aos-shots/v17-en-final.png | Bin aos-shots/v17-en-persisted.png | Bin aos-shots/v17-en-real-final.png | Bin aos-shots/v17-en-real.png | Bin aos-shots/v17-en-verified.png | Bin aos-shots/v17-en.png | Bin aos-shots/v17-locale-dropdown.png | Bin aos-shots/v17-settings.png | Bin aos-shots/v17-tr-persisted.png | Bin aos-shots/v17-tr.png | Bin aos-shots/v18-kb-article.png | Bin aos-shots/v18-kb.png | Bin aos-shots/v18-notifications.png | Bin aos-shots/v19-calendar-next.png | Bin aos-shots/v19-calendar.png | Bin aos-shots/v2-CRM.png | Bin aos-shots/v2-Dashboard.png | Bin aos-shots/v2-Erweiterungen.png | Bin aos-shots/v2-Finanzen.png | Bin aos-shots/v2-Lizenzen.png | Bin aos-shots/v2-Projekte.png | Bin aos-shots/v2-ai-explain.png | Bin aos-shots/v2-leads-initial.png | Bin aos-shots/v20-onboarding-dismissed.png | Bin aos-shots/v20-onboarding-last.png | Bin aos-shots/v20-onboarding-step1.png | Bin aos-shots/v20-onboarding-step2.png | Bin aos-shots/v21-dark-crm.png | Bin aos-shots/v21-dark-dashboard.png | Bin aos-shots/v21-dark-finance.png | Bin aos-shots/v21-dark-portal.png | Bin aos-shots/v22-mobile-calendar.png | Bin aos-shots/v22-mobile-dashboard.png | Bin aos-shots/v22-mobile-leads.png | Bin aos-shots/v22-mobile-menu.png | Bin aos-shots/v22-mobile-projects.png | Bin aos-shots/v22-mobile-search.png | Bin aos-shots/v3-audit.png | Bin aos-shots/v3-company-drawer.png | Bin aos-shots/v3-invoice-dialog.png | Bin aos-shots/v3-projects-task.png | Bin aos-shots/v4-audit.png | Bin aos-shots/v4-company-drawer.png | Bin aos-shots/v4-create-deal.png | Bin aos-shots/v4-invoice-dialog.png | Bin aos-shots/v4-lead-approved.png | Bin aos-shots/v4-lead-converted.png | Bin aos-shots/v4-projects-task.png | Bin aos-shots/v4-projects-time.png | Bin aos-shots/v5-company-drawer.png | Bin aos-shots/v6-audit-detail.png | Bin aos-shots/v6-audit.png | Bin aos-shots/v6-command-search.png | Bin aos-shots/v6-company-drawer.png | Bin aos-shots/v6-create-deal-done.png | Bin aos-shots/v6-create-deal-filled.png | Bin aos-shots/v6-create-deal.png | Bin aos-shots/v6-invoice-dialog.png | Bin aos-shots/v6-lead-approved.png | Bin aos-shots/v6-lead-converted.png | Bin aos-shots/v6-projects-task.png | Bin aos-shots/v6-projects-time.png | Bin aos-shots/v7-palette-open.png | Bin aos-shots/v7-search-results.png | Bin aos-shots/v8-search-bauer.png | Bin aos-shots/v9-contract-detail.png | Bin aos-shots/v9-contracts.png | Bin aos-shots/v9-expenses-report.png | Bin aos-shots/v9-expenses.png | Bin bun.lock | 0 ...2fa408b3f1b913561de2ff77ff4772e704580fc7a6 | 2 + db/custom.db | Bin 466944 -> 466944 bytes src/app/api/aos/belege/upload/route.ts | 103 +++++++++++ src/app/api/aos/crm/mail-sync/route.ts | 0 src/app/api/aos/invoices/[id]/pdf/route.ts | 165 ++++++++++++++++++ src/app/api/aos/leads/discover/route.ts | 0 src/app/api/aos/licenses/validate/route.ts | 0 src/lib/storage.ts | 0 117 files changed, 271 insertions(+), 1 deletion(-) mode change 100644 => 100755 .zscripts/dev.pid mode change 100644 => 100755 aos-shots/01-dashboard.png mode change 100644 => 100755 aos-shots/02-crm.png mode change 100644 => 100755 aos-shots/03-leads.png mode change 100644 => 100755 aos-shots/04-projects.png mode change 100644 => 100755 aos-shots/05-licenses.png mode change 100644 => 100755 aos-shots/06-finance.png mode change 100644 => 100755 aos-shots/07-settings.png mode change 100644 => 100755 aos-shots/08-lead-explain.png mode change 100644 => 100755 aos-shots/09-mobile-dashboard.png mode change 100644 => 100755 aos-shots/10-mobile-crm.png mode change 100644 => 100755 aos-shots/v10-estimate-detail.png mode change 100644 => 100755 aos-shots/v10-estimates.png mode change 100644 => 100755 aos-shots/v10-support-summarized.png mode change 100644 => 100755 aos-shots/v10-support.png mode change 100644 => 100755 aos-shots/v11-reporting-mrr.png mode change 100644 => 100755 aos-shots/v11-reporting-utilization.png mode change 100644 => 100755 aos-shots/v11-reporting.png mode change 100644 => 100755 aos-shots/v11-wizard-step1.png mode change 100644 => 100755 aos-shots/v11-wizard-step3-modules.png mode change 100644 => 100755 aos-shots/v11-wizard-step4-mail.png mode change 100644 => 100755 aos-shots/v12-wizard.png mode change 100644 => 100755 aos-shots/v13-accounting-bank.png mode change 100644 => 100755 aos-shots/v13-accounting-dunning.png mode change 100644 => 100755 aos-shots/v13-accounting-eur.png mode change 100644 => 100755 aos-shots/v13-accounting-vat.png mode change 100644 => 100755 aos-shots/v14-mail-real-test.png mode change 100644 => 100755 aos-shots/v14-vat-real-xml.png mode change 100644 => 100755 aos-shots/v15-api-docs.png mode change 100644 => 100755 aos-shots/v15-api-tokens.png mode change 100644 => 100755 aos-shots/v16-portal-approval-dialog.png mode change 100644 => 100755 aos-shots/v16-portal-approval-done.png mode change 100644 => 100755 aos-shots/v16-portal-approvals.png mode change 100644 => 100755 aos-shots/v16-portal-invoices.png mode change 100644 => 100755 aos-shots/v16-portal-login.png mode change 100644 => 100755 aos-shots/v16-portal-overview.png mode change 100644 => 100755 aos-shots/v17-de.png mode change 100644 => 100755 aos-shots/v17-debug1.png mode change 100644 => 100755 aos-shots/v17-dropdown-open.png mode change 100644 => 100755 aos-shots/v17-en-final.png mode change 100644 => 100755 aos-shots/v17-en-persisted.png mode change 100644 => 100755 aos-shots/v17-en-real-final.png mode change 100644 => 100755 aos-shots/v17-en-real.png mode change 100644 => 100755 aos-shots/v17-en-verified.png mode change 100644 => 100755 aos-shots/v17-en.png mode change 100644 => 100755 aos-shots/v17-locale-dropdown.png mode change 100644 => 100755 aos-shots/v17-settings.png mode change 100644 => 100755 aos-shots/v17-tr-persisted.png mode change 100644 => 100755 aos-shots/v17-tr.png mode change 100644 => 100755 aos-shots/v18-kb-article.png mode change 100644 => 100755 aos-shots/v18-kb.png mode change 100644 => 100755 aos-shots/v18-notifications.png mode change 100644 => 100755 aos-shots/v19-calendar-next.png mode change 100644 => 100755 aos-shots/v19-calendar.png mode change 100644 => 100755 aos-shots/v2-CRM.png mode change 100644 => 100755 aos-shots/v2-Dashboard.png mode change 100644 => 100755 aos-shots/v2-Erweiterungen.png mode change 100644 => 100755 aos-shots/v2-Finanzen.png mode change 100644 => 100755 aos-shots/v2-Lizenzen.png mode change 100644 => 100755 aos-shots/v2-Projekte.png mode change 100644 => 100755 aos-shots/v2-ai-explain.png mode change 100644 => 100755 aos-shots/v2-leads-initial.png mode change 100644 => 100755 aos-shots/v20-onboarding-dismissed.png mode change 100644 => 100755 aos-shots/v20-onboarding-last.png mode change 100644 => 100755 aos-shots/v20-onboarding-step1.png mode change 100644 => 100755 aos-shots/v20-onboarding-step2.png mode change 100644 => 100755 aos-shots/v21-dark-crm.png mode change 100644 => 100755 aos-shots/v21-dark-dashboard.png mode change 100644 => 100755 aos-shots/v21-dark-finance.png mode change 100644 => 100755 aos-shots/v21-dark-portal.png mode change 100644 => 100755 aos-shots/v22-mobile-calendar.png mode change 100644 => 100755 aos-shots/v22-mobile-dashboard.png mode change 100644 => 100755 aos-shots/v22-mobile-leads.png mode change 100644 => 100755 aos-shots/v22-mobile-menu.png mode change 100644 => 100755 aos-shots/v22-mobile-projects.png mode change 100644 => 100755 aos-shots/v22-mobile-search.png mode change 100644 => 100755 aos-shots/v3-audit.png mode change 100644 => 100755 aos-shots/v3-company-drawer.png mode change 100644 => 100755 aos-shots/v3-invoice-dialog.png mode change 100644 => 100755 aos-shots/v3-projects-task.png mode change 100644 => 100755 aos-shots/v4-audit.png mode change 100644 => 100755 aos-shots/v4-company-drawer.png mode change 100644 => 100755 aos-shots/v4-create-deal.png mode change 100644 => 100755 aos-shots/v4-invoice-dialog.png mode change 100644 => 100755 aos-shots/v4-lead-approved.png mode change 100644 => 100755 aos-shots/v4-lead-converted.png mode change 100644 => 100755 aos-shots/v4-projects-task.png mode change 100644 => 100755 aos-shots/v4-projects-time.png mode change 100644 => 100755 aos-shots/v5-company-drawer.png mode change 100644 => 100755 aos-shots/v6-audit-detail.png mode change 100644 => 100755 aos-shots/v6-audit.png mode change 100644 => 100755 aos-shots/v6-command-search.png mode change 100644 => 100755 aos-shots/v6-company-drawer.png mode change 100644 => 100755 aos-shots/v6-create-deal-done.png mode change 100644 => 100755 aos-shots/v6-create-deal-filled.png mode change 100644 => 100755 aos-shots/v6-create-deal.png mode change 100644 => 100755 aos-shots/v6-invoice-dialog.png mode change 100644 => 100755 aos-shots/v6-lead-approved.png mode change 100644 => 100755 aos-shots/v6-lead-converted.png mode change 100644 => 100755 aos-shots/v6-projects-task.png mode change 100644 => 100755 aos-shots/v6-projects-time.png mode change 100644 => 100755 aos-shots/v7-palette-open.png mode change 100644 => 100755 aos-shots/v7-search-results.png mode change 100644 => 100755 aos-shots/v8-search-bauer.png mode change 100644 => 100755 aos-shots/v9-contract-detail.png mode change 100644 => 100755 aos-shots/v9-contracts.png mode change 100644 => 100755 aos-shots/v9-expenses-report.png mode change 100644 => 100755 aos-shots/v9-expenses.png mode change 100644 => 100755 bun.lock create mode 100644 data/files/7d/7dc3bd0691230ae41dbbf62fa408b3f1b913561de2ff77ff4772e704580fc7a6 mode change 100755 => 100644 db/custom.db create mode 100644 src/app/api/aos/belege/upload/route.ts mode change 100644 => 100755 src/app/api/aos/crm/mail-sync/route.ts create mode 100644 src/app/api/aos/invoices/[id]/pdf/route.ts mode change 100644 => 100755 src/app/api/aos/leads/discover/route.ts mode change 100644 => 100755 src/app/api/aos/licenses/validate/route.ts mode change 100644 => 100755 src/lib/storage.ts diff --git a/.zscripts/dev.pid b/.zscripts/dev.pid old mode 100644 new mode 100755 index 4524e19..2b4ff56 --- a/.zscripts/dev.pid +++ b/.zscripts/dev.pid @@ -1 +1 @@ -984 +1114 diff --git a/aos-shots/01-dashboard.png b/aos-shots/01-dashboard.png old mode 100644 new mode 100755 diff --git a/aos-shots/02-crm.png b/aos-shots/02-crm.png old mode 100644 new mode 100755 diff --git a/aos-shots/03-leads.png b/aos-shots/03-leads.png old mode 100644 new mode 100755 diff --git a/aos-shots/04-projects.png b/aos-shots/04-projects.png old mode 100644 new mode 100755 diff --git a/aos-shots/05-licenses.png b/aos-shots/05-licenses.png old mode 100644 new mode 100755 diff --git a/aos-shots/06-finance.png b/aos-shots/06-finance.png old mode 100644 new mode 100755 diff --git a/aos-shots/07-settings.png b/aos-shots/07-settings.png old mode 100644 new mode 100755 diff --git a/aos-shots/08-lead-explain.png b/aos-shots/08-lead-explain.png old mode 100644 new mode 100755 diff --git a/aos-shots/09-mobile-dashboard.png b/aos-shots/09-mobile-dashboard.png old mode 100644 new mode 100755 diff --git a/aos-shots/10-mobile-crm.png b/aos-shots/10-mobile-crm.png old mode 100644 new mode 100755 diff --git a/aos-shots/v10-estimate-detail.png b/aos-shots/v10-estimate-detail.png old mode 100644 new mode 100755 diff --git a/aos-shots/v10-estimates.png b/aos-shots/v10-estimates.png old mode 100644 new mode 100755 diff --git a/aos-shots/v10-support-summarized.png b/aos-shots/v10-support-summarized.png old mode 100644 new mode 100755 diff --git a/aos-shots/v10-support.png b/aos-shots/v10-support.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-reporting-mrr.png b/aos-shots/v11-reporting-mrr.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-reporting-utilization.png b/aos-shots/v11-reporting-utilization.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-reporting.png b/aos-shots/v11-reporting.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-wizard-step1.png b/aos-shots/v11-wizard-step1.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-wizard-step3-modules.png b/aos-shots/v11-wizard-step3-modules.png old mode 100644 new mode 100755 diff --git a/aos-shots/v11-wizard-step4-mail.png b/aos-shots/v11-wizard-step4-mail.png old mode 100644 new mode 100755 diff --git a/aos-shots/v12-wizard.png b/aos-shots/v12-wizard.png old mode 100644 new mode 100755 diff --git a/aos-shots/v13-accounting-bank.png b/aos-shots/v13-accounting-bank.png old mode 100644 new mode 100755 diff --git a/aos-shots/v13-accounting-dunning.png b/aos-shots/v13-accounting-dunning.png old mode 100644 new mode 100755 diff --git a/aos-shots/v13-accounting-eur.png b/aos-shots/v13-accounting-eur.png old mode 100644 new mode 100755 diff --git a/aos-shots/v13-accounting-vat.png b/aos-shots/v13-accounting-vat.png old mode 100644 new mode 100755 diff --git a/aos-shots/v14-mail-real-test.png b/aos-shots/v14-mail-real-test.png old mode 100644 new mode 100755 diff --git a/aos-shots/v14-vat-real-xml.png b/aos-shots/v14-vat-real-xml.png old mode 100644 new mode 100755 diff --git a/aos-shots/v15-api-docs.png b/aos-shots/v15-api-docs.png old mode 100644 new mode 100755 diff --git a/aos-shots/v15-api-tokens.png b/aos-shots/v15-api-tokens.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-approval-dialog.png b/aos-shots/v16-portal-approval-dialog.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-approval-done.png b/aos-shots/v16-portal-approval-done.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-approvals.png b/aos-shots/v16-portal-approvals.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-invoices.png b/aos-shots/v16-portal-invoices.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-login.png b/aos-shots/v16-portal-login.png old mode 100644 new mode 100755 diff --git a/aos-shots/v16-portal-overview.png b/aos-shots/v16-portal-overview.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-de.png b/aos-shots/v17-de.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-debug1.png b/aos-shots/v17-debug1.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-dropdown-open.png b/aos-shots/v17-dropdown-open.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en-final.png b/aos-shots/v17-en-final.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en-persisted.png b/aos-shots/v17-en-persisted.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en-real-final.png b/aos-shots/v17-en-real-final.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en-real.png b/aos-shots/v17-en-real.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en-verified.png b/aos-shots/v17-en-verified.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-en.png b/aos-shots/v17-en.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-locale-dropdown.png b/aos-shots/v17-locale-dropdown.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-settings.png b/aos-shots/v17-settings.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-tr-persisted.png b/aos-shots/v17-tr-persisted.png old mode 100644 new mode 100755 diff --git a/aos-shots/v17-tr.png b/aos-shots/v17-tr.png old mode 100644 new mode 100755 diff --git a/aos-shots/v18-kb-article.png b/aos-shots/v18-kb-article.png old mode 100644 new mode 100755 diff --git a/aos-shots/v18-kb.png b/aos-shots/v18-kb.png old mode 100644 new mode 100755 diff --git a/aos-shots/v18-notifications.png b/aos-shots/v18-notifications.png old mode 100644 new mode 100755 diff --git a/aos-shots/v19-calendar-next.png b/aos-shots/v19-calendar-next.png old mode 100644 new mode 100755 diff --git a/aos-shots/v19-calendar.png b/aos-shots/v19-calendar.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-CRM.png b/aos-shots/v2-CRM.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-Dashboard.png b/aos-shots/v2-Dashboard.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-Erweiterungen.png b/aos-shots/v2-Erweiterungen.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-Finanzen.png b/aos-shots/v2-Finanzen.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-Lizenzen.png b/aos-shots/v2-Lizenzen.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-Projekte.png b/aos-shots/v2-Projekte.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-ai-explain.png b/aos-shots/v2-ai-explain.png old mode 100644 new mode 100755 diff --git a/aos-shots/v2-leads-initial.png b/aos-shots/v2-leads-initial.png old mode 100644 new mode 100755 diff --git a/aos-shots/v20-onboarding-dismissed.png b/aos-shots/v20-onboarding-dismissed.png old mode 100644 new mode 100755 diff --git a/aos-shots/v20-onboarding-last.png b/aos-shots/v20-onboarding-last.png old mode 100644 new mode 100755 diff --git a/aos-shots/v20-onboarding-step1.png b/aos-shots/v20-onboarding-step1.png old mode 100644 new mode 100755 diff --git a/aos-shots/v20-onboarding-step2.png b/aos-shots/v20-onboarding-step2.png old mode 100644 new mode 100755 diff --git a/aos-shots/v21-dark-crm.png b/aos-shots/v21-dark-crm.png old mode 100644 new mode 100755 diff --git a/aos-shots/v21-dark-dashboard.png b/aos-shots/v21-dark-dashboard.png old mode 100644 new mode 100755 diff --git a/aos-shots/v21-dark-finance.png b/aos-shots/v21-dark-finance.png old mode 100644 new mode 100755 diff --git a/aos-shots/v21-dark-portal.png b/aos-shots/v21-dark-portal.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-calendar.png b/aos-shots/v22-mobile-calendar.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-dashboard.png b/aos-shots/v22-mobile-dashboard.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-leads.png b/aos-shots/v22-mobile-leads.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-menu.png b/aos-shots/v22-mobile-menu.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-projects.png b/aos-shots/v22-mobile-projects.png old mode 100644 new mode 100755 diff --git a/aos-shots/v22-mobile-search.png b/aos-shots/v22-mobile-search.png old mode 100644 new mode 100755 diff --git a/aos-shots/v3-audit.png b/aos-shots/v3-audit.png old mode 100644 new mode 100755 diff --git a/aos-shots/v3-company-drawer.png b/aos-shots/v3-company-drawer.png old mode 100644 new mode 100755 diff --git a/aos-shots/v3-invoice-dialog.png b/aos-shots/v3-invoice-dialog.png old mode 100644 new mode 100755 diff --git a/aos-shots/v3-projects-task.png b/aos-shots/v3-projects-task.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-audit.png b/aos-shots/v4-audit.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-company-drawer.png b/aos-shots/v4-company-drawer.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-create-deal.png b/aos-shots/v4-create-deal.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-invoice-dialog.png b/aos-shots/v4-invoice-dialog.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-lead-approved.png b/aos-shots/v4-lead-approved.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-lead-converted.png b/aos-shots/v4-lead-converted.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-projects-task.png b/aos-shots/v4-projects-task.png old mode 100644 new mode 100755 diff --git a/aos-shots/v4-projects-time.png b/aos-shots/v4-projects-time.png old mode 100644 new mode 100755 diff --git a/aos-shots/v5-company-drawer.png b/aos-shots/v5-company-drawer.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-audit-detail.png b/aos-shots/v6-audit-detail.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-audit.png b/aos-shots/v6-audit.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-command-search.png b/aos-shots/v6-command-search.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-company-drawer.png b/aos-shots/v6-company-drawer.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-create-deal-done.png b/aos-shots/v6-create-deal-done.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-create-deal-filled.png b/aos-shots/v6-create-deal-filled.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-create-deal.png b/aos-shots/v6-create-deal.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-invoice-dialog.png b/aos-shots/v6-invoice-dialog.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-lead-approved.png b/aos-shots/v6-lead-approved.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-lead-converted.png b/aos-shots/v6-lead-converted.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-projects-task.png b/aos-shots/v6-projects-task.png old mode 100644 new mode 100755 diff --git a/aos-shots/v6-projects-time.png b/aos-shots/v6-projects-time.png old mode 100644 new mode 100755 diff --git a/aos-shots/v7-palette-open.png b/aos-shots/v7-palette-open.png old mode 100644 new mode 100755 diff --git a/aos-shots/v7-search-results.png b/aos-shots/v7-search-results.png old mode 100644 new mode 100755 diff --git a/aos-shots/v8-search-bauer.png b/aos-shots/v8-search-bauer.png old mode 100644 new mode 100755 diff --git a/aos-shots/v9-contract-detail.png b/aos-shots/v9-contract-detail.png old mode 100644 new mode 100755 diff --git a/aos-shots/v9-contracts.png b/aos-shots/v9-contracts.png old mode 100644 new mode 100755 diff --git a/aos-shots/v9-expenses-report.png b/aos-shots/v9-expenses-report.png old mode 100644 new mode 100755 diff --git a/aos-shots/v9-expenses.png b/aos-shots/v9-expenses.png old mode 100644 new mode 100755 diff --git a/bun.lock b/bun.lock old mode 100644 new mode 100755 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/db/custom.db b/db/custom.db old mode 100755 new mode 100644 index 8639c571b7c01d7058c29a809534205db9944cdd..eba06761476745be01db1be1c2479e6c23b05de4 GIT binary patch delta 34155 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 Iw3qq60WkUoe*gdg 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/crm/mail-sync/route.ts b/src/app/api/aos/crm/mail-sync/route.ts old mode 100644 new mode 100755 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/leads/discover/route.ts b/src/app/api/aos/leads/discover/route.ts old mode 100644 new mode 100755 diff --git a/src/app/api/aos/licenses/validate/route.ts b/src/app/api/aos/licenses/validate/route.ts old mode 100644 new mode 100755 diff --git a/src/lib/storage.ts b/src/lib/storage.ts old mode 100644 new mode 100755 From aa6365e3732fc695cf9a2ca09493aa0eec622907 Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 01:30:32 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20GitHub/Gitea=20webhook=20receiver?= =?UTF-8?q?=20+=20.env.example=20(PRD=20=C2=A76.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub webhook receiver (POST /api/aos/webhooks/github): - Push events: extracts commit messages, matches #TKT-XXXX or task: references, adds commit links as task comments, audit-logs - Pull request events: matches branch name to project (e.g. feature/intranet-portal → project), audit-logs PR creation - HMAC signature verification (X-Hub-Signature-256, GITHUB_WEBHOOK_SECRET env) - Rate limited (10/min), handles ping events gracefully - Tested: push (3 commits parsed), PR opened (branch→project matching), ping (acknowledged) .env.example template: - All config keys documented with comments - Database, encryption, auth, mail, Leads Finder providers - GitHub webhook secret, bank PSD2, storage dir - German defaults, production notes 0 TS errors, ESLint clean. --- db/custom.db | Bin 466944 -> 466944 bytes src/app/api/aos/webhooks/github/route.ts | 159 +++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/app/api/aos/webhooks/github/route.ts diff --git a/db/custom.db b/db/custom.db index eba06761476745be01db1be1c2479e6c23b05de4..fe55c14776140c1a69b5e7d8c0316fc83aa8e346 100644 GIT binary patch delta 282 zcmZoTAk%O_W`ZX(!p%>6+n@F_0x=U1GXpUT5VLN7+RG+A2>|}HVx|B9 delta 72 zcmZoTAk%O_W`ZwyOY)h8SYJ(MFHqgAD6o%p^Q%%RuI(%jnBMR + 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 }) + } +} From 21e112e5cb1026c1ce08c1ce43e69c1b31e9f71a Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 01:36:28 +0000 Subject: [PATCH 3/6] 5355f1bc-7891-4c2d-b8b0-203f174ee94e --- db/custom.db | Bin 466944 -> 466944 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/db/custom.db b/db/custom.db index fe55c14776140c1a69b5e7d8c0316fc83aa8e346..bfc070fed782276eeb41d27839c9e4879f863911 100644 GIT binary patch delta 51 zcmZoTAk%O_W`Z Date: Wed, 28 Oct 2026 02:36:53 +0000 Subject: [PATCH 4/6] feat: ReactBits micro components integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installed 6 ReactBits micro components (src/components/): - SwipeRow: swipe-to-action cards (lead approve/reject/snooze) - CometDial: circular dial selector (future: dashboard KPI selector) - CodeSlots: code input slots (future: API token confirmation) - FuseButton: fuse-animated CTA with undo window - SlideCommit: slide-to-confirm (timesheet week lock) - HoldButton: hold-to-confirm (contract sign, dangerous actions) Integrated into views: - Timesheet: SlideCommit replaces 'Woche sperren' button - Contracts: HoldButton replaces 'E-Signatur anfordern' (hold 1.5s to sign) - Recurring: FuseButton replaces 'Jetzt ausführen' (with undo window) - Leads: SwipeableLeadCard created (swipe to approve/reject/snooze) Created wrapper components: - swipeable-lead-card.tsx: SwipeRow wrapper for lead cards - hold-to-confirm.tsx: HoldButton wrapper with loading state - fuse-cta.tsx: FuseButton wrapper with toast feedback - slide-to-lock.tsx: SlideCommit wrapper for GoBD week lock 0 TS errors, ESLint clean, no console errors. --- bun.lock | 31 +- components.json | 7 +- eslint.config.mjs | 1 + package.json | 9 +- src/components/CodeSlots.tsx | 530 +++++++++++++++++++++ src/components/CometDial.tsx | 446 +++++++++++++++++ src/components/FuseButton.tsx | 322 +++++++++++++ src/components/HoldButton.tsx | 358 ++++++++++++++ src/components/SlideCommit.tsx | 426 +++++++++++++++++ src/components/SwipeRow.tsx | 490 +++++++++++++++++++ src/components/aos/fuse-cta.tsx | 61 +++ src/components/aos/hold-to-confirm.tsx | 62 +++ src/components/aos/slide-to-lock.tsx | 55 +++ src/components/aos/swipeable-lead-card.tsx | 77 +++ src/components/aos/views/contracts.tsx | 16 +- src/components/aos/views/recurring.tsx | 12 +- src/components/aos/views/timesheet.tsx | 6 +- 17 files changed, 2884 insertions(+), 25 deletions(-) mode change 100755 => 100644 bun.lock create mode 100644 src/components/CodeSlots.tsx create mode 100644 src/components/CometDial.tsx create mode 100644 src/components/FuseButton.tsx create mode 100644 src/components/HoldButton.tsx create mode 100644 src/components/SlideCommit.tsx create mode 100644 src/components/SwipeRow.tsx create mode 100644 src/components/aos/fuse-cta.tsx create mode 100644 src/components/aos/hold-to-confirm.tsx create mode 100644 src/components/aos/slide-to-lock.tsx create mode 100644 src/components/aos/swipeable-lead-card.tsx diff --git a/bun.lock b/bun.lock old mode 100755 new mode 100644 index 9d56187..91a309a --- 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/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 068be6a..bceff70 100755 --- a/package.json +++ b/package.json @@ -17,6 +17,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", @@ -54,10 +56,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", @@ -73,7 +76,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/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/FuseButton.tsx b/src/components/FuseButton.tsx new file mode 100644 index 0000000..2c4202b --- /dev/null +++ b/src/components/FuseButton.tsx @@ -0,0 +1,322 @@ +'use client'; + +import React, { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Archive02Icon, Tick02Icon, Undo02Icon } from '@hugeicons/core-free-icons'; + +export type FusePosition = 'outline' | 'bottom' | 'top'; +export type FuseCommitOn = 'press' | 'fuseEnd'; +export type FuseSettle = 'reset' | 'stay'; +export type FusePhase = 'idle' | 'armed' | 'settled'; +export type FuseSize = 'sm' | 'md' | 'lg'; + +export interface FuseButtonProps { + label?: string; + undoLabel?: string; + doneLabel?: string; + icon?: ReactNode; + color?: string; + background?: string; + fuseColor?: string; + size?: FuseSize; + radius?: number; + undoWindow?: number; + fuse?: FusePosition; + fuseThickness?: number; + crossfadeMs?: number; + commitOn?: FuseCommitOn; + pauseOnHover?: boolean; + settle?: FuseSettle; + disabled?: boolean; + onCommit?: (reason: FuseCommitOn) => void; + onUndo?: () => void; + onFuseEnd?: () => void; + onPhaseChange?: (phase: FusePhase) => void; + className?: string; + type?: 'button' | 'submit' | 'reset'; +} + +interface Latest { + onCommit?: (reason: FuseCommitOn) => void; + onUndo?: () => void; + onFuseEnd?: () => void; + onPhaseChange?: (phase: FusePhase) => void; + commitOn: FuseCommitOn; + settle: FuseSettle; +} + +const LINE: Keyframe[] = [{ transform: 'scaleX(1)' }, { transform: 'scaleX(0)' }]; +const OUTLINE: Keyframe[] = [{ strokeDashoffset: 0 }, { strokeDashoffset: -1 }]; +const SIZES: Record = { + sm: { height: 36, font: 13, icon: 14, px: 16 }, + md: { height: 44, font: 14, icon: 15, px: 20 }, + lg: { height: 52, font: 15, icon: 17, px: 24 } +}; + +const FuseButton: React.FC = ({ + label = 'Archive', + undoLabel = 'Undo', + doneLabel = 'Archived', + icon, + color = '#f5f5f5', + background = '#27272a', + fuseColor = '#f5a524', + size = 'md', + radius = 22, + undoWindow = 4000, + fuse = 'outline', + fuseThickness = 1.5, + crossfadeMs = 200, + commitOn = 'press', + pauseOnHover = true, + settle = 'reset', + disabled = false, + onCommit, + onUndo, + onFuseEnd, + onPhaseChange, + className = '', + type = 'button' +}) => { + const [phase, setPhase] = useState('idle'); + const [instant, setInstant] = useState(false); + const rootRef = useRef(null); + const idleRef = useRef(null); + const undoRef = useRef(null); + const lineRef = useRef(null); + const rimRef = useRef(null); + const anim = useRef(null); + const pause = useRef({ hover: false, hidden: false, canHoverPause: false }); + const lastInput = useRef<'pointer' | 'keyboard'>('pointer'); + const windowRef = useRef(undoWindow); + const latest = useRef({ commitOn, settle }); + latest.current = { onCommit, onUndo, onFuseEnd, onPhaseChange, commitOn, settle }; + const statusId = useId(); + const preset = SIZES[size] || SIZES.md; + + const go = (next: FusePhase) => { + setInstant(lastInput.current === 'keyboard'); + setPhase(next); + latest.current.onPhaseChange?.(next); + }; + + const syncPlayState = () => { + const a = anim.current; + if (!a) return; + const { hover, hidden } = pause.current; + if (hover || hidden) { + if (a.playState === 'running') a.pause(); + } else if (a.playState === 'paused') { + a.play(); + } + }; + + const light = (from = 0) => { + const el = fuse === 'outline' ? rimRef.current : lineRef.current; + if (!el) return; + anim.current?.cancel(); + const a = el.animate(fuse === 'outline' ? OUTLINE : LINE, { + duration: windowRef.current, + easing: 'linear', + fill: 'forwards' + }); + if (from) a.currentTime = from; + a.onfinish = () => { + const l = latest.current; + l.onFuseEnd?.(); + if (l.commitOn === 'fuseEnd') l.onCommit?.('fuseEnd'); + lastInput.current = 'pointer'; + go(l.settle === 'stay' ? 'settled' : 'idle'); + }; + anim.current = a; + syncPlayState(); + }; + + const arm = () => { + if (disabled || phase !== 'idle') return; + windowRef.current = undoWindow; + light(); + pause.current.canHoverPause = false; + pause.current.hover = false; + if (commitOn === 'press') onCommit?.('press'); + go('armed'); + }; + + const undo = () => { + if (phase !== 'armed') return; + const a = anim.current; + if (a) { + a.onfinish = null; + a.pause(); + } + onUndo?.(); + go('idle'); + }; + + useEffect(() => { + const inside = rootRef.current?.contains(document.activeElement); + if (phase === 'armed') undoRef.current?.focus({ preventScroll: true }); + else if (inside) (phase === 'idle' ? idleRef.current : rootRef.current)?.focus({ preventScroll: true }); + }, [phase]); + + useEffect(() => { + const onVisibility = () => { + pause.current.hidden = document.hidden; + syncPlayState(); + }; + document.addEventListener('visibilitychange', onVisibility); + return () => { + document.removeEventListener('visibilitychange', onVisibility); + anim.current?.cancel(); + }; + }, []); + + useEffect(() => { + const a = anim.current; + if (!a || phase !== 'armed') return; + light(Number(a.currentTime) || 0); + }, [fuse]); + + useEffect(() => { + if (pauseOnHover) return; + pause.current.hover = false; + syncPlayState(); + }, [pauseOnHover]); + + const handlePointerDown = (e: React.PointerEvent) => { + lastInput.current = 'pointer'; + const pressable = phase === 'armed' || (phase === 'idle' && !disabled); + if (e.button === 0 && pressable && rootRef.current) rootRef.current.dataset.pressed = ''; + }; + const release = () => { + if (rootRef.current) delete rootRef.current.dataset.pressed; + }; + const handlePointerEnter = (e: React.PointerEvent) => { + if (pauseOnHover && e.pointerType === 'mouse' && pause.current.canHoverPause) { + pause.current.hover = true; + syncPlayState(); + } + }; + const handlePointerLeave = (e: React.PointerEvent) => { + release(); + if (e.pointerType !== 'mouse') return; + pause.current.canHoverPause = true; + pause.current.hover = false; + syncPlayState(); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') lastInput.current = 'keyboard'; + if (e.key === 'Escape' && phase === 'armed') { + e.preventDefault(); + lastInput.current = 'keyboard'; + undo(); + } + }; + + const actionIcon = icon ?? ; + const line = ( +