3 Commits

Author SHA1 Message Date
soconnor 0e46fdafb2 feat: add administration page and account role management
- Implemented `AdministrationContent` component for managing account roles.
- Created `AdministrationPage` to serve as the main entry point for administration tasks.
- Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation.
- Introduced `InputColor` component for advanced color selection with various formats.
- Established color conversion utilities in `color-converter.ts` for handling color formats.
- Defined appearance-related schemas and types in `appearance.ts` for consistent theme management.
2026-04-30 10:50:50 -04:00
soconnor ddc2b42672 Refactor invoice data table and templates page for improved readability and functionality
- Cleaned up imports and formatted code for better readability in invoices-data-table.tsx.
- Enhanced invoice interface definitions for clarity.
- Improved toast messages for bulk delete and update actions.
- Refactored date formatting and status type retrieval for better readability.
- Simplified template management in templates page, extracting TemplateList component.
- Added registration toggle based on environment variable DISABLE_SIGNUPS.
- Updated navbar to conditionally render registration link based on allowRegistration prop.
- Enhanced error handling and validation in expenses and settings routers.
- Improved PDF export footer handling.
- Updated TRPC react integration for cleaner type imports.
2026-04-29 22:49:07 -04:00
soconnor dbb739b060 refactor: update SendEmailPage layout and remove SendEmailDialog component 2026-04-28 01:30:38 -04:00
102 changed files with 5457 additions and 3338 deletions
+38 -4
View File
@@ -44,22 +44,26 @@ A modern, professional invoicing application built for freelancers and small bus
### Quick Start ### Quick Start
1. **Clone the repository** 1. **Clone the repository**
```bash ```bash
git clone https://github.com/yourusername/beenvoice.git git clone https://github.com/yourusername/beenvoice.git
cd beenvoice cd beenvoice
``` ```
2. **Install dependencies** 2. **Install dependencies**
```bash ```bash
bun install bun install
``` ```
3. **Set up environment variables** 3. **Set up environment variables**
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
``` ```
Edit `.env.local` and add your configuration: Edit `.env.local` and add your configuration:
```env ```env
# Database # Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice" DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
@@ -78,17 +82,20 @@ A modern, professional invoicing application built for freelancers and small bus
RESEND_DOMAIN="yourdomain.com" RESEND_DOMAIN="yourdomain.com"
``` ```
4. **Start the database** 4. **Start the development database**
```bash ```bash
docker-compose up -d docker compose -f docker-compose.dev.yml up -d db
``` ```
5. **Push the database schema** 5. **Push the database schema**
```bash ```bash
bun run db:push bun run db:push
``` ```
6. **Start the development server** 6. **Start the development server**
```bash ```bash
bun run dev bun run dev
``` ```
@@ -123,7 +130,8 @@ beenvoice/
├── drizzle/ # Database migrations ├── drizzle/ # Database migrations
├── public/ # Static assets ├── public/ # Static assets
├── docs/ # Documentation ├── docs/ # Documentation
── docker-compose.yml # Local PostgreSQL setup ── docker-compose.yml # Deployment compose stack
└── docker-compose.dev.yml # Development overrides with exposed PostgreSQL
``` ```
## 🎯 Usage ## 🎯 Usage
@@ -155,12 +163,14 @@ beenvoice/
### Features Overview ### Features Overview
#### Client Management #### Client Management
- Create and edit client profiles - Create and edit client profiles
- Store contact information and addresses - Store contact information and addresses
- Set default hourly rates per client - Set default hourly rates per client
- Search and filter client list - Search and filter client list
#### Invoice Creation #### Invoice Creation
- Select from existing clients and business profiles - Select from existing clients and business profiles
- Add multiple line items with drag-and-drop reordering - Add multiple line items with drag-and-drop reordering
- Set custom rates per item - Set custom rates per item
@@ -169,12 +179,14 @@ beenvoice/
- Professional invoice formatting - Professional invoice formatting
#### Invoice Delivery #### Invoice Delivery
- Send invoices via email directly from the app - Send invoices via email directly from the app
- Rich text email composer with preview - Rich text email composer with preview
- Resend and re-deliver sent invoices - Resend and re-deliver sent invoices
- Track invoice status: Draft → Sent → Paid (+ Overdue) - Track invoice status: Draft → Sent → Paid (+ Overdue)
#### User Interface #### User Interface
- Clean, modern design - Clean, modern design
- Fully responsive — desktop, tablet, and mobile - Fully responsive — desktop, tablet, and mobile
- Intuitive navigation with breadcrumbs - Intuitive navigation with breadcrumbs
@@ -198,7 +210,8 @@ bun run db:studio # Open Drizzle Studio
bun run db:generate # Generate new migration bun run db:generate # Generate new migration
# Docker # Docker
bun run docker:up # Start local PostgreSQL via Docker bun run docker:up # Start deployment compose stack
bun run docker:dev:up # Start development compose stack with exposed PostgreSQL
bun run docker:down # Stop Docker services bun run docker:down # Stop Docker services
# Code Quality # Code Quality
@@ -208,6 +221,24 @@ bun run format:write # Format code with Prettier
bun run typecheck # Run TypeScript type checking bun run typecheck # Run TypeScript type checking
``` ```
### Docker Compose
Use the base compose file for deployment. It keeps PostgreSQL internal to the
compose network:
```bash
docker compose up -d
```
For local development, use the dev compose file to expose PostgreSQL on
`${POSTGRES_PORT:-5432}`:
```bash
docker compose -f docker-compose.dev.yml up -d
```
Set `DISABLE_SIGNUPS=true` to block new email/password account registration.
### Database Schema ### Database Schema
The application uses the following core tables: The application uses the following core tables:
@@ -243,6 +274,7 @@ The app uses Tailwind CSS v4 with a custom design system:
### Branding ### Branding
Update the logo and colors in: Update the logo and colors in:
- `src/components/logo.tsx` - Main logo component - `src/components/logo.tsx` - Main logo component
- `src/styles/globals.css` - Color variables - `src/styles/globals.css` - Color variables
- `src/app/layout.tsx` - Font configuration - `src/app/layout.tsx` - Font configuration
@@ -252,6 +284,7 @@ Update the logo and colors in:
You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.). You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.).
1. **Build the application:** 1. **Build the application:**
```bash ```bash
bun run build bun run build
``` ```
@@ -259,6 +292,7 @@ You can deploy this application to any platform that supports Next.js and Postgr
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production) 2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
3. **Run database migrations:** 3. **Run database migrations:**
```bash ```bash
bun run db:push bun run db:push
``` ```
+20 -7
View File
@@ -10,6 +10,7 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/playfair-display": "^5.2.8",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -50,11 +51,12 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^16.2.2", "next": "^16.2.4",
"pg": "8.13.1", "pg": "8.13.1",
"react": "^19.2.4", "react": "^19.2.5",
"react-colorful": "^5.6.1",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.5",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"recharts": "^3.5.1", "recharts": "^3.5.1",
"resend": "^4.8.0", "resend": "^4.8.0",
@@ -71,12 +73,13 @@
"@types/node": "^20.19.26", "@types/node": "^20.19.26",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/raf": "^3.4.3", "@types/raf": "^3.4.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.6", "babel-plugin-react-compiler": "^1.0.0",
"baseline-browser-mapping": "^2.10.24",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.10", "eslint-config-next": "^16.2.4",
"eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "3.6.2", "prettier": "3.6.2",
@@ -250,6 +253,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@fontsource-variable/playfair-display": ["@fontsource-variable/playfair-display@5.2.8", "", {}, "sha512-ZzVIXPOrL85yyOvZYoBzUszIJM+xKkHqni4IYn2CVLaGQQdJR8sBeC8yFNgjxSJ7ludTwta8qpULeOFuk5X75A=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
@@ -750,11 +755,13 @@
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA=="],
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
@@ -1376,6 +1383,8 @@
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="],
"react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
@@ -1692,6 +1701,8 @@
"brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="],
"color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -1714,6 +1725,8 @@
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+21
View File
@@ -0,0 +1,21 @@
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_dev_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
volumes:
beenvoice_dev_pg_data:
+3 -1
View File
@@ -15,6 +15,7 @@ services:
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-} AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
@@ -35,7 +36,8 @@ services:
volumes: volumes:
- beenvoice_pg_data:/var/lib/postgresql/data - beenvoice_pg_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
+1
View File
@@ -7,6 +7,7 @@ import "./src/env.js";
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
output: "standalone", output: "standalone",
reactCompiler: true,
serverExternalPackages: ["pg"], serverExternalPackages: ["pg"],
}; };
+12 -8
View File
@@ -11,8 +11,9 @@
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:clone": "./scripts/clone-local.sh", "db:clone": "./scripts/clone-local.sh",
"docker:up": "colima start && docker compose up -d", "docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
"docker:down": "docker compose down && colima stop", "docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"deploy": "drizzle-kit push && next build", "deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -29,6 +30,7 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/playfair-display": "^5.2.8",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -69,11 +71,12 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^16.2.2", "next": "^16.2.4",
"pg": "8.13.1", "pg": "8.13.1",
"react": "^19.2.4", "react": "^19.2.5",
"react-colorful": "^5.6.1",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.5",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"recharts": "^3.5.1", "recharts": "^3.5.1",
"resend": "^4.8.0", "resend": "^4.8.0",
@@ -90,12 +93,13 @@
"@types/node": "^20.19.26", "@types/node": "^20.19.26",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/raf": "^3.4.3", "@types/raf": "^3.4.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.6", "babel-plugin-react-compiler": "^1.0.0",
"baseline-browser-mapping": "^2.10.24",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.10", "eslint-config-next": "^16.2.4",
"eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "3.6.2", "prettier": "3.6.2",
+4 -3
View File
@@ -35,9 +35,10 @@ export default function TermsOfServicePage() {
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <p>
These Terms of Service (&quot;Terms&quot;) govern your use of the These Terms of Service (&quot;Terms&quot;) govern your use of
beenvoice platform and services (the &quot;Service&quot;) operated by the beenvoice platform and services (the &quot;Service&quot;)
beenvoice (&quot;us&quot;, &quot;we&quot;, or &quot;our&quot;). operated by beenvoice (&quot;us&quot;, &quot;we&quot;, or
&quot;our&quot;).
</p> </p>
<p> <p>
By accessing or using our Service, you agree to be bound by By accessing or using our Service, you agree to be bound by
+54 -16
View File
@@ -2,59 +2,97 @@ import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
const registerSchema = z.object({ const registerSchema = z.object({
firstName: z.string().min(1, "First name is required"), firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"), lastName: z.string().trim().min(1, "Last name is required"),
email: z.string().email("Invalid email address"), email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"), password: z.string().min(8, "Password must be at least 8 characters"),
}); });
const fieldLabels: Record<string, string> = {
firstName: "First name",
lastName: "Last name",
email: "Email address",
password: "Password",
};
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() as z.infer<typeof registerSchema>; if (env.DISABLE_SIGNUPS === true) {
return NextResponse.json(
{ error: "New account registration is currently disabled" },
{ status: 403 },
);
}
const body = (await request.json()) as unknown;
const { firstName, lastName, email, password } = registerSchema.parse(body); const { firstName, lastName, email, password } = registerSchema.parse(body);
const normalizedEmail = email.toLowerCase();
// Check if user already exists // Check if user already exists
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.email, email), where: eq(users.email, normalizedEmail),
}); });
if (existingUser) { if (existingUser) {
return NextResponse.json( return NextResponse.json(
{ error: "User with this email already exists" }, { error: "User with this email already exists" },
{ status: 400 } { status: 400 },
); );
} }
// Hash password // Hash password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Create user await db.transaction(async (tx) => {
await db.insert(users).values({ const [user] = await tx
name: `${firstName} ${lastName}`, .insert(users)
email, .values({
password: hashedPassword, name: `${firstName} ${lastName}`,
email: normalizedEmail,
password: hashedPassword,
})
.returning({ id: users.id });
if (!user) {
throw new Error("Failed to create user");
}
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}); });
return NextResponse.json( return NextResponse.json(
{ message: "User created successfully" }, { message: "User created successfully" },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
const issue = error.errors[0];
const field = issue?.path[0];
const fallback =
typeof field === "string"
? `${fieldLabels[field] ?? field} is required`
: "Please check the registration form";
return NextResponse.json( return NextResponse.json(
{ error: error.errors[0]?.message ?? "Validation error" }, { error: issue?.message === "Required" ? fallback : issue?.message },
{ status: 400 } { status: 400 },
); );
} }
console.error("Registration error:", error); console.error("Registration error:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 } { status: 500 },
); );
} }
} }
+35 -10
View File
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -47,15 +47,40 @@ export async function POST(request: NextRequest) {
// Hash the new password // Hash the new password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Update user with new password and clear reset token await db.transaction(async (tx) => {
await db await tx
.update(users) .update(users)
.set({ .set({
password: hashedPassword, password: hashedPassword,
resetToken: null, resetToken: null,
resetTokenExpiry: null, resetTokenExpiry: null,
}) })
.where(eq(users.id, user.id)); .where(eq(users.id, user.id));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, user.id),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}
});
return NextResponse.json( return NextResponse.json(
{ {
+2 -1
View File
@@ -28,7 +28,8 @@ function RegisterForm() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
name: `${firstName} ${lastName}`, firstName,
lastName,
email, email,
password, password,
}), }),
+3 -2
View File
@@ -29,11 +29,12 @@ function ResetPasswordForm() {
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [tokenValid, setTokenValid] = useState<boolean | null>(null); const [tokenValid, setTokenValid] = useState<boolean | null>(() =>
token ? null : false,
);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
setTokenValid(false);
return; return;
} }
+3 -282
View File
@@ -1,290 +1,11 @@
"use client"; import { Suspense } from "react";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import { env } from "~/env"; import { env } from "~/env";
import { import { SignInForm } from "./signin-form";
Mail,
Lock,
ArrowRight,
Users,
FileText,
TrendingUp,
Shield,
} from "lucide-react";
function SignInForm() {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const { error } = await authClient.signIn.email({
email,
password,
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
}
async function handleSocialSignIn() {
setLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: callbackUrl,
});
// The signIn.sso method will automatically redirect to the SSO provider
} catch (error) {
console.error("[SSO Error]", error);
setLoading(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
{/* Blob Background */}
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
Welcome back to your
<span className="text-primary italic">
{" "}
invoicing workspace
</span>
</h1>
<p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional
invoices that get you paid faster.
</p>
</div>
</div>
<div className="grid gap-6">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<Users className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Client Management
</h3>
<p className="text-muted-foreground text-sm">
Organize and track all your clients in one place
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<FileText className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Professional Invoices
</h3>
<p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<TrendingUp className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Payment Tracking
</h3>
<p className="text-muted-foreground text-sm">
Monitor your income with real-time insights
</p>
</div>
</div>
</div>
</div>
</div>
{/* Sign In Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
<p className="text-muted-foreground">
Enter your credentials to access your account
</p>
</div>
{authentikEnabled && (
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="relative h-11 w-full rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="border-border/50 w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="m@example.com"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password"
className="text-primary text-sm hover:underline"
>
Forgot password?
</a>
</div>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="Enter your password"
/>
</div>
</div>
<Button
type="submit"
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Signing in...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Sign In</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a
href="/auth/register"
className="text-primary font-medium hover:underline"
>
Sign up
</a>
</div>
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By signing in, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function SignInPage() { export default function SignInPage() {
return ( return (
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<SignInForm /> <SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
</Suspense> </Suspense>
); );
} }
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import { env } from "~/env";
import {
Mail,
Lock,
ArrowRight,
Users,
FileText,
TrendingUp,
Shield,
} from "lucide-react";
interface SignInFormProps {
allowRegistration: boolean;
}
export function SignInForm({ allowRegistration }: SignInFormProps) {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const signupDisabled = searchParams.get("signup") === "disabled";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const { error } = await authClient.signIn.email({
email,
password,
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
}
async function handleSocialSignIn() {
setLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: callbackUrl,
});
// The signIn.sso method will automatically redirect to the SSO provider
} catch (error) {
console.error("[SSO Error]", error);
setLoading(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
{/* Blob Background */}
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
Welcome back to your
<span className="text-primary italic">
{" "}
invoicing workspace
</span>
</h1>
<p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional
invoices that get you paid faster.
</p>
</div>
</div>
<div className="grid gap-6">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<Users className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Client Management
</h3>
<p className="text-muted-foreground text-sm">
Organize and track all your clients in one place
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<FileText className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Professional Invoices
</h3>
<p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<TrendingUp className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Payment Tracking
</h3>
<p className="text-muted-foreground text-sm">
Monitor your income with real-time insights
</p>
</div>
</div>
</div>
</div>
</div>
{/* Sign In Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
<p className="text-muted-foreground">
Enter your credentials to access your account
</p>
</div>
{signupDisabled && (
<div className="border-border bg-muted/50 text-muted-foreground rounded-lg border px-3 py-2 text-sm">
New account registration is currently disabled.
</div>
)}
{authentikEnabled && (
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="relative h-11 w-full rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="border-border/50 w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="m@example.com"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password"
className="text-primary text-sm hover:underline"
>
Forgot password?
</a>
</div>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="Enter your password"
/>
</div>
</div>
<Button
type="submit"
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Signing in...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Sign In</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
{allowRegistration && (
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a
href="/auth/register"
className="text-primary font-medium hover:underline"
>
Sign up
</a>
</div>
)}
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By signing in, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function SignInPageClient() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SignInForm allowRegistration />
</Suspense>
);
}
@@ -16,6 +16,47 @@ interface InvoiceStatusChartProps {
invoices: Invoice[]; invoices: Invoice[];
} }
const STATUS_COLORS = {
draft: "hsl(0, 0%, 60%)",
sent: "hsl(217, 91%, 60%)",
pending: "hsl(217, 91%, 60%)",
paid: "hsl(142, 71%, 45%)",
overdue: "hsl(var(--destructive))",
} as const;
const formatChartCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
function StatusTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { name: string; count: number; value: number };
}>;
}) {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{data.name}</p>
<p className="text-sm">
{data.count} invoice{data.count !== 1 ? "s" : ""}
</p>
<p className="text-sm">{formatChartCurrency(data.value)}</p>
</div>
);
}
return null;
}
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
// Process invoice data to create status breakdown // Process invoice data to create status breakdown
const statusData = invoices.reduce( const statusData = invoices.reduce(
@@ -44,14 +85,6 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
name: item.status.charAt(0).toUpperCase() + item.status.slice(1), name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
})); }));
// Use theme-aware colors
const COLORS = {
draft: "hsl(0, 0%, 60%)", // neutral grey - matches monthly metrics chart
sent: "hsl(217, 91%, 60%)", // vibrant blue
pending: "hsl(217, 91%, 60%)", // blue
paid: "hsl(142, 71%, 45%)", // vibrant green
overdue: "hsl(var(--destructive))", // red
};
// Animation / motion preferences // Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } = const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences(); useAnimationPreferences();
@@ -59,39 +92,6 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
600 / (animationSpeedMultiplier || 1), 600 / (animationSpeedMultiplier || 1),
); );
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const CustomTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { name: string; count: number; value: number };
}>;
}) => {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{data.name}</p>
<p className="text-sm">
{data.count} invoice{data.count !== 1 ? "s" : ""}
</p>
<p className="text-sm">{formatCurrency(data.value)}</p>
</div>
);
}
return null;
};
if (chartData.length === 0) { if (chartData.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -127,11 +127,13 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
{chartData.map((entry, index) => ( {chartData.map((entry, index) => (
<Cell <Cell
key={`cell-${index}`} key={`cell-${index}`}
fill={COLORS[entry.status as keyof typeof COLORS]} fill={
STATUS_COLORS[entry.status as keyof typeof STATUS_COLORS]
}
/> />
))} ))}
</Pie> </Pie>
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<StatusTooltip />} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -144,7 +146,8 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
<div <div
className="h-3 w-3 rounded-full" className="h-3 w-3 rounded-full"
style={{ style={{
backgroundColor: COLORS[item.status as keyof typeof COLORS], backgroundColor:
STATUS_COLORS[item.status as keyof typeof STATUS_COLORS],
}} }}
/> />
<span className="text-sm font-medium">{item.name}</span> <span className="text-sm font-medium">{item.name}</span>
@@ -152,7 +155,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
<div className="text-right"> <div className="text-right">
<p className="text-sm font-medium">{item.count}</p> <p className="text-sm font-medium">{item.count}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{formatCurrency(item.value)} {formatChartCurrency(item.value)}
</p> </p>
</div> </div>
</div> </div>
@@ -24,6 +24,43 @@ interface MonthlyMetricsChartProps {
invoices: Invoice[]; invoices: Invoice[];
} }
function MonthlyMetricsTooltip({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
payload: {
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
totalInvoices: number;
};
}>;
label?: string;
}) {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
<p className="text-primary/80">Pending: {data.pendingInvoices}</p>
<p className="text-destructive">Overdue: {data.overdueInvoices}</p>
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p>
<p className="text-foreground border-t pt-1 font-medium">
Total: {data.totalInvoices}
</p>
</div>
</div>
);
}
return null;
}
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) { export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
// Process invoice data to create monthly metrics // Process invoice data to create monthly metrics
const monthlyData = invoices.reduce( const monthlyData = invoices.reduce(
@@ -95,49 +132,6 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
500 / (animationSpeedMultiplier || 1), 500 / (animationSpeedMultiplier || 1),
); );
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
payload: {
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
totalInvoices: number;
};
}>;
label?: string;
}) => {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
<p className="text-primary/80">
Pending: {data.pendingInvoices}
</p>
<p className="text-destructive">
Overdue: {data.overdueInvoices}
</p>
<p className="text-muted-foreground">
Draft: {data.draftInvoices}
</p>
<p className="text-foreground font-medium border-t pt-1">
Total: {data.totalInvoices}
</p>
</div>
</div>
);
}
return null;
};
if (chartData.length === 0) { if (chartData.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -169,7 +163,7 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
tickLine={false} tickLine={false}
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }} tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<MonthlyMetricsTooltip />} />
<Bar <Bar
dataKey="draftInvoices" dataKey="draftInvoices"
stackId="a" stackId="a"
@@ -235,9 +229,7 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
<span className="text-xs">Pending</span> <span className="text-xs">Pending</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div className="bg-destructive h-3 w-3 rounded-full" />
className="h-3 w-3 rounded-full bg-destructive"
/>
<span className="text-xs">Overdue</span> <span className="text-xs">Overdue</span>
</div> </div>
</div> </div>
@@ -10,8 +10,6 @@ import {
} from "recharts"; } from "recharts";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface RevenueChartProps { interface RevenueChartProps {
data: { data: {
month: string; month: string;
@@ -91,7 +89,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
<AreaChart data={chartData}> <AreaChart data={chartData}>
<defs> <defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.4} /> <stop
offset="5%"
stopColor="hsl(217, 91%, 60%)"
stopOpacity={0.4}
/>
<stop <stop
offset="95%" offset="95%"
stopColor="hsl(217, 91%, 60%)" stopColor="hsl(217, 91%, 60%)"
@@ -229,7 +229,7 @@ export function StatusManager({
{/* Overdue Warning */} {/* Overdue Warning */}
{isOverdue && ( {isOverdue && (
<div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3"> <div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue {daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue
@@ -325,7 +325,7 @@ export function StatusManager({
{/* No Email Warning */} {/* No Email Warning */}
{!clientEmail && effectiveStatus !== "paid" && ( {!clientEmail && effectiveStatus !== "paid" && (
<div className="bg-muted text-muted-foreground p-3"> <div className="bg-muted text-muted-foreground p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
@@ -0,0 +1,101 @@
"use client";
import { Shield } from "lucide-react";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { api } from "~/trpc/react";
export function AdministrationContent() {
const {
data: accounts = [],
refetch,
error,
} = api.settings.listAccounts.useQuery();
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
onSuccess: () => {
toast.success("Account role updated");
void refetch();
},
onError: (mutationError: { message: string }) => {
toast.error(`Failed to update role: ${mutationError.message}`);
},
});
if (error) {
return (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
Administration
</CardTitle>
<CardDescription>
Administrative access is required for this page.
</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
Accounts
</CardTitle>
<CardDescription>
Manage account access and roles without opening customer data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{accounts.map((account) => (
<div
key={account.id}
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium">{account.name}</p>
<p className="text-muted-foreground truncate text-xs">
{account.email}
</p>
<p className="text-muted-foreground mt-1 text-xs">
Created {new Date(account.createdAt).toLocaleDateString()}
</p>
</div>
<Select
value={account.role}
onValueChange={(role) =>
updateAccountRoleMutation.mutate({
userId: account.id,
role: role as "user" | "admin",
})
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
))}
</CardContent>
</Card>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { DataTableSkeleton } from "~/components/data/data-table";
import { PageHeader } from "~/components/layout/page-header";
import { HydrateClient } from "~/trpc/server";
import { AdministrationContent } from "./_components/administration-content";
export default async function AdministrationPage() {
return (
<div className="page-enter space-y-6">
<PageHeader
title="Administration"
description="Manage account access and platform administration"
variant="gradient"
/>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<AdministrationContent />
</Suspense>
</HydrateClient>
</div>
);
}
+245 -52
View File
@@ -68,20 +68,39 @@ export default function ExpensesPage() {
const { data: clients = [] } = api.clients.getAll.useQuery(); const { data: clients = [] } = api.clients.getAll.useQuery();
const create = api.expenses.create.useMutation({ const create = api.expenses.create.useMutation({
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); }, onSuccess: () => {
toast.success("Expense added");
void utils.expenses.getAll.invalidate();
setOpen(false);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const update = api.expenses.update.useMutation({ const update = api.expenses.update.useMutation({
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); }, onSuccess: () => {
toast.success("Expense updated");
void utils.expenses.getAll.invalidate();
setOpen(false);
setEditId(null);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const del = api.expenses.delete.useMutation({ const del = api.expenses.delete.useMutation({
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); }, onSuccess: () => {
toast.success("Expense deleted");
void utils.expenses.getAll.invalidate();
setDeleteId(null);
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); }; const handleOpen = () => {
const handleEdit = (expense: typeof expenses[0]) => { setEditId(null);
setForm(defaultForm);
setOpen(true);
};
const handleEdit = (expense: (typeof expenses)[0]) => {
setEditId(expense.id); setEditId(expense.id);
setForm({ setForm({
date: new Date(expense.date), date: new Date(expense.date),
@@ -98,21 +117,45 @@ export default function ExpensesPage() {
setOpen(true); setOpen(true);
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (!form.description.trim()) { toast.error("Description is required"); return; } if (!form.description.trim()) {
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; } toast.error("Description is required");
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible }; return;
}
if (form.amount <= 0) {
toast.error("Amount must be greater than 0");
return;
}
const payload = {
...form,
clientId: form.clientId || undefined,
category: form.category || undefined,
notes: form.notes || undefined,
taxDeductible: form.taxDeductible,
};
if (editId) update.mutate({ id: editId, ...payload }); if (editId) update.mutate({ id: editId, ...payload });
else create.mutate(payload); else create.mutate(payload);
}; };
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0); const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0); const billableTotal = expenses
const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0); .filter((e) => e.billable)
.reduce((s, e) => s + e.amount, 0);
const deductibleTotal = expenses
.filter((e) => e.taxDeductible)
.reduce((s, e) => s + e.amount, 0);
return ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient"> <PageHeader
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md"> title="Expenses"
description="Track billable and non-billable expenses"
variant="gradient"
>
<Button
onClick={handleOpen}
variant="default"
className="hover-lift shadow-md"
>
<Plus className="mr-2 h-5 w-5" /> Add Expense <Plus className="mr-2 h-5 w-5" /> Add Expense
</Button> </Button>
</PageHeader> </PageHeader>
@@ -121,25 +164,39 @@ export default function ExpensesPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p> Total
</p>
<p className="mt-1 text-2xl font-bold">
{formatCurrency(totalExpenses)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p> Billable
</p>
<p className="text-primary mt-1 text-2xl font-bold">
{formatCurrency(billableTotal)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p> Deductible
</p>
<p className="mt-1 text-2xl font-bold text-green-600">
{formatCurrency(deductibleTotal)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Count
</p>
<p className="mt-1 text-2xl font-bold">{expenses.length}</p> <p className="mt-1 text-2xl font-bold">{expenses.length}</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -154,34 +211,84 @@ export default function ExpensesPage() {
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">Loading</div> <div className="text-muted-foreground p-6 text-center text-sm">
Loading
</div>
) : expenses.length === 0 ? ( ) : expenses.length === 0 ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" /> <Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p> <p className="text-muted-foreground text-sm">
No expenses yet. Add your first expense.
</p>
</div> </div>
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{expenses.map((expense) => ( {expenses.map((expense) => (
<div key={expense.id} className="flex items-start justify-between gap-3 p-4"> <div
key={expense.id}
className="flex items-start justify-between gap-3 p-4"
>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{expense.description}</p> <p className="font-medium">{expense.description}</p>
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>} {expense.billable && (
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>} <Badge variant="secondary" className="text-xs">
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>} Billable
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>} </Badge>
)}
{expense.reimbursable && (
<Badge variant="outline" className="text-xs">
Reimbursable
</Badge>
)}
{expense.taxDeductible && (
<Badge
variant="outline"
className="border-green-300 text-xs text-green-600"
>
Tax Deductible
</Badge>
)}
{expense.category && (
<Badge variant="outline" className="text-xs">
{expense.category}
</Badge>
)}
</div> </div>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))} {new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(expense.date))}
{expense.client ? ` · ${expense.client.name}` : ""} {expense.client ? ` · ${expense.client.name}` : ""}
</p> </p>
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>} {expense.notes && (
<p className="text-muted-foreground mt-1 text-xs">
{expense.notes}
</p>
)}
</div> </div>
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
<p className="font-semibold">{formatCurrency(expense.amount, expense.currency)}</p> <p className="font-semibold">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(expense)}><Pencil className="h-3.5 w-3.5" /></Button> {formatCurrency(expense.amount, expense.currency)}
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(expense.id)}><Trash2 className="h-3.5 w-3.5" /></Button> </p>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleEdit(expense)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => setDeleteId(expense.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div> </div>
</div> </div>
))} ))}
@@ -199,70 +306,150 @@ export default function ExpensesPage() {
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Description *</Label> <Label>Description *</Label>
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" /> <Input
value={form.description}
onChange={(e) =>
setForm((p) => ({ ...p, description: e.target.value }))
}
placeholder="e.g. Laptop charger"
/>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>Amount *</Label> <Label>Amount *</Label>
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} /> <NumberInput
value={form.amount}
onChange={(v) => setForm((p) => ({ ...p, amount: v }))}
min={0}
step={0.01}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Currency</Label> <Label>Currency</Label>
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}> <Select
<SelectTrigger><SelectValue /></SelectTrigger> value={form.currency}
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent> onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code}
</SelectItem>
))}
</SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>Date</Label> <Label>Date</Label>
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" /> <DatePicker
date={form.date}
onDateChange={(d) =>
setForm((p) => ({ ...p, date: d ?? new Date() }))
}
className="w-full"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Category</Label> <Label>Category</Label>
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}> <Select
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger> value={form.category || "none"}
onValueChange={(v) =>
setForm((p) => ({ ...p, category: v === "none" ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)} {EXPENSE_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Client (optional)</Label> <Label>Client (optional)</Label>
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}> <Select
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger> value={form.clientId || "none"}
onValueChange={(v) =>
setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No client</SelectItem> <SelectItem value="none">No client</SelectItem>
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)} {clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-wrap gap-6"> <div className="flex flex-wrap gap-6">
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} /> <Checkbox
checked={form.billable}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, billable: !!v }))
}
/>
<span className="text-sm">Billable</span> <span className="text-sm">Billable</span>
</label> </label>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} /> <Checkbox
checked={form.reimbursable}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, reimbursable: !!v }))
}
/>
<span className="text-sm">Reimbursable</span> <span className="text-sm">Reimbursable</span>
</label> </label>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} /> <Checkbox
checked={form.taxDeductible}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, taxDeductible: !!v }))
}
/>
<span className="text-sm">Tax Deductible</span> <span className="text-sm">Tax Deductible</span>
</label> </label>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Notes (optional)</Label> <Label>Notes (optional)</Label>
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" /> <Input
value={form.notes}
onChange={(e) =>
setForm((p) => ({ ...p, notes: e.target.value }))
}
placeholder="Additional details…"
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setOpen(false)}>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}> Cancel
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"} </Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Add Expense"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -276,8 +463,14 @@ export default function ExpensesPage() {
<DialogDescription>This action cannot be undone.</DialogDescription> <DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button> <Button variant="outline" onClick={() => setDeleteId(null)}>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}> Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"} {del.isPending ? "Deleting…" : "Delete"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -53,14 +53,13 @@ const columns: ColumnDef<InvoiceItem>[] = [
return ( return (
<> <>
{/* Desktop: plain description */} {/* Desktop: plain description */}
<div className="hidden font-medium sm:block"> <div className="hidden font-medium sm:block">{item.description}</div>
{item.description}
</div>
{/* Mobile: description + date + hours @ rate stacked */} {/* Mobile: description + date + hours @ rate stacked */}
<div className="sm:hidden"> <div className="sm:hidden">
<p className="font-medium">{item.description}</p> <p className="font-medium">{item.description}</p>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{formatDate(item.date)} &middot; {item.hours}h @ {formatCurrency(item.rate)}/hr {formatDate(item.date)} &middot; {item.hours}h @{" "}
{formatCurrency(item.rate)}/hr
</p> </p>
</div> </div>
</> </>
+5 -7
View File
@@ -75,7 +75,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const handleMarkAsPaid = () => { const handleMarkAsPaid = () => {
updateStatus.mutate({ updateStatus.mutate({
id: invoiceId, id: invoiceId,
status: "paid" as StoredInvoiceStatus, status: "paid",
}); });
}; };
@@ -109,17 +109,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0); const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = (subtotal * invoice.taxRate) / 100; const taxAmount = (subtotal * invoice.taxRate) / 100;
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
const storedStatus = invoice.status as StoredInvoiceStatus;
const effectiveStatus = getEffectiveInvoiceStatus( const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, storedStatus,
invoice.dueDate,
);
const isOverdue = isInvoiceOverdue(
invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
); );
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
const getStatusType = (): StatusType => { const getStatusType = (): StatusType => {
return effectiveStatus as StatusType; return effectiveStatus;
}; };
return ( return (
@@ -292,7 +292,7 @@ export default function SendEmailPage() {
if (!invoice) { if (!invoice) {
return ( return (
<div className="container mx-auto max-w-4xl p-6"> <div className="page-enter space-y-6">
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription>Invoice not found.</AlertDescription> <AlertDescription>Invoice not found.</AlertDescription>
@@ -302,7 +302,7 @@ export default function SendEmailPage() {
} }
return ( return (
<div className="container mx-auto max-w-6xl space-y-6 pb-32"> <div className="page-enter space-y-6 pb-32">
<PageHeader <PageHeader
title={`Send Invoice ${invoice.invoiceNumber}`} title={`Send Invoice ${invoice.invoiceNumber}`}
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat( description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat(
@@ -23,7 +23,15 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { Eye, Edit, Trash2, FileText, CheckCircle, Send, ChevronDown } from "lucide-react"; import {
Eye,
Edit,
Trash2,
FileText,
CheckCircle,
Send,
ChevronDown,
} from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
@@ -45,11 +53,28 @@ interface Invoice {
createdById: string; createdById: string;
createdAt: Date; createdAt: Date;
updatedAt: Date | null; updatedAt: Date | null;
client?: { id: string; name: string; email: string | null; phone: string | null } | null; client?: {
business?: { id: string; name: string; email: string | null; phone: string | null } | null; id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
business?: {
id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
items?: Array<{ items?: Array<{
id: string; invoiceId: string; date: Date; description: string; id: string;
hours: number; rate: number; amount: number; position: number; createdAt: Date; invoiceId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position: number;
createdAt: Date;
}> | null; }> | null;
} }
@@ -58,10 +83,17 @@ interface InvoicesDataTableProps {
} }
const getStatusType = (invoice: Invoice): StatusType => const getStatusType = (invoice: Invoice): StatusType =>
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType; getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new Date(date)); new Intl.DateTimeFormat("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(date));
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const router = useRouter(); const router = useRouter();
@@ -84,7 +116,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const bulkDelete = api.invoices.bulkDelete.useMutation({ const bulkDelete = api.invoices.bulkDelete.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`); toast.success(
`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`,
);
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
setBulkDeleteDialogOpen(false); setBulkDeleteDialogOpen(false);
setPendingBulkDelete([]); setPendingBulkDelete([]);
@@ -94,7 +128,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({ const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`); toast.success(
`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`,
);
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
}, },
onError: (e) => toast.error(e.message ?? "Failed to update invoices"), onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
@@ -105,7 +141,10 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="Select all" aria-label="Select all"
data-action-button="true" data-action-button="true"
@@ -124,7 +163,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
}, },
{ {
accessorKey: "client.name", accessorKey: "client.name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Client" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Client" />
),
cell: ({ row }) => { cell: ({ row }) => {
const invoice = row.original; const invoice = row.original;
return ( return (
@@ -133,10 +174,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<FileText className="text-primary h-4 w-4" /> <FileText className="text-primary h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p> <p className="truncate font-medium">
<p className="text-muted-foreground truncate text-xs sm:text-sm">{invoice.invoiceNumber}</p> {invoice.client?.name ?? "—"}
</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{invoice.invoiceNumber}
</p>
<div className="mt-1 flex items-center gap-2 sm:hidden"> <div className="mt-1 flex items-center gap-2 sm:hidden">
<StatusBadge status={getStatusType(invoice)} className="text-xs" /> <StatusBadge
status={getStatusType(invoice)}
className="text-xs"
/>
<span className="text-foreground text-xs font-semibold"> <span className="text-foreground text-xs font-semibold">
{formatCurrency(invoice.totalAmount, invoice.currency)} {formatCurrency(invoice.totalAmount, invoice.currency)}
</span> </span>
@@ -148,38 +196,59 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
}, },
{ {
accessorKey: "issueDate", accessorKey: "issueDate",
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Date" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm">{formatDate(row.getValue("issueDate") as Date)}</p> <p className="truncate text-sm">
<p className="text-muted-foreground truncate text-xs">Due {formatDate(new Date(row.original.dueDate))}</p> {formatDate(row.getValue("issueDate"))}
</p>
<p className="text-muted-foreground truncate text-xs">
Due {formatDate(new Date(row.original.dueDate))}
</p>
</div> </div>
), ),
}, },
{ {
accessorKey: "status", accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<StatusBadge <StatusBadge
status={getStatusType(row.original)} status={getStatusType(row.original)}
className={getStatusType(row.original) === "sent" ? "status-pending" : ""} className={
getStatusType(row.original) === "sent" ? "status-pending" : ""
}
/> />
), ),
filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)), filterFn: (row, _id, value: string[]) =>
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, value.includes(getStatusType(row.original)),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
}, },
{ {
accessorKey: "totalAmount", accessorKey: "totalAmount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-right"> <div className="text-right">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{formatCurrency(row.getValue("totalAmount") as number, row.original.currency)} {formatCurrency(row.getValue("totalAmount"), row.original.currency)}
</p>
<p className="text-muted-foreground text-xs">
{row.original.items?.length ?? 0} items
</p> </p>
<p className="text-muted-foreground text-xs">{row.original.items?.length ?? 0} items</p>
</div> </div>
), ),
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
}, },
{ {
id: "actions", id: "actions",
@@ -188,19 +257,34 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
return ( return (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Link href={`/dashboard/invoices/${invoice.id}`}> <Link href={`/dashboard/invoices/${invoice.id}`}>
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true"> <Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Eye className="h-3.5 w-3.5" /> <Eye className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}> <Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true"> <Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Button <Button
variant="ghost" size="sm" variant="ghost"
size="sm"
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0" className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
onClick={(e) => { e.stopPropagation(); setInvoiceToDelete(invoice); setDeleteDialogOpen(true); }} onClick={(e) => {
e.stopPropagation();
setInvoiceToDelete(invoice);
setDeleteDialogOpen(true);
}}
data-action-button="true" data-action-button="true"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -237,12 +321,18 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchKey="invoiceNumber" searchKey="invoiceNumber"
searchPlaceholder="Search invoices..." searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns} filterableColumns={filterableColumns}
onRowClick={(invoice) => router.push(`/dashboard/invoices/${invoice.id}`)} onRowClick={(invoice) =>
router.push(`/dashboard/invoices/${invoice.id}`)
}
selectionActions={(selected, clear) => ( selectionActions={(selected, clear) => (
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={bulkUpdateStatus.isPending}> <Button
variant="outline"
size="sm"
disabled={bulkUpdateStatus.isPending}
>
<Send className="mr-1.5 h-3.5 w-3.5" /> <Send className="mr-1.5 h-3.5 w-3.5" />
Mark as Mark as
<ChevronDown className="ml-1.5 h-3.5 w-3.5" /> <ChevronDown className="ml-1.5 h-3.5 w-3.5" />
@@ -306,16 +396,24 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<DialogDescription> <DialogDescription>
Are you sure you want to delete invoice{" "} Are you sure you want to delete invoice{" "}
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "} <strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
<strong>{invoiceToDelete?.client?.name}</strong>? This action cannot be undone. <strong>{invoiceToDelete?.client?.name}</strong>? This action
cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleteInvoice.isPending}> <Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteInvoice.isPending}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => invoiceToDelete && deleteInvoice.mutate({ id: invoiceToDelete.id })} onClick={() =>
invoiceToDelete &&
deleteInvoice.mutate({ id: invoiceToDelete.id })
}
disabled={deleteInvoice.isPending} disabled={deleteInvoice.isPending}
> >
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"} {deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
@@ -325,25 +423,40 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
</Dialog> </Dialog>
{/* Bulk delete dialog */} {/* Bulk delete dialog */}
<Dialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}> <Dialog
open={bulkDeleteDialogOpen}
onOpenChange={setBulkDeleteDialogOpen}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete {pendingBulkDelete.length} Invoice{pendingBulkDelete.length !== 1 ? "s" : ""}</DialogTitle> <DialogTitle>
Delete {pendingBulkDelete.length} Invoice
{pendingBulkDelete.length !== 1 ? "s" : ""}
</DialogTitle>
<DialogDescription> <DialogDescription>
This will permanently delete {pendingBulkDelete.length} invoice{pendingBulkDelete.length !== 1 ? "s" : ""}. This will permanently delete {pendingBulkDelete.length} invoice
This action cannot be undone. {pendingBulkDelete.length !== 1 ? "s" : ""}. This action cannot be
undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setBulkDeleteDialogOpen(false)} disabled={bulkDelete.isPending}> <Button
variant="outline"
onClick={() => setBulkDeleteDialogOpen(false)}
disabled={bulkDelete.isPending}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })} onClick={() =>
bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })
}
disabled={bulkDelete.isPending} disabled={bulkDelete.isPending}
> >
{bulkDelete.isPending ? "Deleting..." : `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`} {bulkDelete.isPending
? "Deleting..."
: `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+7 -7
View File
@@ -29,7 +29,7 @@ function FormatInstructions() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="bg-muted/50 p-4"> <div className="bg-muted/50 p-4">
<p className="text-muted-foreground font-mono text-sm"> <p className="text-muted-foreground font-mono text-sm">
DATE,DESCRIPTION,HOURS,RATE,AMOUNT DATE,DESCRIPTION,HOURS,RATE,AMOUNT
</p> </p>
@@ -85,7 +85,7 @@ function FormatInstructions() {
for importing time entries. for importing time entries.
</p> </p>
<div className="bg-primary/10 p-4"> <div className="bg-primary/10 p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Info className="text-primary mt-0.5 h-5 w-5" /> <Info className="text-primary mt-0.5 h-5 w-5" />
<div> <div>
@@ -100,7 +100,7 @@ function FormatInstructions() {
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Row:</h4> <h4 className="text-sm font-semibold">Sample Row:</h4>
<div className="bg-muted/50 p-3"> <div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs break-all"> <p className="text-muted font-mono text-xs break-all">
1/15/24,&quot;Web development work&quot;,8,75.00,600.00 1/15/24,&quot;Web development work&quot;,8,75.00,600.00
</p> </p>
@@ -109,7 +109,7 @@ function FormatInstructions() {
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Filename:</h4> <h4 className="text-sm font-semibold">Sample Filename:</h4>
<div className="bg-muted/50 p-3"> <div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs">2024-01-15.csv</p> <p className="text-muted font-mono text-xs">2024-01-15.csv</p>
</div> </div>
</div> </div>
@@ -168,7 +168,7 @@ function FileFormatHelp() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<div className="bg-accent mx-auto w-fit p-3"> <div className="bg-accent mx-auto w-fit p-3">
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" /> <FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
</div> </div>
<h4 className="font-semibold">CSV Files</h4> <h4 className="font-semibold">CSV Files</h4>
@@ -178,7 +178,7 @@ function FileFormatHelp() {
</p> </p>
</div> </div>
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<div className="bg-primary/10 mx-auto w-fit p-3"> <div className="bg-primary/10 mx-auto w-fit p-3">
<Upload className="text-primary h-6 w-6" /> <Upload className="text-primary h-6 w-6" />
</div> </div>
<h4 className="font-semibold">Max Size</h4> <h4 className="font-semibold">Max Size</h4>
@@ -187,7 +187,7 @@ function FileFormatHelp() {
</p> </p>
</div> </div>
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<div className="bg-secondary mx-auto w-fit p-3"> <div className="bg-secondary mx-auto w-fit p-3">
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" /> <CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
</div> </div>
<h4 className="font-semibold">Validation</h4> <h4 className="font-semibold">Validation</h4>
+185 -74
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { api } from "~/trpc/react"; import { api, type RouterOutputs } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@@ -18,12 +18,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "~/components/ui/tabs";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react"; import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
@@ -34,87 +29,81 @@ interface TemplateForm {
isDefault: boolean; isDefault: boolean;
} }
const defaultForm: TemplateForm = { name: "", type: "notes", content: "", isDefault: false }; const defaultForm: TemplateForm = {
name: "",
type: "notes",
content: "",
isDefault: false,
};
export default function TemplatesPage() { type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils(); interface TemplateListProps {
const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery(); items: InvoiceTemplate[];
type: "notes" | "terms";
isLoading: boolean;
onCreate: (type: "notes" | "terms") => void;
onEdit: (template: InvoiceTemplate) => void;
onDelete: (id: string) => void;
}
const create = api.invoiceTemplates.create.useMutation({ function TemplateList({
onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); }, items,
onError: (e) => toast.error(e.message), type,
}); isLoading,
const update = api.invoiceTemplates.update.useMutation({ onCreate,
onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); }, onEdit,
onError: (e) => toast.error(e.message), onDelete,
}); }: TemplateListProps) {
const del = api.invoiceTemplates.delete.useMutation({ return (
onSuccess: () => { toast.success("Template deleted"); void utils.invoiceTemplates.getAll.invalidate(); setDeleteId(null); },
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: typeof templates[0]) => {
setEditId(t.id);
setForm({ name: t.name, type: t.type as "notes" | "terms", content: t.content, isDefault: t.isDefault });
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) { toast.error("Name is required"); return; }
if (!form.content.trim()) { toast.error("Content is required"); return; }
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
};
const notesTemplates = templates.filter((t) => t.type === "notes");
const termsTemplates = templates.filter((t) => t.type === "terms");
const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" onClick={() => handleOpen(type)}> <Button size="sm" onClick={() => onCreate(type)}>
<Plus className="mr-1.5 h-3.5 w-3.5" /> New {type === "notes" ? "Notes" : "Terms"} Template <Plus className="mr-1.5 h-3.5 w-3.5" /> New{" "}
{type === "notes" ? "Notes" : "Terms"} Template
</Button> </Button>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm">Loading</div> <div className="text-muted-foreground py-8 text-center text-sm">
Loading...
</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-sm">
No {type} templates yet. No {type} templates yet.
</div> </div>
) : ( ) : (
items.map((t) => ( items.map((template) => (
<Card key={t.id}> <Card key={template.id}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium">{t.name}</p> <p className="font-medium">{template.name}</p>
{t.isDefault && ( {template.isDefault && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<Star className="mr-1 h-3 w-3" /> Default <Star className="mr-1 h-3 w-3" /> Default
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap"> <p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
{t.content} {template.content}
</p> </p>
</div> </div>
<div className="flex flex-shrink-0 gap-1"> <div className="flex flex-shrink-0 gap-1">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(t)}> <Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onEdit(template)}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(t.id)}> <Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => onDelete(template.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -125,6 +114,77 @@ export default function TemplatesPage() {
)} )}
</div> </div>
); );
}
export default function TemplatesPage() {
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils();
const { data: templates = [], isLoading } =
api.invoiceTemplates.getAll.useQuery();
const create = api.invoiceTemplates.create.useMutation({
onSuccess: () => {
toast.success("Template created");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const update = api.invoiceTemplates.update.useMutation({
onSuccess: () => {
toast.success("Template updated");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setEditId(null);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const del = api.invoiceTemplates.delete.useMutation({
onSuccess: () => {
toast.success("Template deleted");
void utils.invoiceTemplates.getAll.invalidate();
setDeleteId(null);
},
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: InvoiceTemplate) => {
setEditId(t.id);
setForm({
name: t.name,
type: t.type as "notes" | "terms",
content: t.content,
isDefault: t.isDefault,
});
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) {
toast.error("Name is required");
return;
}
if (!form.content.trim()) {
toast.error("Content is required");
return;
}
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
};
const notesTemplates = templates.filter((t) => t.type === "notes");
const termsTemplates = templates.filter((t) => t.type === "terms");
return ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
@@ -137,17 +197,33 @@ export default function TemplatesPage() {
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}> <Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes"> <TabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes ({notesTemplates.length}) <FileText className="mr-1.5 h-4 w-4" /> Notes (
{notesTemplates.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="terms"> <TabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms ({termsTemplates.length}) <FileText className="mr-1.5 h-4 w-4" /> Terms (
{termsTemplates.length})
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="notes" className="mt-4"> <TabsContent value="notes" className="mt-4">
<TemplateList items={notesTemplates} type="notes" /> <TemplateList
items={notesTemplates}
type="notes"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent> </TabsContent>
<TabsContent value="terms" className="mt-4"> <TabsContent value="terms" className="mt-4">
<TemplateList items={termsTemplates} type="terms" /> <TemplateList
items={termsTemplates}
type="terms"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -155,16 +231,29 @@ export default function TemplatesPage() {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{editId ? "Edit Template" : "New Template"}</DialogTitle> <DialogTitle>
{editId ? "Edit Template" : "New Template"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Name *</Label> <Label>Name *</Label>
<Input value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" /> <Input
value={form.name}
onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="e.g. Standard Payment Terms"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Type</Label>
<Tabs value={form.type} onValueChange={(v) => setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}> <Tabs
value={form.type}
onValueChange={(v) =>
setForm((p) => ({ ...p, type: v as "notes" | "terms" }))
}
>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">Notes</TabsTrigger> <TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="terms">Terms</TabsTrigger> <TabsTrigger value="terms">Terms</TabsTrigger>
@@ -175,20 +264,36 @@ export default function TemplatesPage() {
<Label>Content *</Label> <Label>Content *</Label>
<Textarea <Textarea
value={form.content} value={form.content}
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} onChange={(e) =>
setForm((p) => ({ ...p, content: e.target.value }))
}
placeholder="Template content…" placeholder="Template content…"
className="min-h-[120px]" className="min-h-[120px]"
/> />
</div> </div>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.isDefault} onCheckedChange={(v) => setForm((p) => ({ ...p, isDefault: !!v }))} /> <Checkbox
checked={form.isDefault}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, isDefault: !!v }))
}
/>
<span className="text-sm">Set as default for {form.type}</span> <span className="text-sm">Set as default for {form.type}</span>
</label> </label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setOpen(false)}>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}> Cancel
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Create"} </Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Create"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -202,8 +307,14 @@ export default function TemplatesPage() {
<DialogDescription>This action cannot be undone.</DialogDescription> <DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button> <Button variant="outline" onClick={() => setDeleteId(null)}>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}> Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"} {del.isPending ? "Deleting…" : "Delete"}
</Button> </Button>
</DialogFooter> </DialogFooter>
+12 -7
View File
@@ -28,9 +28,9 @@ import type { DashboardStats, RecentInvoice } from "./types";
// Hero section with clean mono design // Hero section with clean mono design
// Enhanced stats cards with better visuals // Enhanced stats cards with better visuals
function DashboardStats({ stats }: { stats: DashboardStats }) { // TODO: Import RouterOutput type function DashboardStats({ stats }: { stats: DashboardStats }) {
// TODO: Import RouterOutput type
const formatTrend = (value: number, isCount = false) => { const formatTrend = (value: number, isCount = false) => {
if (isCount) { if (isCount) {
return value > 0 ? `+${value}` : value.toString(); return value > 0 ? `+${value}` : value.toString();
@@ -193,10 +193,11 @@ function QuickActions() {
<Link <Link
key={action.title} key={action.title}
href={action.href} href={action.href}
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${
? "border-foreground/20 bg-muted/50 hover:bg-muted" action.featured
: "border-border bg-background hover:bg-muted/50" ? "border-foreground/20 bg-muted/50 hover:bg-muted"
}`} : "border-border bg-background hover:bg-muted/50"
}`}
> >
<Icon className="h-5 w-5 flex-shrink-0" /> <Icon className="h-5 w-5 flex-shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -310,7 +311,11 @@ async function CurrentWork() {
} }
// Enhanced recent activity // Enhanced recent activity
async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoice[] }) { async function RecentActivity({
recentInvoices,
}: {
recentInvoices: RecentInvoice[];
}) {
// Use passed recentInvoices instead of fetching all // Use passed recentInvoices instead of fetching all
const getStatusStyle = (status: string) => { const getStatusStyle = (status: string) => {
@@ -0,0 +1,124 @@
"use client";
import { BlobProvider } from "@react-pdf/renderer";
import {
InvoicePDF,
type InvoiceData,
type PDFGenerationSettings,
} from "~/lib/pdf-export";
const previewInvoice: InvoiceData = {
invoiceNumber: "BV-2026-001",
issueDate: new Date("2026-04-30T12:00:00.000Z"),
dueDate: new Date("2026-05-30T12:00:00.000Z"),
status: "sent",
totalAmount: 3150,
taxRate: 0,
currency: "USD",
notes: "Thank you for the work. Payment is due within 30 days.",
business: {
name: "Sample Studio",
email: "hello@beenvoice.test",
phone: "(555) 014-1024",
addressLine1: "100 Terminal Way",
city: "New York",
state: "NY",
postalCode: "10001",
country: "USA",
website: "beenvoice.test",
},
client: {
name: "Client Studio",
email: "ap@clientstudio.test",
addressLine1: "42 Market Street",
city: "Brooklyn",
state: "NY",
postalCode: "11201",
country: "USA",
},
items: [
{
date: new Date("2026-04-08T12:00:00.000Z"),
description: "Invoice workflow design and implementation",
hours: 12,
rate: 150,
amount: 1800,
},
{
date: new Date("2026-04-16T12:00:00.000Z"),
description: "Client import cleanup",
hours: 5,
rate: 150,
amount: 750,
},
{
date: new Date("2026-04-24T12:00:00.000Z"),
description: "Reporting polish",
hours: 4,
rate: 150,
amount: 600,
},
],
};
export function PdfPreviewFrame({
settings,
businessName,
}: {
settings: Required<PDFGenerationSettings>;
businessName: string;
}) {
const previewBusinessName =
businessName.trim() !== ""
? businessName
: (previewInvoice.business?.name ?? "Sample Studio");
const invoice = {
...previewInvoice,
business: {
...previewInvoice.business,
name: previewBusinessName,
},
};
return (
<div className="bg-muted/30 overflow-hidden border">
<div className="bg-background flex h-10 items-center justify-between border-b px-3">
<span className="text-muted-foreground text-xs font-medium">
PDF preview
</span>
<span className="text-muted-foreground text-xs">
Generated from sample invoice data
</span>
</div>
<BlobProvider
document={<InvoicePDF invoice={invoice} settings={settings} />}
>
{({ url, loading, error }) => {
if (loading) {
return (
<div className="text-muted-foreground flex aspect-[8.5/11] items-center justify-center p-6 text-sm">
Rendering PDF preview...
</div>
);
}
if (error || !url) {
return (
<div className="text-destructive flex aspect-[8.5/11] items-center justify-center p-6 text-sm">
PDF preview could not be rendered.
</div>
);
}
return (
<iframe
src={url}
title="Invoice PDF preview"
className="h-[640px] w-full bg-white"
/>
);
}}
</BlobProvider>
</div>
);
}
@@ -23,6 +23,7 @@ import {
Paintbrush, Paintbrush,
Type, Type,
} from "lucide-react"; } from "lucide-react";
import dynamic from "next/dynamic";
import { authClient } from "~/lib/auth-client"; import { authClient } from "~/lib/auth-client";
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
@@ -62,6 +63,7 @@ import {
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { InputColor } from "~/components/ui/input-color";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -92,6 +94,18 @@ import {
type InterfaceTheme, type InterfaceTheme,
} from "~/lib/branding"; } from "~/lib/branding";
const PdfPreviewFrame = dynamic(
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
{
ssr: false,
loading: () => (
<div className="bg-muted/30 text-muted-foreground flex h-[680px] items-center justify-center border text-sm">
Loading PDF preview...
</div>
),
},
);
function hslChannelsToHex(channels?: string) { function hslChannelsToHex(channels?: string) {
const [hue, saturation, lightness] = const [hue, saturation, lightness] =
channels?.match(/[\d.]+/g)?.map(Number) ?? []; channels?.match(/[\d.]+/g)?.map(Number) ?? [];
@@ -158,6 +172,10 @@ function hexToHslChannels(hex: string) {
)}% ${Number((lightness * 100).toFixed(1))}%`; )}% ${Number((lightness * 100).toFixed(1))}%`;
} }
function isFullHexColor(value: string) {
return /^#[0-9A-Fa-f]{6}$/.test(value);
}
export function SettingsContent() { export function SettingsContent() {
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
// const session = { user: null } as any; // const session = { user: null } as any;
@@ -195,6 +213,7 @@ export function SettingsContent() {
pdfShowLogo, pdfShowLogo,
pdfShowPageNumbers, pdfShowPageNumbers,
updateAppearance, updateAppearance,
updateAppearanceDebounced,
isUpdating: appearanceUpdating, isUpdating: appearanceUpdating,
} = useAppearance(); } = useAppearance();
const activePreset = themePresets[interfaceTheme]; const activePreset = themePresets[interfaceTheme];
@@ -203,7 +222,9 @@ export function SettingsContent() {
activePreset.headingFontPreference !== headingFontPreference || activePreset.headingFontPreference !== headingFontPreference ||
activePreset.colorTheme !== colorTheme || activePreset.colorTheme !== colorTheme ||
activePreset.radiusPreference !== radiusPreference || activePreset.radiusPreference !== radiusPreference ||
activePreset.sidebarStyle !== sidebarStyle; activePreset.sidebarStyle !== sidebarStyle ||
activePreset.pdfTemplate !== pdfTemplate ||
activePreset.pdfAccentColor !== pdfAccentColor;
const customColorValue = customColor ?? "142.1 76.2% 36.3%"; const customColorValue = customColor ?? "142.1 76.2% 36.3%";
const selectAccent = (nextColorTheme: ColorTheme) => { const selectAccent = (nextColorTheme: ColorTheme) => {
updateAppearance({ updateAppearance({
@@ -249,10 +270,6 @@ export function SettingsContent() {
api.settings.getProfile.useQuery(); api.settings.getProfile.useQuery();
const isAdmin = profile?.role === "admin"; const isAdmin = profile?.role === "admin";
const { data: dataStats } = api.settings.getDataStats.useQuery(); const { data: dataStats } = api.settings.getDataStats.useQuery();
const { data: accounts = [], refetch: refetchAccounts } =
api.settings.listAccounts.useQuery(undefined, {
enabled: isAdmin,
});
// Mutations // Mutations
const updateProfileMutation = api.settings.updateProfile.useMutation({ const updateProfileMutation = api.settings.updateProfile.useMutation({
@@ -321,16 +338,6 @@ export function SettingsContent() {
toast.error(`Delete failed: ${error.message}`); toast.error(`Delete failed: ${error.message}`);
}, },
}); });
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
onSuccess: () => {
toast.success("Account role updated");
void refetchAccounts();
},
onError: (error: { message: string }) => {
toast.error(`Failed to update role: ${error.message}`);
},
});
const handleUpdateProfile = (e: React.FormEvent) => { const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
@@ -449,6 +456,7 @@ export function SettingsContent() {
// Set initial name value when profile loads // Set initial name value when profile loads
React.useEffect(() => { React.useEffect(() => {
if (profile?.name && !name) { if (profile?.name && !name) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
setName(profile.name); setName(profile.name);
} }
if (session?.user) { if (session?.user) {
@@ -483,13 +491,10 @@ export function SettingsContent() {
]; ];
return ( return (
<Tabs defaultValue="general" className="space-y-4"> <Tabs defaultValue="general">
<TabsList <TabsList className="bg-muted/50 grid w-full grid-cols-3">
className={`bg-muted/50 grid w-full ${isAdmin ? "grid-cols-4 lg:w-[520px]" : "grid-cols-3 lg:w-[400px]"}`}
>
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="preferences">Preferences</TabsTrigger> <TabsTrigger value="preferences">Preferences</TabsTrigger>
{isAdmin && <TabsTrigger value="admin">Admin</TabsTrigger>}
<TabsTrigger value="data">Data</TabsTrigger> <TabsTrigger value="data">Data</TabsTrigger>
</TabsList> </TabsList>
@@ -729,7 +734,9 @@ export function SettingsContent() {
<Input <Input
value={brandName} value={brandName}
onChange={(event) => onChange={(event) =>
updateAppearance({ brandName: event.target.value }) updateAppearanceDebounced({
brandName: event.target.value,
})
} }
/> />
</div> </div>
@@ -739,7 +746,9 @@ export function SettingsContent() {
<Input <Input
value={brandLogoText} value={brandLogoText}
onChange={(event) => onChange={(event) =>
updateAppearance({ brandLogoText: event.target.value }) updateAppearanceDebounced({
brandLogoText: event.target.value,
})
} }
/> />
</div> </div>
@@ -749,7 +758,9 @@ export function SettingsContent() {
<Input <Input
value={brandIcon} value={brandIcon}
onChange={(event) => onChange={(event) =>
updateAppearance({ brandIcon: event.target.value }) updateAppearanceDebounced({
brandIcon: event.target.value,
})
} }
/> />
</div> </div>
@@ -759,7 +770,9 @@ export function SettingsContent() {
<Input <Input
value={brandTagline} value={brandTagline}
onChange={(event) => onChange={(event) =>
updateAppearance({ brandTagline: event.target.value }) updateAppearanceDebounced({
brandTagline: event.target.value,
})
} }
/> />
</div> </div>
@@ -826,8 +839,8 @@ export function SettingsContent() {
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground text-xs leading-snug"> <p className="text-muted-foreground text-xs leading-snug">
Applies the theme, fonts, accent, corner radius, and Applies the theme, fonts, accent, corner radius,
navigation chrome. navigation chrome, and PDF defaults.
</p> </p>
<p className="text-muted-foreground text-xs leading-snug"> <p className="text-muted-foreground text-xs leading-snug">
{ {
@@ -1013,32 +1026,25 @@ export function SettingsContent() {
</button> </button>
</div> </div>
{colorTheme === "custom" && ( {colorTheme === "custom" && (
<div className="flex flex-col gap-2 sm:flex-row"> <div className="space-y-2">
<label className="border-input bg-background hover:bg-muted flex h-10 w-full cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-xs transition-colors sm:w-40"> <InputColor
<span label="Custom Accent"
className="size-5 rounded-sm border" value={hslChannelsToHex(customColorValue)}
style={{ onBlur={() => undefined}
backgroundColor: `hsl(${customColorValue})`, onChange={(value) => {
}} if (isFullHexColor(value)) {
/> updateAppearanceDebounced({
Pick color
<input
type="color"
value={hslChannelsToHex(customColorValue)}
onChange={(event) =>
updateAppearance({
colorTheme: "custom", colorTheme: "custom",
customColor: hexToHslChannels(event.target.value), customColor: hexToHslChannels(value),
}) });
} }
className="sr-only" }}
aria-label="Pick custom accent color" className="mt-0"
/> />
</label>
<Input <Input
value={customColorValue} value={customColorValue}
onChange={(event) => onChange={(event) =>
updateAppearance({ updateAppearanceDebounced({
colorTheme: "custom", colorTheme: "custom",
customColor: event.target.value, customColor: event.target.value,
}) })
@@ -1138,119 +1144,6 @@ export function SettingsContent() {
</div> </div>
</section> </section>
<section className="space-y-4 border-t pt-6">
<div>
<h3 className="text-sm font-medium">PDF</h3>
<p className="text-muted-foreground text-xs">
Controls the generated invoice PDF used for downloads and
email attachments.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="flex items-center gap-2">
<FileText className="h-4 w-4" />
PDF Template
</Label>
<Select
value={pdfTemplate}
onValueChange={(value) =>
updateAppearance({
pdfTemplate: value as typeof pdfTemplate,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="classic">Classic</SelectItem>
<SelectItem value="minimal">Minimal</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
Minimal removes shaded table fills for a cleaner document.
</p>
</div>
<div className="space-y-2">
<Label>PDF Accent</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<label className="border-input bg-background hover:bg-muted flex h-10 w-full cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-xs transition-colors sm:w-40">
<span
className="size-5 rounded-sm border"
style={{ backgroundColor: pdfAccentColor }}
/>
Pick color
<input
type="color"
value={pdfAccentColor}
onChange={(event) =>
updateAppearance({
pdfAccentColor: event.target.value,
})
}
className="sr-only"
aria-label="Pick PDF accent color"
/>
</label>
<Input
value={pdfAccentColor}
onChange={(event) =>
updateAppearance({
pdfAccentColor: event.target.value,
})
}
placeholder="#111827"
/>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Footer Text</Label>
<Input
value={pdfFooterText}
onChange={(event) =>
updateAppearance({ pdfFooterText: event.target.value })
}
/>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-3">
<div className="space-y-1">
<Label>Show Logo</Label>
<p className="text-muted-foreground text-xs">
Include the beenvoice logo in the PDF footer.
</p>
</div>
<Switch
checked={pdfShowLogo}
onCheckedChange={(checked) =>
updateAppearance({ pdfShowLogo: Boolean(checked) })
}
aria-label="Toggle PDF logo"
/>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-3">
<div className="space-y-1">
<Label>Page Numbers</Label>
<p className="text-muted-foreground text-xs">
Show page count in the PDF footer.
</p>
</div>
<Switch
checked={pdfShowPageNumbers}
onCheckedChange={(checked) =>
updateAppearance({
pdfShowPageNumbers: Boolean(checked),
})
}
aria-label="Toggle PDF page numbers"
/>
</div>
</div>
</section>
{appearanceUpdating && ( {appearanceUpdating && (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Saving appearance... Saving appearance...
@@ -1260,6 +1153,130 @@ export function SettingsContent() {
)} )}
</Card> </Card>
{isAdmin && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Invoice Settings
</CardTitle>
<CardDescription>
Configure generated invoice PDFs and preview the real document
output.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
<div className="space-y-2">
<Label className="flex items-center gap-2">
<FileText className="h-4 w-4" />
PDF Template
</Label>
<Select
value={pdfTemplate}
onValueChange={(value) =>
updateAppearance({
pdfTemplate: value as typeof pdfTemplate,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="classic">Classic</SelectItem>
<SelectItem value="minimal">Minimal</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
Minimal removes shaded table fills for a cleaner
document.
</p>
</div>
<div className="space-y-2">
<InputColor
label="PDF Accent"
value={pdfAccentColor}
onBlur={() => undefined}
onChange={(value) => {
if (isFullHexColor(value)) {
updateAppearance({
pdfAccentColor: value,
});
}
}}
className="mt-0"
/>
</div>
</div>
<div className="space-y-2">
<Label>Footer Text</Label>
<Input
value={pdfFooterText}
onChange={(event) =>
updateAppearanceDebounced({
pdfFooterText: event.target.value,
})
}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div className="flex items-start justify-between gap-4 border p-3">
<div className="space-y-1">
<Label>Show Logo</Label>
<p className="text-muted-foreground text-xs">
Include the beenvoice logo in the PDF footer.
</p>
</div>
<Switch
checked={pdfShowLogo}
onCheckedChange={(checked) =>
updateAppearance({ pdfShowLogo: Boolean(checked) })
}
aria-label="Toggle PDF logo"
/>
</div>
<div className="flex items-start justify-between gap-4 border p-3">
<div className="space-y-1">
<Label>Page Numbers</Label>
<p className="text-muted-foreground text-xs">
Show page count in the PDF footer.
</p>
</div>
<Switch
checked={pdfShowPageNumbers}
onCheckedChange={(checked) =>
updateAppearance({
pdfShowPageNumbers: Boolean(checked),
})
}
aria-label="Toggle PDF page numbers"
/>
</div>
</div>
</div>
<PdfPreviewFrame
businessName={brandName}
settings={{
pdfTemplate,
pdfAccentColor,
pdfFooterText,
pdfShowLogo,
pdfShowPageNumbers,
}}
/>
</div>
</CardContent>
</Card>
)}
{/* Accessibility & Animation */} {/* Accessibility & Animation */}
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
@@ -1357,57 +1374,6 @@ export function SettingsContent() {
</Card> </Card>
</TabsContent> </TabsContent>
{isAdmin && (
<TabsContent value="admin" className="space-y-8">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
Accounts
</CardTitle>
<CardDescription>
Manage account access and roles without opening customer data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{accounts.map((account) => (
<div
key={account.id}
className="border-border flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium">{account.name}</p>
<p className="text-muted-foreground truncate text-xs">
{account.email}
</p>
<p className="text-muted-foreground mt-1 text-xs">
Created {new Date(account.createdAt).toLocaleDateString()}
</p>
</div>
<Select
value={account.role}
onValueChange={(role) =>
updateAccountRoleMutation.mutate({
userId: account.id,
role: role as "user" | "admin",
})
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
))}
</CardContent>
</Card>
</TabsContent>
)}
<TabsContent value="data" className="space-y-8"> <TabsContent value="data" className="space-y-8">
{/* Data Overview */} {/* Data Overview */}
<Card className="form-section bg-card border-border border"> <Card className="form-section bg-card border-border border">
+5 -10
View File
@@ -3,7 +3,6 @@ import { HydrateClient } from "~/trpc/server";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
import { SettingsContent } from "./_components/settings-content"; import { SettingsContent } from "./_components/settings-content";
import { Card, CardContent } from "~/components/ui/card";
export default async function SettingsPage() { export default async function SettingsPage() {
return ( return (
@@ -14,15 +13,11 @@ export default async function SettingsPage() {
variant="gradient" variant="gradient"
/> />
<Card> <HydrateClient>
<CardContent className="p-6"> <Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<HydrateClient> <SettingsContent />
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}> </Suspense>
<SettingsContent /> </HydrateClient>
</Suspense>
</HydrateClient>
</CardContent>
</Card>
</div> </div>
); );
} }
+28 -15
View File
@@ -1,7 +1,7 @@
import "~/styles/globals.css"; import "~/styles/globals.css";
import { type Metadata } from "next"; import { type Metadata } from "next";
import { Inter, Playfair_Display, Geist_Mono } from "next/font/google"; import localFont from "next/font/local";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
import { Toaster } from "~/components/ui/sonner"; import { Toaster } from "~/components/ui/sonner";
@@ -10,7 +10,6 @@ import { AppearanceProvider } from "~/components/providers/appearance-provider";
import { import {
brand, brand,
defaultBodyFontPreference, defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference, defaultHeadingFontPreference,
defaultInterfaceTheme, defaultInterfaceTheme,
defaultRadiusPreference, defaultRadiusPreference,
@@ -25,20 +24,37 @@ export const metadata: Metadata = {
icons: [{ rel: "icon", url: "/favicon.ico" }], icons: [{ rel: "icon", url: "/favicon.ico" }],
}; };
const inter = Inter({ const geistSans = localFont({
subsets: ["latin"], src: "../../public/fonts/geist/sans/Geist-VariableFont_wght.ttf",
variable: "--font-inter", variable: "--font-geist-sans",
display: "swap", display: "swap",
}); });
const playfair = Playfair_Display({ const playfair = localFont({
subsets: ["latin"], src: "../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
variable: "--font-playfair", variable: "--font-playfair",
display: "swap", display: "swap",
}); });
const geistMono = Geist_Mono({ const frutiger = localFont({
subsets: ["latin"], src: [
{
path: "../../public/fonts/frutiger/Frutiger.ttf",
weight: "400",
style: "normal",
},
{
path: "../../public/fonts/frutiger/Frutiger_bold.ttf",
weight: "700",
style: "normal",
},
],
variable: "--font-frutiger",
display: "swap",
});
const geistMono = localFont({
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
variable: "--font-geist-mono", variable: "--font-geist-mono",
display: "swap", display: "swap",
}); });
@@ -51,14 +67,13 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
lang="en" lang="en"
data-interface-theme={defaultInterfaceTheme} data-interface-theme={defaultInterfaceTheme}
data-font={defaultFontPreference}
data-body-font={defaultBodyFontPreference} data-body-font={defaultBodyFontPreference}
data-heading-font={defaultHeadingFontPreference} data-heading-font={defaultHeadingFontPreference}
data-radius={defaultRadiusPreference} data-radius={defaultRadiusPreference}
data-sidebar-style={defaultSidebarStyle} data-sidebar-style={defaultSidebarStyle}
data-color-mode="system" data-color-mode="system"
data-color-theme="slate" data-color-theme="slate"
className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`} className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
> >
<head> <head>
<script <script
@@ -68,7 +83,6 @@ export default function RootLayout({
try { try {
var defaults = { var defaults = {
interfaceTheme: "${defaultInterfaceTheme}", interfaceTheme: "${defaultInterfaceTheme}",
fontPreference: "${defaultFontPreference}",
bodyFontPreference: "${defaultBodyFontPreference}", bodyFontPreference: "${defaultBodyFontPreference}",
headingFontPreference: "${defaultHeadingFontPreference}", headingFontPreference: "${defaultHeadingFontPreference}",
radiusPreference: "${defaultRadiusPreference}", radiusPreference: "${defaultRadiusPreference}",
@@ -80,9 +94,8 @@ export default function RootLayout({
var appearance = Object.assign(defaults, stored); var appearance = Object.assign(defaults, stored);
var root = document.documentElement; var root = document.documentElement;
root.dataset.interfaceTheme = appearance.interfaceTheme; root.dataset.interfaceTheme = appearance.interfaceTheme;
root.dataset.font = appearance.fontPreference; root.dataset.bodyFont = appearance.bodyFontPreference;
root.dataset.bodyFont = appearance.bodyFontPreference || appearance.fontPreference; root.dataset.headingFont = appearance.headingFontPreference;
root.dataset.headingFont = appearance.headingFontPreference || appearance.fontPreference;
root.dataset.radius = appearance.radiusPreference; root.dataset.radius = appearance.radiusPreference;
root.dataset.sidebarStyle = appearance.sidebarStyle; root.dataset.sidebarStyle = appearance.sidebarStyle;
root.dataset.colorMode = appearance.colorMode; root.dataset.colorMode = appearance.colorMode;
+84 -254
View File
@@ -1,278 +1,108 @@
import Link from "next/link"; import Link from "next/link";
import { Button } from "~/components/ui/button"; import { ArrowRight, FileText, UserRound } from "lucide-react";
import { AuthRedirect } from "~/components/AuthRedirect"; import { AuthRedirect } from "~/components/AuthRedirect";
import { Card, CardContent } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { import { Button } from "~/components/ui/button";
ArrowRight, import { env } from "~/env";
Check,
Zap,
Shield,
BarChart3,
Rocket,
} from "lucide-react";
import { brand } from "~/lib/branding"; import { brand } from "~/lib/branding";
export default function HomePage() { export default function HomePage() {
const allowRegistration = env.DISABLE_SIGNUPS !== true;
return ( return (
<div className="relative min-h-screen overflow-x-hidden"> <main className="bg-background text-foreground min-h-screen">
<AuthRedirect /> <AuthRedirect />
{/* Blob Background for Homepage */} <div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-5 py-5 sm:px-6 lg:px-8">
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden"> <header className="flex items-center justify-between gap-4 border-b py-4">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div> <Logo animated={false} />
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div> <nav className="flex items-center gap-2">
</div> <Link href="/auth/signin">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
{allowRegistration && (
<Link href="/auth/register">
<Button size="sm">Create account</Button>
</Link>
)}
</nav>
</header>
{/* Navigation */} <section className="grid flex-1 items-center gap-10 py-14 md:grid-cols-[1fr_320px] md:py-20">
<nav className="border-border/60 bg-background/80 fixed top-4 right-4 left-4 z-50 m-4 rounded-2xl border backdrop-blur-md"> <div className="max-w-2xl space-y-7">
<div className="mx-auto px-6"> <div className="space-y-4">
<div className="flex h-16 items-center justify-between"> <p className="text-muted-foreground text-sm font-medium">
<Logo /> Personal invoicing
<div className="hidden items-center space-x-8 md:flex"> </p>
<a <h1 className="font-heading text-4xl leading-tight font-bold tracking-normal sm:text-5xl">
href="#features" {brand.name} is a place to make and track invoices.
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" </h1>
> <p className="text-muted-foreground max-w-xl text-base leading-7 sm:text-lg">
Features Built for one person managing real clients, real work, and the
</a> small admin loop around getting paid.
<a </p>
href="#pricing"
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
>
Pricing
</a>
</div> </div>
<div className="flex items-center space-x-4">
<div className="flex flex-col gap-3 sm:flex-row">
<Link href="/auth/signin"> <Link href="/auth/signin">
<Button <Button size="lg" className="h-11 px-5">
variant="ghost" Open workspace
size="sm" <ArrowRight className="ml-2 h-4 w-4" />
className="text-muted-foreground hover:text-foreground"
>
Sign In
</Button> </Button>
</Link> </Link>
<Link href="/auth/register"> {allowRegistration && (
<Button size="sm" variant="default" className="rounded-xl px-6"> <Link href="/auth/register">
Get Started <Button variant="outline" size="lg" className="h-11 px-5">
</Button> Create account
</Link>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="relative pt-48 pb-32">
<div className="container mx-auto px-4 text-center">
<div className="mx-auto max-w-4xl">
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 rounded-full border px-4 py-1 text-sm">
<Zap className="mr-2 h-3.5 w-3.5" />
Completely Free for Everyone
</Badge>
<h1 className="text-foreground font-heading mb-8 text-6xl leading-tight font-bold tracking-tight sm:text-7xl lg:text-8xl">
{brand.name} <br />
<span className="text-primary italic">Beautifully Simple.</span>
</h1>
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl font-sans text-xl leading-relaxed">
{brand.tagline}
</p>
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
<Link href="/auth/register">
<Button
size="lg"
className="shadow-primary/20 hover:shadow-primary/30 h-14 rounded-2xl px-10 text-lg shadow-xl transition-all duration-300 hover:shadow-2xl"
>
Start For Free
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<a href="#features">
<Button
variant="outline"
size="lg"
className="border-border/50 bg-background/50 hover:bg-background/80 h-14 rounded-2xl px-10 text-lg backdrop-blur-sm"
>
Learn More
</Button>
</a>
</div>
<div className="text-muted-foreground/80 mt-16 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8">
<div className="flex items-center gap-2">
<Check className="text-primary h-4 w-4" />
<span>No credit card required</span>
</div>
<div className="flex items-center gap-2">
<Check className="text-primary h-4 w-4" />
<span>Setup in 2 minutes</span>
</div>
<div className="flex items-center gap-2">
<Check className="text-primary h-4 w-4" />
<span>Free forever</span>
</div>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="relative py-24">
<div className="relative z-10 container mx-auto px-4">
<div className="mb-20 text-center">
<h2 className="text-foreground font-heading mb-6 text-4xl font-bold sm:text-5xl">
Everything you need to{" "}
<span className="text-primary italic">thrive</span>
</h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-lg">
Powerful features wrapped in a calm, focused interface.
</p>
</div>
<div className="grid gap-8 md:grid-cols-3">
{[
{
icon: Rocket,
title: "Quick Setup",
description:
"Start creating invoices immediately. No complicated setup required.",
items: [
"Simple client management",
"Professional templates",
"Easy invoice sending",
],
},
{
icon: BarChart3,
title: "Payment Tracking",
description:
"Keep track of invoice status and monitor your payments effortlessly.",
items: [
"Invoice status tracking",
"Payment history",
"Overdue notifications",
],
},
{
icon: Shield,
title: "Professional Features",
description:
"Tools that make you look professional and get you paid faster.",
items: [
"PDF generation",
"Custom tax rates",
"Professional numbering",
],
},
].map((feature, i) => (
<Card
key={i}
className="group border-border/40 bg-background/60 backdrop-blur-xl transition-transform duration-500 hover:-translate-y-2"
>
<CardContent className="p-8">
<div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4">
<feature.icon className="h-8 w-8" />
</div>
<h3 className="text-foreground font-heading mb-4 text-2xl font-bold">
{feature.title}
</h3>
<p className="text-muted-foreground mb-6 leading-relaxed">
{feature.description}
</p>
<ul className="space-y-3">
{feature.items.map((item, j) => (
<li
key={j}
className="text-foreground/80 flex items-center gap-3 text-sm"
>
<div className="bg-primary h-1.5 w-1.5 rounded-full" />
{item}
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* Pricing Section */}
<section id="pricing" className="relative overflow-hidden py-24">
<div className="relative z-10 container mx-auto px-4">
<div className="mx-auto mb-16 max-w-4xl text-center">
<h2 className="font-heading mb-6 text-5xl font-bold">
Simple Pricing
</h2>
<p className="text-muted-foreground text-xl">
Focus on your work, not on fees.
</p>
</div>
<div className="mx-auto max-w-md">
<Card className="border-primary/50 shadow-primary/5 bg-background/80 relative overflow-visible shadow-2xl backdrop-blur-xl">
<div className="bg-primary text-primary-foreground absolute -top-4 left-1/2 -translate-x-1/2 rounded-full px-6 py-1.5 text-sm font-medium shadow-lg">
Forever Free
</div>
<CardContent className="p-10 text-center">
<div className="font-heading mb-2 text-6xl font-bold">$0</div>
<div className="text-muted-foreground mb-8">
No credit card required.
</div>
<div className="mb-10 space-y-4 pl-8 text-left">
{[
"Unlimited Invoices",
"Unlimited Clients",
"PDF Downloads",
"Payment Tracking",
"Email Support",
].map((item, i) => (
<div key={i} className="flex items-center gap-3">
<Check className="text-primary h-5 w-5 shrink-0" />
<span className="text-foreground/90">{item}</span>
</div>
))}
</div>
<Link href="/auth/register" className="block">
<Button size="lg" className="h-12 w-full rounded-xl text-lg">
Get Started
</Button> </Button>
</Link> </Link>
</CardContent> )}
</Card> </div>
</div> </div>
</div>
</section>
{/* Footer */} <div className="border-border bg-card text-card-foreground rounded-xl border p-5 shadow-sm">
<footer className="border-border/40 bg-background/50 mt-12 border-t py-12 backdrop-blur-sm"> <div className="space-y-5">
<div className="container mx-auto flex flex-col items-center justify-between gap-6 px-6 md:flex-row"> <div className="flex items-start gap-3">
<div className="flex items-center gap-3"> <div className="bg-primary/10 text-primary rounded-md p-2">
<Logo size="sm" /> <UserRound className="h-4 w-4" />
<span className="text-muted-foreground text-sm"> </div>
© 2024 beenvoice <div>
</span> <h2 className="text-sm font-semibold">Clients</h2>
<p className="text-muted-foreground mt-1 text-sm leading-6">
Keep the people and businesses you invoice in one place.
</p>
</div>
</div>
<div className="flex items-start gap-3 border-t pt-5">
<div className="bg-primary/10 text-primary rounded-md p-2">
<FileText className="h-4 w-4" />
</div>
<div>
<h2 className="text-sm font-semibold">Invoices</h2>
<p className="text-muted-foreground mt-1 text-sm leading-6">
Draft, send, mark paid, and export the PDF when you need it.
</p>
</div>
</div>
</div>
</div> </div>
<div className="text-muted-foreground flex gap-8 text-sm"> </section>
<a href="#" className="hover:text-foreground transition-colors">
<footer className="text-muted-foreground flex flex-col gap-3 border-t py-5 text-sm sm:flex-row sm:items-center sm:justify-between">
<span>© 2026 {brand.name}</span>
<div className="flex gap-5">
<Link href="/privacy" className="hover:text-foreground">
Privacy Privacy
</a> </Link>
<a href="#" className="hover:text-foreground transition-colors"> <Link href="/terms" className="hover:text-foreground">
Terms Terms
</a> </Link>
<a href="#" className="hover:text-foreground transition-colors">
Contact
</a>
</div> </div>
</div> </footer>
</footer> </div>
</div> </main>
); );
} }
+14 -14
View File
@@ -4,20 +4,20 @@ import Script from "next/script";
import { env } from "~/env"; import { env } from "~/env";
export function UmamiScript() { export function UmamiScript() {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
return null; return null;
} }
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) { if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
return null; return null;
} }
return ( return (
<Script <Script
defer defer
src={env.NEXT_PUBLIC_UMAMI_SCRIPT_URL} src={env.NEXT_PUBLIC_UMAMI_SCRIPT_URL}
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID} data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
strategy="afterInteractive" strategy="afterInteractive"
/> />
); );
} }
@@ -64,7 +64,7 @@ export function AddressAutocomplete({
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
/> />
{showSuggestions && suggestions.length > 0 && ( {showSuggestions && suggestions.length > 0 && (
<Card className="bg-card border-border border absolute z-10 mt-1 max-h-60 w-full overflow-auto"> <Card className="bg-card border-border absolute z-10 mt-1 max-h-60 w-full overflow-auto border">
<ul> <ul>
{suggestions.map((s) => ( {suggestions.map((s) => (
<li <li
+25 -10
View File
@@ -11,10 +11,24 @@ interface LogoProps {
animated?: boolean; animated?: boolean;
} }
function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
export function Logo({ className, size = "md", animated = true }: LogoProps) { export function Logo({ className, size = "md", animated = true }: LogoProps) {
const appearance = useAppearance(); const appearance = useAppearance();
const logoText = appearance.brandLogoText || brand.logoText; const logoText = appearance.brandLogoText || brand.logoText;
const icon = appearance.brandIcon || brand.icon; const icon = appearance.brandIcon || brand.icon;
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
const sizeClasses = { const sizeClasses = {
sm: "text-base", sm: "text-base",
md: "text-xl", md: "text-xl",
@@ -29,7 +43,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
className={className} className={className}
size={size} size={size}
sizeClasses={sizeClasses} sizeClasses={sizeClasses}
logoText={logoText} logoPrefix={logoPrefix}
logoSuffix={logoSuffix}
icon={icon} icon={icon}
/> />
); );
@@ -68,7 +83,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.04, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.04, duration: 0.05, ease: "easeOut" }}
className="text-foreground font-bold tracking-tight" className="text-foreground font-bold tracking-tight"
> >
{logoText.slice(0, Math.ceil(logoText.length / 2))} {logoPrefix}
</motion.span> </motion.span>
<motion.span <motion.span
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -76,7 +91,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.06, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.06, duration: 0.05, ease: "easeOut" }}
className="text-foreground/70 font-bold tracking-tight" className="text-foreground/70 font-bold tracking-tight"
> >
{logoText.slice(Math.ceil(logoText.length / 2))} {logoSuffix}
</motion.span> </motion.span>
</> </>
)} )}
@@ -88,13 +103,15 @@ function LogoContent({
className, className,
size, size,
sizeClasses, sizeClasses,
logoText, logoPrefix,
logoSuffix,
icon, icon,
}: { }: {
className?: string; className?: string;
size: "sm" | "md" | "lg" | "xl" | "icon"; size: "sm" | "md" | "lg" | "xl" | "icon";
sizeClasses: Record<string, string>; sizeClasses: Record<string, string>;
logoText: string; logoPrefix: string;
logoSuffix: string;
icon: string; icon: string;
}) { }) {
return ( return (
@@ -105,17 +122,15 @@ function LogoContent({
className, className,
)} )}
> >
<span className="text-primary font-bold tracking-tight"> <span className="text-primary font-bold tracking-tight">{icon}</span>
{icon}
</span>
{size !== "icon" && ( {size !== "icon" && (
<> <>
<span className="inline-block w-1"></span> <span className="inline-block w-1"></span>
<span className="text-foreground font-bold tracking-tight"> <span className="text-foreground font-bold tracking-tight">
{logoText.slice(0, Math.ceil(logoText.length / 2))} {logoPrefix}
</span> </span>
<span className="text-foreground/70 font-bold tracking-tight"> <span className="text-foreground/70 font-bold tracking-tight">
{logoText.slice(Math.ceil(logoText.length / 2))} {logoSuffix}
</span> </span>
</> </>
)} )}
+6 -9
View File
@@ -460,7 +460,7 @@ export function CSVImportPage() {
applyGlobalClient(newClientId); applyGlobalClient(newClientId);
} }
}} }}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients} disabled={loadingClients}
> >
<option value="">No default client (select individually)</option> <option value="">No default client (select individually)</option>
@@ -506,7 +506,7 @@ export function CSVImportPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4"> <div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
<div className="text-center"> <div className="text-center">
<div className="text-primary text-2xl font-bold"> <div className="text-primary text-2xl font-bold">
{totalFiles} {totalFiles}
@@ -556,10 +556,7 @@ export function CSVImportPage() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{files.map((fileData, index) => ( {files.map((fileData, index) => (
<div <div key={index} className="border-border bg-card border p-4">
key={index}
className="border-border bg-card border p-4"
>
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FileText className="text-primary h-5 w-5" /> <FileText className="text-primary h-5 w-5" />
@@ -619,7 +616,7 @@ export function CSVImportPage() {
onChange={(e) => onChange={(e) =>
updateFileData(index, { clientId: e.target.value }) updateFileData(index, { clientId: e.target.value })
} }
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients} disabled={loadingClients}
> >
<option value="">Select Client</option> <option value="">Select Client</option>
@@ -662,7 +659,7 @@ export function CSVImportPage() {
{/* Error Display */} {/* Error Display */}
{fileData.errors.length > 0 && ( {fileData.errors.length > 0 && (
<div className="border-destructive/20 bg-destructive/10 mt-4 border p-3"> <div className="border-destructive/20 bg-destructive/10 mt-4 border p-3">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" /> <AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium"> <span className="text-destructive text-sm font-medium">
@@ -772,7 +769,7 @@ export function CSVImportPage() {
{/* Preview Modal */} {/* Preview Modal */}
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}> <Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
<DialogContent className="bg-card border-border border flex max-h-[90vh] max-w-4xl flex-col"> <DialogContent className="bg-card border-border flex max-h-[90vh] max-w-4xl flex-col border">
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
<DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold"> <DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold">
<FileText className="text-primary h-5 w-5" /> <FileText className="text-primary h-5 w-5" />
+35 -42
View File
@@ -3,6 +3,7 @@
import type { import type {
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
RowData,
SortingState, SortingState,
VisibilityState, VisibilityState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
@@ -53,6 +54,14 @@ import {
} from "~/components/ui/table"; } from "~/components/ui/table";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Generic names must match TanStack's declaration for module augmentation.
interface ColumnMeta<TData extends RowData, TValue> {
headerClassName?: string;
cellClassName?: string;
}
}
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
data: TData[]; data: TData[];
@@ -125,23 +134,9 @@ export function DataTable<TData, TValue>({
...column, ...column,
// Add a meta property to control responsive visibility // Add a meta property to control responsive visibility
meta: { meta: {
...(( ...(column.meta ?? {}),
column as ColumnDef<TData, TValue> & { headerClassName: column.meta?.headerClassName ?? "",
meta?: { headerClassName?: string; cellClassName?: string }; cellClassName: column.meta?.cellClassName ?? "",
}
).meta ?? {}),
headerClassName:
(
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta?.headerClassName ?? "",
cellClassName:
(
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta?.cellClassName ?? "",
}, },
})); }));
}, [columns]); }, [columns]);
@@ -369,9 +364,7 @@ export function DataTable<TData, TValue>({
className="bg-muted/50 hover:bg-muted/50" className="bg-muted/50 hover:bg-muted/50"
> >
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as const meta = header.column.columnDef.meta;
| { headerClassName?: string; cellClassName?: string }
| undefined;
return ( return (
<TableHead <TableHead
key={header.id} key={header.id}
@@ -383,9 +376,9 @@ export function DataTable<TData, TValue>({
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext(), header.getContext(),
)} )}
</TableHead> </TableHead>
); );
})} })}
@@ -407,9 +400,7 @@ export function DataTable<TData, TValue>({
} }
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as const meta = cell.column.columnDef.meta;
| { headerClassName?: string; cellClassName?: string }
| undefined;
return ( return (
<TableCell <TableCell
key={cell.id} key={cell.id}
@@ -451,26 +442,28 @@ export function DataTable<TData, TValue>({
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm"> <p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
{table.getFilteredRowModel().rows.length === 0 {table.getFilteredRowModel().rows.length === 0
? "No entries" ? "No entries"
: `Showing ${table.getState().pagination.pageIndex * : `Showing ${
table.getState().pagination.pageSize + table.getState().pagination.pageIndex *
1 table.getState().pagination.pageSize +
} to ${Math.min( 1
(table.getState().pagination.pageIndex + 1) * } to ${Math.min(
table.getState().pagination.pageSize, (table.getState().pagination.pageIndex + 1) *
table.getFilteredRowModel().rows.length, table.getState().pagination.pageSize,
)} of ${table.getFilteredRowModel().rows.length} entries`} table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length} entries`}
</p> </p>
<p className="text-muted-foreground text-xs sm:hidden"> <p className="text-muted-foreground text-xs sm:hidden">
{table.getFilteredRowModel().rows.length === 0 {table.getFilteredRowModel().rows.length === 0
? "0" ? "0"
: `${table.getState().pagination.pageIndex * : `${
table.getState().pagination.pageSize + table.getState().pagination.pageIndex *
1 table.getState().pagination.pageSize +
}-${Math.min( 1
(table.getState().pagination.pageIndex + 1) * }-${Math.min(
table.getState().pagination.pageSize, (table.getState().pagination.pageIndex + 1) *
table.getFilteredRowModel().rows.length, table.getState().pagination.pageSize,
)} of ${table.getFilteredRowModel().rows.length}`} table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length}`}
</p> </p>
<Select <Select
value={table.getState().pagination.pageSize.toString()} value={table.getState().pagination.pageSize.toString()}
@@ -87,8 +87,9 @@ function SortableItem({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`card-secondary transition-colors ${isDragging ? "opacity-50 shadow-lg" : "" className={`card-secondary transition-colors ${
}`} isDragging ? "opacity-50 shadow-lg" : ""
}`}
> >
{/* Desktop Layout - Hidden on Mobile */} {/* Desktop Layout - Hidden on Mobile */}
<div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12"> <div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12">
@@ -153,7 +154,7 @@ function SortableItem({
{/* Amount */} {/* Amount */}
<div className="col-span-1"> <div className="col-span-1">
<div className="bg-muted/30 text-primary flex h-9 items-center border px-3 font-medium"> <div className="bg-muted/30 text-primary flex h-9 items-center border px-3 font-medium">
${item.amount.toFixed(2)} ${item.amount.toFixed(2)}
</div> </div>
</div> </div>
@@ -265,7 +266,7 @@ function SortableItem({
</div> </div>
{/* Amount */} {/* Amount */}
<div className="bg-muted/20 border p-3"> <div className="bg-muted/20 border p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">Total Amount:</span> <span className="text-muted-foreground text-sm">Total Amount:</span>
<span className="text-primary font-mono text-lg font-bold"> <span className="text-primary font-mono text-lg font-bold">
@@ -360,10 +361,7 @@ export function EditableInvoiceItems({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{items.map((item, _index) => ( {items.map((item, _index) => (
<div <div key={item.id} className="card-secondary animate-pulse p-4">
key={item.id}
className="card-secondary animate-pulse p-4"
>
{/* Desktop Skeleton */} {/* Desktop Skeleton */}
<div className="hidden grid-cols-12 gap-3 md:grid"> <div className="hidden grid-cols-12 gap-3 md:grid">
<div className="col-span-1"> <div className="col-span-1">
+1 -1
View File
@@ -80,7 +80,7 @@ export function StatsCard({
)} )}
</div> </div>
{Icon && ( {Icon && (
<div className={cn(" p-3", styles.background)}> <div className={cn("p-3", styles.background)}>
<Icon className={cn("h-6 w-6", styles.icon)} /> <Icon className={cn("h-6 w-6", styles.icon)} />
</div> </div>
)} )}
+1
View File
@@ -143,6 +143,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Load business data when editing // Load business data when editing
useEffect(() => { useEffect(() => {
if (business && mode === "edit") { if (business && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
setFormData({ setFormData({
name: business.name, name: business.name,
nickname: business.nickname ?? "", nickname: business.nickname ?? "",
+1
View File
@@ -119,6 +119,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
// Load client data when editing // Load client data when editing
useEffect(() => { useEffect(() => {
if (client && mode === "edit") { if (client && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
setFormData({ setFormData({
name: client.name, name: client.name,
email: client.email ?? "", email: client.email ?? "",
+4 -4
View File
@@ -56,7 +56,7 @@ function FilePreview({
return ( return (
<div <div
className={cn( className={cn(
"flex items-center justify-between border p-3", "flex items-center justify-between border p-3",
getStatusColor(), getStatusColor(),
)} )}
> >
@@ -152,7 +152,7 @@ export function FileUpload({
<div <div
{...getRootProps()} {...getRootProps()}
className={cn( className={cn(
"cursor-pointer border-2 border-dashed p-8 text-center transition-colors", "cursor-pointer border-2 border-dashed p-8 text-center transition-colors",
"hover:border-primary/40 hover:bg-primary/10", "hover:border-primary/40 hover:bg-primary/10",
isDragActive && "border-primary/40 bg-primary/10", isDragActive && "border-primary/40 bg-primary/10",
isDragReject && "border-destructive/40 bg-destructive/10", isDragReject && "border-destructive/40 bg-destructive/10",
@@ -164,7 +164,7 @@ export function FileUpload({
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div <div
className={cn( className={cn(
" p-3 transition-colors", "p-3 transition-colors",
isDragActive ? "bg-primary/10" : "bg-muted", isDragActive ? "bg-primary/10" : "bg-muted",
isDragReject && "bg-destructive/10", isDragReject && "bg-destructive/10",
)} )}
@@ -222,7 +222,7 @@ export function FileUpload({
{/* Error Summary */} {/* Error Summary */}
{Object.keys(errors).length > 0 && ( {Object.keys(errors).length > 0 && (
<div className="border-destructive/20 bg-destructive/10 border p-3"> <div className="border-destructive/20 bg-destructive/10 border p-3">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" /> <AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium"> <span className="text-destructive text-sm font-medium">
+492 -370
View File
@@ -1,398 +1,520 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, subWeeks, addWeeks, subMonths, addMonths } from "date-fns"; import {
format,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameDay,
subWeeks,
addWeeks,
subMonths,
addMonths,
} from "date-fns";
import { Calendar } from "~/components/ui/calendar"; import { Calendar } from "~/components/ui/calendar";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
SheetFooter, SheetFooter,
} from "~/components/ui/sheet"; } from "~/components/ui/sheet";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { Plus, Trash2, Clock, Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"; import {
Plus,
Trash2,
Clock,
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface InvoiceItem { interface InvoiceItem {
id: string; id: string;
date: Date; date: Date;
description: string; description: string;
hours: number; hours: number;
rate: number; rate: number;
amount: number; amount: number;
} }
interface InvoiceCalendarViewProps { interface InvoiceCalendarViewProps {
items: InvoiceItem[]; items: InvoiceItem[];
onUpdateItem: ( onUpdateItem: (
index: number, index: number,
field: string, field: string,
value: string | number | Date value: string | number | Date,
) => void; ) => void;
onAddItem: (date?: Date) => void; onAddItem: (date?: Date) => void;
onRemoveItem: (index: number) => void; onRemoveItem: (index: number) => void;
className?: string; className?: string;
defaultHourlyRate: number | null; defaultHourlyRate: number | null;
} }
export function InvoiceCalendarView({ export function InvoiceCalendarView({
items, items,
onUpdateItem, onUpdateItem,
onAddItem, onAddItem,
onRemoveItem, onRemoveItem,
className, className,
defaultHourlyRate: _defaultHourlyRate, defaultHourlyRate: _defaultHourlyRate,
}: InvoiceCalendarViewProps) { }: InvoiceCalendarViewProps) {
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week) const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
const [view, setView] = React.useState<"month" | "week">("month"); const [view, setView] = React.useState<"month" | "week">("month");
const [sheetOpen, setSheetOpen] = React.useState(false); const [sheetOpen, setSheetOpen] = React.useState(false);
// Derived state for selected date items - solves cursor jumping // Derived state for selected date items - solves cursor jumping
const selectedDateItems = React.useMemo(() => { const selectedDateItems = React.useMemo(() => {
if (!date) return []; if (!date) return [];
return items return items
.map((item, index) => ({ item, index })) .map((item, index) => ({ item, index }))
.filter((wrapper) => { .filter((wrapper) => {
const itemDate = new Date(wrapper.item.date); const itemDate = new Date(wrapper.item.date);
return isSameDay(itemDate, date); return isSameDay(itemDate, date);
}); });
}, [items, date]); }, [items, date]);
// Helper to get items for any date (for calendar view) // Helper to get items for any date (for calendar view)
const getItemsForDate = React.useCallback((targetDate: Date) => { const getItemsForDate = React.useCallback(
return items (targetDate: Date) => {
.map((item, index) => ({ item, index })) return items
.filter((wrapper) => { .map((item, index) => ({ item, index }))
const itemDate = new Date(wrapper.item.date); .filter((wrapper) => {
return isSameDay(itemDate, targetDate); const itemDate = new Date(wrapper.item.date);
}); return isSameDay(itemDate, targetDate);
}, [items]); });
},
[items],
);
const handleSelectDate = (newDate: Date | undefined) => { const handleSelectDate = (newDate: Date | undefined) => {
if (!newDate) return; if (!newDate) return;
setDate(newDate); setDate(newDate);
setSheetOpen(true); setSheetOpen(true);
}; };
const handleAddNewItem = () => { const handleAddNewItem = () => {
if (date) { if (date) {
onAddItem(date); onAddItem(date);
} }
}; };
// Week View Logic - Uses viewDate // Week View Logic - Uses viewDate
const currentWeekStart = startOfWeek(viewDate); const currentWeekStart = startOfWeek(viewDate);
const currentWeekEnd = endOfWeek(viewDate); const currentWeekEnd = endOfWeek(viewDate);
const weekDays = eachDayOfInterval({ start: currentWeekStart, end: currentWeekEnd }); const weekDays = eachDayOfInterval({
start: currentWeekStart,
end: currentWeekEnd,
});
const handleCloseSheet = (isOpen: boolean) => { const handleCloseSheet = (isOpen: boolean) => {
setSheetOpen(isOpen); setSheetOpen(isOpen);
if (!isOpen) { if (!isOpen) {
setDate(undefined); setDate(undefined);
} }
}; };
return ( return (
<div className={cn("flex flex-col gap-4 h-full w-full", className)}> <div className={cn("flex h-full w-full flex-col gap-4", className)}>
<div className="flex items-center justify-between px-4 pt-4 w-full gap-4"> <div className="flex w-full items-center justify-between gap-4 px-4 pt-4">
{/* Navigation Controls */} {/* Navigation Controls */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{view === "week" ? ( {view === "week" ? (
<> <>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subWeeks(d, 1))} className="h-8 w-8 rounded-lg"> <Button
<ChevronLeft className="h-4 w-4" /> variant="outline"
</Button> size="icon"
<span className="text-sm font-medium w-36 text-center"> onClick={() => setViewDate((d) => subWeeks(d, 1))}
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`} className="h-8 w-8 rounded-lg"
</span> >
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addWeeks(d, 1))} className="h-8 w-8 rounded-lg"> <ChevronLeft className="h-4 w-4" />
<ChevronRight className="h-4 w-4" /> </Button>
</Button> <span className="w-36 text-center text-sm font-medium">
</> {`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
) : ( </span>
<> <Button
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subMonths(d, 1))} className="h-8 w-8 rounded-lg"> variant="outline"
<ChevronLeft className="h-4 w-4" /> size="icon"
</Button> onClick={() => setViewDate((d) => addWeeks(d, 1))}
<span className="text-sm font-medium w-36 text-center"> className="h-8 w-8 rounded-lg"
{format(viewDate, "MMMM yyyy")} >
</span> <ChevronRight className="h-4 w-4" />
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addMonths(d, 1))} className="h-8 w-8 rounded-lg"> </Button>
<ChevronRight className="h-4 w-4" /> </>
</Button> ) : (
</> <>
)} <Button
</div> variant="outline"
size="icon"
<div className="flex items-center space-x-2 ml-auto"> onClick={() => setViewDate((d) => subMonths(d, 1))}
{/* View Switcher */} className="h-8 w-8 rounded-lg"
<div className="bg-muted p-1 rounded-lg flex text-sm"> >
<button <ChevronLeft className="h-4 w-4" />
type="button" </Button>
onClick={() => setView("month")} <span className="w-36 text-center text-sm font-medium">
className={cn("px-3 py-1.5 rounded-md transition-all text-center font-medium", view === "month" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground")} {format(viewDate, "MMMM yyyy")}
> </span>
Month <Button
</button> variant="outline"
<button size="icon"
type="button" onClick={() => setViewDate((d) => addMonths(d, 1))}
onClick={() => setView("week")} className="h-8 w-8 rounded-lg"
className={cn("px-3 py-1.5 rounded-md transition-all text-center font-medium", view === "week" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground")} >
> <ChevronRight className="h-4 w-4" />
Week </Button>
</button> </>
</div> )}
</div>
</div>
<div className="flex-1 w-full overflow-hidden">
{view === "month" ? (
<Calendar
mode="single"
selected={date}
onSelect={handleSelectDate}
month={viewDate}
onMonthChange={setViewDate}
className="rounded-md border-0 w-full p-0"
classNames={{
root: "w-full p-0",
months: "flex flex-col w-full",
month: "flex flex-col w-full space-y-4",
// Grid - Revert to Flex but Enforce 1/7th Width
// table: "w-full border-collapse", // No table-fixed
head_row: "flex w-full",
row: "flex w-full mt-2",
// Cells & Headers: Explicit width 14.28%
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
// Better: w-[14.28%] flex-none (approx 1/7)
weekdays: "flex w-full border-b",
weekday: "w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
week: "flex w-full mt-2",
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
// Hide internal navigation & caption entirely
nav: "hidden",
caption: "hidden",
day: cn(
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl"
),
day_selected: "bg-primary/5 text-primary",
day_today: "bg-accent/20",
day_outside: "text-muted-foreground opacity-30",
}}
formatters={{
formatMonthCaption: () => "", // Clear default caption text to prevent duplication
}}
components={{
DayButton: (props) => {
const { day, modifiers, className, ...buttonProps } = props;
const DayDate = day.date;
const dayItems = getItemsForDate(DayDate);
// const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); // Unused now
return (
<button
{...buttonProps}
type="button"
className={cn(
"relative flex h-full w-full flex-col items-start justify-between p-2 transition-all rounded-xl border border-transparent hover:border-border/50 hover:bg-secondary/30 text-left overflow-hidden",
// Selected State: Filled Box, No Outline
modifiers.selected && "bg-primary text-primary-foreground hover:bg-primary/90 shadow-md transform scale-[0.98]",
modifiers.today && !modifiers.selected && "bg-accent/40 rounded-xl",
className
)}
>
<span className="text-sm font-medium z-10">{DayDate.getDate()}</span>
{dayItems.length > 0 && (
<div className="flex flex-col gap-1 w-full mt-1 overflow-hidden h-full justify-end pb-1">
<div className="flex flex-col gap-1 w-full mt-1">
{dayItems.slice(0, 4).map((item, idx) => (
<div key={idx} className={cn("h-1 w-full rounded-full", modifiers.selected ? "bg-primary-foreground/50" : "bg-primary/50")} />
))}
{dayItems.length > 4 && <div className={cn("h-1 w-1/3 rounded-full", modifiers.selected ? "bg-primary-foreground/30" : "bg-muted-foreground/30")} />}
</div>
</div>
)}
</button>
);
}
}}
/>
) : (
<div className="flex gap-3 overflow-x-auto p-4 pb-6 w-full">
{weekDays.map((day) => {
const isSelected = date && isSameDay(day, date);
const isToday = isSameDay(day, new Date());
const dayItems = getItemsForDate(day);
const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0);
return (
<button
key={day.toString()}
type="button"
onClick={() => handleSelectDate(day)}
className={cn(
"flex flex-col min-h-[260px] flex-shrink-0 w-[120px] sm:flex-1 sm:w-auto border rounded-3xl p-3 text-left transition-all hover:bg-accent/30",
isSelected ? "ring-2 ring-primary ring-offset-2 bg-primary/5" : "bg-background/40",
isToday && !isSelected ? "bg-accent/40" : ""
)}
>
<div className="flex flex-col items-center mb-4 pb-4 border-b w-full">
<span className="text-xs font-bold text-muted-foreground uppercase">{format(day, "EEE")}</span>
<span className="text-2xl font-light">{format(day, "d")}</span>
</div>
<div className="flex-1 space-y-2 w-full overflow-hidden">
{dayItems.length > 0 ? (
dayItems.map(({ item }, i) => (
<div key={i} className="bg-background rounded-xl p-2 text-xs shadow-sm border">
<div className="font-medium line-clamp-2 text-wrap break-words">{item.description || "No description"}</div>
<div className="text-muted-foreground whitespace-nowrap">{item.hours}h</div>
</div>
))
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/20">
<Plus className="w-8 h-8" />
</div>
)}
</div>
{dayItems.length > 0 && (
<div className="pt-2 mt-auto text-center w-full">
<span className="text-sm font-semibold">{totalHours}h Total</span>
</div>
)}
</button>
);
})}
</div>
)}
</div>
{/* Sheet for Day Details */}
<Sheet
open={sheetOpen}
onOpenChange={handleCloseSheet}
>
<SheetContent side="right" className="w-full max-w-full sm:w-[400px] sm:max-w-[540px] flex flex-col gap-0 p-0">
<SheetHeader className="p-6 border-b">
<SheetTitle className="flex items-center gap-3 text-2xl flex-wrap">
<div className="bg-primary/10 p-2.5 rounded-full flex-shrink-0">
<CalendarIcon className="w-6 h-6 text-primary" />
</div>
<span className="break-words text-left">{date ? format(date, "EEEE, MMMM do") : "Details"}</span>
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-6">
{date && selectedDateItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4 bg-secondary/20 rounded-3xl border border-dashed border-border/60">
<div className="bg-background p-4 rounded-full shadow-sm">
<Clock className="w-8 h-8 text-muted-foreground/50" />
</div>
<div className="space-y-1">
<p className="font-semibold text-lg text-foreground">No hours logged</p>
<p className="text-sm text-muted-foreground/80 max-w-[200px]">There are no time entries recorded for this day yet.</p>
</div>
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
<Plus className="w-4 h-4 mr-2" />
Log Time
</Button>
</div>
) : (
<div className="space-y-4">
{selectedDateItems.map(({ item, index }) => (
<div key={item.id} className="border-border bg-card overflow-hidden rounded-lg border group hover:border-primary/50 transition-colors">
<div className="space-y-3 p-4">
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Description</Label>
<Input
value={item.description}
onChange={(e) => onUpdateItem(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
/>
</div>
{/* Hours and Rate in a row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Hours</Label>
<NumberInput
value={item.hours}
onChange={v => onUpdateItem(index, "hours", v)}
step={0.25}
min={0}
width="full"
/>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Rate</Label>
<NumberInput
value={item.rate}
onChange={v => onUpdateItem(index, "rate", v)}
prefix="$"
min={0}
step={1}
width="full"
/>
</div>
</div>
</div>
{/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveItem(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 px-3 text-center">
<span className="text-muted-foreground block text-sm font-medium">
Item #{index + 1}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">Total</span>
<span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
))}
<Button variant="outline" onClick={handleAddNewItem} className="w-full border-dashed py-8 rounded-xl hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary transition-all gap-2 group">
<div className="bg-muted group-hover:bg-primary/10 p-1 rounded-md transition-colors">
<Plus className="w-4 h-4" />
</div>
<span>Add Another Entry</span>
</Button>
</div>
)}
</div>
</div>
<SheetFooter className="p-6 border-t bg-muted/10 mt-auto">
<Button className="w-full sm:w-full rounded-xl h-12 text-base shadow-md" size="lg" onClick={() => handleCloseSheet(false)}>Done</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div> </div>
);
<div className="ml-auto flex items-center space-x-2">
{/* View Switcher */}
<div className="bg-muted flex rounded-lg p-1 text-sm">
<button
type="button"
onClick={() => setView("month")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
view === "month"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
Month
</button>
<button
type="button"
onClick={() => setView("week")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
view === "week"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
Week
</button>
</div>
</div>
</div>
<div className="w-full flex-1 overflow-hidden">
{view === "month" ? (
<Calendar
mode="single"
selected={date}
onSelect={handleSelectDate}
month={viewDate}
onMonthChange={setViewDate}
className="w-full rounded-md border-0 p-0"
classNames={{
root: "w-full p-0",
months: "flex flex-col w-full",
month: "flex flex-col w-full space-y-4",
// Grid - Revert to Flex but Enforce 1/7th Width
// table: "w-full border-collapse", // No table-fixed
head_row: "flex w-full",
row: "flex w-full mt-2",
// Cells & Headers: Explicit width 14.28%
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
// Better: w-[14.28%] flex-none (approx 1/7)
weekdays: "flex w-full border-b",
weekday:
"w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
week: "flex w-full mt-2",
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
// Hide internal navigation & caption entirely
nav: "hidden",
caption: "hidden",
day: cn(
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl",
),
day_selected: "bg-primary/5 text-primary",
day_today: "bg-accent/20",
day_outside: "text-muted-foreground opacity-30",
}}
formatters={{
formatMonthCaption: () => "", // Clear default caption text to prevent duplication
}}
components={{
DayButton: (props) => {
const { day, modifiers, className, ...buttonProps } = props;
const DayDate = day.date;
const dayItems = getItemsForDate(DayDate);
// const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); // Unused now
return (
<button
{...buttonProps}
type="button"
className={cn(
"hover:border-border/50 hover:bg-secondary/30 relative flex h-full w-full flex-col items-start justify-between overflow-hidden rounded-xl border border-transparent p-2 text-left transition-all",
// Selected State: Filled Box, No Outline
modifiers.selected &&
"bg-primary text-primary-foreground hover:bg-primary/90 scale-[0.98] transform shadow-md",
modifiers.today &&
!modifiers.selected &&
"bg-accent/40 rounded-xl",
className,
)}
>
<span className="z-10 text-sm font-medium">
{DayDate.getDate()}
</span>
{dayItems.length > 0 && (
<div className="mt-1 flex h-full w-full flex-col justify-end gap-1 overflow-hidden pb-1">
<div className="mt-1 flex w-full flex-col gap-1">
{dayItems.slice(0, 4).map((item, idx) => (
<div
key={idx}
className={cn(
"h-1 w-full rounded-full",
modifiers.selected
? "bg-primary-foreground/50"
: "bg-primary/50",
)}
/>
))}
{dayItems.length > 4 && (
<div
className={cn(
"h-1 w-1/3 rounded-full",
modifiers.selected
? "bg-primary-foreground/30"
: "bg-muted-foreground/30",
)}
/>
)}
</div>
</div>
)}
</button>
);
},
}}
/>
) : (
<div className="flex w-full gap-3 overflow-x-auto p-4 pb-6">
{weekDays.map((day) => {
const isSelected = date && isSameDay(day, date);
const isToday = isSameDay(day, new Date());
const dayItems = getItemsForDate(day);
const totalHours = dayItems.reduce(
(acc, curr) => acc + curr.item.hours,
0,
);
return (
<button
key={day.toString()}
type="button"
onClick={() => handleSelectDate(day)}
className={cn(
"hover:bg-accent/30 flex min-h-[260px] w-[120px] flex-shrink-0 flex-col rounded-3xl border p-3 text-left transition-all sm:w-auto sm:flex-1",
isSelected
? "ring-primary bg-primary/5 ring-2 ring-offset-2"
: "bg-background/40",
isToday && !isSelected ? "bg-accent/40" : "",
)}
>
<div className="mb-4 flex w-full flex-col items-center border-b pb-4">
<span className="text-muted-foreground text-xs font-bold uppercase">
{format(day, "EEE")}
</span>
<span className="text-2xl font-light">
{format(day, "d")}
</span>
</div>
<div className="w-full flex-1 space-y-2 overflow-hidden">
{dayItems.length > 0 ? (
dayItems.map(({ item }, i) => (
<div
key={i}
className="bg-background rounded-xl border p-2 text-xs shadow-sm"
>
<div className="line-clamp-2 font-medium text-wrap break-words">
{item.description || "No description"}
</div>
<div className="text-muted-foreground whitespace-nowrap">
{item.hours}h
</div>
</div>
))
) : (
<div className="text-muted-foreground/20 flex h-full items-center justify-center">
<Plus className="h-8 w-8" />
</div>
)}
</div>
{dayItems.length > 0 && (
<div className="mt-auto w-full pt-2 text-center">
<span className="text-sm font-semibold">
{totalHours}h Total
</span>
</div>
)}
</button>
);
})}
</div>
)}
</div>
{/* Sheet for Day Details */}
<Sheet open={sheetOpen} onOpenChange={handleCloseSheet}>
<SheetContent
side="right"
className="flex w-full max-w-full flex-col gap-0 p-0 sm:w-[400px] sm:max-w-[540px]"
>
<SheetHeader className="border-b p-6">
<SheetTitle className="flex flex-wrap items-center gap-3 text-2xl">
<div className="bg-primary/10 flex-shrink-0 rounded-full p-2.5">
<CalendarIcon className="text-primary h-6 w-6" />
</div>
<span className="text-left break-words">
{date ? format(date, "EEEE, MMMM do") : "Details"}
</span>
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-6">
{date && selectedDateItems.length === 0 ? (
<div className="bg-secondary/20 border-border/60 flex flex-col items-center justify-center space-y-4 rounded-3xl border border-dashed py-16 text-center">
<div className="bg-background rounded-full p-4 shadow-sm">
<Clock className="text-muted-foreground/50 h-8 w-8" />
</div>
<div className="space-y-1">
<p className="text-foreground text-lg font-semibold">
No hours logged
</p>
<p className="text-muted-foreground/80 max-w-[200px] text-sm">
There are no time entries recorded for this day yet.
</p>
</div>
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
<Plus className="mr-2 h-4 w-4" />
Log Time
</Button>
</div>
) : (
<div className="space-y-4">
{selectedDateItems.map(({ item, index }) => (
<div
key={item.id}
className="border-border bg-card group hover:border-primary/50 overflow-hidden rounded-lg border transition-colors"
>
<div className="space-y-3 p-4">
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">
Description
</Label>
<Input
value={item.description}
onChange={(e) =>
onUpdateItem(index, "description", e.target.value)
}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
/>
</div>
{/* Hours and Rate in a row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">
Hours
</Label>
<NumberInput
value={item.hours}
onChange={(v) => onUpdateItem(index, "hours", v)}
step={0.25}
min={0}
width="full"
/>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">
Rate
</Label>
<NumberInput
value={item.rate}
onChange={(v) => onUpdateItem(index, "rate", v)}
prefix="$"
min={0}
step={1}
width="full"
/>
</div>
</div>
</div>
{/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveItem(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 px-3 text-center">
<span className="text-muted-foreground block text-sm font-medium">
Item #{index + 1}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">
Total
</span>
<span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
))}
<Button
variant="outline"
onClick={handleAddNewItem}
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
>
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
<Plus className="h-4 w-4" />
</div>
<span>Add Another Entry</span>
</Button>
</div>
)}
</div>
</div>
<SheetFooter className="bg-muted/10 mt-auto border-t p-6">
<Button
className="h-12 w-full rounded-xl text-base shadow-md sm:w-full"
size="lg"
onClick={() => handleCloseSheet(false)}
>
Done
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
} }
+15 -7
View File
@@ -93,12 +93,8 @@ function plainTextToHtml(value: string) {
.replace(/\n/g, "<br>"); .replace(/\n/g, "<br>");
} }
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { function createDefaultInvoiceFormData(): InvoiceFormData {
const router = useRouter(); return {
const utils = api.useUtils();
// State
const [formData, setFormData] = useState<InvoiceFormData>({
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`, invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#", invoicePrefix: "#",
businessId: "", businessId: "",
@@ -121,7 +117,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
amount: 0, amount: 0,
}, },
], ],
}); };
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const utils = api.useUtils();
// State
const [formData, setFormData] = useState<InvoiceFormData>(
createDefaultInvoiceFormData,
);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
@@ -153,6 +159,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// Init Effects (Same as before) // Init Effects (Same as before)
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset initialization state when the routed invoice changes.
setInitialized(false); setInitialized(false);
}, [invoiceId]); }, [invoiceId]);
useEffect(() => { useEffect(() => {
@@ -167,6 +174,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
rate: item.rate, rate: item.rate,
amount: item.amount, amount: item.amount,
})) || []; })) || [];
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
setFormData({ setFormData({
invoiceNumber: existingInvoice.invoiceNumber, invoiceNumber: existingInvoice.invoiceNumber,
invoicePrefix: existingInvoice.invoicePrefix ?? "#", invoicePrefix: existingInvoice.invoicePrefix ?? "#",
@@ -7,196 +7,212 @@ import { Textarea } from "~/components/ui/textarea";
import { DatePicker } from "~/components/ui/date-picker"; import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { import { STATUS_OPTIONS } from "./types";
STATUS_OPTIONS, import type { InvoiceFormData, ClientType, BusinessType } from "./types";
} from "./types";
import type {
InvoiceFormData,
ClientType,
BusinessType,
} from "./types";
interface InvoiceMetaSidebarProps { interface InvoiceMetaSidebarProps {
formData: InvoiceFormData; formData: InvoiceFormData;
updateField: <K extends keyof InvoiceFormData>( updateField: <K extends keyof InvoiceFormData>(
field: K, field: K,
value: InvoiceFormData[K] value: InvoiceFormData[K],
) => void; ) => void;
clients: ClientType[] | undefined; clients: ClientType[] | undefined;
businesses: BusinessType[] | undefined; businesses: BusinessType[] | undefined;
className?: string; className?: string;
} }
export function InvoiceMetaSidebar({ export function InvoiceMetaSidebar({
formData, formData,
updateField, updateField,
clients, clients,
businesses, businesses,
className, className,
}: InvoiceMetaSidebarProps) { }: InvoiceMetaSidebarProps) {
return ( return (
<div className={cn("flex flex-col gap-6 p-4 h-full", className)}> <div className={cn("flex h-full flex-col gap-6 p-4", className)}>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider"> <h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Invoice Details Invoice Details
</h3> </h3>
{/* Status */} {/* Status */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="status" className="text-xs">Status</Label> <Label htmlFor="status" className="text-xs">
<Select Status
value={formData.status} </Label>
onValueChange={(value: "draft" | "sent" | "paid") => <Select
updateField("status", value) value={formData.status}
} onValueChange={(value: "draft" | "sent" | "paid") =>
> updateField("status", value)
<SelectTrigger className="bg-background/50"> }
<SelectValue /> >
</SelectTrigger> <SelectTrigger className="bg-background/50">
<SelectContent> <SelectValue />
{STATUS_OPTIONS.map((option) => ( </SelectTrigger>
<SelectItem key={option.value} value={option.value}> <SelectContent>
{option.label} {STATUS_OPTIONS.map((option) => (
</SelectItem> <SelectItem key={option.value} value={option.value}>
))} {option.label}
</SelectContent> </SelectItem>
</Select> ))}
</div> </SelectContent>
</Select>
{/* Invoice Number */}
<div className="space-y-1.5">
<Label htmlFor="invoiceNumber" className="text-xs">Invoice Number</Label>
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
placeholder="INV-..."
disabled
className="bg-muted/50 font-mono text-sm"
/>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
Involved Parties
</h3>
{/* From (Business) */}
<div className="space-y-1.5">
<Label htmlFor="business" className="text-xs">From (Business)</Label>
<Select
value={formData.businessId}
onValueChange={(value) => updateField("businessId", value)}
>
<SelectTrigger aria-label="From Business" className="bg-background/50 text-sm">
<span className="truncate">
<SelectValue placeholder="Select business" />
</span>
</SelectTrigger>
<SelectContent>
{businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.name}{business.nickname ? ` (${business.nickname})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bill To (Client) */}
<div className="space-y-1.5">
<Label htmlFor="client" className="text-xs">Bill To (Client)</Label>
<Select
value={formData.clientId}
onValueChange={(value) => updateField("clientId", value)}
>
<SelectTrigger aria-label="Bill To Client" className="bg-background/50 text-sm">
<span className="truncate">
<SelectValue placeholder="Select client" />
</span>
</SelectTrigger>
<SelectContent>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
Dates
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Issued</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(date) => updateField("issueDate", date ?? new Date())}
className="w-full bg-background/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Due</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(date) => updateField("dueDate", date ?? new Date())}
className="w-full bg-background/50"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
Config
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Tax Rate</Label>
<NumberInput
value={formData.taxRate}
onChange={(v) => updateField("taxRate", v)}
min={0}
max={100}
step={1}
suffix="%"
className="bg-background/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hourly Rate</Label>
<NumberInput
value={formData.defaultHourlyRate ?? 0}
onChange={(v) => updateField("defaultHourlyRate", v)}
min={0}
prefix="$"
placeholder={!formData.clientId ? "Select client" : "Rate"}
disabled={!formData.clientId}
className={cn("bg-background/50", !formData.clientId && "opacity-50")}
/>
</div>
</div>
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-xs">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)}
placeholder="Notes for client..."
className="bg-background/50 resize-none h-24"
/>
</div>
</div> </div>
);
{/* Invoice Number */}
<div className="space-y-1.5">
<Label htmlFor="invoiceNumber" className="text-xs">
Invoice Number
</Label>
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
placeholder="INV-..."
disabled
className="bg-muted/50 font-mono text-sm"
/>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Involved Parties
</h3>
{/* From (Business) */}
<div className="space-y-1.5">
<Label htmlFor="business" className="text-xs">
From (Business)
</Label>
<Select
value={formData.businessId}
onValueChange={(value) => updateField("businessId", value)}
>
<SelectTrigger
aria-label="From Business"
className="bg-background/50 text-sm"
>
<span className="truncate">
<SelectValue placeholder="Select business" />
</span>
</SelectTrigger>
<SelectContent>
{businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.name}
{business.nickname ? ` (${business.nickname})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bill To (Client) */}
<div className="space-y-1.5">
<Label htmlFor="client" className="text-xs">
Bill To (Client)
</Label>
<Select
value={formData.clientId}
onValueChange={(value) => updateField("clientId", value)}
>
<SelectTrigger
aria-label="Bill To Client"
className="bg-background/50 text-sm"
>
<span className="truncate">
<SelectValue placeholder="Select client" />
</span>
</SelectTrigger>
<SelectContent>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Dates
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Issued</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(date) =>
updateField("issueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Due</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(date) =>
updateField("dueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Config
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Tax Rate</Label>
<NumberInput
value={formData.taxRate}
onChange={(v) => updateField("taxRate", v)}
min={0}
max={100}
step={1}
suffix="%"
className="bg-background/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hourly Rate</Label>
<NumberInput
value={formData.defaultHourlyRate ?? 0}
onChange={(v) => updateField("defaultHourlyRate", v)}
min={0}
prefix="$"
placeholder={!formData.clientId ? "Select client" : "Rate"}
disabled={!formData.clientId}
className={cn(
"bg-background/50",
!formData.clientId && "opacity-50",
)}
/>
</div>
</div>
</div>
<div className="flex-1 space-y-1.5">
<Label className="text-xs">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)}
placeholder="Notes for client..."
className="bg-background/50 h-24 resize-none"
/>
</div>
</div>
);
} }
-455
View File
@@ -1,455 +0,0 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { EmailComposer } from "./email-composer";
import { EmailPreview } from "./email-preview";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import {
Send,
Loader2,
Eye,
Edit3,
CheckCircle,
AlertTriangle,
Mail,
} from "lucide-react";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface SendEmailDialogProps {
invoiceId: string;
trigger: React.ReactNode;
invoice?: {
id: string;
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
taxRate: number;
currency?: string | null;
client?: {
name: string;
email: string | null;
};
business?: {
name: string;
email: string | null;
};
items?: Array<{
id: string;
date?: Date;
description?: string;
hours: number;
rate: number;
amount?: number;
}>;
};
onEmailSent?: () => void;
}
export function SendEmailDialog({
invoiceId,
trigger,
invoice,
onEmailSent,
}: SendEmailDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState("compose");
const [isSending, setIsSending] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
// Email content state
const [subject, setSubject] = useState(() =>
invoice
? `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`
: "Invoice from Your Business",
);
const [ccEmail, setCcEmail] = useState("");
const [bccEmail, setBccEmail] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [emailContent, setEmailContent] = useState(() => {
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
if (!invoice) return "";
const businessName = invoice.business?.name ?? "Your Business";
const issueDate = formatDate(invoice.issueDate);
// Calculate total from items
const subtotal =
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) ??
0;
const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount;
return `<p>${getTimeOfDayGreeting()},</p>
<p>I hope this email finds you well. Please find attached invoice <strong>${invoice.invoiceNumber}</strong> dated ${issueDate}.</p>
<p>The invoice details are as follows:</p>
<ul>
<li><strong>Invoice Number:</strong> ${invoice.invoiceNumber}</li>
<li><strong>Issue Date:</strong> ${issueDate}</li>
<li><strong>Amount Due:</strong> ${new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)}</li>
</ul>
<p>Please let me know if you have any questions or need any clarification regarding this invoice. I appreciate your prompt attention to this matter.</p>
<p>Thank you for your business!</p>
<p>Best regards,<br><strong>${businessName}</strong></p>`;
});
// Get utils for cache invalidation
const utils = api.useUtils();
// Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
toast.success("Email sent successfully!", {
description: data.message,
duration: 5000,
});
// Reset state and close dialog
setIsOpen(false);
setActiveTab("compose");
setIsSending(false);
setIsConfirming(false);
// Refresh invoice data
void utils.invoices.getById.invalidate({ id: invoiceId });
// Callback for parent component
onEmailSent?.();
},
onError: (error) => {
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = error.message;
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
setIsSending(false);
setIsConfirming(false);
},
});
const handleSendEmail = async () => {
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
toast.error("No email address", {
description: "This client doesn't have an email address on file.",
});
return;
}
if (!subject.trim()) {
toast.error("Subject required", {
description: "Please enter an email subject before sending.",
});
return;
}
if (!emailContent.trim()) {
toast.error("Message required", {
description: "Please enter an email message before sending.",
});
return;
}
setIsSending(true);
try {
// Use the enhanced API with custom subject and content
await sendEmailMutation.mutateAsync({
invoiceId,
customSubject: subject,
customContent: emailContent,
customMessage: customMessage.trim() || undefined,
useHtml: true,
ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined,
});
} catch (error) {
// Error handling is done in the mutation's onError
console.error("Send email error:", error);
}
};
const handleConfirmSend = () => {
setIsConfirming(true);
setActiveTab("confirm");
};
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
const toEmail = invoice?.client?.email ?? "";
const canSend =
!isSending &&
subject.trim() &&
emailContent.trim() &&
toEmail &&
toEmail.trim() !== "";
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="text-primary h-5 w-5" />
Send Invoice Email
</DialogTitle>
<DialogDescription>
Compose and preview your invoice email before sending to{" "}
{invoice?.client?.name ?? "client"}.
</DialogDescription>
</DialogHeader>
{/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This client doesn&apos;t have an email address. Please add an
email address to the client before sending the invoice.
</AlertDescription>
</Alert>
)}
{/* Branded Template Info */}
<Alert>
<Mail className="h-4 w-4" />
<AlertDescription>
<strong>Professional Email Template:</strong> Your email will be
sent using a beautifully designed, beenvoice-branded template with
proper fonts and styling. Any custom content you add will be
incorporated into the professional template automatically.
</AlertDescription>
</Alert>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="min-h-0 flex-1"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="compose" className="flex items-center gap-2">
<Edit3 className="h-4 w-4" />
Compose
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
Preview
</TabsTrigger>
<TabsTrigger
value="confirm"
className="flex items-center gap-2"
disabled={!isConfirming}
>
<CheckCircle className="h-4 w-4" />
Confirm
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent
value="compose"
className="mt-4 h-full overflow-y-auto"
>
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={emailContent}
onContentChange={setEmailContent}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
onCcEmailChange={setCcEmail}
bccEmail={bccEmail}
onBccEmailChange={setBccEmail}
/>
</TabsContent>
<TabsContent
value="preview"
className="mt-4 h-full overflow-y-auto"
>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
className="pr-2"
/>
</TabsContent>
<TabsContent
value="confirm"
className="mt-4 h-full overflow-y-auto"
>
<div className="space-y-6 pr-2">
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
You&apos;re about to send this email to{" "}
<strong>{toEmail}</strong>. The invoice PDF will be
automatically attached.
</AlertDescription>
</Alert>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
/>
{invoice?.status === "draft" && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This invoice is currently in <strong>draft</strong>{" "}
status. Sending it will automatically change the status to{" "}
<strong>sent</strong>.
</AlertDescription>
</Alert>
)}
</div>
</TabsContent>
</div>
</Tabs>
<DialogFooter className="flex items-center justify-between">
<div className="flex items-center gap-2">
{activeTab === "compose" && (
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
)}
{activeTab === "preview" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("compose")}
disabled={isSending}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
onClick={handleConfirmSend}
disabled={!canSend}
variant="default"
>
<CheckCircle className="mr-2 h-4 w-4" />
Review & Send
</Button>
</>
)}
{activeTab === "confirm" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
Back to Preview
</Button>
<Button
onClick={handleSendEmail}
disabled={!canSend || isSending}
className="bg-primary hover:bg-primary/90"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send Email
</>
)}
</Button>
</>
)}
</div>
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
disabled={isSending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+68 -60
View File
@@ -2,7 +2,10 @@
import * as React from "react"; import * as React from "react";
import { Sidebar } from "~/components/layout/sidebar"; import { Sidebar } from "~/components/layout/sidebar";
import { SidebarProvider, useSidebar } from "~/components/layout/sidebar-provider"; import {
SidebarProvider,
useSidebar,
} from "~/components/layout/sidebar-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
@@ -11,70 +14,75 @@ import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { useAppearance } from "~/components/providers/appearance-provider"; import { useAppearance } from "~/components/providers/appearance-provider";
function DashboardContent({ children }: { children: React.ReactNode }) { function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance(); const { sidebarStyle } = useAppearance();
const [isMobileOpen, setIsMobileOpen] = React.useState(false); const [isMobileOpen, setIsMobileOpen] = React.useState(false);
return ( return (
<div className="bg-dashboard relative min-h-screen flex"> <div className="bg-dashboard relative flex min-h-screen">
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<div className="hidden md:block"> <div className="hidden md:block">
<Sidebar /> <Sidebar />
</div> </div>
{/* Mobile Sidebar (Sheet) */} {/* Mobile Sidebar (Sheet) */}
<div className="fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b bg-background/80 px-4 backdrop-blur-md md:hidden"> <div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden">
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}> <Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="outline" size="icon" className="h-10 w-10 bg-background shadow-sm" suppressHydrationWarning> <Button
<Menu className="h-5 w-5" /> variant="outline"
<span className="sr-only">Toggle menu</span> size="icon"
</Button> className="bg-background h-10 w-10 shadow-sm"
</SheetTrigger> suppressHydrationWarning
{/* Mobile Link / Logo */}
<div className="ml-4 flex items-center gap-2">
<Logo size="sm" />
</div>
<SheetContent side="left" className="p-0 w-72">
<div className="sr-only">
<h2 id="mobile-nav-title">Navigation Menu</h2>
</div>
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
</SheetContent>
</Sheet>
</div>
{/* Main Content */}
<main
suppressHydrationWarning
className={cn(
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out",
"md:ml-0",
sidebarStyle === "floating"
? isCollapsed
? "md:ml-24"
: "md:ml-[18rem]"
: isCollapsed
? "md:ml-16"
: "md:ml-64",
)}
> >
<div className="p-4 pt-16 md:pt-4"> <Menu className="h-5 w-5" />
{/* Mobile header spacer is handled by pt-16 on mobile */} <span className="sr-only">Toggle menu</span>
<div className="md:hidden mb-4"> </Button>
{/* Mobile Breadcrumbs could go here or be part of the page */} </SheetTrigger>
</div> {/* Mobile Link / Logo */}
{children} <div className="ml-4 flex items-center gap-2">
</div> <Logo size="sm" />
</main> </div>
<SheetContent side="left" className="w-72 p-0">
<div className="sr-only">
<h2 id="mobile-nav-title">Navigation Menu</h2>
</div>
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
</SheetContent>
</Sheet>
</div>
{/* Main Content */}
<main
suppressHydrationWarning
className={cn(
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
"md:ml-0",
sidebarStyle === "floating"
? isCollapsed
? "md:ml-24"
: "md:ml-[18rem]"
: isCollapsed
? "md:ml-16"
: "md:ml-64",
)}
>
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
{/* Mobile header spacer is handled by pt-16 on mobile */}
<div className="mb-4 md:hidden">
{/* Mobile Breadcrumbs could go here or be part of the page */}
</div>
{children}
</div> </div>
); </main>
</div>
);
} }
export function DashboardShell({ children }: { children: React.ReactNode }) { export function DashboardShell({ children }: { children: React.ReactNode }) {
return ( return (
<SidebarProvider> <SidebarProvider>
<DashboardContent>{children}</DashboardContent> <DashboardContent>{children}</DashboardContent>
</SidebarProvider> </SidebarProvider>
); );
} }
+21 -21
View File
@@ -3,25 +3,25 @@
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
export function MotionBackground() { export function MotionBackground() {
return ( return (
<div className="fixed inset-0 -z-50 overflow-hidden pointer-events-none bg-background"> <div className="bg-background pointer-events-none fixed inset-0 -z-50 overflow-hidden">
<div <div
className={cn( className={cn(
"absolute inset-[-50%] w-[200%] h-[200%]", "absolute inset-[-50%] h-[200%] w-[200%]",
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]", "bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
"from-[oklch(var(--primary)/0.15)] via-transparent to-transparent", "from-[oklch(var(--primary)/0.15)] via-transparent to-transparent",
"animate-subtle-spin opacity-100" "animate-subtle-spin opacity-100",
)} )}
/> />
<div <div
className={cn( className={cn(
"absolute inset-[-50%] w-[200%] h-[200%]", "absolute inset-[-50%] h-[200%] w-[200%]",
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]", "bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
"from-[oklch(var(--accent)/0.15)] via-transparent to-transparent", "from-[oklch(var(--accent)/0.15)] via-transparent to-transparent",
"animate-subtle-wave opacity-100" "animate-subtle-wave opacity-100",
)} )}
/> />
<div className="absolute inset-0 bg-[url('/noise.svg')] opacity-[0.02] mix-blend-overlay" /> <div className="absolute inset-0 bg-[url('/noise.svg')] opacity-[0.02] mix-blend-overlay" />
</div> </div>
); );
} }
+16 -10
View File
@@ -8,7 +8,11 @@ import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export function Navbar() { interface NavbarProps {
allowRegistration?: boolean;
}
export function Navbar({ allowRegistration = true }: NavbarProps) {
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
// const session = { user: null } as any; const isPending = false; // const session = { user: null } as any; const isPending = false;
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
@@ -63,15 +67,17 @@ export function Navbar() {
Sign In Sign In
</Button> </Button>
</Link> </Link>
<Link href="/auth/register"> {allowRegistration && (
<Button <Link href="/auth/register">
size="sm" <Button
variant="default" size="sm"
className="text-xs font-medium md:text-sm" variant="default"
> className="text-xs font-medium md:text-sm"
Register >
</Button> Register
</Link> </Button>
</Link>
)}
</> </>
)} )}
</div> </div>
+10 -8
View File
@@ -42,22 +42,24 @@ export function PageHeader({
return ( return (
<div className={`animate-fade-in-down mb-6 ${className}`}> <div className={`animate-fade-in-down mb-6 ${className}`}>
{variant === "large-gradient" || variant === "gradient" ? ( {variant === "large-gradient" || variant === "gradient" ? (
<div className="platform-header-surface rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden relative"> <div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm">
<div className="platform-header-gradient absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" /> <div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" />
<div className="platform-header-content p-6 relative"> <div className="platform-header-content relative p-6">
<DashboardBreadcrumbs className="mb-4" /> <DashboardBreadcrumbs className="mb-4" />
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */} {/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1> <h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
{description && ( {description && (
<p className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}> <p
className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}
>
{description} {description}
</p> </p>
)} )}
</div> </div>
{children && ( {children && (
<div className="flex flex-shrink-0 gap-2 sm:gap-3 w-full sm:w-auto"> <div className="flex w-full flex-shrink-0 gap-2 sm:w-auto sm:gap-3">
{children} {children}
</div> </div>
)} )}
@@ -68,7 +70,7 @@ export function PageHeader({
<> <>
<DashboardBreadcrumbs className="mb-2 sm:mb-4" /> <DashboardBreadcrumbs className="mb-2 sm:mb-4" />
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */} {/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="animate-fade-in-up space-y-1"> <div className="animate-fade-in-up space-y-1">
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1> <h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
{description && ( {description && (
@@ -80,7 +82,7 @@ export function PageHeader({
)} )}
</div> </div>
{children && ( {children && (
<div className="animate-slide-in-right animate-delay-200 flex flex-shrink-0 gap-2 sm:gap-3 w-full sm:w-auto"> <div className="animate-slide-in-right animate-delay-200 flex w-full flex-shrink-0 gap-2 sm:w-auto sm:gap-3">
{children} {children}
</div> </div>
)} )}
+19 -28
View File
@@ -7,11 +7,7 @@ interface PageLayoutProps {
} }
export function PageLayout({ children, className }: PageLayoutProps) { export function PageLayout({ children, className }: PageLayoutProps) {
return ( return <div className={cn("min-h-screen", className)}>{children}</div>;
<div className={cn("min-h-screen", className)}>
{children}
</div>
);
} }
interface PageContentProps { interface PageContentProps {
@@ -23,18 +19,16 @@ interface PageContentProps {
export function PageContent({ export function PageContent({
children, children,
className, className,
spacing = "default" spacing = "default",
}: PageContentProps) { }: PageContentProps) {
const spacingClasses = { const spacingClasses = {
default: "space-y-8", default: "space-y-8",
compact: "space-y-4", compact: "space-y-4",
large: "space-y-12" large: "space-y-12",
}; };
return ( return (
<div className={cn(spacingClasses[spacing], className)}> <div className={cn(spacingClasses[spacing], className)}>{children}</div>
{children}
</div>
); );
} }
@@ -51,7 +45,7 @@ export function PageSection({
className, className,
title, title,
description, description,
actions actions,
}: PageSectionProps) { }: PageSectionProps) {
return ( return (
<section className={cn("space-y-4", className)}> <section className={cn("space-y-4", className)}>
@@ -59,15 +53,15 @@ export function PageSection({
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div> <div>
{title && ( {title && (
<h2 className="text-xl font-semibold text-foreground">{title}</h2> <h2 className="text-foreground text-xl font-semibold">{title}</h2>
)} )}
{description && ( {description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p> <p className="text-muted-foreground mt-1 text-sm">
{description}
</p>
)} )}
</div> </div>
{actions && ( {actions && <div className="flex flex-shrink-0 gap-3">{actions}</div>}
<div className="flex flex-shrink-0 gap-3">{actions}</div>
)}
</div> </div>
)} )}
{children} {children}
@@ -86,28 +80,25 @@ export function PageGrid({
children, children,
className, className,
columns = 3, columns = 3,
gap = "default" gap = "default",
}: PageGridProps) { }: PageGridProps) {
const columnClasses = { const columnClasses = {
1: "grid-cols-1", 1: "grid-cols-1",
2: "grid-cols-1 md:grid-cols-2", 2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3", 3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" 4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
}; };
const gapClasses = { const gapClasses = {
default: "gap-4", default: "gap-4",
compact: "gap-2", compact: "gap-2",
large: "gap-6" large: "gap-6",
}; };
return ( return (
<div className={cn( <div
"grid", className={cn("grid", columnClasses[columns], gapClasses[gap], className)}
columnClasses[columns], >
gapClasses[gap],
className
)}>
{children} {children}
</div> </div>
); );
@@ -127,18 +118,18 @@ export function EmptyState({
title, title,
description, description,
action, action,
className className,
}: EmptyStateProps) { }: EmptyStateProps) {
return ( return (
<div className={cn("py-12 text-center", className)}> <div className={cn("py-12 text-center", className)}>
{icon && ( {icon && (
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center bg-muted/50"> <div className="bg-muted/50 mx-auto mb-4 flex h-16 w-16 items-center justify-center">
{icon} {icon}
</div> </div>
)} )}
<h3 className="mb-2 text-lg font-semibold">{title}</h3> <h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && ( {description && (
<p className="text-muted-foreground mb-4 max-w-sm mx-auto"> <p className="text-muted-foreground mx-auto mb-4 max-w-sm">
{description} {description}
</p> </p>
)} )}
+2 -2
View File
@@ -56,7 +56,7 @@ export function QuickActionCard({
<CardContent className="p-6 text-center"> <CardContent className="p-6 text-center">
<div <div
className={cn( className={cn(
"mx-auto mb-3 flex h-12 w-12 items-center justify-center transition-colors", "mx-auto mb-3 flex h-12 w-12 items-center justify-center transition-colors",
styles.background, styles.background,
styles.hoverBackground, styles.hoverBackground,
)} )}
@@ -101,7 +101,7 @@ export function QuickActionCardSkeleton() {
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="animate-pulse"> <div className="animate-pulse">
<div className="bg-muted mx-auto mb-3 h-12 w-12 "></div> <div className="bg-muted mx-auto mb-3 h-12 w-12"></div>
<div className="bg-muted mx-auto mb-2 h-4 w-2/3 rounded"></div> <div className="bg-muted mx-auto mb-2 h-4 w-2/3 rounded"></div>
<div className="bg-muted mx-auto h-3 w-1/2 rounded"></div> <div className="bg-muted mx-auto h-3 w-1/2 rounded"></div>
</div> </div>
+37 -41
View File
@@ -3,58 +3,54 @@
import * as React from "react"; import * as React from "react";
interface SidebarContextType { interface SidebarContextType {
isCollapsed: boolean; isCollapsed: boolean;
toggleCollapse: () => void; toggleCollapse: () => void;
expand: () => void; expand: () => void;
collapse: () => void; collapse: () => void;
} }
const SidebarContext = React.createContext<SidebarContextType | undefined>( const SidebarContext = React.createContext<SidebarContextType | undefined>(
undefined, undefined,
); );
export function SidebarProvider({ children }: { children: React.ReactNode }) { export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [isCollapsed, setIsCollapsed] = React.useState(false); const [isCollapsed, setIsCollapsed] = React.useState(() => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem("sidebar-collapsed");
return saved ? (JSON.parse(saved) as boolean) : false;
});
// Persist state if needed, for now just local state const toggleCollapse = React.useCallback(() => {
React.useEffect(() => { setIsCollapsed((prev) => {
const saved = localStorage.getItem("sidebar-collapsed"); const next = !prev;
if (saved) { localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
setIsCollapsed(JSON.parse(saved) as boolean); return next;
} });
}, []); }, []);
const toggleCollapse = React.useCallback(() => { const expand = React.useCallback(() => {
setIsCollapsed((prev) => { setIsCollapsed(false);
const next = !prev; localStorage.setItem("sidebar-collapsed", JSON.stringify(false));
localStorage.setItem("sidebar-collapsed", JSON.stringify(next)); }, []);
return next;
});
}, []);
const expand = React.useCallback(() => { const collapse = React.useCallback(() => {
setIsCollapsed(false); setIsCollapsed(true);
localStorage.setItem("sidebar-collapsed", JSON.stringify(false)); localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
}, []); }, []);
const collapse = React.useCallback(() => { return (
setIsCollapsed(true); <SidebarContext.Provider
localStorage.setItem("sidebar-collapsed", JSON.stringify(true)); value={{ isCollapsed, toggleCollapse, expand, collapse }}
}, []); >
{children}
return ( </SidebarContext.Provider>
<SidebarContext.Provider );
value={{ isCollapsed, toggleCollapse, expand, collapse }}
>
{children}
</SidebarContext.Provider>
);
} }
export function useSidebar() { export function useSidebar() {
const context = React.useContext(SidebarContext); const context = React.useContext(SidebarContext);
if (context === undefined) { if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider"); throw new Error("useSidebar must be used within a SidebarProvider");
} }
return context; return context;
} }
+95 -46
View File
@@ -5,16 +5,17 @@ import { usePathname } from "next/navigation";
import { authClient } from "~/lib/auth-client"; import { authClient } from "~/lib/auth-client";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
LogOut,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { navigationConfig } from "~/lib/navigation"; import { navigationConfig } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider"; import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -46,10 +47,12 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<div className="flex h-full flex-col justify-between"> <div className="flex h-full flex-col justify-between">
<div> <div>
{/* Header / Logo */} {/* Header / Logo */}
<div className={cn( <div
"flex items-center h-14 px-4 mb-2", className={cn(
collapsed ? "justify-center px-2" : "justify-between" "mb-2 flex h-14 items-center px-4",
)}> collapsed ? "justify-center px-2" : "justify-between",
)}
>
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Logo size="sm" /> <Logo size="sm" />
@@ -63,11 +66,16 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className={cn("flex flex-col px-2 gap-6 mt-4", collapsed && "items-center")}> <nav
className={cn(
"mt-4 flex flex-col gap-6 px-2",
collapsed && "items-center",
)}
>
{navigationConfig.map((section) => ( {navigationConfig.map((section) => (
<div key={section.title}> <div key={section.title}>
{!collapsed && ( {!collapsed && (
<div className="px-2 mb-2 text-xs font-semibold text-muted-foreground/60 tracking-wider uppercase"> <div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
{section.title} {section.title}
</div> </div>
)} )}
@@ -84,17 +92,21 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link <Link
href={link.href} href={link.href}
data-active={isActive ? "true" : undefined}
className={cn( className={cn(
"flex items-center justify-center h-10 w-10 rounded-md transition-colors", "flex h-10 w-10 items-center justify-center rounded-md transition-colors",
isActive isActive
? "bg-primary text-primary-foreground shadow-sm" ? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)} )}
> >
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="font-medium"> <TooltipContent
side="right"
className="font-medium"
>
{link.name} {link.name}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -106,12 +118,13 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<Link <Link
key={link.href} key={link.href}
href={link.href} href={link.href}
data-active={isActive ? "true" : undefined}
onClick={mobile ? onClose : undefined} onClick={mobile ? onClose : undefined}
className={cn( className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors", "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive isActive
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@@ -127,29 +140,45 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
</div> </div>
{/* Footer / User */} {/* Footer / User */}
<div className="p-2 mt-auto space-y-2"> <div className="mt-auto space-y-2 p-2">
{!mobile && ( {!mobile && (
<div className={cn("flex", collapsed ? "justify-center" : "justify-end px-2")}> <div
className={cn(
"flex",
collapsed ? "justify-center" : "justify-end px-2",
)}
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-muted-foreground" className="text-muted-foreground h-8 w-8"
onClick={toggleCollapse} onClick={toggleCollapse}
> >
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />} {collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</Button> </Button>
</div> </div>
)} )}
<div className={cn( <div
"border-t border-border/50 pt-4", className={cn(
collapsed ? "flex flex-col items-center gap-2" : "px-2" "border-border/50 border-t pt-4",
)}> collapsed ? "flex flex-col items-center gap-2" : "px-2",
)}
>
{isPending ? ( {isPending ? (
<div className={cn("flex items-center gap-3", collapsed ? "justify-center" : "px-2")}> <div
className={cn(
"flex items-center gap-3",
collapsed ? "justify-center" : "px-2",
)}
>
<Skeleton className="h-9 w-9 rounded-full" /> <Skeleton className="h-9 w-9 rounded-full" />
{!collapsed && ( {!collapsed && (
<div className="space-y-1 flex-1"> <div className="flex-1 space-y-1">
<Skeleton className="h-3 w-20" /> <Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-24" /> <Skeleton className="h-2 w-24" />
</div> </div>
@@ -158,17 +187,37 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
) : session?.user ? ( ) : session?.user ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className={cn("w-full justify-start p-0 hover:bg-transparent", collapsed && "justify-center")}> <Button
variant="ghost"
className={cn(
"w-full justify-start p-0 hover:bg-transparent",
collapsed && "justify-center",
)}
>
{/* FIXED: Changed div to span to prevent hydration error */} {/* FIXED: Changed div to span to prevent hydration error */}
<span className={cn("flex items-center gap-3", collapsed ? "justify-center" : "w-full")}> <span
<Avatar className="h-9 w-9 border border-border"> className={cn(
<AvatarImage src={getGravatarUrl(session.user.email)} alt={session.user.name ?? "User"} /> "flex items-center gap-3",
<AvatarFallback>{session.user.name?.[0] ?? "U"}</AvatarFallback> collapsed ? "justify-center" : "w-full",
)}
>
<Avatar className="border-border h-9 w-9 border">
<AvatarImage
src={getGravatarUrl(session.user.email)}
alt={session.user.name ?? "User"}
/>
<AvatarFallback>
{session.user.name?.[0] ?? "U"}
</AvatarFallback>
</Avatar> </Avatar>
{!collapsed && ( {!collapsed && (
<span className="flex-1 min-w-0 text-left"> <span className="min-w-0 flex-1 text-left">
<span className="block text-sm font-medium truncate">{session.user.name}</span> <span className="block truncate text-sm font-medium">
<span className="block text-xs text-muted-foreground truncate">{session.user.email}</span> {session.user.name}
</span>
<span className="text-muted-foreground block truncate text-xs">
{session.user.email}
</span>
</span> </span>
)} )}
</span> </span>
@@ -177,13 +226,17 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<DropdownMenuContent <DropdownMenuContent
side="right" side="right"
align="end" align="end"
className="w-56 bg-background/80 backdrop-blur-xl border-border/50" className="bg-background/80 border-border/50 w-56 backdrop-blur-xl"
sideOffset={10} sideOffset={10}
> >
<DropdownMenuLabel> <DropdownMenuLabel>
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{session.user.name}</p> <p className="text-sm leading-none font-medium">
<p className="text-xs leading-none text-muted-foreground">{session.user.email}</p> {session.user.name}
</p>
<p className="text-muted-foreground text-xs leading-none">
{session.user.email}
</p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -192,7 +245,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
await authClient.signOut(); await authClient.signOut();
window.location.href = "/"; window.location.href = "/";
}} }}
className="text-red-600 focus:text-red-600 focus:bg-red-100/50 dark:focus:bg-red-900/20" className="text-red-600 focus:bg-red-100/50 focus:text-red-600 dark:focus:bg-red-900/20"
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
Sign Out Sign Out
@@ -206,11 +259,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
); );
if (mobile) { if (mobile) {
return ( return <div className="bg-background h-full">{SidebarContent}</div>;
<div className="h-full bg-background">
{SidebarContent}
</div>
);
} }
return ( return (
@@ -218,8 +267,8 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
className={cn( className={cn(
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex", "fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
sidebarStyle === "floating" sidebarStyle === "floating"
? "top-4 bottom-4 left-4 border-border/50 rounded-3xl border bg-background/80 shadow-xl backdrop-blur-xl" ? "border-border/50 bg-background/80 top-4 bottom-4 left-4 rounded-3xl border shadow-xl backdrop-blur-xl"
: "top-0 bottom-0 left-0 rounded-none border-r border-border bg-background shadow-none", : "border-border bg-background top-0 bottom-0 left-0 rounded-none border-r shadow-none",
isCollapsed ? "w-16" : "w-64", isCollapsed ? "w-16" : "w-64",
)} )}
> >
+5 -2
View File
@@ -14,12 +14,15 @@ export function Breadcrumbs() {
})), })),
]; ];
return ( return (
<nav className="flex items-center text-sm text-muted-foreground" aria-label="Breadcrumb"> <nav
className="text-muted-foreground flex items-center text-sm"
aria-label="Breadcrumb"
>
{crumbs.map((crumb, i) => ( {crumbs.map((crumb, i) => (
<span key={crumb.href} className="flex items-center"> <span key={crumb.href} className="flex items-center">
{i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />} {i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />}
{i < crumbs.length - 1 ? ( {i < crumbs.length - 1 ? (
<Link href={crumb.href} className="hover:underline text-gray-500"> <Link href={crumb.href} className="text-gray-500 hover:underline">
{crumb.name} {crumb.name}
</Link> </Link>
) : ( ) : (
@@ -53,7 +53,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
(_, i) => ( (_, i) => (
<div <div
key={i} key={i}
className="flex items-center gap-3 px-3 py-2.5" className="flex items-center gap-3 px-3 py-2.5"
> >
<Skeleton className="bg-muted/20 h-4 w-4" /> <Skeleton className="bg-muted/20 h-4 w-4" />
<Skeleton className="bg-muted/20 h-4 w-20" /> <Skeleton className="bg-muted/20 h-4 w-20" />
@@ -71,10 +71,11 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
aria-current={ aria-current={
pathname === link.href ? "page" : undefined pathname === link.href ? "page" : undefined
} }
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${pathname === link.href className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
? "bg-primary/10 text-primary" pathname === link.href
: "text-foreground hover:bg-muted" ? "bg-primary/10 text-primary"
}`} : "text-foreground hover:bg-muted"
}`}
onClick={onToggle} onClick={onToggle}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@@ -205,9 +205,9 @@ export function AnimationPreferencesProvider({
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const stored = readLocalStorage(); const stored = readLocalStorage();
const systemReduced = const systemReduced = window.matchMedia?.(
window.matchMedia && "(prefers-reduced-motion: reduce)",
window.matchMedia("(prefers-reduced-motion: reduce)").matches; ).matches;
const finalPrefers = const finalPrefers =
stored?.prefersReducedMotion ?? stored?.prefersReducedMotion ??
@@ -216,10 +216,11 @@ export function AnimationPreferencesProvider({
DEFAULT_PREFERS_REDUCED; DEFAULT_PREFERS_REDUCED;
const finalSpeed = clampSpeed( const finalSpeed = clampSpeed(
stored?.animationSpeedMultiplier ?? stored?.animationSpeedMultiplier ??
initial?.animationSpeedMultiplier ?? initial?.animationSpeedMultiplier ??
DEFAULT_SPEED, DEFAULT_SPEED,
); );
// eslint-disable-next-line react-hooks/set-state-in-effect -- Hydrate preferences from localStorage/system settings on mount.
setPrefersReducedMotion(finalPrefers); setPrefersReducedMotion(finalPrefers);
setAnimationSpeedMultiplier(finalSpeed); setAnimationSpeedMultiplier(finalSpeed);
applyPreferencesToDOM({ applyPreferencesToDOM({
@@ -279,7 +280,8 @@ export function AnimationPreferencesProvider({
// Optionally sync to server // Optionally sync to server
const shouldSync = opts?.sync ?? autoSync; const shouldSync = opts?.sync ?? autoSync;
if (shouldSync && serverPrefs) { // If serverPrefs exists, user is authenticated if (shouldSync && serverPrefs) {
// If serverPrefs exists, user is authenticated
pendingSyncRef.current = { pendingSyncRef.current = {
prefersReducedMotion: patch.prefersReducedMotion, prefersReducedMotion: patch.prefersReducedMotion,
animationSpeedMultiplier: patch.animationSpeedMultiplier, animationSpeedMultiplier: patch.animationSpeedMultiplier,
@@ -334,6 +336,7 @@ export function AnimationPreferencesProvider({
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier; serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) { if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reconcile loaded server preferences once after query hydration.
performUpdate( performUpdate(
{ {
prefersReducedMotion: serverPrefs.prefersReducedMotion, prefersReducedMotion: serverPrefs.prefersReducedMotion,
@@ -402,9 +405,15 @@ export function useAnimationPreferences(): AnimationPreferencesContextValue {
return { return {
prefersReducedMotion: false, prefersReducedMotion: false,
animationSpeedMultiplier: 1, animationSpeedMultiplier: 1,
updatePreferences: () => { /* no-op fallback */ }, updatePreferences: () => {
setPrefersReducedMotion: () => { /* no-op fallback */ }, /* no-op fallback */
setAnimationSpeedMultiplier: () => { /* no-op fallback */ }, },
setPrefersReducedMotion: () => {
/* no-op fallback */
},
setAnimationSpeedMultiplier: () => {
/* no-op fallback */
},
isUpdating: false, isUpdating: false,
lastSyncedAt: null, lastSyncedAt: null,
}; };
+118 -93
View File
@@ -6,10 +6,22 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { import {
defaultFontPreference, fallbackAppearance,
isColorMode,
isColorTheme,
isFontPreference,
isHslChannels,
isInterfaceTheme,
isPdfTemplate,
isRadiusPreference,
isSidebarStyle,
type PdfTemplate,
} from "~/lib/appearance";
import {
defaultBodyFontPreference, defaultBodyFontPreference,
defaultHeadingFontPreference, defaultHeadingFontPreference,
defaultInterfaceTheme, defaultInterfaceTheme,
@@ -27,7 +39,6 @@ import { api } from "~/trpc/react";
type AppearancePreferences = { type AppearancePreferences = {
interfaceTheme: InterfaceTheme; interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference; bodyFontPreference: FontPreference;
headingFontPreference: FontPreference; headingFontPreference: FontPreference;
radiusPreference: RadiusPreference; radiusPreference: RadiusPreference;
@@ -39,7 +50,7 @@ type AppearancePreferences = {
brandTagline: string; brandTagline: string;
brandLogoText: string; brandLogoText: string;
brandIcon: string; brandIcon: string;
pdfTemplate: "classic" | "minimal"; pdfTemplate: PdfTemplate;
pdfAccentColor: string; pdfAccentColor: string;
pdfFooterText: string; pdfFooterText: string;
pdfShowLogo: boolean; pdfShowLogo: boolean;
@@ -50,7 +61,6 @@ type AppearancePatch = Partial<AppearancePreferences>;
type ServerAppearance = { type ServerAppearance = {
interfaceTheme: InterfaceTheme; interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference; bodyFontPreference: FontPreference;
headingFontPreference: FontPreference; headingFontPreference: FontPreference;
radiusPreference: RadiusPreference; radiusPreference: RadiusPreference;
@@ -62,7 +72,7 @@ type ServerAppearance = {
brandTagline: string; brandTagline: string;
brandLogoText: string; brandLogoText: string;
brandIcon: string; brandIcon: string;
pdfTemplate: "classic" | "minimal"; pdfTemplate: PdfTemplate;
pdfAccentColor: string; pdfAccentColor: string;
pdfFooterText: string; pdfFooterText: string;
pdfShowLogo: boolean; pdfShowLogo: boolean;
@@ -71,6 +81,7 @@ type ServerAppearance = {
type AppearanceContextValue = AppearancePreferences & { type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void; updateAppearance: (patch: AppearancePatch) => void;
updateAppearanceDebounced: (patch: AppearancePatch) => void;
isUpdating: boolean; isUpdating: boolean;
}; };
@@ -78,22 +89,21 @@ const STORAGE_KEY = "bv.appearance";
const defaultAppearance: AppearancePreferences = { const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme, interfaceTheme: defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference: defaultBodyFontPreference, bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference, headingFontPreference: defaultHeadingFontPreference,
radiusPreference: defaultRadiusPreference, radiusPreference: defaultRadiusPreference,
sidebarStyle: defaultSidebarStyle, sidebarStyle: defaultSidebarStyle,
colorMode: "system", colorMode: fallbackAppearance.colorMode,
colorTheme: "slate", colorTheme: fallbackAppearance.colorTheme,
brandName: defaultBrand.name, brandName: defaultBrand.name,
brandTagline: defaultBrand.tagline, brandTagline: defaultBrand.tagline,
brandLogoText: defaultBrand.logoText, brandLogoText: defaultBrand.logoText,
brandIcon: defaultBrand.icon, brandIcon: defaultBrand.icon,
pdfTemplate: "classic", pdfTemplate: fallbackAppearance.pdfTemplate,
pdfAccentColor: "#111827", pdfAccentColor: fallbackAppearance.pdfAccentColor,
pdfFooterText: "Professional Invoicing", pdfFooterText: fallbackAppearance.pdfFooterText,
pdfShowLogo: true, pdfShowLogo: fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers: true, pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
}; };
const AppearanceContext = createContext<AppearanceContextValue | null>(null); const AppearanceContext = createContext<AppearanceContextValue | null>(null);
@@ -103,7 +113,6 @@ function getServerAppearancePatch(
): AppearancePatch { ): AppearancePatch {
return { return {
interfaceTheme: serverAppearance.interfaceTheme, interfaceTheme: serverAppearance.interfaceTheme,
fontPreference: serverAppearance.fontPreference,
bodyFontPreference: serverAppearance.bodyFontPreference, bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference, headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference, radiusPreference: serverAppearance.radiusPreference,
@@ -123,53 +132,6 @@ function getServerAppearancePatch(
}; };
} }
function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return (
value === "beenvoice" ||
value === "shadcn" ||
value === "minimal" ||
value === "editorial"
);
}
function isFontPreference(value: unknown): value is FontPreference {
return (
value === "brand" ||
value === "platform" ||
value === "inter" ||
value === "serif"
);
}
function isColorMode(value: unknown): value is ColorMode {
return value === "light" || value === "dark" || value === "system";
}
function isColorTheme(value: unknown): value is ColorTheme {
return (
value === "slate" ||
value === "blue" ||
value === "green" ||
value === "rose" ||
value === "orange" ||
value === "custom"
);
}
function isRadiusPreference(value: unknown): value is RadiusPreference {
return (
value === "none" ||
value === "sm" ||
value === "md" ||
value === "lg" ||
value === "xl"
);
}
function isSidebarStyle(value: unknown): value is SidebarStyle {
return value === "floating" || value === "docked";
}
function readStoredAppearance(): Partial<AppearancePreferences> | null { function readStoredAppearance(): Partial<AppearancePreferences> | null {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
@@ -179,9 +141,6 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme) interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
? parsed.interfaceTheme ? parsed.interfaceTheme
: undefined, : undefined,
fontPreference: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
bodyFontPreference: isFontPreference(parsed.bodyFontPreference) bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
? parsed.bodyFontPreference ? parsed.bodyFontPreference
: isFontPreference(parsed.fontPreference) : isFontPreference(parsed.fontPreference)
@@ -202,8 +161,9 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
colorTheme: isColorTheme(parsed.colorTheme) colorTheme: isColorTheme(parsed.colorTheme)
? parsed.colorTheme ? parsed.colorTheme
: undefined, : undefined,
customColor: customColor: isHslChannels(parsed.customColor)
typeof parsed.customColor === "string" ? parsed.customColor : undefined, ? parsed.customColor
: undefined,
brandName: brandName:
typeof parsed.brandName === "string" ? parsed.brandName : undefined, typeof parsed.brandName === "string" ? parsed.brandName : undefined,
brandTagline: brandTagline:
@@ -216,10 +176,9 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
: undefined, : undefined,
brandIcon: brandIcon:
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined, typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
pdfTemplate: pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
parsed.pdfTemplate === "classic" || parsed.pdfTemplate === "minimal" ? parsed.pdfTemplate
? parsed.pdfTemplate : undefined,
: undefined,
pdfAccentColor: pdfAccentColor:
typeof parsed.pdfAccentColor === "string" typeof parsed.pdfAccentColor === "string"
? parsed.pdfAccentColor ? parsed.pdfAccentColor
@@ -255,7 +214,6 @@ function applyAppearance(prefs: AppearancePreferences) {
const root = document.documentElement; const root = document.documentElement;
root.dataset.interfaceTheme = prefs.interfaceTheme; root.dataset.interfaceTheme = prefs.interfaceTheme;
root.dataset.font = prefs.fontPreference;
root.dataset.bodyFont = prefs.bodyFontPreference; root.dataset.bodyFont = prefs.bodyFontPreference;
root.dataset.headingFont = prefs.headingFontPreference; root.dataset.headingFont = prefs.headingFontPreference;
root.dataset.radius = prefs.radiusPreference; root.dataset.radius = prefs.radiusPreference;
@@ -279,6 +237,8 @@ export function AppearanceProvider({
}) { }) {
const [appearance, setAppearance] = const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance); useState<AppearancePreferences>(defaultAppearance);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const utils = api.useUtils(); const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({ const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => { onSuccess: async () => {
@@ -299,6 +259,38 @@ export function AppearanceProvider({
}, },
}); });
const persistAppearance = useCallback(
(patch: AppearancePatch) => {
if (
patch.customColor !== undefined &&
!isHslChannels(patch.customColor)
) {
return;
}
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
},
[updateMutation],
);
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, { const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false, retry: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
@@ -328,6 +320,15 @@ export function AppearanceProvider({
const updateAppearance = useCallback( const updateAppearance = useCallback(
(patch: AppearancePatch) => { (patch: AppearancePatch) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
}
setAppearance((prev) => { setAppearance((prev) => {
const next = { ...prev, ...patch }; const next = { ...prev, ...patch };
applyAppearance(next); applyAppearance(next);
@@ -335,37 +336,61 @@ export function AppearanceProvider({
return next; return next;
}); });
updateMutation.mutate({ persistAppearance(patch);
interfaceTheme: patch.interfaceTheme,
fontPreference: patch.fontPreference,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
}, },
[updateMutation], [persistAppearance],
);
const updateAppearanceDebounced = useCallback(
(patch: AppearancePatch) => {
pendingDebouncedPatchRef.current = {
...pendingDebouncedPatchRef.current,
...patch,
};
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
debounceTimerRef.current = null;
}, 500);
},
[persistAppearance],
);
useEffect(
() => () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
pendingDebouncedPatchRef.current = {};
},
[],
); );
const value = useMemo<AppearanceContextValue>( const value = useMemo<AppearanceContextValue>(
() => ({ () => ({
...appearance, ...appearance,
updateAppearance, updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending, isUpdating: updateMutation.isPending,
}), }),
[appearance, updateAppearance, updateMutation.isPending], [
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
); );
return ( return (
+21 -21
View File
@@ -1,15 +1,15 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
import { buttonVariants } from "~/components/ui/button" import { buttonVariants } from "~/components/ui/button";
function AlertDialog({ function AlertDialog({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
} }
function AlertDialogTrigger({ function AlertDialogTrigger({
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return ( return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
) );
} }
function AlertDialogPortal({ function AlertDialogPortal({
@@ -25,7 +25,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return ( return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
) );
} }
function AlertDialogOverlay({ function AlertDialogOverlay({
@@ -37,11 +37,11 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogContent({ function AlertDialogContent({
@@ -54,13 +54,13 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
) );
} }
function AlertDialogHeader({ function AlertDialogHeader({
@@ -73,7 +73,7 @@ function AlertDialogHeader({
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogFooter({ function AlertDialogFooter({
@@ -85,11 +85,11 @@ function AlertDialogFooter({
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogTitle({ function AlertDialogTitle({
@@ -102,7 +102,7 @@ function AlertDialogTitle({
className={cn("text-lg font-semibold", className)} className={cn("text-lg font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogDescription({ function AlertDialogDescription({
@@ -115,7 +115,7 @@ function AlertDialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogAction({ function AlertDialogAction({
@@ -127,7 +127,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)} className={cn(buttonVariants(), className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogCancel({ function AlertDialogCancel({
@@ -139,7 +139,7 @@ function AlertDialogCancel({
className={cn(buttonVariants({ variant: "outline" }), className)} className={cn(buttonVariants({ variant: "outline" }), className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -154,4 +154,4 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };
+38 -38
View File
@@ -1,50 +1,50 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar" import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
const Avatar = React.forwardRef< const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>, React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AvatarPrimitive.Root <AvatarPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Avatar.displayName = AvatarPrimitive.Root.displayName Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef< const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>, React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AvatarPrimitive.Image <AvatarPrimitive.Image
ref={ref} ref={ref}
className={cn("aspect-square h-full w-full", className)} className={cn("aspect-square h-full w-full", className)}
{...props} {...props}
/> />
)) ));
AvatarImage.displayName = AvatarPrimitive.Image.displayName AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef< const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>, React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted", "bg-muted flex h-full w-full items-center justify-center rounded-full",
className className,
)} )}
{...props} {...props}
/> />
)) ));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback };
+15 -15
View File
@@ -1,11 +1,11 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react" import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} /> return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
} }
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@@ -14,11 +14,11 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn( className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
@@ -28,7 +28,7 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("inline-flex items-center gap-1.5", className)} className={cn("inline-flex items-center gap-1.5", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbLink({ function BreadcrumbLink({
@@ -36,9 +36,9 @@ function BreadcrumbLink({
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : "a";
return ( return (
<Comp <Comp
@@ -46,7 +46,7 @@ function BreadcrumbLink({
className={cn("hover:text-foreground transition-colors", className)} className={cn("hover:text-foreground transition-colors", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
@@ -59,7 +59,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
className={cn("text-foreground font-normal", className)} className={cn("text-foreground font-normal", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbSeparator({ function BreadcrumbSeparator({
@@ -77,7 +77,7 @@ function BreadcrumbSeparator({
> >
{children ?? <ChevronRight />} {children ?? <ChevronRight />}
</li> </li>
) );
} }
function BreadcrumbEllipsis({ function BreadcrumbEllipsis({
@@ -95,7 +95,7 @@ function BreadcrumbEllipsis({
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) );
} }
export { export {
@@ -106,4 +106,4 @@ export {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
BreadcrumbEllipsis, BreadcrumbEllipsis,
} };
+1 -1
View File
@@ -36,7 +36,7 @@ const buttonVariants = cva(
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
} }
+49 -43
View File
@@ -1,15 +1,19 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { import {
ChevronDownIcon, ChevronDownIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
} from "lucide-react" } from "lucide-react";
import { DayPicker, getDefaultClassNames, type DayButton } from "react-day-picker" import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
import { Button, buttonVariants } from "~/components/ui/button" import { Button, buttonVariants } from "~/components/ui/button";
function Calendar({ function Calendar({
className, className,
@@ -21,9 +25,9 @@ function Calendar({
components, components,
...props ...props
}: React.ComponentProps<typeof DayPicker> & { }: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"] buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) { }) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames();
return ( return (
<DayPicker <DayPicker
@@ -32,7 +36,7 @@ function Calendar({
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent", "bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className className,
)} )}
captionLayout={captionLayout} captionLayout={captionLayout}
formatters={{ formatters={{
@@ -44,86 +48,88 @@ function Calendar({
root: cn("w-fit", defaultClassNames.root), root: cn("w-fit", defaultClassNames.root),
months: cn( months: cn(
"flex gap-4 flex-col md:flex-row relative", "flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months defaultClassNames.months,
), ),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month), month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn( nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav defaultClassNames.nav,
), ),
button_previous: cn( button_previous: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous defaultClassNames.button_previous,
), ),
button_next: cn( button_next: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next defaultClassNames.button_next,
), ),
month_caption: cn( month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption defaultClassNames.month_caption,
), ),
dropdowns: cn( dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns defaultClassNames.dropdowns,
), ),
dropdown_root: cn( dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root defaultClassNames.dropdown_root,
), ),
dropdown: cn( dropdown: cn(
"absolute bg-popover inset-0 opacity-0", "absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown defaultClassNames.dropdown,
), ),
caption_label: cn( caption_label: cn(
"select-none font-medium", "select-none font-medium",
captionLayout === "label" captionLayout === "label"
? "text-sm" ? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label defaultClassNames.caption_label,
), ),
table: "w-full border-collapse", table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday defaultClassNames.weekday,
), ),
week: cn("flex w-full mt-2", defaultClassNames.week), week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn( week_number_header: cn(
"select-none w-(--cell-size)", "select-none w-(--cell-size)",
defaultClassNames.week_number_header defaultClassNames.week_number_header,
), ),
week_number: cn( week_number: cn(
"text-[0.8rem] select-none text-muted-foreground", "text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number defaultClassNames.week_number,
), ),
day: cn( day: cn(
"relative w-full h-full p-0 text-center group/day aspect-square select-none", "relative w-full h-full p-0 text-center group/day aspect-square select-none",
props.mode !== "single" && "[&:last-child[data-selected=true]_button]:rounded-r-md", props.mode !== "single" &&
props.mode !== "single" && (props.showWeekNumber "[&:last-child[data-selected=true]_button]:rounded-r-md",
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" props.mode !== "single" &&
: "[&:first-child[data-selected=true]_button]:rounded-l-md"), (props.showWeekNumber
defaultClassNames.day ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md"),
defaultClassNames.day,
), ),
range_start: cn( range_start: cn(
"rounded-l-md bg-accent", "rounded-l-md bg-accent",
defaultClassNames.range_start defaultClassNames.range_start,
), ),
range_middle: cn("rounded-none", defaultClassNames.range_middle), range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn( today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today defaultClassNames.today,
), ),
outside: cn( outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground", "text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside defaultClassNames.outside,
), ),
disabled: cn( disabled: cn(
"text-muted-foreground opacity-50", "text-muted-foreground opacity-50",
defaultClassNames.disabled defaultClassNames.disabled,
), ),
hidden: cn("invisible", defaultClassNames.hidden), hidden: cn("invisible", defaultClassNames.hidden),
...classNames, ...classNames,
@@ -137,13 +143,13 @@ function Calendar({
className={cn(className)} className={cn(className)}
{...props} {...props}
/> />
) );
}, },
Chevron: ({ className, orientation, ...props }) => { Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") { if (orientation === "left") {
return ( return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} /> <ChevronLeftIcon className={cn("size-4", className)} {...props} />
) );
} }
if (orientation === "right") { if (orientation === "right") {
@@ -152,12 +158,12 @@ function Calendar({
className={cn("size-4", className)} className={cn("size-4", className)}
{...props} {...props}
/> />
) );
} }
return ( return (
<ChevronDownIcon className={cn("size-4", className)} {...props} /> <ChevronDownIcon className={cn("size-4", className)} {...props} />
) );
}, },
DayButton: CalendarDayButton, DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => { WeekNumber: ({ children, ...props }) => {
@@ -167,13 +173,13 @@ function Calendar({
{children} {children}
</div> </div>
</td> </td>
) );
}, },
...components, ...components,
}} }}
{...props} {...props}
/> />
) );
} }
function CalendarDayButton({ function CalendarDayButton({
@@ -182,12 +188,12 @@ function CalendarDayButton({
modifiers, modifiers,
...props ...props
}: React.ComponentProps<typeof DayButton>) { }: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null) const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (modifiers.focused) ref.current?.focus() if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]) }, [modifiers.focused]);
return ( return (
<Button <Button
@@ -207,11 +213,11 @@ function CalendarDayButton({
className={cn( className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70", "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day, defaultClassNames.day,
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Calendar, CalendarDayButton } export { Calendar, CalendarDayButton };
+1 -1
View File
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-background/80 backdrop-blur-xl border-border/50 text-card-foreground flex flex-col rounded-3xl border shadow-sm overflow-hidden", "bg-background/80 border-border/50 text-card-foreground flex flex-col overflow-hidden rounded-3xl border shadow-sm backdrop-blur-xl",
className, className,
)} )}
{...props} {...props}
+8 -8
View File
@@ -1,10 +1,10 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react" import { CheckIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Checkbox({ function Checkbox({
className, className,
@@ -15,7 +15,7 @@ function Checkbox({
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -26,7 +26,7 @@ function Checkbox({
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) );
} }
export { Checkbox } export { Checkbox };
+6 -6
View File
@@ -1,11 +1,11 @@
"use client" "use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({ function Collapsible({
...props ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
} }
function CollapsibleTrigger({ function CollapsibleTrigger({
@@ -16,7 +16,7 @@ function CollapsibleTrigger({
data-slot="collapsible-trigger" data-slot="collapsible-trigger"
{...props} {...props}
/> />
) );
} }
function CollapsibleContent({ function CollapsibleContent({
@@ -27,7 +27,7 @@ function CollapsibleContent({
data-slot="collapsible-content" data-slot="collapsible-content"
{...props} {...props}
/> />
) );
} }
export { Collapsible, CollapsibleTrigger, CollapsibleContent } export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+18 -7
View File
@@ -3,13 +3,24 @@
import { motion, useSpring, useTransform } from "framer-motion"; import { motion, useSpring, useTransform } from "framer-motion";
import { useEffect } from "react"; import { useEffect } from "react";
export function CountUp({ value, prefix = "", suffix = "" }: { value: number, prefix?: string, suffix?: string }) { export function CountUp({
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 }); value,
const display = useTransform(spring, (current) => `${prefix}${current.toFixed(2)}${suffix}`); prefix = "",
suffix = "",
}: {
value: number;
prefix?: string;
suffix?: string;
}) {
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
const display = useTransform(
spring,
(current) => `${prefix}${current.toFixed(2)}${suffix}`,
);
useEffect(() => { useEffect(() => {
spring.set(value); spring.set(value);
}, [spring, value]); }, [spring, value]);
return <motion.span>{display}</motion.span>; return <motion.span>{display}</motion.span>;
} }
+14 -5
View File
@@ -60,12 +60,13 @@ export function DatePicker({
const inputWidthClass = className?.includes("w-full") const inputWidthClass = className?.includes("w-full")
? "w-full" ? "w-full"
: className?.includes("w-32") || : className?.includes("w-32") ||
className?.includes("w-28") || className?.includes("w-28") ||
className?.includes("w-36") className?.includes("w-36")
? className ? className
: "w-full md:w-32 md:min-w-32"; : "w-full md:w-32 md:min-w-32";
React.useEffect(() => { React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
setValue(formatDate(date)); setValue(formatDate(date));
setMonth(date); setMonth(date);
}, [date]); }, [date]);
@@ -77,7 +78,12 @@ export function DatePicker({
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
className={cn("bg-background pr-10", sizeClasses[size], "w-full", inputClassName)} className={cn(
"bg-background pr-10",
sizeClasses[size],
"w-full",
inputClassName,
)}
onChange={(e) => { onChange={(e) => {
setValue(e.target.value); setValue(e.target.value);
const parsedDate = parseDate(e.target.value); const parsedDate = parseDate(e.target.value);
@@ -98,13 +104,16 @@ export function DatePicker({
<Button <Button
variant="ghost" variant="ghost"
disabled={disabled} disabled={disabled}
className="absolute top-1/2 right-2 size-6 p-0 -translate-y-1/2 text-primary/80 hover:text-primary transition-colors z-20" className="text-primary/80 hover:text-primary absolute top-1/2 right-2 z-20 size-6 -translate-y-1/2 p-0 transition-colors"
> >
<CalendarIcon className="size-4" /> <CalendarIcon className="size-4" />
<span className="sr-only">Select date</span> <span className="sr-only">Select date</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0 rounded-xl" align="end"> <PopoverContent
className="w-auto overflow-hidden rounded-xl p-0"
align="end"
>
<Calendar <Calendar
mode="single" mode="single"
selected={date} selected={date}
+21 -21
View File
@@ -1,33 +1,33 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@@ -39,11 +39,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -52,7 +52,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@@ -60,8 +60,8 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@@ -77,7 +77,7 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -87,7 +87,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -96,11 +96,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogTitle({ function DialogTitle({
@@ -113,7 +113,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@@ -126,7 +126,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -140,4 +140,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
+6 -6
View File
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
className, className,
)} )}
{...props} {...props}
@@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-foreground-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-foreground-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
checked={checked} checked={checked}
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-foreground-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground-foreground flex cursor-default items-center px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", "focus:bg-accent focus:text-foreground-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground-foreground flex cursor-default items-center px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className, className,
)} )}
{...props} {...props}
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden border-0 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden border-0 shadow-lg",
className, className,
)} )}
{...props} {...props}
+25 -25
View File
@@ -4,34 +4,34 @@ import { cn } from "~/lib/utils";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
interface ImageWithSkeletonProps extends ImageProps { interface ImageWithSkeletonProps extends ImageProps {
containerClassName?: string; containerClassName?: string;
} }
export function ImageWithSkeleton({ export function ImageWithSkeleton({
className, className,
containerClassName, containerClassName,
alt, alt,
...props ...props
}: ImageWithSkeletonProps) { }: ImageWithSkeletonProps) {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
return ( return (
<div className={cn("relative overflow-hidden", containerClassName)}> <div className={cn("relative overflow-hidden", containerClassName)}>
{isLoading && ( {isLoading && (
<Skeleton className="absolute inset-0 h-full w-full animate-pulse" /> <Skeleton className="absolute inset-0 h-full w-full animate-pulse" />
)} )}
<Image <Image
className={cn( className={cn(
"duration-700 ease-in-out", "duration-700 ease-in-out",
isLoading isLoading
? "scale-110 blur-2xl grayscale" ? "scale-110 blur-2xl grayscale"
: "scale-100 blur-0 grayscale-0", : "blur-0 scale-100 grayscale-0",
className className,
)} )}
onLoad={() => setIsLoading(false)} onLoad={() => setIsLoading(false)}
alt={alt} alt={alt}
{...props} {...props}
/> />
</div> </div>
); );
} }
+568
View File
@@ -0,0 +1,568 @@
"use client";
import { useEffect, useState } from "react";
import { HexAlphaColorPicker, HexColorPicker } from "react-colorful";
import { Loader2, PipetteIcon } from "lucide-react";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
hexToRgb,
hexToRgba,
hslToRgb,
hslaToRgba,
rgbToHex,
rgbToHsl,
rgbaToHex,
rgbaToHsla,
} from "~/lib/color-converter";
import { cn } from "~/lib/utils";
declare global {
interface Window {
EyeDropper?: new () => {
open: () => Promise<{ sRGBHex: string }>;
};
}
}
export const colorSchema = z
.string()
.regex(
/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/,
"Color must be a valid hex color (e.g., #FF0000 or #FF0000FF)",
)
.transform((val) => val.toUpperCase());
interface ColorPickerProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
isLoading?: boolean;
label: string;
error?: string;
className?: string;
alpha?: boolean;
}
interface ColorValues {
hex: string;
rgb: { r: number; g: number; b: number };
hsl: { h: number; s: number; l: number };
rgba?: { r: number; g: number; b: number; a: number };
hsla?: { h: number; s: number; l: number; a: number };
}
export function InputColor({
value,
onChange,
onBlur = () => undefined,
isLoading = false,
label,
error,
className = "mt-6",
alpha = false,
}: ColorPickerProps) {
const [colorFormat, setColorFormat] = useState(alpha ? "HEXA" : "HEX");
const [colorValues, setColorValues] = useState<ColorValues>(() =>
getColorValues(value, alpha),
);
const [hexInputValue, setHexInputValue] = useState(value);
const [hexInputError, setHexInputError] = useState<string | null>(null);
const updateColorValues = (newColor: string) => {
const nextValues = getColorValues(newColor, alpha);
setColorValues(nextValues);
setHexInputValue(newColor.toUpperCase());
};
const handleColorChange = (newColor: string) => {
updateColorValues(newColor);
onChange(newColor.toUpperCase());
};
const handleHexChange = (nextValue: string) => {
let formattedValue = nextValue.toUpperCase();
if (!formattedValue.startsWith("#")) {
formattedValue = `#${formattedValue}`;
}
const maxLength = alpha ? 9 : 7;
if (
formattedValue.length <= maxLength &&
/^#[0-9A-Fa-f]*$/.test(formattedValue)
) {
setHexInputValue(formattedValue);
onChange(formattedValue);
updateColorValues(formattedValue);
try {
if (formattedValue.length === maxLength) {
colorSchema.parse(formattedValue);
setHexInputError(null);
} else {
setHexInputError("Enter a valid color");
}
} catch (validationError) {
if (validationError instanceof z.ZodError) {
setHexInputError("Enter a valid color");
}
}
}
};
const handleRgbChange = (component: "r" | "g" | "b", nextValue: string) => {
const numValue = Number.parseInt(nextValue) || 0;
const clampedValue = Math.max(0, Math.min(255, numValue));
const newRgb = { ...colorValues.rgb, [component]: clampedValue };
const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b);
const hsl = rgbToHsl(newRgb.r, newRgb.g, newRgb.b);
setColorValues({ ...colorValues, hex, rgb: newRgb, hsl });
setHexInputValue(hex);
onChange(hex);
};
const handleRgbaChange = (
component: "r" | "g" | "b" | "a",
nextValue: string,
) => {
if (!alpha || !colorValues.rgba) return;
const numValue = Number.parseFloat(nextValue) || 0;
const clampedValue =
component === "a"
? Math.max(0, Math.min(1, numValue))
: Math.max(0, Math.min(255, Math.floor(numValue)));
const newRgba = { ...colorValues.rgba, [component]: clampedValue };
const hex = rgbaToHex(newRgba.r, newRgba.g, newRgba.b, newRgba.a);
const hsla = rgbaToHsla(newRgba.r, newRgba.g, newRgba.b, newRgba.a);
setColorValues({
...colorValues,
hex: hex.slice(0, 7),
rgb: { r: newRgba.r, g: newRgba.g, b: newRgba.b },
hsl: rgbToHsl(newRgba.r, newRgba.g, newRgba.b),
rgba: newRgba,
hsla,
});
setHexInputValue(hex);
onChange(hex);
};
const handleHslChange = (component: "h" | "s" | "l", nextValue: string) => {
const numValue = Number.parseInt(nextValue) || 0;
const clampedValue =
component === "h"
? Math.max(0, Math.min(360, numValue))
: Math.max(0, Math.min(100, numValue));
const newHsl = { ...colorValues.hsl, [component]: clampedValue };
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l);
const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
setColorValues({ ...colorValues, hex, rgb, hsl: newHsl });
setHexInputValue(hex);
onChange(hex);
};
const handleHslaChange = (
component: "h" | "s" | "l" | "a",
nextValue: string,
) => {
if (!alpha || !colorValues.hsla) return;
const numValue = Number.parseFloat(nextValue) || 0;
const clampedValue =
component === "a"
? Math.max(0, Math.min(1, numValue))
: component === "h"
? Math.max(0, Math.min(360, numValue))
: Math.max(0, Math.min(100, numValue));
const newHsla = { ...colorValues.hsla, [component]: clampedValue };
const rgba = hslaToRgba(newHsla.h, newHsla.s, newHsla.l, newHsla.a);
const hex = rgbaToHex(rgba.r, rgba.g, rgba.b, rgba.a);
setColorValues({
...colorValues,
hex: hex.slice(0, 7),
rgb: { r: rgba.r, g: rgba.g, b: rgba.b },
hsl: { h: newHsla.h, s: newHsla.s, l: newHsla.l },
rgba,
hsla: newHsla,
});
setHexInputValue(hex);
onChange(hex);
};
const handlePopoverChange = (open: boolean) => {
if (!open) {
setColorFormat(alpha ? "HEXA" : "HEX");
onBlur();
}
};
const handleEyeDropper = async () => {
const EyeDropper = window.EyeDropper;
if (!EyeDropper) return;
try {
const eyeDropper = new EyeDropper();
const result = await eyeDropper.open();
const pickedColor = result.sRGBHex;
updateColorValues(pickedColor);
onChange(pickedColor);
} catch {
// User canceled the browser picker.
}
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronize controlled color value into the picker fields.
updateColorValues(value);
setHexInputValue(value.toUpperCase());
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateColorValues intentionally derives all picker state from value.
}, [value]);
const getCurrentHexValue = () => {
if (colorFormat === "HEX" || colorFormat === "HEXA") {
return hexInputValue;
}
if (alpha && colorValues.rgba) {
return rgbaToHex(
colorValues.rgba.r,
colorValues.rgba.g,
colorValues.rgba.b,
colorValues.rgba.a,
);
}
return colorValues.hex;
};
return (
<div className={cn(className)}>
<Label className="mb-3">{label}</Label>
<div className="flex items-center gap-4">
<Popover onOpenChange={handlePopoverChange}>
<PopoverTrigger asChild>
<Button
className="border-border relative h-12 w-12 overflow-hidden border shadow-none"
size="icon"
style={{ backgroundColor: hexInputValue }}
type="button"
variant="outline"
>
{alpha && colorValues.rgba && colorValues.rgba.a < 1 && (
<span
className="absolute inset-0 opacity-20"
style={{
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "8px 8px",
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
}}
/>
)}
<span className="sr-only">Open {label} picker</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3" align="start">
<div className="color-picker space-y-3">
<div className="relative">
<Button
variant="ghost"
size="icon"
className="absolute -top-1.5 -left-1 z-10 flex h-7 w-7 items-center gap-1 bg-transparent hover:bg-transparent"
onClick={handleEyeDropper}
disabled={!isEyeDropperAvailable()}
type="button"
>
<PipetteIcon className="h-3 w-3" />
<span className="sr-only">Pick color from screen</span>
</Button>
{alpha ? (
<HexAlphaColorPicker
className="!aspect-square !h-[244.79px] !w-[244.79px]"
color={value}
onChange={handleColorChange}
/>
) : (
<HexColorPicker
className="!aspect-square !h-[244.79px] !w-[244.79px]"
color={value}
onChange={handleColorChange}
/>
)}
</div>
<div className="flex gap-2">
<Select value={colorFormat} onValueChange={setColorFormat}>
<SelectTrigger className="!h-7 !w-[4.8rem] rounded-sm px-2 py-1 !text-sm">
<SelectValue placeholder="Color" />
</SelectTrigger>
<SelectContent className="min-w-20">
{alpha ? (
<>
<SelectItem value="HEXA" className="h-7 text-sm">
HEXA
</SelectItem>
<SelectItem value="RGBA" className="h-7 text-sm">
RGBA
</SelectItem>
<SelectItem value="HSLA" className="h-7 text-sm">
HSLA
</SelectItem>
</>
) : (
<>
<SelectItem value="HEX" className="h-7 text-sm">
HEX
</SelectItem>
<SelectItem value="RGB" className="h-7 text-sm">
RGB
</SelectItem>
<SelectItem value="HSL" className="h-7 text-sm">
HSL
</SelectItem>
</>
)}
</SelectContent>
</Select>
<ColorFormatFields
alpha={alpha}
colorFormat={colorFormat}
colorValues={colorValues}
currentHexValue={getCurrentHexValue()}
handleHexChange={handleHexChange}
handleHslChange={handleHslChange}
handleHslaChange={handleHslaChange}
handleRgbChange={handleRgbChange}
handleRgbaChange={handleRgbaChange}
/>
</div>
</div>
</PopoverContent>
</Popover>
<div className="relative flex-1 sm:flex-none">
<Input
placeholder={label}
value={getCurrentHexValue()}
onChange={(event) => handleHexChange(event.target.value)}
onBlur={onBlur}
className={cn("h-12 uppercase", error && "border-destructive")}
/>
{isLoading && (
<span className="absolute inset-y-0 right-0 flex items-center pr-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</span>
)}
</div>
</div>
{error && <p className="text-destructive mt-1.5 text-sm">{error}</p>}
{hexInputError && (
<p className="text-destructive mt-1.5 text-sm">{hexInputError}</p>
)}
</div>
);
}
function ColorFormatFields({
alpha,
colorFormat,
colorValues,
currentHexValue,
handleHexChange,
handleRgbChange,
handleRgbaChange,
handleHslChange,
handleHslaChange,
}: {
alpha: boolean;
colorFormat: string;
colorValues: ColorValues;
currentHexValue: string;
handleHexChange: (value: string) => void;
handleRgbChange: (component: "r" | "g" | "b", value: string) => void;
handleRgbaChange: (component: "r" | "g" | "b" | "a", value: string) => void;
handleHslChange: (component: "h" | "s" | "l", value: string) => void;
handleHslaChange: (component: "h" | "s" | "l" | "a", value: string) => void;
}) {
if (colorFormat === "HEX" || colorFormat === "HEXA") {
return (
<Input
className="h-7 w-[160px] rounded-sm text-sm"
value={currentHexValue}
onChange={(event) => handleHexChange(event.target.value)}
placeholder={alpha ? "#FF0000FF" : "#FF0000"}
maxLength={alpha ? 9 : 7}
/>
);
}
if (colorFormat === "RGB") {
return (
<div className="flex items-center">
<Input
className="h-7 w-13 rounded-l-sm rounded-r-none text-center text-sm"
value={colorValues.rgb.r}
onChange={(event) => handleRgbChange("r", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-none border-x-0 text-center text-sm"
value={colorValues.rgb.g}
onChange={(event) => handleRgbChange("g", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-l-none rounded-r-sm text-center text-sm"
value={colorValues.rgb.b}
onChange={(event) => handleRgbChange("b", event.target.value)}
placeholder="255"
maxLength={3}
/>
</div>
);
}
if (colorFormat === "RGBA" && alpha && colorValues.rgba) {
return (
<div className="flex items-center">
<Input
className="h-7 w-10 rounded-l-sm rounded-r-none px-1 text-center text-sm"
value={colorValues.rgba.r}
onChange={(event) => handleRgbaChange("r", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.rgba.g}
onChange={(event) => handleRgbaChange("g", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.rgba.b}
onChange={(event) => handleRgbaChange("b", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-l-none rounded-r-sm px-1 text-center text-sm"
value={colorValues.rgba.a.toFixed(2)}
onChange={(event) => handleRgbaChange("a", event.target.value)}
placeholder="1.00"
maxLength={4}
/>
</div>
);
}
if (colorFormat === "HSL") {
return (
<div className="flex items-center">
<Input
className="h-7 w-13 rounded-l-sm rounded-r-none text-center text-sm"
value={colorValues.hsl.h}
onChange={(event) => handleHslChange("h", event.target.value)}
placeholder="360"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-none border-x-0 text-center text-sm"
value={colorValues.hsl.s}
onChange={(event) => handleHslChange("s", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-l-none rounded-r-sm text-center text-sm"
value={colorValues.hsl.l}
onChange={(event) => handleHslChange("l", event.target.value)}
placeholder="100"
maxLength={3}
/>
</div>
);
}
if (colorFormat === "HSLA" && alpha && colorValues.hsla) {
return (
<div className="flex items-center">
<Input
className="h-7 w-10 rounded-l-sm rounded-r-none px-1 text-center text-sm"
value={colorValues.hsla.h}
onChange={(event) => handleHslaChange("h", event.target.value)}
placeholder="360"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.hsla.s}
onChange={(event) => handleHslaChange("s", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.hsla.l}
onChange={(event) => handleHslaChange("l", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-l-none rounded-r-sm px-1 text-center text-sm"
value={colorValues.hsla.a.toFixed(2)}
onChange={(event) => handleHslaChange("a", event.target.value)}
placeholder="1.00"
maxLength={4}
/>
</div>
);
}
return null;
}
function getColorValues(value: string, alpha: boolean): ColorValues {
if (alpha) {
const rgba = hexToRgba(value);
const hsla = rgbaToHsla(rgba.r, rgba.g, rgba.b, rgba.a);
return {
hex: value.length === 9 ? value.slice(0, 7) : value,
rgb: { r: rgba.r, g: rgba.g, b: rgba.b },
hsl: rgbToHsl(rgba.r, rgba.g, rgba.b),
rgba,
hsla,
};
}
const rgb = hexToRgb(value);
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
return {
hex: value.toUpperCase(),
rgb,
hsl,
};
}
function isEyeDropperAvailable() {
return typeof window !== "undefined" && Boolean(window.EyeDropper);
}
+7 -7
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Label({ function Label({
className, className,
@@ -14,11 +14,11 @@ function Label({
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };
+27 -27
View File
@@ -1,9 +1,9 @@
import * as React from "react" import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react" import { ChevronDownIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function NavigationMenu({ function NavigationMenu({
className, className,
@@ -11,7 +11,7 @@ function NavigationMenu({
viewport = true, viewport = true,
...props ...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & { }: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean viewport?: boolean;
}) { }) {
return ( return (
<NavigationMenuPrimitive.Root <NavigationMenuPrimitive.Root
@@ -19,14 +19,14 @@ function NavigationMenu({
data-viewport={viewport} data-viewport={viewport}
className={cn( className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center", "group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
{viewport && <NavigationMenuViewport />} {viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root> </NavigationMenuPrimitive.Root>
) );
} }
function NavigationMenuList({ function NavigationMenuList({
@@ -38,11 +38,11 @@ function NavigationMenuList({
data-slot="navigation-menu-list" data-slot="navigation-menu-list"
className={cn( className={cn(
"group flex flex-1 list-none items-center justify-center gap-1", "group flex flex-1 list-none items-center justify-center gap-1",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuItem({ function NavigationMenuItem({
@@ -55,12 +55,12 @@ function NavigationMenuItem({
className={cn("relative", className)} className={cn("relative", className)}
{...props} {...props}
/> />
) );
} }
const navigationMenuTriggerStyle = cva( const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-foreground-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1" "group inline-flex h-9 w-max items-center justify-center bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-foreground-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
) );
function NavigationMenuTrigger({ function NavigationMenuTrigger({
className, className,
@@ -79,7 +79,7 @@ function NavigationMenuTrigger({
aria-hidden="true" aria-hidden="true"
/> />
</NavigationMenuPrimitive.Trigger> </NavigationMenuPrimitive.Trigger>
) );
} }
function NavigationMenuContent({ function NavigationMenuContent({
@@ -91,12 +91,12 @@ function NavigationMenuContent({
data-slot="navigation-menu-content" data-slot="navigation-menu-content"
className={cn( className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto", "data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu: group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none", "group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu: group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuViewport({ function NavigationMenuViewport({
@@ -106,19 +106,19 @@ function NavigationMenuViewport({
return ( return (
<div <div
className={cn( className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center" "absolute top-full left-0 isolate z-50 flex justify-center",
)} )}
> >
<NavigationMenuPrimitive.Viewport <NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport" data-slot="navigation-menu-viewport"
className={cn( className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden border shadow md:w-[var(--radix-navigation-menu-viewport-width)]", "origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function NavigationMenuLink({ function NavigationMenuLink({
@@ -129,12 +129,12 @@ function NavigationMenuLink({
<NavigationMenuPrimitive.Link <NavigationMenuPrimitive.Link
data-slot="navigation-menu-link" data-slot="navigation-menu-link"
className={cn( className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4", "data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuIndicator({ function NavigationMenuIndicator({
@@ -146,13 +146,13 @@ function NavigationMenuIndicator({
data-slot="navigation-menu-indicator" data-slot="navigation-menu-indicator"
className={cn( className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden", "data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className className,
)} )}
{...props} {...props}
> >
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" /> <div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator> </NavigationMenuPrimitive.Indicator>
) );
} }
export { export {
@@ -165,4 +165,4 @@ export {
NavigationMenuIndicator, NavigationMenuIndicator,
NavigationMenuViewport, NavigationMenuViewport,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} };
+11 -11
View File
@@ -1,20 +1,20 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
@@ -30,19 +30,19 @@ function PopoverContent({
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
function PopoverAnchor({ function PopoverAnchor({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+1 -1
View File
@@ -14,7 +14,7 @@ function Progress({
<ProgressPrimitive.Root <ProgressPrimitive.Root
data-slot="progress" data-slot="progress"
className={cn( className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden ", "bg-primary/20 relative h-2 w-full overflow-hidden",
className, className,
)} )}
{...props} {...props}
+11 -11
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
const Separator = React.forwardRef< const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>, React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -11,21 +11,21 @@ const Separator = React.forwardRef<
>( >(
( (
{ className, orientation = "horizontal", decorative = true, ...props }, { className, orientation = "horizontal", decorative = true, ...props },
ref ref,
) => ( ) => (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
ref={ref} ref={ref}
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border", "bg-border shrink-0",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className className,
)} )}
{...props} {...props}
/> />
) ),
) );
Separator.displayName = SeparatorPrimitive.Root.displayName Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator } export { Separator };
+19 -19
View File
@@ -1,31 +1,31 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@@ -37,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
@@ -50,7 +50,7 @@ function SheetContent({
side = "right", side = "right",
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@@ -67,7 +67,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className,
)} )}
{...props} {...props}
> >
@@ -78,7 +78,7 @@ function SheetContent({
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -88,7 +88,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -98,7 +98,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@@ -111,7 +111,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@@ -124,7 +124,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -136,4 +136,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };
+10 -21
View File
@@ -4,12 +4,7 @@ function Skeleton({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) { }: React.HTMLAttributes<HTMLDivElement>) {
return ( return <div className={cn("bg-muted animate-pulse", className)} {...props} />;
<div
className={cn("bg-muted animate-pulse ", className)}
{...props}
/>
);
} }
// Modern dashboard skeleton components // Modern dashboard skeleton components
@@ -17,12 +12,9 @@ export function DashboardStatsSkeleton() {
return ( return (
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"> <div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div <div key={i} className="border border-gray-100 bg-white p-6 shadow-sm">
key={i}
className=" border border-gray-100 bg-white p-6 shadow-sm"
>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<Skeleton className="h-9 w-9 " /> <Skeleton className="h-9 w-9" />
<Skeleton className="h-4 w-12" /> <Skeleton className="h-4 w-12" />
</div> </div>
<div> <div>
@@ -39,10 +31,7 @@ export function DashboardCardsSkeleton() {
return ( return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => ( {Array.from({ length: 2 }).map((_, i) => (
<div <div key={i} className="border border-gray-100 bg-white p-6 shadow-sm">
key={i}
className=" border border-gray-100 bg-white p-6 shadow-sm"
>
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" /> <Skeleton className="h-5 w-5 rounded" />
@@ -69,7 +58,7 @@ export function DashboardCardsSkeleton() {
export function DashboardActivitySkeleton() { export function DashboardActivitySkeleton() {
return ( return (
<div className=" border border-gray-100 bg-white p-6 shadow-sm"> <div className="border border-gray-100 bg-white p-6 shadow-sm">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" /> <Skeleton className="h-5 w-5 rounded" />
@@ -81,17 +70,17 @@ export function DashboardActivitySkeleton() {
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<div <div
key={i} key={i}
className="flex items-center justify-between border border-gray-100 p-4" className="flex items-center justify-between border border-gray-100 p-4"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 " /> <Skeleton className="h-8 w-8" />
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-4 w-24" /> <Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" /> <Skeleton className="h-3 w-32" />
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-6 w-16 " /> <Skeleton className="h-6 w-16" />
<Skeleton className="h-4 w-16" /> <Skeleton className="h-4 w-16" />
<Skeleton className="h-8 w-8 rounded" /> <Skeleton className="h-8 w-8 rounded" />
</div> </div>
@@ -115,14 +104,14 @@ export function DashboardHeroSkeleton() {
export function QuickActionsSkeleton() { export function QuickActionsSkeleton() {
return ( return (
<div className=" border border-gray-100 bg-white p-6 shadow-sm"> <div className="border border-gray-100 bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" /> <Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<div key={i} className=" border border-gray-200 p-4"> <div key={i} className="border border-gray-200 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-5 w-5" /> <Skeleton className="h-5 w-5" />
<div className="space-y-2"> <div className="space-y-2">
+1
View File
@@ -113,6 +113,7 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
if (lockValue !== null) { if (lockValue !== null) {
// Only update internal & emit if changed // Only update internal & emit if changed
if (!isControlled && internal !== 1) { if (!isControlled && internal !== 1) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Force the uncontrolled slider to the reduced-motion lock value.
setInternal(1); setInternal(1);
} }
if (lastEmittedRef.current !== 1) { if (lastEmittedRef.current !== 1) {
+4 -4
View File
@@ -11,7 +11,7 @@ const Tabs = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<TabsPrimitive.Root <TabsPrimitive.Root
ref={ref} ref={ref}
className={cn("flex flex-col gap-2", className)} className={cn("flex flex-col gap-1", className)}
{...props} {...props}
/> />
)); ));
@@ -24,7 +24,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"bg-muted text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1", "bg-muted text-muted-foreground flex h-9 w-full items-center justify-center rounded-lg p-1",
className, className,
)} )}
{...props} {...props}
@@ -39,7 +39,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow", "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex flex-1 items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow",
className, className,
)} )}
{...props} {...props}
@@ -54,7 +54,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none", "ring-offset-background focus-visible:ring-ring mt-1 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
className, className,
)} )}
{...props} {...props}
+10 -10
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@@ -15,7 +15,7 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
@@ -25,13 +25,13 @@ function Tooltip({
<TooltipProvider> <TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> <TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider> </TooltipProvider>
) );
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@@ -47,7 +47,7 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className className,
)} )}
{...props} {...props}
> >
@@ -55,7 +55,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+16 -5
View File
@@ -19,6 +19,7 @@ export const env = createEnv({
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
DB_DISABLE_SSL: z.coerce.boolean().optional(), DB_DISABLE_SSL: z.coerce.boolean().optional(),
DISABLE_SIGNUPS: z.coerce.boolean().optional(),
// SSO / Authentik (optional) // SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(), AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(), AUTHENTIK_CLIENT_ID: z.string().optional(),
@@ -41,18 +42,27 @@ export const env = createEnv({
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(), NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
NEXT_PUBLIC_BRAND_ICON: z.string().optional(), NEXT_PUBLIC_BRAND_ICON: z.string().optional(),
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z
.enum(["beenvoice", "shadcn", "minimal", "editorial"]) .enum([
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
])
.optional(), .optional(),
NEXT_PUBLIC_DEFAULT_FONT: z NEXT_PUBLIC_DEFAULT_FONT: z
.enum(["brand", "platform", "inter", "serif"]) .enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(), .optional(),
NEXT_PUBLIC_DEFAULT_BODY_FONT: z NEXT_PUBLIC_DEFAULT_BODY_FONT: z
.enum(["brand", "platform", "inter", "serif"]) .enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(), .optional(),
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
.enum(["brand", "platform", "inter", "serif"]) .enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z
.enum(["none", "sm", "md", "lg", "xl"])
.optional(), .optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z
.enum(["floating", "docked"]) .enum(["floating", "docked"])
.optional(), .optional(),
@@ -70,6 +80,7 @@ export const env = createEnv({
RESEND_DOMAIN: process.env.RESEND_DOMAIN, RESEND_DOMAIN: process.env.RESEND_DOMAIN,
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
DB_DISABLE_SSL: process.env.DB_DISABLE_SSL, DB_DISABLE_SSL: process.env.DB_DISABLE_SSL,
DISABLE_SIGNUPS: process.env.DISABLE_SIGNUPS,
AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER, AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER,
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET, AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
+1
View File
@@ -39,6 +39,7 @@ export function useCountUp({
useEffect(() => { useEffect(() => {
// Reset when end value changes // Reset when end value changes
// eslint-disable-next-line react-hooks/set-state-in-effect -- Restart the animation from the configured start value when inputs change.
setCount(start); setCount(start);
setIsAnimating(false); setIsAnimating(false);
+126
View File
@@ -0,0 +1,126 @@
import { z } from "zod";
export const interfaceThemeValues = [
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
] as const;
export const fontPreferenceValues = [
"brand",
"frutiger",
"platform",
"inter",
"serif",
] as const;
export const radiusPreferenceValues = ["none", "sm", "md", "lg", "xl"] as const;
export const sidebarStyleValues = ["floating", "docked"] as const;
export const colorModeValues = ["light", "dark", "system"] as const;
export const colorThemeValues = [
"slate",
"blue",
"green",
"rose",
"orange",
"custom",
] as const;
export const pdfTemplateValues = ["classic", "minimal"] as const;
export const interfaceThemeSchema = z.enum(interfaceThemeValues);
export const fontPreferenceSchema = z.enum(fontPreferenceValues);
export const radiusPreferenceSchema = z.enum(radiusPreferenceValues);
export const sidebarStyleSchema = z.enum(sidebarStyleValues);
export const colorModeSchema = z.enum(colorModeValues);
export const colorThemeSchema = z.enum(colorThemeValues);
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
export const hslChannelsSchema = z
.string()
.trim()
.regex(
/^(?:360(?:\.0)?|3[0-5]\d(?:\.\d)?|[12]?\d?\d(?:\.\d)?)\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%$/,
"Use HSL channels like 142.1 76.2% 36.3%",
);
export type InterfaceTheme = z.infer<typeof interfaceThemeSchema>;
export type FontPreference = z.infer<typeof fontPreferenceSchema>;
export type RadiusPreference = z.infer<typeof radiusPreferenceSchema>;
export type SidebarStyle = z.infer<typeof sidebarStyleSchema>;
export type ColorMode = z.infer<typeof colorModeSchema>;
export type ColorTheme = z.infer<typeof colorThemeSchema>;
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
export const fallbackAppearance = {
interfaceTheme: "beenvoice",
fontPreference: "brand",
bodyFontPreference: "brand",
headingFontPreference: "brand",
radiusPreference: "xl",
sidebarStyle: "floating",
colorMode: "system",
colorTheme: "slate",
customColor: undefined,
brandName: "beenvoice",
brandTagline:
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: "beenvoice",
brandIcon: "$",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
} satisfies {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
export function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return interfaceThemeSchema.safeParse(value).success;
}
export function isFontPreference(value: unknown): value is FontPreference {
return fontPreferenceSchema.safeParse(value).success;
}
export function isColorMode(value: unknown): value is ColorMode {
return colorModeSchema.safeParse(value).success;
}
export function isColorTheme(value: unknown): value is ColorTheme {
return colorThemeSchema.safeParse(value).success;
}
export function isRadiusPreference(value: unknown): value is RadiusPreference {
return radiusPreferenceSchema.safeParse(value).success;
}
export function isSidebarStyle(value: unknown): value is SidebarStyle {
return sidebarStyleSchema.safeParse(value).success;
}
export function isPdfTemplate(value: unknown): value is PdfTemplate {
return pdfTemplateSchema.safeParse(value).success;
}
export function isHslChannels(value: unknown): value is string {
return hslChannelsSchema.safeParse(value).success;
}
+2 -2
View File
@@ -7,6 +7,6 @@ import { genericOAuthClient } from "better-auth/client/plugins";
* Auth client configuration * Auth client configuration
*/ */
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL, baseURL: process.env.NEXT_PUBLIC_APP_URL,
plugins: [genericOAuthClient()], plugins: [genericOAuthClient()],
}); });
+2
View File
@@ -10,6 +10,7 @@ const authentikEnabled = Boolean(
process.env.AUTHENTIK_CLIENT_ID && process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET, process.env.AUTHENTIK_CLIENT_SECRET,
); );
const signupsDisabled = process.env.DISABLE_SIGNUPS === "true";
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
@@ -34,6 +35,7 @@ export const auth = betterAuth({
}), }),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
disableSignUp: signupsDisabled,
password: { password: {
hash: async (password) => { hash: async (password) => {
const bcrypt = await import("bcryptjs"); const bcrypt = await import("bcryptjs");
+101 -58
View File
@@ -1,17 +1,36 @@
import { env } from "~/env"; import { env } from "~/env";
import {
fallbackAppearance,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type PdfTemplate,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/appearance";
export type InterfaceTheme = "beenvoice" | "shadcn" | "minimal" | "editorial"; export type {
export type FontPreference = "brand" | "platform" | "inter" | "serif"; ColorMode,
export type RadiusPreference = "none" | "sm" | "md" | "lg" | "xl"; ColorTheme,
export type SidebarStyle = "floating" | "docked"; FontPreference,
export type ColorMode = "light" | "dark" | "system"; InterfaceTheme,
export type ColorTheme = PdfTemplate,
| "slate" RadiusPreference,
| "blue" SidebarStyle,
| "green" } from "~/lib/appearance";
| "rose"
| "orange" export {
| "custom"; colorModeSchema,
colorThemeSchema,
fallbackAppearance,
fontPreferenceSchema,
hslChannelsSchema,
interfaceThemeSchema,
pdfTemplateSchema,
radiusPreferenceSchema,
sidebarStyleSchema,
} from "~/lib/appearance";
export const interfaceThemes: { export const interfaceThemes: {
value: InterfaceTheme; value: InterfaceTheme;
@@ -21,7 +40,20 @@ export const interfaceThemes: {
{ {
value: "beenvoice", value: "beenvoice",
label: "beenvoice", label: "beenvoice",
description: "Opinionated brand system with expressive headings.", description:
"Playfair Display headings, Geist body text, and soft product chrome.",
},
{
value: "frutiger",
label: "Frutiger Airport",
description:
"Rectangular blue-and-yellow wayfinding UI with Frutiger typography and docked navigation.",
},
{
value: "frutiger-aero",
label: "Frutiger Aero",
description:
"Glossy sky-and-glass interface with Frutiger typography and softer surfaces.",
}, },
{ {
value: "shadcn", value: "shadcn",
@@ -49,6 +81,8 @@ export const themePresets: Record<
colorTheme: ColorTheme; colorTheme: ColorTheme;
radiusPreference: RadiusPreference; radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle; sidebarStyle: SidebarStyle;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
} }
> = { > = {
beenvoice: { beenvoice: {
@@ -58,6 +92,28 @@ export const themePresets: Record<
colorTheme: "slate", colorTheme: "slate",
radiusPreference: "xl", radiusPreference: "xl",
sidebarStyle: "floating", sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
},
frutiger: {
interfaceTheme: "frutiger",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "none",
sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#003b5c",
},
"frutiger-aero": {
interfaceTheme: "frutiger-aero",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "lg",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#0077be",
}, },
shadcn: { shadcn: {
interfaceTheme: "shadcn", interfaceTheme: "shadcn",
@@ -66,6 +122,8 @@ export const themePresets: Record<
colorTheme: "slate", colorTheme: "slate",
radiusPreference: "md", radiusPreference: "md",
sidebarStyle: "docked", sidebarStyle: "docked",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
}, },
minimal: { minimal: {
interfaceTheme: "minimal", interfaceTheme: "minimal",
@@ -74,6 +132,8 @@ export const themePresets: Record<
colorTheme: "slate", colorTheme: "slate",
radiusPreference: "sm", radiusPreference: "sm",
sidebarStyle: "docked", sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#111827",
}, },
editorial: { editorial: {
interfaceTheme: "editorial", interfaceTheme: "editorial",
@@ -82,36 +142,11 @@ export const themePresets: Record<
colorTheme: "rose", colorTheme: "rose",
radiusPreference: "lg", radiusPreference: "lg",
sidebarStyle: "floating", sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#be123c",
}, },
}; };
export const fontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Brand",
description: "Inter body with Playfair headings.",
},
{
value: "platform",
label: "Platform",
description: "Native system fonts for the current OS.",
},
{
value: "inter",
label: "Inter",
description: "Inter for both body and headings.",
},
{
value: "serif",
label: "Editorial",
description: "Serif headings with system body text.",
},
];
export const bodyFontPreferences: { export const bodyFontPreferences: {
value: FontPreference; value: FontPreference;
label: string; label: string;
@@ -119,8 +154,13 @@ export const bodyFontPreferences: {
}[] = [ }[] = [
{ {
value: "brand", value: "brand",
label: "Brand Sans", label: "Geist",
description: "Inter body text for a clean product feel.", description: "Geist body text for the core beenvoice product feel.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger body text for signage-like operational screens.",
}, },
{ {
value: "platform", value: "platform",
@@ -129,8 +169,8 @@ export const bodyFontPreferences: {
}, },
{ {
value: "inter", value: "inter",
label: "Inter", label: "Geist Legacy",
description: "Inter body text, explicitly selected.", description: "Legacy sans option mapped to Geist for older installs.",
}, },
{ {
value: "serif", value: "serif",
@@ -146,8 +186,13 @@ export const headingFontPreferences: {
}[] = [ }[] = [
{ {
value: "brand", value: "brand",
label: "Brand Serif", label: "Playfair Display",
description: "Playfair headings for the BeenVoice identity.", description: "Playfair Display headings for the beenvoice identity.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger headings for airport-inspired wayfinding.",
}, },
{ {
value: "platform", value: "platform",
@@ -156,8 +201,8 @@ export const headingFontPreferences: {
}, },
{ {
value: "inter", value: "inter",
label: "Inter", label: "Geist Legacy",
description: "Inter headings for a plain shadcn-style baseline.", description: "Legacy sans option mapped to Geist for older installs.",
}, },
{ {
value: "serif", value: "serif",
@@ -222,10 +267,10 @@ export const colorModes: {
]; ];
export const defaultInterfaceTheme: InterfaceTheme = export const defaultInterfaceTheme: InterfaceTheme =
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? "beenvoice"; env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? fallbackAppearance.interfaceTheme;
export const defaultFontPreference: FontPreference = export const defaultFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_FONT ?? "brand"; env.NEXT_PUBLIC_DEFAULT_FONT ?? fallbackAppearance.fontPreference;
export const defaultBodyFontPreference: FontPreference = export const defaultBodyFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference; env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
@@ -234,16 +279,14 @@ export const defaultHeadingFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference; env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
export const defaultRadiusPreference: RadiusPreference = export const defaultRadiusPreference: RadiusPreference =
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? "xl"; env.NEXT_PUBLIC_DEFAULT_RADIUS ?? fallbackAppearance.radiusPreference;
export const defaultSidebarStyle: SidebarStyle = export const defaultSidebarStyle: SidebarStyle =
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? "floating"; env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? fallbackAppearance.sidebarStyle;
export const brand = { export const brand = {
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice", name: env.NEXT_PUBLIC_BRAND_NAME ?? fallbackAppearance.brandName,
tagline: tagline: env.NEXT_PUBLIC_BRAND_TAGLINE ?? fallbackAppearance.brandTagline,
env.NEXT_PUBLIC_BRAND_TAGLINE ?? logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? fallbackAppearance.brandLogoText,
"Simple and efficient invoicing for freelancers and small businesses", icon: env.NEXT_PUBLIC_BRAND_ICON ?? fallbackAppearance.brandIcon,
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
}; };
+113
View File
@@ -0,0 +1,113 @@
export function hexToRgb(hex: string) {
const normalized = normalizeHex(hex).slice(1, 7);
return {
r: parseInt(normalized.slice(0, 2), 16),
g: parseInt(normalized.slice(2, 4), 16),
b: parseInt(normalized.slice(4, 6), 16),
};
}
export function rgbToHex(r: number, g: number, b: number) {
return `#${[r, g, b]
.map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0"))
.join("")}`.toUpperCase();
}
export function rgbToHsl(r: number, g: number, b: number) {
const red = clamp(r, 0, 255) / 255;
const green = clamp(g, 0, 255) / 255;
const blue = clamp(b, 0, 255) / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const lightness = (max + min) / 2;
const delta = max - min;
if (delta === 0) {
return { h: 0, s: 0, l: Math.round(lightness * 100) };
}
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
const hue =
max === red
? 60 * (((green - blue) / delta) % 6)
: max === green
? 60 * ((blue - red) / delta + 2)
: 60 * ((red - green) / delta + 4);
return {
h: Math.round((hue + 360) % 360),
s: Math.round(saturation * 100),
l: Math.round(lightness * 100),
};
}
export function hslToRgb(h: number, s: number, l: number) {
const hue = clamp(h, 0, 360);
const saturation = clamp(s, 0, 100) / 100;
const lightness = clamp(l, 0, 100) / 100;
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = lightness - c / 2;
const [red, green, blue] =
hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x];
return {
r: Math.round((red + m) * 255),
g: Math.round((green + m) * 255),
b: Math.round((blue + m) * 255),
};
}
export function hexToRgba(hex: string) {
const normalized = normalizeHex(hex, true);
const rgb = hexToRgb(normalized);
const alphaHex = normalized.length === 9 ? normalized.slice(7, 9) : "ff";
return {
...rgb,
a: Number((parseInt(alphaHex, 16) / 255).toFixed(2)),
};
}
export function rgbaToHex(r: number, g: number, b: number, a: number) {
const alpha = clamp(Math.round(clampAlpha(a) * 255), 0, 255)
.toString(16)
.padStart(2, "0");
return `${rgbToHex(r, g, b)}${alpha}`.toUpperCase();
}
export function rgbaToHsla(r: number, g: number, b: number, a: number) {
return { ...rgbToHsl(r, g, b), a: clampAlpha(a) };
}
export function hslaToRgba(h: number, s: number, l: number, a: number) {
return { ...hslToRgb(h, s, l), a: clampAlpha(a) };
}
function normalizeHex(hex: string, alpha = false) {
const fallback = alpha ? "#FFFFFFff" : "#FFFFFF";
const withHash = hex.startsWith("#") ? hex : `#${hex}`;
if (/^#[0-9A-Fa-f]{6}$/.test(withHash)) return withHash;
if (alpha && /^#[0-9A-Fa-f]{8}$/.test(withHash)) return withHash;
return fallback;
}
function clamp(value: number, min: number, max: number) {
return Math.max(
min,
Math.min(max, Math.floor(Number.isFinite(value) ? value : min)),
);
}
function clampAlpha(value: number) {
return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 1));
}
+15 -10
View File
@@ -36,8 +36,9 @@ export function generateAccentColors(hex: string) {
"--popover": `oklch(1 ${base.c * 0.02} ${base.h})`, "--popover": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`, "--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--primary": `oklch(0.6 ${base.c} ${base.h})`, "--primary": `oklch(0.6 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2 "--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
} ${base.h})`, base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`, "--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`, "--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`, "--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`,
@@ -56,8 +57,9 @@ export function generateAccentColors(hex: string) {
"--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`, "--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`,
"--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`, "--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`, "--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2 "--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
} ${base.h})`, base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`, "--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`, "--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`, "--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
@@ -75,11 +77,13 @@ export function generateAccentColors(hex: string) {
"--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`, "--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`,
"--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`, "--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--primary": `oklch(0.7 ${base.c} ${base.h})`, "--primary": `oklch(0.7 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2 "--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
} ${base.h})`, base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`, "--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2 "--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
} ${base.h})`, base.c * 0.2
} ${base.h})`,
"--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`, "--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`,
"--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`, "--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`,
"--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`, "--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`,
@@ -96,8 +100,9 @@ export function generateAccentColors(hex: string) {
"--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`, "--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`, "--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`, "--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2 "--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
} ${base.h})`, base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`, "--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`, "--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`, "--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
+3 -3
View File
@@ -1,7 +1,7 @@
import { createHash } from "crypto"; import { createHash } from "crypto";
export function getGravatarUrl(email: string, size = 200) { export function getGravatarUrl(email: string, size = 200) {
const trimmedEmail = email.trim().toLowerCase(); const trimmedEmail = email.trim().toLowerCase();
const hash = createHash("sha256").update(trimmedEmail).digest("hex"); const hash = createHash("sha256").update(trimmedEmail).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`; return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
} }
+6
View File
@@ -6,6 +6,7 @@ import {
Building, Building,
Receipt, Receipt,
BarChart2, BarChart2,
Shield,
} from "lucide-react"; } from "lucide-react";
export interface NavLink { export interface NavLink {
@@ -35,6 +36,11 @@ export const navigationConfig: NavSection[] = [
title: "Account", title: "Account",
links: [ links: [
{ name: "Settings", href: "/dashboard/settings", icon: Settings }, { name: "Settings", href: "/dashboard/settings", icon: Settings },
{
name: "Administration",
href: "/dashboard/administration",
icon: Shield,
},
], ],
}, },
]; ];
+595 -159
View File
@@ -54,7 +54,7 @@ function downloadBlob(blob: Blob, filename: string): void {
} }
} }
interface InvoiceData { export interface InvoiceData {
invoiceNumber: string; invoiceNumber: string;
invoicePrefix?: string | null; invoicePrefix?: string | null;
issueDate: Date; issueDate: Date;
@@ -537,6 +537,170 @@ const styles = StyleSheet.create({
}, },
}); });
const minimalStyles = StyleSheet.create({
page: {
fontSize: 9,
paddingTop: 28,
paddingBottom: 48,
paddingHorizontal: 32,
},
denseHeader: {
marginBottom: 16,
paddingBottom: 12,
},
headerTop: {
marginBottom: 10,
},
businessName: {
fontSize: 14,
marginBottom: 2,
},
businessInfo: {
fontSize: 8,
lineHeight: 1.25,
marginBottom: 1,
},
businessAddress: {
fontSize: 8,
lineHeight: 1.25,
marginTop: 2,
},
invoiceTitle: {
fontSize: 18,
marginBottom: 3,
},
invoiceNumber: {
fontSize: 10,
marginBottom: 2,
},
statusBadge: {
paddingHorizontal: 0,
paddingVertical: 0,
backgroundColor: "#ffffff",
fontSize: 8,
},
headerSeparator: {
marginVertical: 4,
},
detailsSection: {
marginBottom: 10,
},
detailsColumn: {
marginRight: 14,
},
sectionTitle: {
fontSize: 8,
marginBottom: 5,
},
clientName: {
fontSize: 9,
marginBottom: 1,
},
clientInfo: {
fontSize: 8,
lineHeight: 1.25,
marginBottom: 1,
},
clientAddress: {
fontSize: 8,
lineHeight: 1.25,
marginTop: 2,
},
detailRow: {
marginBottom: 2,
},
detailLabel: {
fontSize: 8,
},
detailValue: {
fontSize: 8,
},
tableContainer: {
marginBottom: 10,
},
tableHeader: {
backgroundColor: "#ffffff",
paddingVertical: 4,
paddingHorizontal: 0,
},
tableHeaderCell: {
fontSize: 8,
paddingHorizontal: 2,
},
tableRow: {
paddingVertical: 3,
minHeight: 16,
},
tableCell: {
fontSize: 8,
paddingHorizontal: 2,
paddingVertical: 1,
},
tableCellDescription: {
lineHeight: 1.2,
paddingVertical: 1,
paddingHorizontal: 2,
},
bottomSection: {
marginTop: 10,
},
notesContainer: {
width: 260,
},
notesSection: {
padding: 0,
backgroundColor: "#ffffff",
},
notesTitle: {
fontSize: 8,
marginBottom: 4,
},
notesContent: {
fontSize: 8,
lineHeight: 1.25,
},
totalsContainer: {
width: 190,
},
totalsBox: {
padding: 0,
backgroundColor: "#ffffff",
},
totalRow: {
marginBottom: 2,
paddingVertical: 0,
},
totalLabel: {
fontSize: 8,
},
totalAmount: {
fontSize: 8,
},
finalTotalRow: {
marginTop: 5,
paddingTop: 5,
},
finalTotalLabel: {
fontSize: 9,
},
finalTotalAmount: {
fontSize: 10,
},
itemCount: {
fontSize: 7,
marginTop: 4,
},
footer: {
bottom: 20,
left: 32,
right: 32,
paddingTop: 7,
},
pageNumber: {
fontSize: 8,
},
});
// Helper functions // Helper functions
const formatCurrency = (amount: number, currency = "USD") => { const formatCurrency = (amount: number, currency = "USD") => {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
@@ -602,109 +766,264 @@ function getColumnWidths(showRate: boolean) {
const DenseHeader: React.FC<{ const DenseHeader: React.FC<{
invoice: InvoiceData; invoice: InvoiceData;
settings: Required<PDFGenerationSettings>; settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => ( }> = ({ invoice, settings }) => {
<View style={styles.denseHeader}> const isMinimal = settings.pdfTemplate === "minimal";
<View style={styles.headerTop}>
<View style={styles.businessSection}> return (
<Text style={[styles.businessName, { color: settings.pdfAccentColor }]}> <View
{invoice.business?.name ?? "Your Business Name"} style={[styles.denseHeader, isMinimal ? minimalStyles.denseHeader : {}]}
</Text> >
{invoice.business?.email && ( <View
<Text style={styles.businessInfo}>{invoice.business.email}</Text> style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
)} >
{invoice.business?.phone && ( <View style={styles.businessSection}>
<Text style={styles.businessInfo}>{invoice.business.phone}</Text> <Text
)} style={[
{(invoice.business?.addressLine1 ?? styles.businessName,
invoice.business?.city ?? isMinimal ? minimalStyles.businessName : {},
invoice.business?.state) && ( { color: settings.pdfAccentColor },
<Text style={styles.businessAddress}> ]}
{[ >
invoice.business?.addressLine1, {invoice.business?.name ?? "Your Business Name"}
invoice.business?.addressLine2,
invoice.business?.city &&
invoice.business?.state &&
invoice.business?.postalCode
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode}`
: [
invoice.business?.city,
invoice.business?.state,
invoice.business?.postalCode,
]
.filter(Boolean)
.join(", "),
invoice.business?.country,
]
.filter(Boolean)
.join("\n")}
</Text> </Text>
)} {invoice.business?.email && (
<Text
style={[
styles.businessInfo,
isMinimal ? minimalStyles.businessInfo : {},
]}
>
{invoice.business.email}
</Text>
)}
{invoice.business?.phone && (
<Text
style={[
styles.businessInfo,
isMinimal ? minimalStyles.businessInfo : {},
]}
>
{invoice.business.phone}
</Text>
)}
{(invoice.business?.addressLine1 ??
invoice.business?.city ??
invoice.business?.state) && (
<Text
style={[
styles.businessAddress,
isMinimal ? minimalStyles.businessAddress : {},
]}
>
{[
invoice.business?.addressLine1,
invoice.business?.addressLine2,
invoice.business?.city &&
invoice.business?.state &&
invoice.business?.postalCode
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode}`
: [
invoice.business?.city,
invoice.business?.state,
invoice.business?.postalCode,
]
.filter(Boolean)
.join(", "),
invoice.business?.country,
]
.filter(Boolean)
.join("\n")}
</Text>
)}
</View>
<View style={styles.invoiceSection}>
<Text
style={[
styles.invoiceTitle,
isMinimal ? minimalStyles.invoiceTitle : {},
{ color: settings.pdfAccentColor },
]}
>
INVOICE
</Text>
<Text
style={[
styles.invoiceNumber,
isMinimal ? minimalStyles.invoiceNumber : {},
]}
>
{invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber}
</Text>
<View
style={[
...getStatusStyle(invoice.status),
isMinimal ? minimalStyles.statusBadge : {},
]}
>
<Text>{getStatusLabel(invoice.status)}</Text>
</View>
</View>
</View> </View>
<View style={styles.invoiceSection}> <View
<Text style={[styles.invoiceTitle, { color: settings.pdfAccentColor }]}> style={[
INVOICE styles.headerSeparator,
</Text> isMinimal ? minimalStyles.headerSeparator : {},
<Text style={styles.invoiceNumber}> ]}
{invoice.invoicePrefix ?? "#"} />
{invoice.invoiceNumber}
</Text> <View
<View style={getStatusStyle(invoice.status)}> style={[
<Text>{getStatusLabel(invoice.status)}</Text> styles.detailsSection,
isMinimal ? minimalStyles.detailsSection : {},
]}
>
<View
style={[
styles.detailsColumn,
isMinimal ? minimalStyles.detailsColumn : {},
]}
>
<Text
style={[
styles.sectionTitle,
isMinimal ? minimalStyles.sectionTitle : {},
]}
>
BILL TO:
</Text>
<Text
style={[
styles.clientName,
isMinimal ? minimalStyles.clientName : {},
]}
>
{invoice.client?.name ?? "N/A"}
</Text>
{invoice.client?.email && (
<Text
style={[
styles.clientInfo,
isMinimal ? minimalStyles.clientInfo : {},
]}
>
{invoice.client.email}
</Text>
)}
{invoice.client?.phone && (
<Text
style={[
styles.clientInfo,
isMinimal ? minimalStyles.clientInfo : {},
]}
>
{invoice.client.phone}
</Text>
)}
{(invoice.client?.addressLine1 ??
invoice.client?.city ??
invoice.client?.state) && (
<Text
style={[
styles.clientAddress,
isMinimal ? minimalStyles.clientAddress : {},
]}
>
{[
invoice.client?.addressLine1,
invoice.client?.addressLine2,
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
]
.filter(Boolean)
.join(", ")}
{invoice.client?.country ? "\n" + invoice.client.country : ""}
</Text>
)}
</View>
<View
style={[
styles.detailsColumn,
isMinimal ? minimalStyles.detailsColumn : {},
]}
>
<Text
style={[
styles.sectionTitle,
isMinimal ? minimalStyles.sectionTitle : {},
]}
>
INVOICE DETAILS:
</Text>
<View
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
>
<Text
style={[
styles.detailLabel,
isMinimal ? minimalStyles.detailLabel : {},
]}
>
Issue Date:
</Text>
<Text
style={[
styles.detailValue,
isMinimal ? minimalStyles.detailValue : {},
]}
>
{formatDate(invoice.issueDate)}
</Text>
</View>
<View
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
>
<Text
style={[
styles.detailLabel,
isMinimal ? minimalStyles.detailLabel : {},
]}
>
Due Date:
</Text>
<Text
style={[
styles.detailValue,
isMinimal ? minimalStyles.detailValue : {},
]}
>
{formatDate(invoice.dueDate)}
</Text>
</View>
<View
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
>
<Text
style={[
styles.detailLabel,
isMinimal ? minimalStyles.detailLabel : {},
]}
>
Invoice #:
</Text>
<Text
style={[
styles.detailValue,
isMinimal ? minimalStyles.detailValue : {},
]}
>
{invoice.invoiceNumber}
</Text>
</View>
</View> </View>
</View> </View>
</View> </View>
);
<View style={styles.headerSeparator} /> };
<View style={styles.detailsSection}>
<View style={styles.detailsColumn}>
<Text style={styles.sectionTitle}>BILL TO:</Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "N/A"}</Text>
{invoice.client?.email && (
<Text style={styles.clientInfo}>{invoice.client.email}</Text>
)}
{invoice.client?.phone && (
<Text style={styles.clientInfo}>{invoice.client.phone}</Text>
)}
{(invoice.client?.addressLine1 ??
invoice.client?.city ??
invoice.client?.state) && (
<Text style={styles.clientAddress}>
{[
invoice.client?.addressLine1,
invoice.client?.addressLine2,
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
]
.filter(Boolean)
.join(", ")}
{invoice.client?.country ? "\n" + invoice.client.country : ""}
</Text>
)}
</View>
<View style={styles.detailsColumn}>
<Text style={styles.sectionTitle}>INVOICE DETAILS:</Text>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Issue Date:</Text>
<Text style={styles.detailValue}>
{formatDate(invoice.issueDate)}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Due Date:</Text>
<Text style={styles.detailValue}>{formatDate(invoice.dueDate)}</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Invoice #:</Text>
<Text style={styles.detailValue}>{invoice.invoiceNumber}</Text>
</View>
</View>
</View>
</View>
);
// Table header component // Table header component
const TableHeader: React.FC<{ const TableHeader: React.FC<{
@@ -712,22 +1031,33 @@ const TableHeader: React.FC<{
showRate: boolean; showRate: boolean;
}> = ({ settings, showRate }) => { }> = ({ settings, showRate }) => {
const cols = getColumnWidths(showRate); const cols = getColumnWidths(showRate);
const isMinimal = settings.pdfTemplate === "minimal";
return ( return (
<View <View
style={[ style={[styles.tableHeader, isMinimal ? minimalStyles.tableHeader : {}]}
styles.tableHeader,
settings.pdfTemplate === "minimal"
? { backgroundColor: "#ffffff" }
: {},
]}
> >
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text> <Text
<Text style={[styles.tableHeaderCell, { width: cols.description }]}> style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
{ width: cols.date },
]}
>
Date
</Text>
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
{ width: cols.description },
]}
>
Description Description
</Text> </Text>
<Text <Text
style={[ style={[
styles.tableHeaderCell, styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
styles.tableHeaderHours, styles.tableHeaderHours,
{ width: cols.hours }, { width: cols.hours },
]} ]}
@@ -738,6 +1068,7 @@ const TableHeader: React.FC<{
<Text <Text
style={[ style={[
styles.tableHeaderCell, styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
styles.tableHeaderRate, styles.tableHeaderRate,
{ width: cols.rate }, { width: cols.rate },
]} ]}
@@ -748,6 +1079,7 @@ const TableHeader: React.FC<{
<Text <Text
style={[ style={[
styles.tableHeaderCell, styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
styles.tableHeaderAmount, styles.tableHeaderAmount,
{ width: cols.amount }, { width: cols.amount },
]} ]}
@@ -759,14 +1091,39 @@ const TableHeader: React.FC<{
}; };
// Footer component // Footer component
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => { const NotesSection: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => {
if (!invoice.notes) return null; if (!invoice.notes) return null;
const isMinimal = settings.pdfTemplate === "minimal";
return ( return (
<View style={styles.notesContainer}> <View
<View style={styles.notesSection}> style={[
<Text style={styles.notesTitle}>NOTES</Text> styles.notesContainer,
<Text style={styles.notesContent}>{invoice.notes}</Text> isMinimal ? minimalStyles.notesContainer : {},
]}
>
<View
style={[
styles.notesSection,
isMinimal ? minimalStyles.notesSection : {},
]}
>
<Text
style={[styles.notesTitle, isMinimal ? minimalStyles.notesTitle : {}]}
>
NOTES
</Text>
<Text
style={[
styles.notesContent,
isMinimal ? minimalStyles.notesContent : {},
]}
>
{invoice.notes}
</Text>
</View> </View>
</View> </View>
); );
@@ -774,40 +1131,45 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
settings, settings,
}) => ( }) => {
<View style={styles.footer} fixed> const isMinimal = settings.pdfTemplate === "minimal";
<View style={styles.footerLogo}>
{settings.pdfShowLogo && ( return (
<Image <View style={[styles.footer, isMinimal ? minimalStyles.footer : {}]} fixed>
src="/beenvoice-logo.png" <View style={styles.footerLogo}>
{settings.pdfShowLogo && (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
<Image
src="/beenvoice-logo.png"
style={{
width: 120,
height: 18,
marginRight: 8,
}}
/>
)}
<Text
style={{ style={{
width: 120, fontSize: isMinimal ? 8 : 9,
height: 18, fontFamily: "Helvetica",
marginRight: 8, color: "#6b7280",
marginLeft: settings.pdfShowLogo ? 8 : 0,
}} }}
>
{settings.pdfFooterText}
</Text>
</View>
{settings.pdfShowPageNumbers && (
<Text
style={[styles.pageNumber, isMinimal ? minimalStyles.pageNumber : {}]}
render={({ pageNumber, totalPages }) =>
`Page ${pageNumber} of ${totalPages}`
}
/> />
)} )}
<Text
style={{
fontSize: 9,
fontFamily: "Helvetica",
color: "#6b7280",
marginLeft: settings.pdfShowLogo ? 8 : 0,
}}
>
{settings.pdfFooterText}
</Text>
</View> </View>
{settings.pdfShowPageNumbers && ( );
<Text };
style={styles.pageNumber}
render={({ pageNumber, totalPages }) =>
`Page ${pageNumber} of ${totalPages}`
}
/>
)}
</View>
);
// Enhanced totals section component // Enhanced totals section component
const TotalsSection: React.FC<{ const TotalsSection: React.FC<{
@@ -819,14 +1181,21 @@ const TotalsSection: React.FC<{
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0); const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
const taxAmount = (subtotal * invoice.taxRate) / 100; const taxAmount = (subtotal * invoice.taxRate) / 100;
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
const isMinimal = settings.pdfTemplate === "minimal";
return ( return (
<View style={styles.totalsContainer}> <View
style={[
styles.totalsContainer,
isMinimal ? minimalStyles.totalsContainer : {},
]}
>
<View <View
style={[ style={[
styles.totalsBox, styles.totalsBox,
settings.pdfTemplate === "minimal" isMinimal
? { ? {
...minimalStyles.totalsBox,
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
borderTop: "1px solid #e5e7eb", borderTop: "1px solid #e5e7eb",
paddingHorizontal: 0, paddingHorizontal: 0,
@@ -836,38 +1205,79 @@ const TotalsSection: React.FC<{
> >
<Text <Text
style={{ style={{
fontSize: 11, fontSize: isMinimal ? 8 : 11,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
color: "#0f0f0f", color: "#0f0f0f",
textAlign: "center", textAlign: isMinimal ? "left" : "center",
marginBottom: 8, marginBottom: isMinimal ? 5 : 8,
paddingBottom: 6, paddingBottom: isMinimal ? 3 : 6,
}} }}
> >
INVOICE SUMMARY INVOICE SUMMARY
</Text> </Text>
<View style={styles.totalRow}> <View
<Text style={styles.totalLabel}>Subtotal:</Text> style={[styles.totalRow, isMinimal ? minimalStyles.totalRow : {}]}
<Text style={styles.totalAmount}> >
<Text
style={[
styles.totalLabel,
isMinimal ? minimalStyles.totalLabel : {},
]}
>
Subtotal:
</Text>
<Text
style={[
styles.totalAmount,
isMinimal ? minimalStyles.totalAmount : {},
]}
>
{formatCurrency(subtotal, currency)} {formatCurrency(subtotal, currency)}
</Text> </Text>
</View> </View>
{invoice.taxRate > 0 && ( {invoice.taxRate > 0 && (
<View style={styles.totalRow}> <View
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text> style={[styles.totalRow, isMinimal ? minimalStyles.totalRow : {}]}
<Text style={styles.totalAmount}> >
<Text
style={[
styles.totalLabel,
isMinimal ? minimalStyles.totalLabel : {},
]}
>
Tax ({invoice.taxRate}%):
</Text>
<Text
style={[
styles.totalAmount,
isMinimal ? minimalStyles.totalAmount : {},
]}
>
{formatCurrency(taxAmount, currency)} {formatCurrency(taxAmount, currency)}
</Text> </Text>
</View> </View>
)} )}
<View style={styles.finalTotalRow}> <View
<Text style={styles.finalTotalLabel}>TOTAL:</Text> style={[
styles.finalTotalRow,
isMinimal ? minimalStyles.finalTotalRow : {},
]}
>
<Text
style={[
styles.finalTotalLabel,
isMinimal ? minimalStyles.finalTotalLabel : {},
]}
>
TOTAL:
</Text>
<Text <Text
style={[ style={[
styles.finalTotalAmount, styles.finalTotalAmount,
isMinimal ? minimalStyles.finalTotalAmount : {},
{ color: settings.pdfAccentColor }, { color: settings.pdfAccentColor },
]} ]}
> >
@@ -875,7 +1285,9 @@ const TotalsSection: React.FC<{
</Text> </Text>
</View> </View>
<Text style={styles.itemCount}> <Text
style={[styles.itemCount, isMinimal ? minimalStyles.itemCount : {}]}
>
{items.length} line item{items.length !== 1 ? "s" : ""} {items.length} line item{items.length !== 1 ? "s" : ""}
</Text> </Text>
</View> </View>
@@ -893,14 +1305,23 @@ export const InvoicePDF: React.FC<{
const currency = invoice.currency ?? "USD"; const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1; const showRate = new Set(items.map((item) => item?.rate)).size > 1;
const cols = getColumnWidths(showRate); const cols = getColumnWidths(showRate);
const isMinimal = settings.pdfTemplate === "minimal";
return ( return (
<Document> <Document>
<Page size="LETTER" style={styles.page}> <Page
size="LETTER"
style={[styles.page, isMinimal ? minimalStyles.page : {}]}
>
<DenseHeader invoice={invoice} settings={settings} /> <DenseHeader invoice={invoice} settings={settings} />
{items.length > 0 && ( {items.length > 0 && (
<View style={styles.tableContainer}> <View
style={[
styles.tableContainer,
isMinimal ? minimalStyles.tableContainer : {},
]}
>
<TableHeader settings={settings} showRate={showRate} /> <TableHeader settings={settings} showRate={showRate} />
{items.map( {items.map(
(item, index) => (item, index) =>
@@ -910,6 +1331,7 @@ export const InvoicePDF: React.FC<{
wrap={false} wrap={false}
style={[ style={[
styles.tableRow, styles.tableRow,
isMinimal ? minimalStyles.tableRow : {},
settings.pdfTemplate === "classic" && index % 2 === 0 settings.pdfTemplate === "classic" && index % 2 === 0
? styles.tableRowAlt ? styles.tableRowAlt
: {}, : {},
@@ -918,6 +1340,7 @@ export const InvoicePDF: React.FC<{
<Text <Text
style={[ style={[
styles.tableCell, styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellDate, styles.tableCellDate,
{ width: cols.date }, { width: cols.date },
]} ]}
@@ -927,7 +1350,9 @@ export const InvoicePDF: React.FC<{
<Text <Text
style={[ style={[
styles.tableCell, styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellDescription, styles.tableCellDescription,
isMinimal ? minimalStyles.tableCellDescription : {},
{ width: cols.description }, { width: cols.description },
]} ]}
> >
@@ -936,6 +1361,7 @@ export const InvoicePDF: React.FC<{
<Text <Text
style={[ style={[
styles.tableCell, styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellHours, styles.tableCellHours,
{ width: cols.hours }, { width: cols.hours },
]} ]}
@@ -946,6 +1372,7 @@ export const InvoicePDF: React.FC<{
<Text <Text
style={[ style={[
styles.tableCell, styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellRate, styles.tableCellRate,
{ width: cols.rate }, { width: cols.rate },
]} ]}
@@ -956,6 +1383,7 @@ export const InvoicePDF: React.FC<{
<Text <Text
style={[ style={[
styles.tableCell, styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellAmount, styles.tableCellAmount,
{ width: cols.amount }, { width: cols.amount },
]} ]}
@@ -968,8 +1396,16 @@ export const InvoicePDF: React.FC<{
</View> </View>
)} )}
<View style={styles.bottomSection} wrap={false}> <View
{invoice.notes && <NotesSection invoice={invoice} />} style={[
styles.bottomSection,
isMinimal ? minimalStyles.bottomSection : {},
]}
wrap={false}
>
{invoice.notes && (
<NotesSection invoice={invoice} settings={settings} />
)}
<TotalsSection invoice={invoice} items={items} settings={settings} /> <TotalsSection invoice={invoice} items={items} settings={settings} />
</View> </View>
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
+6
View File
@@ -4,6 +4,12 @@ import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (pathname === "/auth/register" && process.env.DISABLE_SIGNUPS === "true") {
const signInUrl = new URL("/auth/signin", request.url);
signInUrl.searchParams.set("signup", "disabled");
return NextResponse.redirect(signInUrl);
}
// Define public routes that don't require authentication // Define public routes that don't require authentication
const publicRoutes = ["/", "/auth/signin", "/auth/register"]; const publicRoutes = ["/", "/auth/signin", "/auth/register"];
+109 -104
View File
@@ -3,123 +3,128 @@ import { invoices, clients } from "~/server/db/schema";
import { eq, desc } from "drizzle-orm"; import { eq, desc } from "drizzle-orm";
export const dashboardRouter = createTRPCRouter({ export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => { getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const now = new Date(); const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
// 1. Fetch all invoices for the user to calculate stats // 1. Fetch all invoices for the user to calculate stats
// Note: For very large datasets, we should use separate count/sum queries, // Note: For very large datasets, we should use separate count/sum queries,
// but for typical usage, fetching fields is fine and allows flexible JS calculation // but for typical usage, fetching fields is fine and allows flexible JS calculation
// where SQL complexity might be high (e.g. dynamic status). // where SQL complexity might be high (e.g. dynamic status).
// However, let's try to be efficient with SQL where possible. // However, let's try to be efficient with SQL where possible.
const userInvoices = await ctx.db.query.invoices.findMany({ const userInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId), where: eq(invoices.createdById, userId),
columns: { columns: {
id: true, id: true,
totalAmount: true, totalAmount: true,
status: true, status: true,
dueDate: true, dueDate: true,
issueDate: true, issueDate: true,
}, },
}); });
const userClientsCount = await ctx.db.$count( const userClientsCount = await ctx.db.$count(
clients, clients,
eq(clients.createdById, userId), eq(clients.createdById, userId),
); );
// Helper to check status // Helper to check status
const getStatus = (inv: typeof userInvoices[0]) => { const getStatus = (inv: (typeof userInvoices)[0]) => {
if (inv.status === "paid") return "paid"; if (inv.status === "paid") return "paid";
if (inv.status === "draft") return "draft"; if (inv.status === "draft") return "draft";
if (new Date(inv.dueDate) < now && inv.status !== "paid") return "overdue"; if (new Date(inv.dueDate) < now && inv.status !== "paid")
return "sent"; return "overdue";
}; return "sent";
};
// Calculate Stats // Calculate Stats
let totalRevenue = 0; let totalRevenue = 0;
let pendingAmount = 0; let pendingAmount = 0;
let overdueCount = 0; let overdueCount = 0;
let currentMonthRevenue = 0; let currentMonthRevenue = 0;
let lastMonthRevenue = 0; let lastMonthRevenue = 0;
for (const inv of userInvoices) { for (const inv of userInvoices) {
const status = getStatus(inv); const status = getStatus(inv);
const amount = inv.totalAmount; const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate); const issueDate = new Date(inv.issueDate);
if (status === "paid") { if (status === "paid") {
totalRevenue += amount; totalRevenue += amount;
if (issueDate >= currentMonthStart) { if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount; currentMonthRevenue += amount;
} else if (issueDate >= lastMonthStart && issueDate < currentMonthStart) { } else if (
lastMonthRevenue += amount; issueDate >= lastMonthStart &&
} issueDate < currentMonthStart
} else if (status === "sent" || status === "overdue") { ) {
pendingAmount += amount; lastMonthRevenue += amount;
}
if (status === "overdue") {
overdueCount++;
}
} }
} else if (status === "sent" || status === "overdue") {
pendingAmount += amount;
}
// Revenue Trend (Last 6 months) if (status === "overdue") {
const revenueByMonth: Record<string, number> = {}; overdueCount++;
for (let i = 0; i < 6; i++) { }
const d = new Date(now.getFullYear(), now.getMonth() - i, 1); }
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
revenueByMonth[key] = 0; // Revenue Trend (Last 6 months)
const revenueByMonth: Record<string, number> = {};
for (let i = 0; i < 6; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
revenueByMonth[key] = 0;
}
for (const inv of userInvoices) {
if (getStatus(inv) === "paid") {
const d = new Date(inv.issueDate);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
if (revenueByMonth[key] !== undefined) {
revenueByMonth[key] += inv.totalAmount;
} }
}
}
for (const inv of userInvoices) { const revenueChartData = Object.entries(revenueByMonth)
if (getStatus(inv) === "paid") { .map(([month, revenue]) => ({
const d = new Date(inv.issueDate); month,
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; revenue,
if (revenueByMonth[key] !== undefined) { monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
revenueByMonth[key] += inv.totalAmount; month: "short",
} year: "2-digit",
} }),
} }))
.sort((a, b) => a.month.localeCompare(b.month));
const revenueChartData = Object.entries(revenueByMonth) // Recent Activity
.map(([month, revenue]) => ({ const recentInvoices = await ctx.db.query.invoices.findMany({
month, where: eq(invoices.createdById, userId),
revenue, orderBy: [desc(invoices.issueDate)],
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", { limit: 5,
month: "short", with: {
year: "2-digit", client: {
}), columns: { name: true },
})) },
.sort((a, b) => a.month.localeCompare(b.month)); },
});
// Recent Activity return {
const recentInvoices = await ctx.db.query.invoices.findMany({ totalRevenue,
where: eq(invoices.createdById, userId), pendingAmount,
orderBy: [desc(invoices.issueDate)], overdueCount,
limit: 5, totalClients: userClientsCount,
with: { revenueChange:
client: { lastMonthRevenue > 0
columns: { name: true }, ? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
}, : 0,
}, revenueChartData,
}); recentInvoices,
};
return { }),
totalRevenue,
pendingAmount,
overdueCount,
totalClients: userClientsCount,
revenueChange: lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
recentInvoices,
};
}),
}); });
+37 -16
View File
@@ -47,7 +47,10 @@ export const expensesRouter = createTRPCRouter({
}); });
if (!expense) { if (!expense) {
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Expense not found",
});
} }
return expense; return expense;
@@ -58,11 +61,11 @@ export const expensesRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const clean = { const clean = {
...input, ...input,
clientId: input.clientId?.trim() || null, clientId: input.clientId?.trim() ?? null,
businessId: input.businessId?.trim() || null, businessId: input.businessId?.trim() ?? null,
invoiceId: input.invoiceId?.trim() || null, invoiceId: input.invoiceId?.trim() ?? null,
category: input.category?.trim() || null, category: input.category?.trim() ?? null,
notes: input.notes?.trim() || null, notes: input.notes?.trim() ?? null,
}; };
if (clean.clientId) { if (clean.clientId) {
@@ -72,7 +75,11 @@ export const expensesRouter = createTRPCRouter({
eq(clients.createdById, ctx.session.user.id), eq(clients.createdById, ctx.session.user.id),
), ),
}); });
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" }); if (!client)
throw new TRPCError({
code: "FORBIDDEN",
message: "Client not found",
});
} }
if (clean.businessId) { if (clean.businessId) {
@@ -82,7 +89,11 @@ export const expensesRouter = createTRPCRouter({
eq(businesses.createdById, ctx.session.user.id), eq(businesses.createdById, ctx.session.user.id),
), ),
}); });
if (!business) throw new TRPCError({ code: "FORBIDDEN", message: "Business not found" }); if (!business)
throw new TRPCError({
code: "FORBIDDEN",
message: "Business not found",
});
} }
if (clean.invoiceId) { if (clean.invoiceId) {
@@ -92,7 +103,11 @@ export const expensesRouter = createTRPCRouter({
eq(invoices.createdById, ctx.session.user.id), eq(invoices.createdById, ctx.session.user.id),
), ),
}); });
if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" }); if (!invoice)
throw new TRPCError({
code: "FORBIDDEN",
message: "Invoice not found",
});
} }
const [expense] = await ctx.db const [expense] = await ctx.db
@@ -116,16 +131,19 @@ export const expensesRouter = createTRPCRouter({
}); });
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Expense not found",
});
} }
const clean = { const clean = {
...data, ...data,
clientId: data.clientId?.trim() || null, clientId: data.clientId?.trim() ?? null,
businessId: data.businessId?.trim() || null, businessId: data.businessId?.trim() ?? null,
invoiceId: data.invoiceId?.trim() || null, invoiceId: data.invoiceId?.trim() ?? null,
category: data.category?.trim() || null, category: data.category?.trim() ?? null,
notes: data.notes?.trim() || null, notes: data.notes?.trim() ?? null,
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -145,7 +163,10 @@ export const expensesRouter = createTRPCRouter({
}); });
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Expense not found",
});
} }
await ctx.db.delete(expenses).where(eq(expenses.id, input.id)); await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
+8 -2
View File
@@ -72,7 +72,10 @@ export const invoiceTemplatesRouter = createTRPCRouter({
}); });
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
});
} }
// If setting as default, unset others of same type // If setting as default, unset others of same type
@@ -108,7 +111,10 @@ export const invoiceTemplatesRouter = createTRPCRouter({
}); });
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
});
} }
await ctx.db await ctx.db
+86 -57
View File
@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { import {
@@ -8,6 +8,7 @@ import {
publicProcedure, publicProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { import {
accounts,
users, users,
clients, clients,
businesses, businesses,
@@ -16,12 +17,20 @@ import {
platformSettings, platformSettings,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { import {
colorModeSchema,
colorThemeSchema,
defaultBodyFontPreference, defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference, defaultHeadingFontPreference,
defaultInterfaceTheme, defaultInterfaceTheme,
defaultRadiusPreference, defaultRadiusPreference,
defaultSidebarStyle, defaultSidebarStyle,
fallbackAppearance,
fontPreferenceSchema,
hslChannelsSchema,
interfaceThemeSchema,
pdfTemplateSchema,
radiusPreferenceSchema,
sidebarStyleSchema,
type ColorMode, type ColorMode,
type ColorTheme, type ColorTheme,
type FontPreference, type FontPreference,
@@ -29,9 +38,10 @@ import {
type RadiusPreference, type RadiusPreference,
type SidebarStyle, type SidebarStyle,
} from "~/lib/branding"; } from "~/lib/branding";
import type { db as database } from "~/server/db";
async function requireAdmin(ctx: { async function requireAdmin(ctx: {
db: typeof import("~/server/db").db; db: typeof database;
session: { user: { id: string } }; session: { user: { id: string } };
}) { }) {
const user = await ctx.db.query.users.findFirst({ const user = await ctx.db.query.users.findFirst({
@@ -217,12 +227,12 @@ export const settingsRouter = createTRPCRouter({
}); });
return { return {
colorTheme: (settings?.colorTheme as ColorTheme) ?? "slate", colorTheme:
(settings?.colorTheme as ColorTheme) ?? fallbackAppearance.colorTheme,
customColor: settings?.customColor ?? undefined, customColor: settings?.customColor ?? undefined,
theme: (settings?.theme as ColorMode) ?? "system", theme: (settings?.theme as ColorMode) ?? fallbackAppearance.colorMode,
interfaceTheme: interfaceTheme:
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme, (settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference: bodyFontPreference:
(settings?.bodyFontPreference as FontPreference) ?? (settings?.bodyFontPreference as FontPreference) ??
defaultBodyFontPreference, defaultBodyFontPreference,
@@ -234,18 +244,21 @@ export const settingsRouter = createTRPCRouter({
defaultRadiusPreference, defaultRadiusPreference,
sidebarStyle: sidebarStyle:
(settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle, (settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle,
brandName: settings?.brandName ?? "beenvoice", brandName: settings?.brandName ?? fallbackAppearance.brandName,
brandTagline: brandTagline: settings?.brandTagline ?? fallbackAppearance.brandTagline,
settings?.brandTagline ?? brandLogoText:
"Simple and efficient invoicing for freelancers and small businesses", settings?.brandLogoText ?? fallbackAppearance.brandLogoText,
brandLogoText: settings?.brandLogoText ?? "beenvoice", brandIcon: settings?.brandIcon ?? fallbackAppearance.brandIcon,
brandIcon: settings?.brandIcon ?? "$",
pdfTemplate: pdfTemplate:
(settings?.pdfTemplate as "classic" | "minimal") ?? "classic", (settings?.pdfTemplate as "classic" | "minimal") ??
pdfAccentColor: settings?.pdfAccentColor ?? "#111827", fallbackAppearance.pdfTemplate,
pdfFooterText: settings?.pdfFooterText ?? "Professional Invoicing", pdfAccentColor:
pdfShowLogo: settings?.pdfShowLogo ?? true, settings?.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
pdfShowPageNumbers: settings?.pdfShowPageNumbers ?? true, pdfFooterText:
settings?.pdfFooterText ?? fallbackAppearance.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers:
settings?.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
}; };
}), }),
@@ -253,30 +266,19 @@ export const settingsRouter = createTRPCRouter({
updateTheme: protectedProcedure updateTheme: protectedProcedure
.input( .input(
z.object({ z.object({
colorTheme: z colorTheme: colorThemeSchema.optional(),
.enum(["slate", "blue", "green", "rose", "orange", "custom"]) customColor: hslChannelsSchema.optional(),
.optional(), theme: colorModeSchema.optional(),
customColor: z.string().optional(), interfaceTheme: interfaceThemeSchema.optional(),
theme: z.enum(["light", "dark", "system"]).optional(), bodyFontPreference: fontPreferenceSchema.optional(),
interfaceTheme: z headingFontPreference: fontPreferenceSchema.optional(),
.enum(["beenvoice", "shadcn", "minimal", "editorial"]) radiusPreference: radiusPreferenceSchema.optional(),
.optional(), sidebarStyle: sidebarStyleSchema.optional(),
fontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
bodyFontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
headingFontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
radiusPreference: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
sidebarStyle: z.enum(["floating", "docked"]).optional(),
brandName: z.string().min(1).max(100).optional(), brandName: z.string().min(1).max(100).optional(),
brandTagline: z.string().min(1).max(255).optional(), brandTagline: z.string().min(1).max(255).optional(),
brandLogoText: z.string().min(1).max(100).optional(), brandLogoText: z.string().min(1).max(100).optional(),
brandIcon: z.string().min(1).max(20).optional(), brandIcon: z.string().min(1).max(20).optional(),
pdfTemplate: z.enum(["classic", "minimal"]).optional(), pdfTemplate: pdfTemplateSchema.optional(),
pdfAccentColor: z.string().min(4).max(50).optional(), pdfAccentColor: z.string().min(4).max(50).optional(),
pdfFooterText: z.string().min(1).max(120).optional(), pdfFooterText: z.string().min(1).max(120).optional(),
pdfShowLogo: z.boolean().optional(), pdfShowLogo: z.boolean().optional(),
@@ -289,15 +291,14 @@ export const settingsRouter = createTRPCRouter({
.insert(platformSettings) .insert(platformSettings)
.values({ .values({
id: "global", id: "global",
brandName: input.brandName ?? "beenvoice", brandName: input.brandName ?? fallbackAppearance.brandName,
brandTagline: brandTagline: input.brandTagline ?? fallbackAppearance.brandTagline,
input.brandTagline ?? brandLogoText:
"Simple and efficient invoicing for freelancers and small businesses", input.brandLogoText ?? fallbackAppearance.brandLogoText,
brandLogoText: input.brandLogoText ?? "beenvoice", brandIcon: input.brandIcon ?? fallbackAppearance.brandIcon,
brandIcon: input.brandIcon ?? "$", colorTheme: input.colorTheme ?? fallbackAppearance.colorTheme,
colorTheme: input.colorTheme ?? "slate",
customColor: input.customColor, customColor: input.customColor,
theme: input.theme ?? "system", theme: input.theme ?? fallbackAppearance.colorMode,
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme, interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
bodyFontPreference: bodyFontPreference:
input.bodyFontPreference ?? defaultBodyFontPreference, input.bodyFontPreference ?? defaultBodyFontPreference,
@@ -305,11 +306,14 @@ export const settingsRouter = createTRPCRouter({
input.headingFontPreference ?? defaultHeadingFontPreference, input.headingFontPreference ?? defaultHeadingFontPreference,
radiusPreference: input.radiusPreference ?? defaultRadiusPreference, radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle, sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
pdfTemplate: input.pdfTemplate ?? "classic", pdfTemplate: input.pdfTemplate ?? fallbackAppearance.pdfTemplate,
pdfAccentColor: input.pdfAccentColor ?? "#111827", pdfAccentColor:
pdfFooterText: input.pdfFooterText ?? "Professional Invoicing", input.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
pdfShowLogo: input.pdfShowLogo ?? true, pdfFooterText:
pdfShowPageNumbers: input.pdfShowPageNumbers ?? true, input.pdfFooterText ?? fallbackAppearance.pdfFooterText,
pdfShowLogo: input.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers:
input.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: platformSettings.id, target: platformSettings.id,
@@ -425,13 +429,38 @@ export const settingsRouter = createTRPCRouter({
saltRounds, saltRounds,
); );
// Update the password await ctx.db.transaction(async (tx) => {
await ctx.db await tx
.update(users) .update(users)
.set({ .set({
password: hashedNewPassword, password: hashedNewPassword,
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, userId),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedNewPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId,
accountId: userId,
providerId: "credential",
password: hashedNewPassword,
});
}
});
return { success: true }; return { success: true };
}), }),
+56 -56
View File
@@ -1,65 +1,65 @@
import { env } from "~/env"; import { env } from "~/env";
type UmamiPayload = { type UmamiPayload = {
payload: { payload: {
hostname: string; hostname: string;
language: string; language: string;
referrer: string; referrer: string;
screen: string; screen: string;
title: string; title: string;
url: string; url: string;
website: string; website: string;
name: string; name: string;
data?: Record<string, unknown>; data?: Record<string, unknown>;
}; };
type: "event"; type: "event";
}; };
export async function trackServerEvent( export async function trackServerEvent(
eventName: string, eventName: string,
eventData?: Record<string, unknown>, eventData?: Record<string, unknown>,
) { ) {
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) { if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
console.warn("Umami not configured, skipping server-side event tracking"); console.warn("Umami not configured, skipping server-side event tracking");
return; return;
} }
// Extract API endpoint from script URL (e.g., https://analytics.umami.is/script.js -> https://analytics.umami.is/api/send) // Extract API endpoint from script URL (e.g., https://analytics.umami.is/script.js -> https://analytics.umami.is/api/send)
const scriptUrl = new URL(env.NEXT_PUBLIC_UMAMI_SCRIPT_URL); const scriptUrl = new URL(env.NEXT_PUBLIC_UMAMI_SCRIPT_URL);
const apiUrl = `${scriptUrl.origin}/api/send`; const apiUrl = `${scriptUrl.origin}/api/send`;
const payload: UmamiPayload = { const payload: UmamiPayload = {
payload: { payload: {
hostname: env.NEXT_PUBLIC_APP_URL hostname: env.NEXT_PUBLIC_APP_URL
? new URL(env.NEXT_PUBLIC_APP_URL).hostname ? new URL(env.NEXT_PUBLIC_APP_URL).hostname
: "localhost", : "localhost",
language: "en-US", language: "en-US",
referrer: "", referrer: "",
screen: "", screen: "",
title: "Server Event", title: "Server Event",
url: "/", url: "/",
website: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, website: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
name: eventName, name: eventName,
data: eventData, data: eventData,
}, },
type: "event", type: "event",
}; };
try { try {
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "User-Agent":
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
console.error("Failed to send Umami event:", await response.text()); console.error("Failed to send Umami event:", await response.text());
}
} catch (error) {
console.error("Error sending Umami event:", error);
} }
} catch (error) {
console.error("Error sending Umami event:", error);
}
} }

Some files were not shown because too many files have changed in this diff Show More