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.
This commit is contained in:
2026-04-30 10:50:50 -04:00
parent ddc2b42672
commit 0e46fdafb2
87 changed files with 4566 additions and 2425 deletions
+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=="],
+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"],
}; };
+11 -9
View File
@@ -11,9 +11,8 @@
"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:dev:up": "colima start && docker compose -f docker-compose.dev.yml up -d", "docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:down": "docker compose down && colima stop",
"docker:dev: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",
@@ -31,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",
@@ -71,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",
@@ -92,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
+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;
} }
@@ -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%)"
@@ -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 (
@@ -86,7 +86,7 @@ const getStatusType = (invoice: Invoice): StatusType =>
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
) as StatusType; );
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { new Intl.DateTimeFormat("en-US", {
+9 -4
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,7 +193,8 @@ 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 ${
action.featured
? "border-foreground/20 bg-muted/50 hover:bg-muted" ? "border-foreground/20 bg-muted/50 hover:bg-muted"
: "border-border bg-background hover:bg-muted/50" : "border-border bg-background hover:bg-muted/50"
}`} }`}
@@ -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"
style={{
backgroundColor: `hsl(${customColorValue})`,
}}
/>
Pick color
<input
type="color"
value={hslChannelsToHex(customColorValue)} value={hslChannelsToHex(customColorValue)}
onChange={(event) => onBlur={() => undefined}
updateAppearance({ onChange={(value) => {
if (isFullHexColor(value)) {
updateAppearanceDebounced({
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,15 +1144,31 @@ export function SettingsContent() {
</div> </div>
</section> </section>
<section className="space-y-4 border-t pt-6"> {appearanceUpdating && (
<div>
<h3 className="text-sm font-medium">PDF</h3>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Controls the generated invoice PDF used for downloads and Saving appearance...
email attachments.
</p> </p>
</div> )}
<div className="grid gap-4 md:grid-cols-2"> </CardContent>
)}
</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"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
@@ -1169,54 +1191,42 @@ 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">
Minimal removes shaded table fills for a cleaner document. Minimal removes shaded table fills for a cleaner
document.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>PDF Accent</Label> <InputColor
<div className="flex flex-col gap-2 sm:flex-row"> label="PDF Accent"
<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} value={pdfAccentColor}
onChange={(event) => onBlur={() => undefined}
onChange={(value) => {
if (isFullHexColor(value)) {
updateAppearance({ updateAppearance({
pdfAccentColor: event.target.value, pdfAccentColor: value,
}) });
} }
className="sr-only" }}
aria-label="Pick PDF accent color" className="mt-0"
/>
</label>
<Input
value={pdfAccentColor}
onChange={(event) =>
updateAppearance({
pdfAccentColor: event.target.value,
})
}
placeholder="#111827"
/> />
</div> </div>
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2">
<Label>Footer Text</Label> <Label>Footer Text</Label>
<Input <Input
value={pdfFooterText} value={pdfFooterText}
onChange={(event) => onChange={(event) =>
updateAppearance({ pdfFooterText: event.target.value }) updateAppearanceDebounced({
pdfFooterText: event.target.value,
})
} }
/> />
</div> </div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-3"> <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"> <div className="space-y-1">
<Label>Show Logo</Label> <Label>Show Logo</Label>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
@@ -1232,7 +1242,7 @@ export function SettingsContent() {
/> />
</div> </div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-3"> <div className="flex items-start justify-between gap-4 border p-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Page Numbers</Label> <Label>Page Numbers</Label>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
@@ -1250,15 +1260,22 @@ export function SettingsContent() {
/> />
</div> </div>
</div> </div>
</section> </div>
{appearanceUpdating && (
<p className="text-muted-foreground text-xs"> <PdfPreviewFrame
Saving appearance... businessName={brandName}
</p> settings={{
)} pdfTemplate,
pdfAccentColor,
pdfFooterText,
pdfShowLogo,
pdfShowPageNumbers,
}}
/>
</div>
</CardContent> </CardContent>
)}
</Card> </Card>
)}
{/* Accessibility & Animation */} {/* Accessibility & Animation */}
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
@@ -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
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>
<CardContent className="p-6">
<HydrateClient> <HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}> <Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<SettingsContent /> <SettingsContent />
</Suspense> </Suspense>
</HydrateClient> </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;
+64 -250
View File
@@ -1,294 +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,
Check,
Zap,
Shield,
BarChart3,
Rocket,
} from "lucide-react";
import { brand } from "~/lib/branding";
import { env } from "~/env"; import { env } from "~/env";
import { brand } from "~/lib/branding";
export default function HomePage() { export default function HomePage() {
const allowRegistration = env.DISABLE_SIGNUPS !== true; 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>
{/* Navigation */}
<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="mx-auto px-6">
<div className="flex h-16 items-center justify-between">
<Logo />
<div className="hidden items-center space-x-8 md:flex">
<a
href="#features"
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
>
Features
</a>
<a
href="#pricing"
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
>
Pricing
</a>
</div>
<div className="flex items-center space-x-4">
<Link href="/auth/signin"> <Link href="/auth/signin">
<Button <Button variant="ghost" size="sm">
variant="ghost" Sign in
size="sm"
className="text-muted-foreground hover:text-foreground"
>
Sign In
</Button> </Button>
</Link> </Link>
{allowRegistration && ( {allowRegistration && (
<Link href="/auth/register"> <Link href="/auth/register">
<Button <Button size="sm">Create account</Button>
size="sm"
variant="default"
className="rounded-xl px-6"
>
Get Started
</Button>
</Link> </Link>
)} )}
</div>
</div>
</div>
</nav> </nav>
</header>
{/* Hero Section */} <section className="grid flex-1 items-center gap-10 py-14 md:grid-cols-[1fr_320px] md:py-20">
<section className="relative pt-48 pb-32"> <div className="max-w-2xl space-y-7">
<div className="container mx-auto px-4 text-center"> <div className="space-y-4">
<div className="mx-auto max-w-4xl"> <p className="text-muted-foreground text-sm font-medium">
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 rounded-full border px-4 py-1 text-sm"> Personal invoicing
<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> </p>
<h1 className="font-heading text-4xl leading-tight font-bold tracking-normal sm:text-5xl">
{brand.name} is a place to make and track invoices.
</h1>
<p className="text-muted-foreground max-w-xl text-base leading-7 sm:text-lg">
Built for one person managing real clients, real work, and the
small admin loop around getting paid.
</p>
</div>
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center"> <div className="flex flex-col gap-3 sm:flex-row">
<Link href="/auth/signin">
<Button size="lg" className="h-11 px-5">
Open workspace
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
{allowRegistration && ( {allowRegistration && (
<Link href="/auth/register"> <Link href="/auth/register">
<Button <Button variant="outline" size="lg" className="h-11 px-5">
size="lg" Create account
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> </Button>
</Link> </Link>
)} )}
<a href="#features"> </div>
<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>
<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="border-border bg-card text-card-foreground rounded-xl border p-5 shadow-sm">
<div className="flex items-center gap-2"> <div className="space-y-5">
<Check className="text-primary h-4 w-4" /> <div className="flex items-start gap-3">
<span>No credit card required</span> <div className="bg-primary/10 text-primary rounded-md p-2">
<UserRound className="h-4 w-4" />
</div> </div>
<div className="flex items-center gap-2"> <div>
<Check className="text-primary h-4 w-4" /> <h2 className="text-sm font-semibold">Clients</h2>
<span>Setup in 2 minutes</span> <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-center gap-2"> </div>
<Check className="text-primary h-4 w-4" />
<span>Free forever</span> <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>
</div> </div>
</section> </section>
{/* Features Section */} <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">
<section id="features" className="relative py-24"> <span>© 2026 {brand.name}</span>
<div className="relative z-10 container mx-auto px-4"> <div className="flex gap-5">
<div className="mb-20 text-center"> <Link href="/privacy" className="hover:text-foreground">
<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>
{allowRegistration && (
<Link href="/auth/register" className="block">
<Button
size="lg"
className="h-12 w-full rounded-xl text-lg"
>
Get Started
</Button>
</Link>
)}
</CardContent>
</Card>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-border/40 bg-background/50 mt-12 border-t py-12 backdrop-blur-sm">
<div className="container mx-auto flex flex-col items-center justify-between gap-6 px-6 md:flex-row">
<div className="flex items-center gap-3">
<Logo size="sm" />
<span className="text-muted-foreground text-sm">
© 2024 beenvoice
</span>
</div>
<div className="text-muted-foreground flex gap-8 text-sm">
<a href="#" className="hover:text-foreground transition-colors">
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>
); );
} }
@@ -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>
</> </>
)} )}
+2 -5
View File
@@ -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" />
@@ -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" />
+18 -25
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}
@@ -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,7 +442,8 @@ 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.pageIndex *
table.getState().pagination.pageSize + table.getState().pagination.pageSize +
1 1
} to ${Math.min( } to ${Math.min(
@@ -463,7 +455,8 @@ export function DataTable<TData, TValue>({
<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.pageIndex *
table.getState().pagination.pageSize + table.getState().pagination.pageSize +
1 1
}-${Math.min( }-${Math.min(
@@ -87,7 +87,8 @@ 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 */}
@@ -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 ?? "",
+1 -1
View File
@@ -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",
)} )}
+199 -77
View File
@@ -1,7 +1,17 @@
"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,
@@ -14,10 +24,16 @@ 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;
@@ -32,7 +48,7 @@ interface InvoiceCalendarViewProps {
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;
@@ -64,14 +80,17 @@ export function InvoiceCalendarView({
}, [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(
(targetDate: Date) => {
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, targetDate); return isSameDay(itemDate, targetDate);
}); });
}, [items]); },
[items],
);
const handleSelectDate = (newDate: Date | undefined) => { const handleSelectDate = (newDate: Date | undefined) => {
if (!newDate) return; if (!newDate) return;
@@ -88,7 +107,10 @@ export function InvoiceCalendarView({
// 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);
@@ -98,51 +120,81 @@ export function InvoiceCalendarView({
}; };
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
variant="outline"
size="icon"
onClick={() => setViewDate((d) => subWeeks(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<span className="text-sm font-medium w-36 text-center"> <span className="w-36 text-center text-sm font-medium">
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`} {`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
</span> </span>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addWeeks(d, 1))} className="h-8 w-8 rounded-lg"> <Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => addWeeks(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subMonths(d, 1))} className="h-8 w-8 rounded-lg"> <Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => subMonths(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<span className="text-sm font-medium w-36 text-center"> <span className="w-36 text-center text-sm font-medium">
{format(viewDate, "MMMM yyyy")} {format(viewDate, "MMMM yyyy")}
</span> </span>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addMonths(d, 1))} className="h-8 w-8 rounded-lg"> <Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => addMonths(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</> </>
)} )}
</div> </div>
<div className="flex items-center space-x-2 ml-auto"> <div className="ml-auto flex items-center space-x-2">
{/* View Switcher */} {/* View Switcher */}
<div className="bg-muted p-1 rounded-lg flex text-sm"> <div className="bg-muted flex rounded-lg p-1 text-sm">
<button <button
type="button" type="button"
onClick={() => setView("month")} onClick={() => setView("month")}
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")} 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 Month
</button> </button>
<button <button
type="button" type="button"
onClick={() => setView("week")} onClick={() => setView("week")}
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")} 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 Week
</button> </button>
@@ -150,7 +202,7 @@ export function InvoiceCalendarView({
</div> </div>
</div> </div>
<div className="flex-1 w-full overflow-hidden"> <div className="w-full flex-1 overflow-hidden">
{view === "month" ? ( {view === "month" ? (
<Calendar <Calendar
mode="single" mode="single"
@@ -158,7 +210,7 @@ export function InvoiceCalendarView({
onSelect={handleSelectDate} onSelect={handleSelectDate}
month={viewDate} month={viewDate}
onMonthChange={setViewDate} onMonthChange={setViewDate}
className="rounded-md border-0 w-full p-0" className="w-full rounded-md border-0 p-0"
classNames={{ classNames={{
root: "w-full p-0", root: "w-full p-0",
months: "flex flex-col w-full", months: "flex flex-col w-full",
@@ -173,7 +225,8 @@ export function InvoiceCalendarView({
// Use calc(100%/7) via tailwind arbitrary or just flex bases. // Use calc(100%/7) via tailwind arbitrary or just flex bases.
// Better: w-[14.28%] flex-none (approx 1/7) // Better: w-[14.28%] flex-none (approx 1/7)
weekdays: "flex w-full border-b", weekdays: "flex w-full border-b",
weekday: "w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4", weekday:
"w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
week: "flex w-full mt-2", 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", 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",
@@ -183,7 +236,7 @@ export function InvoiceCalendarView({
caption: "hidden", caption: "hidden",
day: cn( 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" "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_selected: "bg-primary/5 text-primary",
day_today: "bg-accent/20", day_today: "bg-accent/20",
@@ -204,36 +257,61 @@ export function InvoiceCalendarView({
{...buttonProps} {...buttonProps}
type="button" type="button"
className={cn( 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", "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 // Selected State: Filled Box, No Outline
modifiers.selected && "bg-primary text-primary-foreground hover:bg-primary/90 shadow-md transform scale-[0.98]", modifiers.selected &&
modifiers.today && !modifiers.selected && "bg-accent/40 rounded-xl", "bg-primary text-primary-foreground hover:bg-primary/90 scale-[0.98] transform shadow-md",
className modifiers.today &&
!modifiers.selected &&
"bg-accent/40 rounded-xl",
className,
)} )}
> >
<span className="text-sm font-medium z-10">{DayDate.getDate()}</span> <span className="z-10 text-sm font-medium">
{DayDate.getDate()}
</span>
{dayItems.length > 0 && ( {dayItems.length > 0 && (
<div className="flex flex-col gap-1 w-full mt-1 overflow-hidden h-full justify-end pb-1"> <div className="mt-1 flex h-full w-full flex-col justify-end gap-1 overflow-hidden pb-1">
<div className="flex flex-col gap-1 w-full mt-1"> <div className="mt-1 flex w-full flex-col gap-1">
{dayItems.slice(0, 4).map((item, idx) => ( {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")} /> <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")} />} {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>
</div> </div>
)} )}
</button> </button>
); );
} },
}} }}
/> />
) : ( ) : (
<div className="flex gap-3 overflow-x-auto p-4 pb-6 w-full"> <div className="flex w-full gap-3 overflow-x-auto p-4 pb-6">
{weekDays.map((day) => { {weekDays.map((day) => {
const isSelected = date && isSameDay(day, date); const isSelected = date && isSameDay(day, date);
const isToday = isSameDay(day, new Date()); const isToday = isSameDay(day, new Date());
const dayItems = getItemsForDate(day); const dayItems = getItemsForDate(day);
const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); const totalHours = dayItems.reduce(
(acc, curr) => acc + curr.item.hours,
0,
);
return ( return (
<button <button
@@ -241,34 +319,49 @@ export function InvoiceCalendarView({
type="button" type="button"
onClick={() => handleSelectDate(day)} onClick={() => handleSelectDate(day)}
className={cn( 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", "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-2 ring-primary ring-offset-2 bg-primary/5" : "bg-background/40", isSelected
isToday && !isSelected ? "bg-accent/40" : "" ? "ring-primary bg-primary/5 ring-2 ring-offset-2"
: "bg-background/40",
isToday && !isSelected ? "bg-accent/40" : "",
)} )}
> >
<div className="flex flex-col items-center mb-4 pb-4 border-b w-full"> <div className="mb-4 flex w-full flex-col items-center border-b pb-4">
<span className="text-xs font-bold text-muted-foreground uppercase">{format(day, "EEE")}</span> <span className="text-muted-foreground text-xs font-bold uppercase">
<span className="text-2xl font-light">{format(day, "d")}</span> {format(day, "EEE")}
</span>
<span className="text-2xl font-light">
{format(day, "d")}
</span>
</div> </div>
<div className="flex-1 space-y-2 w-full overflow-hidden"> <div className="w-full flex-1 space-y-2 overflow-hidden">
{dayItems.length > 0 ? ( {dayItems.length > 0 ? (
dayItems.map(({ item }, i) => ( dayItems.map(({ item }, i) => (
<div key={i} className="bg-background rounded-xl p-2 text-xs shadow-sm border"> <div
<div className="font-medium line-clamp-2 text-wrap break-words">{item.description || "No description"}</div> key={i}
<div className="text-muted-foreground whitespace-nowrap">{item.hours}h</div> 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>
)) ))
) : ( ) : (
<div className="h-full flex items-center justify-center text-muted-foreground/20"> <div className="text-muted-foreground/20 flex h-full items-center justify-center">
<Plus className="w-8 h-8" /> <Plus className="h-8 w-8" />
</div> </div>
)} )}
</div> </div>
{dayItems.length > 0 && ( {dayItems.length > 0 && (
<div className="pt-2 mt-auto text-center w-full"> <div className="mt-auto w-full pt-2 text-center">
<span className="text-sm font-semibold">{totalHours}h Total</span> <span className="text-sm font-semibold">
{totalHours}h Total
</span>
</div> </div>
)} )}
</button> </button>
@@ -279,47 +372,60 @@ export function InvoiceCalendarView({
</div> </div>
{/* Sheet for Day Details */} {/* Sheet for Day Details */}
<Sheet <Sheet open={sheetOpen} onOpenChange={handleCloseSheet}>
open={sheetOpen} <SheetContent
onOpenChange={handleCloseSheet} side="right"
className="flex w-full max-w-full flex-col gap-0 p-0 sm:w-[400px] sm:max-w-[540px]"
> >
<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="border-b p-6">
<SheetHeader className="p-6 border-b"> <SheetTitle className="flex flex-wrap items-center gap-3 text-2xl">
<SheetTitle className="flex items-center gap-3 text-2xl flex-wrap"> <div className="bg-primary/10 flex-shrink-0 rounded-full p-2.5">
<div className="bg-primary/10 p-2.5 rounded-full flex-shrink-0"> <CalendarIcon className="text-primary h-6 w-6" />
<CalendarIcon className="w-6 h-6 text-primary" />
</div> </div>
<span className="break-words text-left">{date ? format(date, "EEEE, MMMM do") : "Details"}</span> <span className="text-left break-words">
{date ? format(date, "EEEE, MMMM do") : "Details"}
</span>
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="space-y-6"> <div className="space-y-6">
{date && selectedDateItems.length === 0 ? ( {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-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 p-4 rounded-full shadow-sm"> <div className="bg-background rounded-full p-4 shadow-sm">
<Clock className="w-8 h-8 text-muted-foreground/50" /> <Clock className="text-muted-foreground/50 h-8 w-8" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="font-semibold text-lg text-foreground">No hours logged</p> <p className="text-foreground text-lg font-semibold">
<p className="text-sm text-muted-foreground/80 max-w-[200px]">There are no time entries recorded for this day yet.</p> 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> </div>
<Button onClick={handleAddNewItem} className="mt-2" size="lg"> <Button onClick={handleAddNewItem} className="mt-2" size="lg">
<Plus className="w-4 h-4 mr-2" /> <Plus className="mr-2 h-4 w-4" />
Log Time Log Time
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{selectedDateItems.map(({ item, index }) => ( {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
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"> <div className="space-y-3 p-4">
{/* Description */} {/* Description */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-muted-foreground text-xs">Description</Label> <Label className="text-muted-foreground text-xs">
Description
</Label>
<Input <Input
value={item.description} value={item.description}
onChange={(e) => onUpdateItem(index, "description", e.target.value)} onChange={(e) =>
onUpdateItem(index, "description", e.target.value)
}
placeholder="Describe the work performed..." placeholder="Describe the work performed..."
className="pl-3 text-sm" className="pl-3 text-sm"
/> />
@@ -328,20 +434,24 @@ export function InvoiceCalendarView({
{/* Hours and Rate in a row */} {/* Hours and Rate in a row */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-muted-foreground text-xs">Hours</Label> <Label className="text-muted-foreground text-xs">
Hours
</Label>
<NumberInput <NumberInput
value={item.hours} value={item.hours}
onChange={v => onUpdateItem(index, "hours", v)} onChange={(v) => onUpdateItem(index, "hours", v)}
step={0.25} step={0.25}
min={0} min={0}
width="full" width="full"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-muted-foreground text-xs">Rate</Label> <Label className="text-muted-foreground text-xs">
Rate
</Label>
<NumberInput <NumberInput
value={item.rate} value={item.rate}
onChange={v => onUpdateItem(index, "rate", v)} onChange={(v) => onUpdateItem(index, "rate", v)}
prefix="$" prefix="$"
min={0} min={0}
step={1} step={1}
@@ -370,7 +480,9 @@ export function InvoiceCalendarView({
</span> </span>
</div> </div>
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">Total</span> <span className="text-muted-foreground text-xs">
Total
</span>
<span className="text-primary text-lg font-bold"> <span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)} ${(item.hours * item.rate).toFixed(2)}
</span> </span>
@@ -378,9 +490,13 @@ export function InvoiceCalendarView({
</div> </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"> <Button
<div className="bg-muted group-hover:bg-primary/10 p-1 rounded-md transition-colors"> variant="outline"
<Plus className="w-4 h-4" /> 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> </div>
<span>Add Another Entry</span> <span>Add Another Entry</span>
</Button> </Button>
@@ -388,8 +504,14 @@ export function InvoiceCalendarView({
)} )}
</div> </div>
</div> </div>
<SheetFooter className="p-6 border-t bg-muted/10 mt-auto"> <SheetFooter className="bg-muted/10 mt-auto border-t p-6">
<Button className="w-full sm:w-full rounded-xl h-12 text-base shadow-md" size="lg" onClick={() => handleCloseSheet(false)}>Done</Button> <Button
className="h-12 w-full rounded-xl text-base shadow-md sm:w-full"
size="lg"
onClick={() => handleCloseSheet(false)}
>
Done
</Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
+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 ?? "#",
@@ -13,20 +13,14 @@ import {
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;
@@ -41,15 +35,17 @@ export function InvoiceMetaSidebar({
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">
Status
</Label>
<Select <Select
value={formData.status} value={formData.status}
onValueChange={(value: "draft" | "sent" | "paid") => onValueChange={(value: "draft" | "sent" | "paid") =>
@@ -71,7 +67,9 @@ export function InvoiceMetaSidebar({
{/* Invoice Number */} {/* Invoice Number */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="invoiceNumber" className="text-xs">Invoice Number</Label> <Label htmlFor="invoiceNumber" className="text-xs">
Invoice Number
</Label>
<Input <Input
id="invoiceNumber" id="invoiceNumber"
value={formData.invoiceNumber} value={formData.invoiceNumber}
@@ -83,18 +81,23 @@ export function InvoiceMetaSidebar({
</div> </div>
<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">
Involved Parties Involved Parties
</h3> </h3>
{/* From (Business) */} {/* From (Business) */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="business" className="text-xs">From (Business)</Label> <Label htmlFor="business" className="text-xs">
From (Business)
</Label>
<Select <Select
value={formData.businessId} value={formData.businessId}
onValueChange={(value) => updateField("businessId", value)} onValueChange={(value) => updateField("businessId", value)}
> >
<SelectTrigger aria-label="From Business" className="bg-background/50 text-sm"> <SelectTrigger
aria-label="From Business"
className="bg-background/50 text-sm"
>
<span className="truncate"> <span className="truncate">
<SelectValue placeholder="Select business" /> <SelectValue placeholder="Select business" />
</span> </span>
@@ -102,7 +105,8 @@ export function InvoiceMetaSidebar({
<SelectContent> <SelectContent>
{businesses?.map((business) => ( {businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}> <SelectItem key={business.id} value={business.id}>
{business.name}{business.nickname ? ` (${business.nickname})` : ""} {business.name}
{business.nickname ? ` (${business.nickname})` : ""}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -111,12 +115,17 @@ export function InvoiceMetaSidebar({
{/* Bill To (Client) */} {/* Bill To (Client) */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="client" className="text-xs">Bill To (Client)</Label> <Label htmlFor="client" className="text-xs">
Bill To (Client)
</Label>
<Select <Select
value={formData.clientId} value={formData.clientId}
onValueChange={(value) => updateField("clientId", value)} onValueChange={(value) => updateField("clientId", value)}
> >
<SelectTrigger aria-label="Bill To Client" className="bg-background/50 text-sm"> <SelectTrigger
aria-label="Bill To Client"
className="bg-background/50 text-sm"
>
<span className="truncate"> <span className="truncate">
<SelectValue placeholder="Select client" /> <SelectValue placeholder="Select client" />
</span> </span>
@@ -133,7 +142,7 @@ export function InvoiceMetaSidebar({
</div> </div>
<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">
Dates Dates
</h3> </h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -141,23 +150,27 @@ export function InvoiceMetaSidebar({
<Label className="text-xs">Issued</Label> <Label className="text-xs">Issued</Label>
<DatePicker <DatePicker
date={formData.issueDate} date={formData.issueDate}
onDateChange={(date) => updateField("issueDate", date ?? new Date())} onDateChange={(date) =>
className="w-full bg-background/50" updateField("issueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs">Due</Label> <Label className="text-xs">Due</Label>
<DatePicker <DatePicker
date={formData.dueDate} date={formData.dueDate}
onDateChange={(date) => updateField("dueDate", date ?? new Date())} onDateChange={(date) =>
className="w-full bg-background/50" updateField("dueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/> />
</div> </div>
</div> </div>
</div> </div>
<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">
Config Config
</h3> </h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -182,19 +195,22 @@ export function InvoiceMetaSidebar({
prefix="$" prefix="$"
placeholder={!formData.clientId ? "Select client" : "Rate"} placeholder={!formData.clientId ? "Select client" : "Rate"}
disabled={!formData.clientId} disabled={!formData.clientId}
className={cn("bg-background/50", !formData.clientId && "opacity-50")} className={cn(
"bg-background/50",
!formData.clientId && "opacity-50",
)}
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-1.5 flex-1"> <div className="flex-1 space-y-1.5">
<Label className="text-xs">Notes</Label> <Label className="text-xs">Notes</Label>
<Textarea <Textarea
value={formData.notes} value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)} onChange={(e) => updateField("notes", e.target.value)}
placeholder="Notes for client..." placeholder="Notes for client..."
className="bg-background/50 resize-none h-24" className="bg-background/50 h-24 resize-none"
/> />
</div> </div>
</div> </div>
+16 -8
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";
@@ -16,17 +19,22 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
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
variant="outline"
size="icon"
className="bg-background h-10 w-10 shadow-sm"
suppressHydrationWarning
>
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span> <span className="sr-only">Toggle menu</span>
</Button> </Button>
@@ -35,7 +43,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<div className="ml-4 flex items-center gap-2"> <div className="ml-4 flex items-center gap-2">
<Logo size="sm" /> <Logo size="sm" />
</div> </div>
<SheetContent side="left" className="p-0 w-72"> <SheetContent side="left" className="w-72 p-0">
<div className="sr-only"> <div className="sr-only">
<h2 id="mobile-nav-title">Navigation Menu</h2> <h2 id="mobile-nav-title">Navigation Menu</h2>
</div> </div>
@@ -48,7 +56,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<main <main
suppressHydrationWarning suppressHydrationWarning
className={cn( className={cn(
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out", "min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
"md:ml-0", "md:ml-0",
sidebarStyle === "floating" sidebarStyle === "floating"
? isCollapsed ? isCollapsed
@@ -59,9 +67,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
: "md:ml-64", : "md:ml-64",
)} )}
> >
<div className="p-4 pt-16 md:pt-4"> <div className="dashboard-content-shell p-4 pt-16 md:pt-4">
{/* Mobile header spacer is handled by pt-16 on mobile */} {/* Mobile header spacer is handled by pt-16 on mobile */}
<div className="md:hidden mb-4"> <div className="mb-4 md:hidden">
{/* Mobile Breadcrumbs could go here or be part of the page */} {/* Mobile Breadcrumbs could go here or be part of the page */}
</div> </div>
{children} {children}
+5 -5
View File
@@ -4,21 +4,21 @@ 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" />
+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>
)} )}
+1 -1
View File
@@ -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>
+4 -8
View File
@@ -14,15 +14,11 @@ const SidebarContext = React.createContext<SidebarContextType | 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;
// Persist state if needed, for now just local state
React.useEffect(() => {
const saved = localStorage.getItem("sidebar-collapsed"); const saved = localStorage.getItem("sidebar-collapsed");
if (saved) { return saved ? (JSON.parse(saved) as boolean) : false;
setIsCollapsed(JSON.parse(saved) as boolean); });
}
}, []);
const toggleCollapse = React.useCallback(() => { const toggleCollapse = React.useCallback(() => {
setIsCollapsed((prev) => { setIsCollapsed((prev) => {
+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>
) : ( ) : (
@@ -71,7 +71,8 @@ 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 ${
pathname === link.href
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-foreground hover:bg-muted" : "text-foreground hover:bg-muted"
}`} }`}
@@ -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 ??
@@ -220,6 +220,7 @@ export function AnimationPreferencesProvider({
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,
}; };
+116 -91
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,8 +176,7 @@ 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:
@@ -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 (
+20 -20
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({
@@ -55,12 +55,12 @@ function AlertDialogContent({
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,
} };
+14 -14
View File
@@ -1,9 +1,9 @@
"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>,
@@ -13,12 +13,12 @@ const Avatar = React.forwardRef<
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>,
@@ -29,8 +29,8 @@ const AvatarImage = React.forwardRef<
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>,
@@ -39,12 +39,12 @@ const AvatarFallback = React.forwardRef<
<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,
} };
+47 -41
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",
props.mode !== "single" &&
(props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md"), : "[&:first-child[data-selected=true]_button]:rounded-l-md"),
defaultClassNames.day 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 };
+13 -2
View File
@@ -3,9 +3,20 @@
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({
value,
prefix = "",
suffix = "",
}: {
value: number;
prefix?: string;
suffix?: string;
}) {
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 }); const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
const display = useTransform(spring, (current) => `${prefix}${current.toFixed(2)}${suffix}`); const display = useTransform(
spring,
(current) => `${prefix}${current.toFixed(2)}${suffix}`,
);
useEffect(() => { useEffect(() => {
spring.set(value); spring.set(value);
+12 -3
View File
@@ -66,6 +66,7 @@ export function DatePicker({
: "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}
+20 -20
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">
@@ -61,7 +61,7 @@ function DialogContent({
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,
} };
+2 -2
View File
@@ -25,8 +25,8 @@ export function ImageWithSkeleton({
"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}
+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 };
+25 -25
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({
@@ -130,11 +130,11 @@ function NavigationMenuLink({
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,
} };
+10 -10
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({
@@ -31,18 +31,18 @@ function PopoverContent({
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,
} };
+9 -20
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" />
@@ -84,14 +73,14 @@ export function DashboardActivitySkeleton() {
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 };
+11 -4
View File
@@ -42,16 +42,23 @@ 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(), .optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z NEXT_PUBLIC_DEFAULT_RADIUS: z
.enum(["none", "sm", "md", "lg", "xl"]) .enum(["none", "sm", "md", "lg", "xl"])
+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;
}
+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));
}
+10 -5
View File
@@ -36,7 +36,8 @@ 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.c * 0.2
} ${base.h})`, } ${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})`,
@@ -56,7 +57,8 @@ 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.c * 0.2
} ${base.h})`, } ${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})`,
@@ -75,10 +77,12 @@ 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.c * 0.2
} ${base.h})`, } ${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.c * 0.2
} ${base.h})`, } ${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})`,
@@ -96,7 +100,8 @@ 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.c * 0.2
} ${base.h})`, } ${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})`,
+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,
},
], ],
}, },
]; ];
+501 -66
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,23 +766,55 @@ 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}>
return (
<View
style={[styles.denseHeader, isMinimal ? minimalStyles.denseHeader : {}]}
>
<View
style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
>
<View style={styles.businessSection}> <View style={styles.businessSection}>
<Text style={[styles.businessName, { color: settings.pdfAccentColor }]}> <Text
style={[
styles.businessName,
isMinimal ? minimalStyles.businessName : {},
{ color: settings.pdfAccentColor },
]}
>
{invoice.business?.name ?? "Your Business Name"} {invoice.business?.name ?? "Your Business Name"}
</Text> </Text>
{invoice.business?.email && ( {invoice.business?.email && (
<Text style={styles.businessInfo}>{invoice.business.email}</Text> <Text
style={[
styles.businessInfo,
isMinimal ? minimalStyles.businessInfo : {},
]}
>
{invoice.business.email}
</Text>
)} )}
{invoice.business?.phone && ( {invoice.business?.phone && (
<Text style={styles.businessInfo}>{invoice.business.phone}</Text> <Text
style={[
styles.businessInfo,
isMinimal ? minimalStyles.businessInfo : {},
]}
>
{invoice.business.phone}
</Text>
)} )}
{(invoice.business?.addressLine1 ?? {(invoice.business?.addressLine1 ??
invoice.business?.city ?? invoice.business?.city ??
invoice.business?.state) && ( invoice.business?.state) && (
<Text style={styles.businessAddress}> <Text
style={[
styles.businessAddress,
isMinimal ? minimalStyles.businessAddress : {},
]}
>
{[ {[
invoice.business?.addressLine1, invoice.business?.addressLine1,
invoice.business?.addressLine2, invoice.business?.addressLine2,
@@ -642,35 +838,99 @@ const DenseHeader: React.FC<{
</View> </View>
<View style={styles.invoiceSection}> <View style={styles.invoiceSection}>
<Text style={[styles.invoiceTitle, { color: settings.pdfAccentColor }]}> <Text
style={[
styles.invoiceTitle,
isMinimal ? minimalStyles.invoiceTitle : {},
{ color: settings.pdfAccentColor },
]}
>
INVOICE INVOICE
</Text> </Text>
<Text style={styles.invoiceNumber}> <Text
style={[
styles.invoiceNumber,
isMinimal ? minimalStyles.invoiceNumber : {},
]}
>
{invoice.invoicePrefix ?? "#"} {invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber} {invoice.invoiceNumber}
</Text> </Text>
<View style={getStatusStyle(invoice.status)}> <View
style={[
...getStatusStyle(invoice.status),
isMinimal ? minimalStyles.statusBadge : {},
]}
>
<Text>{getStatusLabel(invoice.status)}</Text> <Text>{getStatusLabel(invoice.status)}</Text>
</View> </View>
</View> </View>
</View> </View>
<View style={styles.headerSeparator} /> <View
style={[
styles.headerSeparator,
isMinimal ? minimalStyles.headerSeparator : {},
]}
/>
<View style={styles.detailsSection}> <View
<View style={styles.detailsColumn}> style={[
<Text style={styles.sectionTitle}>BILL TO:</Text> styles.detailsSection,
<Text style={styles.clientName}>{invoice.client?.name ?? "N/A"}</Text> 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 && ( {invoice.client?.email && (
<Text style={styles.clientInfo}>{invoice.client.email}</Text> <Text
style={[
styles.clientInfo,
isMinimal ? minimalStyles.clientInfo : {},
]}
>
{invoice.client.email}
</Text>
)} )}
{invoice.client?.phone && ( {invoice.client?.phone && (
<Text style={styles.clientInfo}>{invoice.client.phone}</Text> <Text
style={[
styles.clientInfo,
isMinimal ? minimalStyles.clientInfo : {},
]}
>
{invoice.client.phone}
</Text>
)} )}
{(invoice.client?.addressLine1 ?? {(invoice.client?.addressLine1 ??
invoice.client?.city ?? invoice.client?.city ??
invoice.client?.state) && ( invoice.client?.state) && (
<Text style={styles.clientAddress}> <Text
style={[
styles.clientAddress,
isMinimal ? minimalStyles.clientAddress : {},
]}
>
{[ {[
invoice.client?.addressLine1, invoice.client?.addressLine1,
invoice.client?.addressLine2, invoice.client?.addressLine2,
@@ -685,26 +945,85 @@ const DenseHeader: React.FC<{
)} )}
</View> </View>
<View style={styles.detailsColumn}> <View
<Text style={styles.sectionTitle}>INVOICE DETAILS:</Text> style={[
<View style={styles.detailRow}> styles.detailsColumn,
<Text style={styles.detailLabel}>Issue Date:</Text> isMinimal ? minimalStyles.detailsColumn : {},
<Text style={styles.detailValue}> ]}
>
<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)} {formatDate(invoice.issueDate)}
</Text> </Text>
</View> </View>
<View style={styles.detailRow}> <View
<Text style={styles.detailLabel}>Due Date:</Text> style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
<Text style={styles.detailValue}>{formatDate(invoice.dueDate)}</Text> >
<Text
style={[
styles.detailLabel,
isMinimal ? minimalStyles.detailLabel : {},
]}
>
Due Date:
</Text>
<Text
style={[
styles.detailValue,
isMinimal ? minimalStyles.detailValue : {},
]}
>
{formatDate(invoice.dueDate)}
</Text>
</View> </View>
<View style={styles.detailRow}> <View
<Text style={styles.detailLabel}>Invoice #:</Text> style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
<Text style={styles.detailValue}>{invoice.invoiceNumber}</Text> >
<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>
); );
};
// 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={[styles.tableHeader, isMinimal ? minimalStyles.tableHeader : {}]}
>
<Text
style={[ style={[
styles.tableHeader, styles.tableHeaderCell,
settings.pdfTemplate === "minimal" isMinimal ? minimalStyles.tableHeaderCell : {},
? { backgroundColor: "#ffffff" } { width: cols.date },
: {}, ]}
>
Date
</Text>
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
{ width: cols.description },
]} ]}
> >
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
<Text style={[styles.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,8 +1131,11 @@ 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";
return (
<View style={[styles.footer, isMinimal ? minimalStyles.footer : {}]} fixed>
<View style={styles.footerLogo}> <View style={styles.footerLogo}>
{settings.pdfShowLogo && ( {settings.pdfShowLogo && (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt. // eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
@@ -790,7 +1150,7 @@ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
)} )}
<Text <Text
style={{ style={{
fontSize: 9, fontSize: isMinimal ? 8 : 9,
fontFamily: "Helvetica", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
marginLeft: settings.pdfShowLogo ? 8 : 0, marginLeft: settings.pdfShowLogo ? 8 : 0,
@@ -801,14 +1161,15 @@ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
</View> </View>
{settings.pdfShowPageNumbers && ( {settings.pdfShowPageNumbers && (
<Text <Text
style={styles.pageNumber} style={[styles.pageNumber, isMinimal ? minimalStyles.pageNumber : {}]}
render={({ pageNumber, totalPages }) => render={({ pageNumber, totalPages }) =>
`Page ${pageNumber} of ${totalPages}` `Page ${pageNumber} of ${totalPages}`
} }
/> />
)} )}
</View> </View>
); );
};
// Enhanced totals section component // Enhanced totals section component
const TotalsSection: React.FC<{ const TotalsSection: React.FC<{
@@ -820,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,
@@ -837,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 },
]} ]}
> >
@@ -876,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>
@@ -894,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) =>
@@ -911,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
: {}, : {},
@@ -919,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 },
]} ]}
@@ -928,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 },
]} ]}
> >
@@ -937,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 },
]} ]}
@@ -947,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 },
]} ]}
@@ -957,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 },
]} ]}
@@ -969,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));
} }
+9 -4
View File
@@ -32,10 +32,11 @@ export const dashboardRouter = createTRPCRouter({
); );
// 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 "overdue";
return "sent"; return "sent";
}; };
@@ -57,7 +58,10 @@ export const dashboardRouter = createTRPCRouter({
if (issueDate >= currentMonthStart) { if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount; currentMonthRevenue += amount;
} else if (issueDate >= lastMonthStart && issueDate < currentMonthStart) { } else if (
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount; lastMonthRevenue += amount;
} }
} else if (status === "sent" || status === "overdue") { } else if (status === "sent" || status === "overdue") {
@@ -115,7 +119,8 @@ export const dashboardRouter = createTRPCRouter({
pendingAmount, pendingAmount,
overdueCount, overdueCount,
totalClients: userClientsCount, totalClients: userClientsCount,
revenueChange: lastMonthRevenue > 0 revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100 ? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0, : 0,
revenueChartData, revenueChartData,
+27 -6
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;
@@ -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,7 +131,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",
});
} }
const clean = { const clean = {
@@ -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
+50 -48
View File
@@ -17,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,
@@ -219,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,
@@ -236,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,
}; };
}), }),
@@ -255,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(),
@@ -291,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,
@@ -307,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,
+311 -5
View File
@@ -50,6 +50,48 @@
--radius: 1rem; --radius: 1rem;
} }
:root[data-interface-theme="frutiger"] {
--background: 0 0% 100%;
--foreground: 210 24% 8%;
--card: 0 0% 100%;
--card-foreground: 210 24% 8%;
--popover: 0 0% 100%;
--popover-foreground: 210 24% 8%;
--primary: 203 100% 18%;
--primary-foreground: 46 100% 91%;
--secondary: 47 100% 50%;
--secondary-foreground: 210 24% 8%;
--muted: 199 73% 91%;
--muted-foreground: 203 52% 24%;
--accent: 47 100% 50%;
--accent-foreground: 210 24% 8%;
--border: 203 45% 42%;
--input: 203 45% 42%;
--ring: 203 100% 18%;
--radius: 0rem;
}
:root[data-interface-theme="frutiger-aero"] {
--background: 190 76% 96%;
--foreground: 205 50% 12%;
--card: 0 0% 100%;
--card-foreground: 205 50% 12%;
--popover: 0 0% 100%;
--popover-foreground: 205 50% 12%;
--primary: 201 100% 37%;
--primary-foreground: 0 0% 100%;
--secondary: 104 55% 55%;
--secondary-foreground: 205 50% 12%;
--muted: 190 56% 90%;
--muted-foreground: 205 32% 32%;
--accent: 104 55% 55%;
--accent-foreground: 205 50% 12%;
--border: 195 48% 74%;
--input: 195 48% 74%;
--ring: 201 100% 37%;
--radius: 0.75rem;
}
:root[data-interface-theme="minimal"] { :root[data-interface-theme="minimal"] {
--background: 0 0% 100%; --background: 0 0% 100%;
--card: 0 0% 100%; --card: 0 0% 100%;
@@ -80,7 +122,11 @@
:root[data-body-font="brand"], :root[data-body-font="brand"],
:root[data-body-font="inter"] { :root[data-body-font="inter"] {
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
:root[data-body-font="frutiger"] {
--app-font-sans: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
} }
:root[data-body-font="platform"] { :root[data-body-font="platform"] {
@@ -99,6 +145,10 @@
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif; --app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
} }
:root[data-heading-font="frutiger"] {
--app-font-heading: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-heading-font="platform"] { :root[data-heading-font="platform"] {
--app-font-heading: --app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
@@ -106,14 +156,19 @@
} }
:root[data-heading-font="inter"] { :root[data-heading-font="inter"] {
--app-font-heading: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
} }
:root[data-font="brand"]:not([data-body-font]) { :root[data-font="brand"]:not([data-body-font]) {
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif; --app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
} }
:root[data-font="frutiger"]:not([data-body-font]) {
--app-font-sans: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="platform"]:not([data-body-font]) { :root[data-font="platform"]:not([data-body-font]) {
--app-font-sans: --app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
@@ -124,8 +179,8 @@
} }
:root[data-font="inter"]:not([data-body-font]) { :root[data-font="inter"]:not([data-body-font]) {
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
} }
:root[data-font="serif"]:not([data-body-font]) { :root[data-font="serif"]:not([data-body-font]) {
@@ -251,6 +306,46 @@
--accent-foreground: 355.7 100% 97.3%; --accent-foreground: 355.7 100% 97.3%;
} }
:root[data-interface-theme="frutiger"] {
--background: 0 0% 100%;
--foreground: 210 24% 8%;
--card: 0 0% 100%;
--card-foreground: 210 24% 8%;
--popover: 0 0% 100%;
--popover-foreground: 210 24% 8%;
--primary: 203 100% 18%;
--primary-foreground: 46 100% 91%;
--accent: 47 100% 50%;
--accent-foreground: 210 24% 8%;
--secondary: 47 100% 50%;
--secondary-foreground: 210 24% 8%;
--muted: 199 73% 91%;
--muted-foreground: 203 52% 24%;
--border: 203 45% 42%;
--input: 203 45% 42%;
--ring: 203 100% 18%;
}
:root[data-interface-theme="frutiger-aero"] {
--background: 190 76% 96%;
--foreground: 205 50% 12%;
--card: 0 0% 100%;
--card-foreground: 205 50% 12%;
--popover: 0 0% 100%;
--popover-foreground: 205 50% 12%;
--primary: 201 100% 37%;
--primary-foreground: 0 0% 100%;
--accent: 104 55% 55%;
--accent-foreground: 205 50% 12%;
--secondary: 104 55% 55%;
--secondary-foreground: 205 50% 12%;
--muted: 190 56% 90%;
--muted-foreground: 205 32% 32%;
--border: 195 48% 74%;
--input: 195 48% 74%;
--ring: 201 100% 37%;
}
:root[data-color-mode="dark"][data-color-theme="slate"], :root[data-color-mode="dark"][data-color-theme="slate"],
:root.dark[data-color-theme="slate"] { :root.dark[data-color-theme="slate"] {
--primary: 0 0% 98%; --primary: 0 0% 98%;
@@ -325,6 +420,185 @@
display: none; display: none;
} }
:root[data-interface-theme="frutiger"] .brand-background {
display: none;
}
:root[data-interface-theme="frutiger-aero"] .brand-background {
display: flex;
background:
radial-gradient(circle at 18% 22%, hsl(104 55% 55% / 0.35), transparent 32%),
radial-gradient(circle at 82% 18%, hsl(190 88% 66% / 0.42), transparent 30%),
linear-gradient(180deg, hsl(195 100% 94% / 0.95), hsl(0 0% 100% / 0.35));
}
:root[data-interface-theme="frutiger"] .dashboard-content-shell {
padding: 1rem;
padding-top: 4rem;
}
@media (min-width: 768px) {
:root[data-interface-theme="frutiger"] .dashboard-content-shell {
padding: 1.25rem;
}
}
:root[data-interface-theme="frutiger"] .bg-dashboard {
background: hsl(var(--background));
}
:root[data-interface-theme="frutiger-aero"] .bg-dashboard {
background:
radial-gradient(circle at 78% 8%, hsl(104 55% 55% / 0.22), transparent 26rem),
linear-gradient(180deg, hsl(190 76% 96%) 0%, hsl(0 0% 100%) 72%);
}
:root[data-interface-theme="frutiger"] aside {
border-color: hsl(var(--primary));
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
:root[data-interface-theme="frutiger"] aside [class*="text-muted-foreground"] {
color: hsl(var(--primary-foreground) / 0.72);
}
:root[data-interface-theme="frutiger"] aside a {
color: hsl(var(--primary-foreground) / 0.86);
}
:root[data-interface-theme="frutiger"] aside a:hover,
:root[data-interface-theme="frutiger"] aside a[data-active="true"] {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
:root[data-interface-theme="frutiger"] aside button {
color: hsl(var(--primary-foreground) / 0.82);
}
:root[data-interface-theme="frutiger"] .dashboard-mobile-header {
border-color: hsl(var(--primary));
background-color: hsl(var(--accent));
color: hsl(var(--foreground));
backdrop-filter: none;
}
:root[data-interface-theme="frutiger"] .platform-header-surface {
border-color: hsl(var(--primary));
border-top: 0.5rem solid hsl(var(--primary));
background-color: hsl(var(--accent));
color: hsl(var(--foreground));
box-shadow: none;
}
:root[data-interface-theme="frutiger"] .platform-header-gradient {
display: none !important;
background: none !important;
}
:root[data-interface-theme="frutiger"] .platform-header-surface .text-primary,
:root[data-interface-theme="frutiger"] .platform-header-surface [class*="text-primary"],
:root[data-interface-theme="frutiger"] .dashboard-mobile-header .text-primary,
:root[data-interface-theme="frutiger"] .dashboard-mobile-header [class*="text-primary"] {
color: hsl(var(--foreground));
}
:root[data-interface-theme="frutiger"] .platform-header-surface .text-muted-foreground,
:root[data-interface-theme="frutiger"] .platform-header-surface [class*="text-muted-foreground"] {
color: hsl(var(--foreground) / 0.72);
}
:root[data-interface-theme="frutiger-aero"] aside {
border-color: hsl(190 88% 66% / 0.55);
background:
linear-gradient(180deg, hsl(201 100% 37% / 0.82), hsl(190 88% 45% / 0.72)),
hsl(201 100% 37% / 0.78);
color: hsl(var(--primary-foreground));
box-shadow: 0 18px 36px -20px hsl(201 100% 22% / 0.55);
backdrop-filter: blur(18px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] aside [class*="text-muted-foreground"] {
color: hsl(var(--primary-foreground) / 0.76);
}
:root[data-interface-theme="frutiger-aero"] aside a {
color: hsl(var(--primary-foreground) / 0.88);
}
:root[data-interface-theme="frutiger-aero"] aside a:hover,
:root[data-interface-theme="frutiger-aero"] aside a[data-active="true"] {
background-color: hsl(0 0% 100% / 0.92);
color: hsl(var(--primary));
}
:root[data-interface-theme="frutiger-aero"] .dashboard-mobile-header {
border-color: hsl(190 88% 66% / 0.55);
background-color: hsl(0 0% 100% / 0.78);
color: hsl(var(--foreground));
backdrop-filter: blur(18px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] .platform-header-surface {
border-color: hsl(190 88% 66% / 0.6);
background:
linear-gradient(180deg, hsl(0 0% 100% / 0.88), hsl(190 88% 92% / 0.72)),
hsl(0 0% 100% / 0.72);
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.9),
0 18px 36px -28px hsl(201 100% 37% / 0.6);
backdrop-filter: blur(16px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] .platform-header-gradient {
display: block;
background: radial-gradient(circle at 92% 10%, hsl(104 55% 55% / 0.45), transparent 34%),
linear-gradient(135deg, hsl(190 88% 66% / 0.28), transparent 48%);
opacity: 1;
}
:root[data-interface-theme="frutiger"] [data-slot="card"],
:root[data-interface-theme="frutiger"] [data-slot="dialog-content"],
:root[data-interface-theme="frutiger"] [data-slot="popover-content"],
:root[data-interface-theme="frutiger"] [data-slot="select-content"] {
border-color: hsl(var(--primary) / 0.45);
border-radius: 0;
background-color: hsl(var(--card));
box-shadow: none;
}
:root[data-interface-theme="frutiger"] [data-slot="card"] {
border-top: 0.35rem solid hsl(var(--accent));
}
:root[data-interface-theme="frutiger-aero"] [data-slot="card"] {
border-color: hsl(190 88% 66% / 0.45);
background-color: hsl(0 0% 100% / 0.78);
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.9),
0 16px 34px -30px hsl(201 100% 37% / 0.7);
backdrop-filter: blur(14px) saturate(1.25);
}
:root[data-interface-theme="frutiger"] [data-slot="button"],
:root[data-interface-theme="frutiger"] button,
:root[data-interface-theme="frutiger"] input,
:root[data-interface-theme="frutiger"] textarea,
:root[data-interface-theme="frutiger"] [role="combobox"] {
border-radius: 0;
}
:root[data-interface-theme="frutiger"] .button-hover:hover,
:root[data-interface-theme="frutiger"] .card-hover:hover {
transform: none;
box-shadow: none;
}
:root[data-interface-theme="frutiger"] [data-slot="card-header"],
:root[data-interface-theme="frutiger"] [data-slot="card-content"],
:root[data-interface-theme="frutiger"] [data-slot="card-footer"] {
padding-inline: 1rem;
}
:root[data-interface-theme="minimal"] [data-slot="card"] { :root[data-interface-theme="minimal"] [data-slot="card"] {
background-color: transparent; background-color: transparent;
border-color: transparent; border-color: transparent;
@@ -397,6 +671,38 @@
background-color: hsl(var(--background)); background-color: hsl(var(--background));
} }
:root[data-interface-theme="beenvoice"] .dashboard-content-shell {
padding: 0.75rem;
padding-top: 4rem;
}
@media (min-width: 768px) {
:root[data-interface-theme="beenvoice"] .dashboard-content-shell {
padding-top: 0.75rem;
}
}
:root[data-interface-theme="beenvoice"] .platform-header-content {
padding: 1.25rem;
}
:root[data-interface-theme="beenvoice"] [data-slot="card"] {
border-radius: var(--radius-lg);
}
:root[data-interface-theme="beenvoice"] [data-slot="card-header"] {
padding: 1rem 1rem 0.75rem;
}
:root[data-interface-theme="beenvoice"] [data-slot="card-content"] {
padding-inline: 1rem;
padding-bottom: 1rem;
}
:root[data-interface-theme="beenvoice"] [data-slot="card-footer"] {
padding: 1rem;
}
:root[data-interface-theme="editorial"] .brand-background { :root[data-interface-theme="editorial"] .brand-background {
opacity: 0.55; opacity: 0.55;
} }